| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /* | ||
| 2 | * This file is part of the Falcon Player (FPP) and is Copyright (C) | ||
| 3 | * 2013-2025 by the Falcon Player Developers. | ||
| 4 | * | ||
| 5 | * The Falcon Player (FPP) is free software, and is covered under | ||
| 6 | * multiple Open Source licenses. Please see the included 'LICENSES' | ||
| 7 | * file for descriptions of what files are covered by each license. | ||
| 8 | * | ||
| 9 | * This source file is covered under the LGPL v2.1 as described in the | ||
| 10 | * included LICENSE.LGPL file. | ||
| 11 | */ | ||
| 12 | |||
| 13 | #include "fpp-pch.h" | ||
| 14 | |||
| 15 | #include "fpp-json.h" | ||
| 16 | #include "fpphttp.h" // drogon/HTTP helpers used here; no longer pulled transitively (see fpphttp_types.h) | ||
| 17 | |||
| 18 | #include "Warnings.h" // WarningHolder -- needed directly for NOPCH builds | ||
| 19 | |||
| 20 | #include "AES67Manager.h" | ||
| 21 | #include "GStreamerOut.h" | ||
| 22 | #include "PipeWireGraphConfig.h" | ||
| 23 | |||
| 24 | #ifdef HAS_AES67_GSTREAMER | ||
| 25 | |||
| 26 | #include <gst/gst.h> | ||
| 27 | |||
| 28 | #if __has_include(<samplerate.h>) | ||
| 29 | #include <samplerate.h> | ||
| 30 | #define FPP_HAVE_SAMPLERATE 1 | ||
| 31 | #endif | ||
| 32 | |||
| 33 | #include <arpa/inet.h> | ||
| 34 | #include <fcntl.h> | ||
| 35 | #include <ifaddrs.h> | ||
| 36 | // Only for PhcIndexForInterface(): finding a NIC's hardware PTP clock is a | ||
| 37 | // Linux ethtool query. Without it (macOS) every interface reports no PHC and the | ||
| 38 | // clock falls back to CLOCK_REALTIME, as it does on Linux for software-only NICs. | ||
| 39 | #if __has_include(<linux/ethtool.h>) | ||
| 40 | #define FPP_AES67_HAS_ETHTOOL | ||
| 41 | #include <linux/ethtool.h> | ||
| 42 | #include <linux/sockios.h> | ||
| 43 | #endif | ||
| 44 | #include <net/if.h> | ||
| 45 | #include <netinet/in.h> | ||
| 46 | #include <signal.h> | ||
| 47 | #include <sys/ioctl.h> | ||
| 48 | #include <sys/socket.h> | ||
| 49 | #include <sys/wait.h> | ||
| 50 | #include <unistd.h> | ||
| 51 | #if defined(__linux__) | ||
| 52 | #include <poll.h> | ||
| 53 | #include <sys/syscall.h> | ||
| 54 | #endif | ||
| 55 | |||
| 56 | #include <algorithm> | ||
| 57 | #include <cctype> | ||
| 58 | #include <chrono> | ||
| 59 | #include <cmath> | ||
| 60 | #include <cstdio> | ||
| 61 | #include <cstring> | ||
| 62 | #include <deque> | ||
| 63 | #include <fstream> | ||
| 64 | #include <memory> | ||
| 65 | #include <sstream> | ||
| 66 | |||
| 67 | #include "common.h" | ||
| 68 | #include "log.h" | ||
| 69 | #include "mediaoutput.h" | ||
| 70 | #include "settings.h" | ||
| 71 | #include "../SysSocket.h" | ||
| 72 | |||
| 73 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 74 | // AES67 namespace helpers | ||
| 75 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 76 | namespace AES67 { | ||
| 77 | |||
| 78 | ✗ | const char* GetSDPChannelNames(int channels) { | |
| 79 | ✗ | switch (channels) { | |
| 80 | ✗ | case 1: return "M"; | |
| 81 | ✗ | case 2: return "FL, FR"; | |
| 82 | ✗ | case 4: return "FL, FR, RL, RR"; | |
| 83 | ✗ | case 6: return "FL, FR, FC, LFE, RL, RR"; | |
| 84 | ✗ | case 8: return "FL, FR, FC, LFE, RL, RR, SL, SR"; | |
| 85 | ✗ | default: return ""; | |
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | } // namespace AES67 | ||
| 90 | |||
| 91 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 92 | // Singleton instance | ||
| 93 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 94 | static AES67Manager s_aes67Manager; | ||
| 95 | AES67Manager& AES67Manager::INSTANCE = s_aes67Manager; | ||
| 96 | |||
| 97 | // Set a send pipeline's master volume element (see SetMasterVolume()). | ||
| 98 | 21 | static void SetPipelineMasterVolume(GstElement* pipeline, int volume) { | |
| 99 | 21 | GstElement* vol = gst_bin_get_by_name(GST_BIN(pipeline), "mastervol"); | |
| 100 |
1/2✓ Branch 0 taken 21 times.
✗ Branch 1 not taken.
|
21 | if (vol) { |
| 101 | 21 | g_object_set(vol, "volume", masterVolumeGain(volume), nullptr); | |
| 102 | 21 | gst_object_unref(vol); | |
| 103 | } | ||
| 104 | 21 | } | |
| 105 | |||
| 106 |
1/2✓ Branch 0 taken 2346 times.
✗ Branch 1 not taken.
|
2346 | AES67Manager::AES67Manager() { |
| 107 |
2/4✓ Branch 0 taken 2346 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2346 times.
✗ Branch 3 not taken.
|
2346 | m_configPath = getFPPMediaDir("/config/pipewire-aes67-instances.json"); |
| 108 | 2346 | } | |
| 109 | |||
| 110 | 916 | AES67Manager::~AES67Manager() { | |
| 111 | 916 | Shutdown(); | |
| 112 | 916 | } | |
| 113 | |||
| 114 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 115 | // Lifecycle | ||
| 116 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 117 | 3716 | bool AES67Manager::Init() { | |
| 118 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 3716 times.
|
3716 | if (m_initialized.load()) { |
| 119 | ✗ | return true; | |
| 120 | } | ||
| 121 | |||
| 122 | // AES67 uses pipewiresrc/pipewiresink which require the full PipeWire graph. | ||
| 123 | // Only proceed when MediaBackend is "pipewire" (the advanced PipeWire mode). | ||
| 124 | // - ALSA / no backend: PipeWire daemon is not running; gst_parse_launch with | ||
| 125 | // pipewiresrc crashes in gst_value_deserialize (NULL deref at addr 0x4). | ||
| 126 | // - pipewire-simple: the graph lacks the node connections needed for audio | ||
| 127 | // format negotiation, causing the state change to block indefinitely. | ||
| 128 |
2/4✓ Branch 0 taken 3716 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3716 times.
✗ Branch 3 not taken.
|
3716 | std::string mediaBackend = toLowerCopy(getSetting("MediaBackend")); |
| 129 |
8/10✓ Branch 0 taken 3716 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3704 times.
✓ Branch 3 taken 12 times.
✓ Branch 4 taken 3704 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3610 times.
✓ Branch 7 taken 94 times.
✓ Branch 8 taken 3622 times.
✓ Branch 9 taken 94 times.
|
3716 | if (!isPipeWireBackend() || mediaBackend != "pipewire") { |
| 130 |
1/2✓ Branch 0 taken 3622 times.
✗ Branch 1 not taken.
|
3622 | LogDebug(VB_MEDIAOUT, "AES67Manager: MediaBackend='%s' (need 'pipewire'), skipping AES67 init\n", |
| 131 | mediaBackend.c_str()); | ||
| 132 | 3622 | return true; | |
| 133 | } | ||
| 134 | |||
| 135 | // Check if the config file exists | ||
| 136 |
3/4✓ Branch 0 taken 94 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✓ Branch 3 taken 58 times.
|
94 | if (!FileExists(m_configPath)) { |
| 137 |
1/2✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
|
36 | LogDebug(VB_MEDIAOUT, "AES67Manager: No config file at %s, skipping init\n", |
| 138 | m_configPath.c_str()); | ||
| 139 | 36 | return true; // not an error — just no AES67 configured | |
| 140 | } | ||
| 141 | |||
| 142 | // Shared with the playback path and the video managers so gst_init() | ||
| 143 | // happens exactly once, under one lock, whichever subsystem gets here | ||
| 144 | // first. | ||
| 145 |
1/2✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
|
58 | GStreamerOutput::EnsureGStreamerInit(); |
| 146 | |||
| 147 | // Set PipeWire env vars so pipewiresrc/pipewiresink can find the FPP PipeWire runtime | ||
| 148 | 58 | setenv("PIPEWIRE_RUNTIME_DIR", "/run/pipewire-fpp", 0); | |
| 149 | 58 | setenv("XDG_RUNTIME_DIR", "/run/pipewire-fpp", 0); | |
| 150 | 58 | setenv("PULSE_RUNTIME_PATH", "/run/pipewire-fpp/pulse", 0); | |
| 151 | |||
| 152 | 58 | m_initialized.store(true); | |
| 153 |
1/2✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
|
58 | LogInfo(VB_MEDIAOUT, "AES67Manager: Initialized\n"); |
| 154 | 58 | return true; | |
| 155 | 3716 | } | |
| 156 | |||
| 157 | 2325 | void AES67Manager::Shutdown() { | |
| 158 |
2/2✓ Branch 0 taken 2267 times.
✓ Branch 1 taken 58 times.
|
2325 | if (!m_initialized.load()) { |
| 159 | 2267 | return; | |
| 160 | } | ||
| 161 | |||
| 162 |
1/2✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
|
58 | LogInfo(VB_MEDIAOUT, "AES67Manager: Shutting down\n"); |
| 163 | |||
| 164 | // Nothing may apply from here on, including a watchdog rebuild that is | ||
| 165 | // already scheduled: ApplyConfig() would re-Init() and bring back the | ||
| 166 | // pipelines this is about to tear down. | ||
| 167 | 58 | m_stopping.store(true); | |
| 168 | 58 | m_applyGeneration++; | |
| 169 | 58 | m_initialized.store(false); | |
| 170 | |||
| 171 | // Join any in-flight watchdog rebuild thread FIRST, before touching | ||
| 172 | // m_sapAnnounceThread/m_sapRecvThread. The rebuild thread internally | ||
| 173 | // calls ApplyConfig(), which itself joins those two threads -- joining | ||
| 174 | // them here concurrently with the rebuild thread would be a double | ||
| 175 | // join on the same std::thread object (undefined behavior). Taken out | ||
| 176 | // under m_rebuildMutex: the watchdog thread assigns it. | ||
| 177 | 58 | std::thread rebuild; | |
| 178 | { | ||
| 179 |
1/2✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
|
58 | std::lock_guard<std::mutex> rl(m_rebuildMutex); |
| 180 | 58 | rebuild = std::move(m_rebuildThread); | |
| 181 | 58 | } | |
| 182 |
2/2✓ Branch 0 taken 5 times.
✓ Branch 1 taken 53 times.
|
58 | if (rebuild.joinable()) { |
| 183 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | rebuild.join(); |
| 184 | } | ||
| 185 | |||
| 186 | // Only now take the apply lock -- m_rebuildThread calls ApplyConfig(), | ||
| 187 | // so holding this across the join above would deadlock. Taking it here | ||
| 188 | // still blocks until any in-flight ApplyConfig() (e.g. from a command | ||
| 189 | // thread) has finished, so we never tear down threads it is mid-rebuild. | ||
| 190 |
1/2✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
|
58 | std::lock_guard<std::mutex> applyLock(m_applyMutex); |
| 191 | |||
| 192 | // Stop SAP threads | ||
| 193 |
1/2✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
|
58 | StopWorkerThreads(); |
| 194 | |||
| 195 |
1/2✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
|
58 | StopAllPipelines(); |
| 196 |
1/2✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
|
58 | ShutdownPTP(); |
| 197 |
1/2✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
|
58 | ReleaseMediaClock(); |
| 198 | |||
| 199 | 58 | m_active.store(false); | |
| 200 |
1/2✓ Branch 0 taken 58 times.
✗ Branch 1 not taken.
|
58 | LogInfo(VB_MEDIAOUT, "AES67Manager: Shutdown complete\n"); |
| 201 | 58 | } | |
| 202 | |||
| 203 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 204 | // Config loading — reads pipewire-aes67-instances.json | ||
| 205 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 206 | // Accessors -- see m_configMutex. Each copies out under the lock so callers | ||
| 207 | // never hold a reference into a vector or string LoadConfig() may reallocate. | ||
| 208 | 1383 | AES67Config AES67Manager::GetConfigSnapshot() { | |
| 209 |
1/2✓ Branch 0 taken 1383 times.
✗ Branch 1 not taken.
|
1383 | std::lock_guard<std::mutex> lock(m_configMutex); |
| 210 |
1/2✓ Branch 0 taken 1383 times.
✗ Branch 1 not taken.
|
2766 | return m_config; |
| 211 | 1383 | } | |
| 212 | |||
| 213 | 1696 | bool AES67Manager::IsPtpEnabled() { | |
| 214 |
1/2✓ Branch 0 taken 1696 times.
✗ Branch 1 not taken.
|
1696 | std::lock_guard<std::mutex> lock(m_configMutex); |
| 215 | 1696 | return m_config.ptpEnabled; | |
| 216 | 1696 | } | |
| 217 | |||
| 218 | 74 | int AES67Manager::GetPtpDomain() { | |
| 219 |
1/2✓ Branch 0 taken 74 times.
✗ Branch 1 not taken.
|
74 | std::lock_guard<std::mutex> lock(m_configMutex); |
| 220 | 74 | return m_config.ptpDomain; | |
| 221 | 74 | } | |
| 222 | |||
| 223 | 1633 | std::string AES67Manager::GetPtpInterface() { | |
| 224 |
1/2✓ Branch 0 taken 1633 times.
✗ Branch 1 not taken.
|
1633 | std::lock_guard<std::mutex> lock(m_configMutex); |
| 225 |
1/2✓ Branch 0 taken 1633 times.
✗ Branch 1 not taken.
|
3266 | return m_config.ptpInterface; |
| 226 | 1633 | } | |
| 227 | |||
| 228 | // cfg80211 drivers expose phy80211, older wext drivers expose wireless/ | ||
| 229 | 1 | static bool IsWirelessInterface(const std::string& iface) { | |
| 230 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::string base = "/sys/class/net/" + iface; |
| 231 |
3/8✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 1 times.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
|
3 | return access((base + "/wireless").c_str(), F_OK) == 0 || |
| 232 |
3/8✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
|
3 | access((base + "/phy80211").c_str(), F_OK) == 0; |
| 233 | 1 | } | |
| 234 | |||
| 235 | // A blank ptpInterface used to be offered by the web page as "(Default)", but | ||
| 236 | // nothing downstream can act on it: ptp4l is exec'd with -i "" and exits | ||
| 237 | // immediately, and GetInterfaceIP("") returns whichever address getifaddrs | ||
| 238 | // hands back first -- on a Wi-Fi equipped Pi often wlan0, which cannot hold a | ||
| 239 | // PTP lock. The page no longer offers the blank, but configs written by | ||
| 240 | // older builds still carry one, so resolve it to a wired interface that has | ||
| 241 | // an IPv4 address, preferring eth0. | ||
| 242 | 1 | static std::string FirstWiredInterface() { | |
| 243 | 1 | struct ifaddrs* addrs = nullptr; | |
| 244 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (getifaddrs(&addrs) != 0) { |
| 245 | ✗ | return "eth0"; | |
| 246 | } | ||
| 247 | |||
| 248 | 1 | std::string first; | |
| 249 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | for (struct ifaddrs* ifa = addrs; ifa; ifa = ifa->ifa_next) { |
| 250 |
3/4✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 2 times.
|
5 | if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) continue; |
| 251 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | std::string name = ifa->ifa_name; |
| 252 |
7/10✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 1 times.
✓ Branch 8 taken 1 times.
✓ Branch 9 taken 1 times.
|
2 | if (name == "lo" || IsWirelessInterface(name)) continue; |
| 253 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | if (name == "eth0") { |
| 254 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | first = name; |
| 255 | 1 | break; | |
| 256 | } | ||
| 257 | ✗ | if (first.empty()) first = name; | |
| 258 |
2/3✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1 times.
|
2 | } |
| 259 | 1 | freeifaddrs(addrs); | |
| 260 |
3/10✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 1 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
|
1 | return first.empty() ? "eth0" : first; |
| 261 | 1 | } | |
| 262 | |||
| 263 | |||
| 264 | // Values that go into a gst_parse_launch() description unquoted. Anything | ||
| 265 | // outside these characters is refused at load: a space or '!' in a | ||
| 266 | // destination address re-parses as extra pipeline elements (a udpsrc feeding | ||
| 267 | // a filesink is a root file write), and no real address or interface name | ||
| 268 | // needs them. | ||
| 269 | 185 | static bool IsPipelineSafeHost(const std::string& host) { | |
| 270 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 185 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 185 times.
|
185 | if (host.empty() || host.size() > 253) { |
| 271 | ✗ | return false; | |
| 272 | } | ||
| 273 |
2/2✓ Branch 0 taken 1665 times.
✓ Branch 1 taken 185 times.
|
1850 | for (unsigned char c : host) { |
| 274 |
3/8✓ Branch 0 taken 555 times.
✓ Branch 1 taken 1110 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 555 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
|
1665 | if (!std::isalnum(c) && c != '.' && c != '-' && c != ':') { |
| 275 | ✗ | return false; | |
| 276 | } | ||
| 277 | } | ||
| 278 | 185 | return true; | |
| 279 | } | ||
| 280 | 185 | static bool IsPipelineSafeInterface(const std::string& iface) { | |
| 281 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 185 times.
|
185 | if (iface.size() > 15) { // IFNAMSIZ - 1 |
| 282 | ✗ | return false; | |
| 283 | } | ||
| 284 |
2/2✓ Branch 0 taken 21 times.
✓ Branch 1 taken 185 times.
|
206 | for (unsigned char c : iface) { |
| 285 |
1/10✗ Branch 0 not taken.
✓ Branch 1 taken 21 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
|
21 | if (!std::isalnum(c) && c != '.' && c != '-' && c != '_' && c != '@') { |
| 286 | ✗ | return false; | |
| 287 | } | ||
| 288 | } | ||
| 289 | 185 | return true; | |
| 290 | } | ||
| 291 | |||
| 292 | // A top-level setting of the wrong JSON type ("ptpEnabled": "yes") threw from | ||
| 293 | // LoadConfig() to std::terminate, at startup and again on every restart. Such | ||
| 294 | // a setting is reported and left at its default. | ||
| 295 | template <typename T> | ||
| 296 | 7056 | static T ConfigField(const Json::Value& root, const char* key, const T& def, T (Json::Value::*as)() const) { | |
| 297 | try { | ||
| 298 |
4/6✓ Branch 0 taken 3528 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3528 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3526 times.
✓ Branch 5 taken 2 times.
|
7064 | return (root.get(key, Json::Value(def)).*as)(); |
| 299 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
|
4 | } catch (const std::exception& e) { |
| 300 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
4 | WarningHolder::InvalidConfigEntry("pipewire-aes67-instances.json", |
| 301 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
12 | std::string(key) + ": " + e.what()); |
| 302 |
0/2✗ Branch 0 not taken.
✗ Branch 1 not taken.
|
4 | return def; |
| 303 | } | ||
| 304 | } | ||
| 305 | |||
| 306 | 148 | bool AES67Manager::LoadConfig() { | |
| 307 |
1/2✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
|
148 | Json::Value root; |
| 308 |
3/4✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 147 times.
|
148 | if (!LoadJsonFromFile(m_configPath, root, JsonRoot::Object)) { |
| 309 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogWarn(VB_MEDIAOUT, "AES67Manager: Failed to load config from %s\n", |
| 310 | m_configPath.c_str()); | ||
| 311 | 1 | return false; | |
| 312 | } | ||
| 313 | |||
| 314 | // Parse into a local config and publish it in one locked swap at the end. | ||
| 315 | // Filling m_config in place would let a status query on another thread | ||
| 316 | // observe a half-built instance list, or iterate the vector while | ||
| 317 | // push_back() reallocates it. | ||
| 318 | // Every fallback below is the member's own default rather than a literal. | ||
| 319 | // Spelling them out twice does not stay in step: nativeSourceRate and | ||
| 320 | // sourceBufferCopy were changed to false in the struct and kept a literal | ||
| 321 | // true here, so this function -- which always runs -- quietly put them | ||
| 322 | // back. The shipped behaviour was then the opposite of both the struct | ||
| 323 | // and the commit message that changed it. | ||
| 324 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | AES67Config cfg; |
| 325 |
3/4✓ Branch 0 taken 57 times.
✓ Branch 1 taken 90 times.
✓ Branch 2 taken 57 times.
✗ Branch 3 not taken.
|
147 | static const AES67Config kDefault; |
| 326 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.ptpEnabled = ConfigField(root, "ptpEnabled", kDefault.ptpEnabled, &Json::Value::asBool); |
| 327 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.ptpInterface = ConfigField(root, "ptpInterface", kDefault.ptpInterface, &Json::Value::asString); |
| 328 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 146 times.
|
147 | if (cfg.ptpInterface.empty()) { |
| 329 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | cfg.ptpInterface = FirstWiredInterface(); |
| 330 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "AES67Manager: no PTP interface configured, using %s\n", |
| 331 | cfg.ptpInterface.c_str()); | ||
| 332 | } | ||
| 333 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.ptpDomain = ConfigField(root, "ptpDomain", kDefault.ptpDomain, &Json::Value::asInt); |
| 334 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.ptpRole = ConfigField(root, "ptpRole", kDefault.ptpRole, &Json::Value::asString); |
| 335 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.ptpLockWaitMs = ConfigField(root, "ptpLockWaitMs", kDefault.ptpLockWaitMs, &Json::Value::asInt); |
| 336 | // The wait runs inside ApplyConfig(), which holds the apply lock and, at | ||
| 337 | // boot, fppd's startup; Shutdown() has to wait it out too. Anything past | ||
| 338 | // half a minute is not a wait for lock any more. | ||
| 339 | static constexpr int MAX_PTP_LOCK_WAIT_MS = 30000; | ||
| 340 |
2/4✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 147 times.
|
147 | if (cfg.ptpLockWaitMs < 0 || cfg.ptpLockWaitMs > MAX_PTP_LOCK_WAIT_MS) { |
| 341 | ✗ | int clamped = cfg.ptpLockWaitMs < 0 ? 0 : MAX_PTP_LOCK_WAIT_MS; | |
| 342 | ✗ | LogWarn(VB_MEDIAOUT, "AES67Manager: ptpLockWaitMs %d out of range, using %d\n", | |
| 343 | cfg.ptpLockWaitMs, clamped); | ||
| 344 | ✗ | cfg.ptpLockWaitMs = clamped; | |
| 345 | } | ||
| 346 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.ptpMediaClock = ConfigField(root, "ptpMediaClock", kDefault.ptpMediaClock, &Json::Value::asBool); |
| 347 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.sourcePacing = ConfigField(root, "sourcePacing", kDefault.sourcePacing, &Json::Value::asBool); |
| 348 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.sinkPacing = ConfigField(root, "sinkPacing", kDefault.sinkPacing, &Json::Value::asBool); |
| 349 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.sinkPacingMs = ConfigField(root, "sinkPacingMs", kDefault.sinkPacingMs, &Json::Value::asInt); |
| 350 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.driftResample = ConfigField(root, "driftResample", kDefault.driftResample, &Json::Value::asBool); |
| 351 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.pipelineStats = ConfigField(root, "pipelineStats", kDefault.pipelineStats, &Json::Value::asBool); |
| 352 | 147 | cfg.nativeSourceRate = | |
| 353 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | ConfigField(root, "nativeSourceRate", kDefault.nativeSourceRate, &Json::Value::asBool); |
| 354 | 147 | cfg.sourceBufferCopy = | |
| 355 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | ConfigField(root, "sourceBufferCopy", kDefault.sourceBufferCopy, &Json::Value::asBool); |
| 356 | 147 | cfg.sourceMinBuffers = | |
| 357 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | ConfigField(root, "sourceMinBuffers", kDefault.sourceMinBuffers, &Json::Value::asInt); |
| 358 | 147 | cfg.splitClockDomains = | |
| 359 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | ConfigField(root, "splitClockDomains", kDefault.splitClockDomains, &Json::Value::asBool); |
| 360 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.targetLeadMs = ConfigField(root, "targetLeadMs", kDefault.targetLeadMs, &Json::Value::asInt); |
| 361 | 147 | cfg.sourceSilenceFloor = | |
| 362 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | ConfigField(root, "sourceSilenceFloor", kDefault.sourceSilenceFloor, &Json::Value::asBool); |
| 363 | 147 | cfg.sourcePtpGroup = | |
| 364 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | ConfigField(root, "sourcePtpGroup", kDefault.sourcePtpGroup, &Json::Value::asBool); |
| 365 | cfg.sourcePtpGroupName = | ||
| 366 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | ConfigField(root, "sourcePtpGroupName", kDefault.sourcePtpGroupName, &Json::Value::asString); |
| 367 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.sourceAsync = ConfigField(root, "sourceAsync", kDefault.sourceAsync, &Json::Value::asBool); |
| 368 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.rateMatch = ConfigField(root, "rateMatch", kDefault.rateMatch, &Json::Value::asBool); |
| 369 | 147 | cfg.rateMatchToleranceNs = | |
| 370 | 294 | (guint64)ConfigField(root, "rateMatchToleranceNs", | |
| 371 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | (Json::UInt64)kDefault.rateMatchToleranceNs, &Json::Value::asUInt64); |
| 372 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | cfg.adaptiveResample = ConfigField(root, "adaptiveResample", false, &Json::Value::asBool); |
| 373 | 147 | cfg.requireGroupSource = | |
| 374 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | ConfigField(root, "requireGroupSource", kDefault.requireGroupSource, &Json::Value::asBool); |
| 375 | |||
| 376 | // A domain outside 0-127 is not representable in the PTP header; an | ||
| 377 | // unknown role would silently fall through to the "auto" branch below, | ||
| 378 | // so normalise both here where we can tell the user about it. | ||
| 379 |
3/4✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 146 times.
|
147 | if (cfg.ptpDomain < 0 || cfg.ptpDomain > 127) { |
| 380 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogWarn(VB_MEDIAOUT, "AES67Manager: Invalid PTP domain %d, using %d\n", |
| 381 | cfg.ptpDomain, AES67::DEFAULT_PTP_DOMAIN); | ||
| 382 | 1 | cfg.ptpDomain = AES67::DEFAULT_PTP_DOMAIN; | |
| 383 | } | ||
| 384 |
8/10✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 143 times.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 2 times.
✓ Branch 8 taken 1 times.
✓ Branch 9 taken 146 times.
|
149 | if (cfg.ptpRole != "auto" && cfg.ptpRole != "follower" && |
| 385 |
3/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 1 times.
|
2 | cfg.ptpRole != "master") { |
| 386 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogWarn(VB_MEDIAOUT, "AES67Manager: Unknown PTP role '%s', using 'auto'\n", |
| 387 | cfg.ptpRole.c_str()); | ||
| 388 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | cfg.ptpRole = "auto"; |
| 389 | } | ||
| 390 | |||
| 391 |
6/12✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 147 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 147 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 147 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 147 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 147 times.
✗ Branch 11 not taken.
|
147 | if (root.isMember("instances") && root["instances"].isArray()) { |
| 392 |
8/14✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 147 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 147 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 186 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 186 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 333 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 186 times.
✓ Branch 13 taken 147 times.
|
333 | for (const auto& instJson : root["instances"]) { |
| 393 |
1/2✓ Branch 0 taken 186 times.
✗ Branch 1 not taken.
|
186 | AES67Instance inst; |
| 394 | // A value of the wrong JSON type threw from here to | ||
| 395 | // std::terminate, at startup; skip such an instance instead. | ||
| 396 | try { | ||
| 397 |
4/6✓ Branch 0 taken 186 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 186 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✓ Branch 5 taken 1 times.
|
188 | inst.id = instJson.get("id", 0).asInt(); |
| 398 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
|
185 | inst.name = instJson.get("name", "AES67").asString(); |
| 399 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
|
185 | inst.enabled = instJson.get("enabled", true).asBool(); |
| 400 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
|
185 | inst.mode = instJson.get("mode", "send").asString(); |
| 401 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
|
185 | inst.multicastIP = instJson.get("multicastIP", AES67::DEFAULT_MULTICAST_IP).asString(); |
| 402 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
|
185 | inst.port = instJson.get("port", AES67::DEFAULT_PORT).asInt(); |
| 403 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
|
185 | inst.channels = instJson.get("channels", AES67::DEFAULT_CHANNELS).asInt(); |
| 404 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
|
185 | inst.interface = instJson.get("interface", "").asString(); |
| 405 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
|
185 | inst.sessionName = instJson.get("sessionName", inst.name).asString(); |
| 406 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
|
185 | inst.latency = instJson.get("latency", AES67::DEFAULT_LATENCY_MS).asInt(); |
| 407 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
|
185 | inst.sapEnabled = instJson.get("sapEnabled", true).asBool(); |
| 408 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 185 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 185 times.
✗ Branch 5 not taken.
|
185 | inst.ptime = instJson.get("ptime", AES67::DEFAULT_PTIME_MS).asInt(); |
| 409 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | } catch (const std::exception& e) { |
| 410 |
3/6✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
|
3 | WarningHolder::InvalidConfigEntry("pipewire-aes67-instances.json", e.what()); |
| 411 | 1 | continue; | |
| 412 | 1 | } | |
| 413 | |||
| 414 | // Validate ptime | ||
| 415 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 185 times.
|
185 | if (!AES67::IsValidPtime(inst.ptime)) { |
| 416 | ✗ | inst.ptime = AES67::DEFAULT_PTIME_MS; | |
| 417 | } | ||
| 418 | |||
| 419 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 182 times.
|
185 | if (inst.channels > AES67::MAX_SUPPORTED_CHANNELS) { |
| 420 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | LogWarn(VB_MEDIAOUT, |
| 421 | "AES67Manager: instance '%s' asks for %d channels, but the " | ||
| 422 | "audio graph only carries %d -- the stream would not start " | ||
| 423 | "at all. Using %d.\n", | ||
| 424 | inst.name.c_str(), inst.channels, | ||
| 425 | AES67::MAX_SUPPORTED_CHANNELS, AES67::MAX_SUPPORTED_CHANNELS); | ||
| 426 | 3 | inst.channels = AES67::MAX_SUPPORTED_CHANNELS; | |
| 427 | } | ||
| 428 | |||
| 429 | // ...then against the channel count. These are independent | ||
| 430 | // dropdowns in the UI with no interlock, and most of their | ||
| 431 | // combinations describe a packet that cannot be sent: 4ms of L24 | ||
| 432 | // is 2304 bytes at 4 channels and 4608 at 8, against a 1440-byte | ||
| 433 | // limit. Left alone the payloader quietly splits each one, so | ||
| 434 | // the receiver gets several short packets per ptime instead of | ||
| 435 | // the single one a=ptime promises. Clamping is better than | ||
| 436 | // failing -- the stream stays up and stays legal -- but it is a | ||
| 437 | // config error, so say so at a level the user will see. | ||
| 438 | 185 | int maxPtime = AES67::MaxPtimeForChannels(inst.channels); | |
| 439 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 182 times.
|
185 | if (inst.ptime > maxPtime) { |
| 440 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | LogWarn(VB_MEDIAOUT, |
| 441 | "AES67Manager: instance '%s' asks for %dms ptime at %d channels, " | ||
| 442 | "which is %d bytes of L24 -- over the %d-byte packet limit. " | ||
| 443 | "Using %dms instead.\n", | ||
| 444 | inst.name.c_str(), inst.ptime, inst.channels, | ||
| 445 | AES67::L24PayloadBytes(inst.ptime, inst.channels), | ||
| 446 | AES67::MAX_RTP_PACKET_BYTES, maxPtime); | ||
| 447 | 3 | inst.ptime = maxPtime; | |
| 448 | } | ||
| 449 | |||
| 450 |
3/6✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 185 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 185 times.
|
185 | if (!IsPipelineSafeHost(inst.multicastIP) || !IsPipelineSafeInterface(inst.interface)) { |
| 451 | ✗ | LogErr(VB_MEDIAOUT, "AES67Manager: instance '%s' has an invalid address or interface, skipping it\n", | |
| 452 | inst.name.c_str()); | ||
| 453 | ✗ | WarningHolder::AddWarningTimeout(300, AES67::WARNING_ID_PIPELINE, "AES67: stream '" + inst.name + | |
| 454 | ✗ | "' has an invalid address or interface and was not started"); | |
| 455 | ✗ | continue; | |
| 456 | } | ||
| 457 | |||
| 458 |
1/2✓ Branch 0 taken 185 times.
✗ Branch 1 not taken.
|
185 | cfg.instances.push_back(inst); |
| 459 |
2/2✓ Branch 0 taken 185 times.
✓ Branch 1 taken 1 times.
|
186 | } |
| 460 | } | ||
| 461 | |||
| 462 | // These flags are not independent, and a partial set fails quietly in ways | ||
| 463 | // that look like a different bug entirely. A tester enabling only | ||
| 464 | // splitClockDomains and sourceSilenceFloor reported a stream bursting a | ||
| 465 | // whole PipeWire quantum at a time (23 packets back-to-back every 23.22ms) | ||
| 466 | // -- the payload, sequence and timestamps were all perfect, so nothing | ||
| 467 | // pointed at the config. Say so at load rather than let the measurement | ||
| 468 | // be blamed. | ||
| 469 |
3/4✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 146 times.
|
147 | if (cfg.sourceSilenceFloor && !cfg.driftResample) { |
| 470 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogWarn(VB_MEDIAOUT, |
| 471 | "AES67Manager: sourceSilenceFloor is set but driftResample is not, " | ||
| 472 | "so it does nothing -- the gap fill runs inside the drift probe. " | ||
| 473 | "Set driftResample too.\n"); | ||
| 474 | } | ||
| 475 |
3/4✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 146 times.
|
147 | if (cfg.splitClockDomains && !cfg.ptpMediaClock) { |
| 476 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogWarn(VB_MEDIAOUT, |
| 477 | "AES67Manager: splitClockDomains is set but ptpMediaClock is not. " | ||
| 478 | "Without a PTP media clock there is nothing to split, so the whole " | ||
| 479 | "block is skipped -- no PTP anchor and no lead servo. " | ||
| 480 | "ptpMediaClock defaults on, so something has turned it off.\n"); | ||
| 481 | } | ||
| 482 |
3/4✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 81 times.
✓ Branch 3 taken 66 times.
|
147 | if (cfg.splitClockDomains && !cfg.ptpEnabled) { |
| 483 |
1/2✓ Branch 0 taken 81 times.
✗ Branch 1 not taken.
|
81 | LogWarn(VB_MEDIAOUT, |
| 484 | "AES67Manager: splitClockDomains is set but PTP is disabled, so the " | ||
| 485 | "media clock falls back to an undisciplined system clock and the RTP " | ||
| 486 | "timestamps mean nothing to a receiver.\n"); | ||
| 487 | } | ||
| 488 |
3/4✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 146 times.
|
147 | if (cfg.splitClockDomains && !cfg.sinkPacing) { |
| 489 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogWarn(VB_MEDIAOUT, |
| 490 | "AES67Manager: splitClockDomains is set but sinkPacing is not. " | ||
| 491 | "The sink will transmit a whole quantum at once instead of pacing " | ||
| 492 | "it, so receivers see bursts rather than one packet per ptime. " | ||
| 493 | "Set sinkPacing too.\n"); | ||
| 494 | } | ||
| 495 | |||
| 496 |
3/4✓ Branch 0 taken 66 times.
✓ Branch 1 taken 81 times.
✓ Branch 2 taken 147 times.
✗ Branch 3 not taken.
|
147 | LogInfo(VB_MEDIAOUT, "AES67Manager: Loaded config with %d instances, PTP=%s interface=%s domain=%d role=%s\n", |
| 497 | (int)cfg.instances.size(), | ||
| 498 | cfg.ptpEnabled ? "enabled" : "disabled", | ||
| 499 | cfg.ptpInterface.c_str(), | ||
| 500 | cfg.ptpDomain, | ||
| 501 | cfg.ptpRole.c_str()); | ||
| 502 | |||
| 503 | { | ||
| 504 |
1/2✓ Branch 0 taken 147 times.
✗ Branch 1 not taken.
|
147 | std::lock_guard<std::mutex> lock(m_configMutex); |
| 505 | 147 | m_config = std::move(cfg); | |
| 506 | 147 | } | |
| 507 | 147 | return true; | |
| 508 | 148 | } | |
| 509 | |||
| 510 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 511 | // ApplyConfig — called from PHP API and boot sequence | ||
| 512 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 513 | // Retract the pipeline warnings for the kinds of stream that are no longer | ||
| 514 | // failing. CreateSendPipeline()/CreateRecvPipeline() raise these, and nothing | ||
| 515 | // used to take them back: a warning with no timeout stays in the banner until | ||
| 516 | // fppd restarts, so a user who fixed the cause (usually routing a group to the | ||
| 517 | // sender) and re-applied successfully was still told the stream had failed. | ||
| 518 | // Called from every path that ends with no failing pipeline of that kind -- | ||
| 519 | // including the ones that end with no pipelines at all. | ||
| 520 | 149 | static void ClearAES67PipelineWarnings(bool clearSend, bool clearRecv) { | |
| 521 |
2/2✓ Branch 0 taken 35 times.
✓ Branch 1 taken 114 times.
|
149 | if (clearSend) { |
| 522 |
3/6✓ Branch 0 taken 35 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 35 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 35 times.
✗ Branch 5 not taken.
|
140 | WarningHolder::RemoveWarning(AES67::WARNING_ID_PIPELINE, AES67::WARNING_SEND_FAILED); |
| 523 | } | ||
| 524 |
2/2✓ Branch 0 taken 143 times.
✓ Branch 1 taken 6 times.
|
149 | if (clearRecv) { |
| 525 |
3/6✓ Branch 0 taken 143 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 143 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 143 times.
✗ Branch 5 not taken.
|
572 | WarningHolder::RemoveWarning(AES67::WARNING_ID_PIPELINE, AES67::WARNING_RECV_FAILED); |
| 526 | } | ||
| 527 | 149 | } | |
| 528 | |||
| 529 | // Retract the "PTP could not start" warnings. Mirrors | ||
| 530 | // ClearAES67PipelineWarnings() above, and exists for the same reason: these | ||
| 531 | // have no timeout either, so the only thing that ever takes one back is a call | ||
| 532 | // like this one. Without it a user who installed linuxptp, freed the | ||
| 533 | // interface or fixed the config path was still told PTP could not start, for | ||
| 534 | // as long as fppd stayed up. | ||
| 535 | // | ||
| 536 | // Called once ptp4l is confirmed running, and again on the way down -- a | ||
| 537 | // daemon that is not running cannot be failing to start, and InitPTP() raises | ||
| 538 | // whichever of these still applies on the next attempt. | ||
| 539 | 238 | static void ClearAES67PtpStartWarnings() { | |
| 540 |
3/6✓ Branch 0 taken 238 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 238 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 238 times.
✗ Branch 5 not taken.
|
952 | WarningHolder::RemoveWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_NOT_INSTALLED); |
| 541 |
3/6✓ Branch 0 taken 238 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 238 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 238 times.
✗ Branch 5 not taken.
|
952 | WarningHolder::RemoveWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_CONF_WRITE); |
| 542 |
3/6✓ Branch 0 taken 238 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 238 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 238 times.
✗ Branch 5 not taken.
|
952 | WarningHolder::RemoveWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_SPAWN_FAILED); |
| 543 |
3/6✓ Branch 0 taken 238 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 238 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 238 times.
✗ Branch 5 not taken.
|
714 | WarningHolder::RemoveWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_START_FAILED); |
| 544 | 238 | } | |
| 545 | |||
| 546 | 1976 | bool AES67Manager::ApplyConfig() { | |
| 547 | // Serialize against concurrent ApplyConfig()/Shutdown()/Cleanup() calls - | ||
| 548 | // see m_applyMutex. Without this, two callers can both get past the SAP | ||
| 549 | // thread joins below and both reach the std::thread assignments at the | ||
| 550 | // end, where assigning over a joinable thread calls std::terminate(). | ||
| 551 |
1/2✓ Branch 0 taken 1976 times.
✗ Branch 1 not taken.
|
1976 | std::lock_guard<std::mutex> applyLock(m_applyMutex); |
| 552 |
1/2✓ Branch 0 taken 1976 times.
✗ Branch 1 not taken.
|
3952 | return ApplyConfigLocked(); |
| 553 | 1976 | } | |
| 554 | |||
| 555 | 1978 | bool AES67Manager::ApplyConfigLocked() { | |
| 556 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1978 times.
|
1978 | if (m_stopping.load()) { |
| 557 | ✗ | return false; | |
| 558 | } | ||
| 559 | // Any rebuild scheduled before this apply is now redundant. | ||
| 560 | 1978 | m_applyGeneration++; | |
| 561 | |||
| 562 |
2/2✓ Branch 0 taken 1829 times.
✓ Branch 1 taken 149 times.
|
1978 | if (!m_initialized.load()) { |
| 563 |
2/4✓ Branch 0 taken 1829 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1829 times.
|
1829 | if (!Init()) { |
| 564 | ✗ | return false; | |
| 565 | } | ||
| 566 | // Init() may return true but skip initialization (e.g. wrong backend). | ||
| 567 | // If m_initialized is still false, there's nothing to do. | ||
| 568 |
1/2✓ Branch 0 taken 1829 times.
✗ Branch 1 not taken.
|
1829 | if (!m_initialized.load()) { |
| 569 | 1829 | return true; | |
| 570 | } | ||
| 571 | } | ||
| 572 | |||
| 573 | // Stop existing pipelines and SAP threads. ptp4l is left for now: it is | ||
| 574 | // only restarted below if the new config changes what it runs with. | ||
| 575 |
1/2✓ Branch 0 taken 149 times.
✗ Branch 1 not taken.
|
149 | StopWorkerThreads(); |
| 576 |
1/2✓ Branch 0 taken 149 times.
✗ Branch 1 not taken.
|
149 | StopAllPipelines(); |
| 577 | // Re-resolved below against the new config: the PTP interface (and so the | ||
| 578 | // PHC backing the media clock) may have changed. | ||
| 579 |
1/2✓ Branch 0 taken 149 times.
✗ Branch 1 not taken.
|
149 | ReleaseMediaClock(); |
| 580 | |||
| 581 |
3/4✓ Branch 0 taken 149 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 148 times.
|
149 | if (!FileExists(m_configPath)) { |
| 582 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "AES67Manager: No config file, nothing to apply\n"); |
| 583 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | ShutdownPTP(); |
| 584 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | ClearAES67PipelineWarnings(true, true); |
| 585 | 1 | m_active.store(false); | |
| 586 | 1 | return true; | |
| 587 | } | ||
| 588 | |||
| 589 |
3/4✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 147 times.
|
148 | if (!LoadConfig()) { |
| 590 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | ShutdownPTP(); |
| 591 | 1 | return false; | |
| 592 | } | ||
| 593 | |||
| 594 | // Count enabled instances | ||
| 595 | 147 | int enabledCount = 0; | |
| 596 |
2/2✓ Branch 0 taken 185 times.
✓ Branch 1 taken 147 times.
|
332 | for (const auto& inst : m_config.instances) { |
| 597 |
2/2✓ Branch 0 taken 179 times.
✓ Branch 1 taken 6 times.
|
185 | if (inst.enabled) enabledCount++; |
| 598 | } | ||
| 599 | |||
| 600 |
2/2✓ Branch 0 taken 4 times.
✓ Branch 1 taken 143 times.
|
147 | if (enabledCount == 0) { |
| 601 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | LogInfo(VB_MEDIAOUT, "AES67Manager: No enabled instances\n"); |
| 602 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | ShutdownPTP(); |
| 603 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | ClearAES67PipelineWarnings(true, true); |
| 604 | 4 | m_active.store(false); | |
| 605 | 4 | return true; | |
| 606 | } | ||
| 607 | |||
| 608 | // Initialize PTP if enabled. A rebuild -- one stream's error, a watchdog | ||
| 609 | // pass, an Apply that changed only the streams -- keeps a running ptp4l | ||
| 610 | // whose settings are unchanged: restarting it takes every receiver in the | ||
| 611 | // domain off the clock, all of them when this box is the grandmaster. | ||
| 612 |
2/2✓ Branch 0 taken 63 times.
✓ Branch 1 taken 80 times.
|
143 | if (m_config.ptpEnabled) { |
| 613 |
2/6✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
63 | PtpParams wanted{ m_config.ptpInterface, m_config.ptpDomain, m_config.ptpRole }; |
| 614 |
8/10✓ Branch 0 taken 11 times.
✓ Branch 1 taken 52 times.
✓ Branch 2 taken 11 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 11 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 7 times.
✓ Branch 7 taken 4 times.
✓ Branch 8 taken 7 times.
✓ Branch 9 taken 56 times.
|
63 | if (m_ptpInitialized && IsPtp4lRunning() && wanted == m_ptpStartedWith) { |
| 615 |
1/2✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
|
7 | LogInfo(VB_MEDIAOUT, "AES67Manager: PTP settings unchanged, keeping ptp4l (PID %d)\n", |
| 616 | (int)m_ptp4lPid); | ||
| 617 |
1/2✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
|
7 | AnchorToPtpLock(); |
| 618 | } else { | ||
| 619 |
1/2✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
|
56 | ShutdownPTP(); |
| 620 |
3/4✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 22 times.
✓ Branch 3 taken 34 times.
|
56 | if (!InitPTP()) { |
| 621 |
1/2✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
|
22 | LogWarn(VB_MEDIAOUT, "AES67Manager: PTP init failed, continuing without PTP clock\n"); |
| 622 | } | ||
| 623 | } | ||
| 624 | 63 | } else { | |
| 625 |
1/2✓ Branch 0 taken 80 times.
✗ Branch 1 not taken.
|
80 | ShutdownPTP(); |
| 626 | } | ||
| 627 | |||
| 628 | // Create pipelines for each enabled instance | ||
| 629 | 143 | bool anySend = false; | |
| 630 | 143 | bool anyRecv = false; | |
| 631 | 143 | bool anySAP = false; | |
| 632 | 143 | bool sendFailed = false; | |
| 633 | 143 | bool recvFailed = false; | |
| 634 | 143 | int failedStarts = 0; | |
| 635 | // Senders held idle because nothing feeds them -- published to | ||
| 636 | // m_deferredSenders below, once the whole pass has run. | ||
| 637 | 143 | std::map<int, std::string> deferred; | |
| 638 | |||
| 639 |
2/2✓ Branch 0 taken 182 times.
✓ Branch 1 taken 143 times.
|
325 | for (const auto& inst : m_config.instances) { |
| 640 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 179 times.
|
182 | if (!inst.enabled) continue; |
| 641 | |||
| 642 |
5/8✓ Branch 0 taken 179 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✓ Branch 3 taken 170 times.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 9 times.
|
179 | bool wantSend = (inst.mode == "send" || inst.mode == "both"); |
| 643 |
5/8✓ Branch 0 taken 179 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 170 times.
✓ Branch 3 taken 9 times.
✓ Branch 4 taken 170 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 170 times.
|
179 | bool wantRecv = (inst.mode == "receive" || inst.mode == "both"); |
| 644 | |||
| 645 |
2/2✓ Branch 0 taken 170 times.
✓ Branch 1 taken 9 times.
|
179 | if (wantSend) { |
| 646 | // Nothing feeds this sender, so starting it would cost 30 seconds | ||
| 647 | // of blocked apply and end in FAILURE. Hold it instead -- see | ||
| 648 | // AES67Config::requireGroupSource. | ||
| 649 |
2/4✓ Branch 0 taken 170 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 170 times.
✗ Branch 3 not taken.
|
170 | const std::string nodeName = SafeNodeName(inst.name) + "_send"; |
| 650 |
7/8✓ Branch 0 taken 44 times.
✓ Branch 1 taken 126 times.
✓ Branch 2 taken 44 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 43 times.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 169 times.
|
170 | if (m_config.requireGroupSource && !PipeWireGraphFeedsNode(nodeName)) { |
| 651 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, |
| 652 | "AES67 send [%d] '%s': nothing in the audio graph feeds %s, " | ||
| 653 | "holding the stream idle. Add it to an Audio Output Group " | ||
| 654 | "and apply that config to start it.\n", | ||
| 655 | inst.id, inst.name.c_str(), nodeName.c_str()); | ||
| 656 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | deferred[inst.id] = |
| 657 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | "Waiting for audio — not a member of any enabled Audio Output Group"; |
| 658 |
3/4✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✓ Branch 3 taken 149 times.
|
169 | } else if (CreateSendPipeline(inst)) { |
| 659 | 20 | anySend = true; | |
| 660 | } else { | ||
| 661 | 149 | sendFailed = true; | |
| 662 | 149 | failedStarts++; | |
| 663 | } | ||
| 664 | 170 | } | |
| 665 | |||
| 666 |
2/2✓ Branch 0 taken 9 times.
✓ Branch 1 taken 170 times.
|
179 | if (wantRecv) { |
| 667 |
3/4✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✓ Branch 3 taken 6 times.
|
9 | if (CreateRecvPipeline(inst)) { |
| 668 | 3 | anyRecv = true; | |
| 669 | } else { | ||
| 670 | 6 | recvFailed = true; | |
| 671 | 6 | failedStarts++; | |
| 672 | } | ||
| 673 | } | ||
| 674 | |||
| 675 |
2/2✓ Branch 0 taken 45 times.
✓ Branch 1 taken 134 times.
|
179 | if (inst.sapEnabled) { |
| 676 | 45 | anySAP = true; | |
| 677 | } | ||
| 678 | } | ||
| 679 | |||
| 680 | { | ||
| 681 |
1/2✓ Branch 0 taken 143 times.
✗ Branch 1 not taken.
|
143 | std::lock_guard<std::mutex> lock(m_pipelineMutex); |
| 682 | 143 | m_deferredSenders = std::move(deferred); | |
| 683 | 143 | m_failedStarts = failedStarts; | |
| 684 | 143 | } | |
| 685 | |||
| 686 | // Every enabled pipeline of that kind started, so whatever raised the | ||
| 687 | // warning last time has been dealt with. Note this runs after the create | ||
| 688 | // calls that raise it, so a still-failing kind keeps its warning. A held | ||
| 689 | // sender does not count as a failure -- nothing was attempted, and there | ||
| 690 | // is nothing wrong with the stream itself. | ||
| 691 |
1/2✓ Branch 0 taken 143 times.
✗ Branch 1 not taken.
|
143 | ClearAES67PipelineWarnings(!sendFailed, !recvFailed); |
| 692 | |||
| 693 | // Start the drift control loop if anything is sending on the PTP clock | ||
| 694 |
4/6✓ Branch 0 taken 20 times.
✓ Branch 1 taken 123 times.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 20 times.
|
143 | if (anySend && m_config.ptpMediaClock && m_config.adaptiveResample) { |
| 695 | ✗ | m_driftRunning.store(true); | |
| 696 | ✗ | m_driftThread = std::thread(&AES67Manager::DriftControlLoop, this); | |
| 697 | } | ||
| 698 | |||
| 699 | // Start SAP announcer if any send instances have SAP enabled | ||
| 700 |
4/4✓ Branch 0 taken 45 times.
✓ Branch 1 taken 98 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 44 times.
|
143 | if (anySAP && anySend) { |
| 701 | 1 | m_sapAnnounceRunning.store(true); | |
| 702 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | m_sapAnnounceThread = std::thread(&AES67Manager::SAPAnnounceLoop, this); |
| 703 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "AES67Manager: SAP announcer started\n"); |
| 704 | } | ||
| 705 | |||
| 706 | // Start SAP receiver if any receive instances or SAP enabled | ||
| 707 |
2/2✓ Branch 0 taken 45 times.
✓ Branch 1 taken 98 times.
|
143 | if (anySAP) { |
| 708 | 45 | m_sapRecvRunning.store(true); | |
| 709 |
1/2✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
|
45 | m_sapRecvThread = std::thread(&AES67Manager::SAPReceiveLoop, this); |
| 710 |
1/2✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
|
45 | LogInfo(VB_MEDIAOUT, "AES67Manager: SAP receiver started\n"); |
| 711 | } | ||
| 712 | |||
| 713 | // The pipelines are anchored now, so this is where the step detector's | ||
| 714 | // reference belongs -- taking it here rather than at its first check means | ||
| 715 | // a clock that steps in the next 30 seconds is caught on that check | ||
| 716 | // instead of the one after it. | ||
| 717 |
1/2✓ Branch 0 taken 143 times.
✗ Branch 1 not taken.
|
143 | ResetPtpStepReference(); |
| 718 | |||
| 719 | 143 | m_watchdogRunning.store(true); | |
| 720 |
1/2✓ Branch 0 taken 143 times.
✗ Branch 1 not taken.
|
143 | m_watchdogThread = std::thread(&AES67Manager::WatchdogLoop, this); |
| 721 | |||
| 722 | 143 | m_active.store(true); | |
| 723 |
1/2✓ Branch 0 taken 143 times.
✗ Branch 1 not taken.
|
143 | LogInfo(VB_MEDIAOUT, "AES67Manager: Applied config — %d send, %d receive pipelines\n", |
| 724 | (int)m_sendPipelines.size(), (int)m_recvPipelines.size()); | ||
| 725 | 143 | return true; | |
| 726 | 143 | } | |
| 727 | |||
| 728 | // Stops and joins the drift, SAP and watchdog threads. Caller holds | ||
| 729 | // m_applyMutex. Never call it from one of those threads. | ||
| 730 | 208 | void AES67Manager::StopWorkerThreads() { | |
| 731 | 208 | m_sapAnnounceRunning.store(false); | |
| 732 | 208 | m_sapRecvRunning.store(false); | |
| 733 | 208 | m_driftRunning.store(false); | |
| 734 | 208 | m_watchdogRunning.store(false); | |
| 735 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 208 times.
|
208 | if (m_driftThread.joinable()) { |
| 736 | ✗ | m_driftThread.join(); | |
| 737 | } | ||
| 738 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 207 times.
|
208 | if (m_sapAnnounceThread.joinable()) { |
| 739 | 1 | m_sapAnnounceThread.join(); | |
| 740 | } | ||
| 741 |
2/2✓ Branch 0 taken 45 times.
✓ Branch 1 taken 163 times.
|
208 | if (m_sapRecvThread.joinable()) { |
| 742 | 45 | m_sapRecvThread.join(); | |
| 743 | } | ||
| 744 |
2/2✓ Branch 0 taken 143 times.
✓ Branch 1 taken 65 times.
|
208 | if (m_watchdogThread.joinable()) { |
| 745 | 143 | m_watchdogThread.join(); | |
| 746 | } | ||
| 747 | 208 | } | |
| 748 | |||
| 749 | // Pipeline recovery, ptp4l supervision, the PTP step detector and the | ||
| 750 | // no-lock warning review, every SAP_ANNOUNCE_INTERVAL_S. This used to run | ||
| 751 | // inside the SAP announcer, which only exists when something sends with SAP | ||
| 752 | // on: a receive-only box, or one with SAP turned off, got none of it -- a | ||
| 753 | // dead pipeline or ptp4l stayed dead and the no-lock warning never cleared. | ||
| 754 | 143 | void AES67Manager::WatchdogLoop() { | |
| 755 |
1/2✓ Branch 0 taken 146 times.
✗ Branch 1 not taken.
|
146 | while (m_watchdogRunning.load()) { |
| 756 |
6/6✓ Branch 0 taken 627 times.
✓ Branch 1 taken 9 times.
✓ Branch 2 taken 490 times.
✓ Branch 3 taken 137 times.
✓ Branch 4 taken 490 times.
✓ Branch 5 taken 146 times.
|
636 | for (int i = 0; i < AES67::SAP_ANNOUNCE_INTERVAL_S && m_watchdogRunning.load(); i++) { |
| 757 |
1/2✓ Branch 0 taken 490 times.
✗ Branch 1 not taken.
|
490 | std::this_thread::sleep_for(std::chrono::seconds(1)); |
| 758 | } | ||
| 759 |
2/2✓ Branch 0 taken 137 times.
✓ Branch 1 taken 9 times.
|
146 | if (!m_watchdogRunning.load()) { |
| 760 | 137 | break; | |
| 761 | } | ||
| 762 | // Evaluate both before deciding: PollPipelinesWatchdog() also drains | ||
| 763 | // each pipeline's bus, so short-circuiting past it would leave errors | ||
| 764 | // unread until the next pass. | ||
| 765 | 9 | const bool ptpStepped = CheckPtpWatchdog(); | |
| 766 |
5/6✓ Branch 0 taken 3 times.
✓ Branch 1 taken 6 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
✓ Branch 4 taken 6 times.
✓ Branch 5 taken 3 times.
|
9 | if (PollPipelinesWatchdog() || ptpStepped) { |
| 767 | // ApplyConfig() joins this thread, so the rebuild runs on its own | ||
| 768 | // thread once this one has stopped. | ||
| 769 | 6 | LogWarn(VB_MEDIAOUT, "AES67 watchdog: rebuild needed, scheduling ApplyConfig\n"); | |
| 770 | 6 | m_watchdogRunning.store(false); | |
| 771 | 6 | ScheduleRebuild(); | |
| 772 | 6 | break; | |
| 773 | } | ||
| 774 | } | ||
| 775 | 143 | } | |
| 776 | |||
| 777 | 6 | void AES67Manager::ScheduleRebuild() { | |
| 778 |
1/2✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
|
6 | std::lock_guard<std::mutex> rl(m_rebuildMutex); |
| 779 |
5/6✓ Branch 0 taken 5 times.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 5 times.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 5 times.
|
6 | if (m_stopping.load() || m_rebuildRunning.load()) { |
| 780 | // Shutting down, or a rebuild is already on its way. Never join a | ||
| 781 | // running one here: its ApplyConfig() may be waiting to join the | ||
| 782 | // watchdog thread that is calling this. | ||
| 783 | 1 | return; | |
| 784 | } | ||
| 785 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 5 times.
|
5 | if (m_rebuildThread.joinable()) { |
| 786 | ✗ | m_rebuildThread.join(); // finished: returns at once | |
| 787 | } | ||
| 788 | 5 | uint64_t generation = m_applyGeneration.load(); | |
| 789 | 5 | m_rebuildRunning.store(true); | |
| 790 | 5 | m_rebuildThread = std::thread([this, generation]() { | |
| 791 | // Brief delay to let the SAP announce thread finish exiting and | ||
| 792 | // become joinable. | ||
| 793 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | std::this_thread::sleep_for(std::chrono::milliseconds(500)); |
| 794 | { | ||
| 795 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | std::lock_guard<std::mutex> applyLock(m_applyMutex); |
| 796 | // Nothing to do if anything applied, cleaned up or shut down in | ||
| 797 | // the meantime -- it either did this rebuild's work or undid it | ||
| 798 | // on purpose. | ||
| 799 |
6/6✓ Branch 0 taken 3 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 3 times.
|
8 | if (!m_stopping.load() && generation == m_applyGeneration.load()) { |
| 800 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | ApplyConfigLocked(); |
| 801 | } | ||
| 802 | 5 | } | |
| 803 | 5 | m_rebuildRunning.store(false); | |
| 804 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
10 | }); |
| 805 |
2/2✓ Branch 0 taken 5 times.
✓ Branch 1 taken 1 times.
|
6 | } |
| 806 | |||
| 807 | 1 | void AES67Manager::Cleanup() { | |
| 808 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "AES67Manager: Cleanup\n"); |
| 809 | |||
| 810 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::lock_guard<std::mutex> applyLock(m_applyMutex); |
| 811 | // A watchdog rebuild already scheduled must not bring back what this | ||
| 812 | // tears down. | ||
| 813 | 1 | m_applyGeneration++; | |
| 814 | |||
| 815 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | StopWorkerThreads(); |
| 816 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | StopAllPipelines(); |
| 817 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | ShutdownPTP(); |
| 818 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | ReleaseMediaClock(); |
| 819 | |||
| 820 | // Nothing is running any more, so a "failed to start" warning describes a | ||
| 821 | // stream that no longer exists. | ||
| 822 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | ClearAES67PipelineWarnings(true, true); |
| 823 | |||
| 824 | 1 | m_active.store(false); | |
| 825 | 1 | } | |
| 826 | |||
| 827 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 828 | // PTP clock management — uses ptp4l (linuxptp) as an IEEE 1588 PTP daemon | ||
| 829 | // | ||
| 830 | // GStreamer's gst-ptp-helper is a PTP *client only* — it listens for PTP | ||
| 831 | // Announce/Sync messages but never originates them. For AES67, FPP must | ||
| 832 | // participate in PTP as either grandmaster or follower (via BMCA). | ||
| 833 | // | ||
| 834 | // We launch ptp4l as a managed subprocess with an AES67-appropriate config: | ||
| 835 | // - Configurable domain (default 0), two-step, announce every 2s, 8 syncs/sec | ||
| 836 | // - Hardware timestamping when available (/dev/ptp0) | ||
| 837 | // - BMCA priority driven by the "ptpRole" setting -- see AES67Config | ||
| 838 | // - DSCP EF (46) on PTP event/general messages -- see AES67::PTP_DSCP | ||
| 839 | // | ||
| 840 | // phc2sys is deliberately not launched -- see the note in InitPTP(). The | ||
| 841 | // pipeline reads PTP time directly rather than via the system clock. | ||
| 842 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 843 | |||
| 844 | // Absolute path so we do not depend on whatever PATH systemd handed fppd -- | ||
| 845 | // the ptp4l presence check below is absolute for the same reason. | ||
| 846 | static const char* PMC_BINARY = "/usr/sbin/pmc"; | ||
| 847 | static const char* PTP4L_BINARY = "/usr/sbin/ptp4l"; | ||
| 848 | |||
| 849 | // Does this ptp4l understand the given config key? | ||
| 850 | // | ||
| 851 | // linuxptp 4.0 renamed slaveOnly to clientOnly (and masterOnly to serverOnly) | ||
| 852 | // and kept the old spellings as deprecated aliases, which 4.2 announces on | ||
| 853 | // every start: "option slaveOnly is deprecated, please use clientOnly | ||
| 854 | // instead". Neither spelling is safe to hard-code. An unknown key is a hard | ||
| 855 | // config-parse failure -- ptp4l exits before it does anything at all -- so | ||
| 856 | // writing clientOnly takes PTP down entirely on a pre-4.0 install, while | ||
| 857 | // writing slaveOnly is fine today but relies on an alias that has already | ||
| 858 | // been announced as going away. | ||
| 859 | // | ||
| 860 | // Asking is better than either. ptp4l parses its config file before it looks | ||
| 861 | // at anything else, so a config carrying nothing but the key under test, run | ||
| 862 | // with no -i, answers the question without starting PTP, touching the network | ||
| 863 | // or opening a clock. Measured on linuxptp 4.2: | ||
| 864 | // | ||
| 865 | // clientOnly -> "no interface specified" (255) | ||
| 866 | // notARealOption -> "unknown option notARealOption at line 2 ..." (254) | ||
| 867 | // | ||
| 868 | // The exit codes differ too, but the message is the part that is actually | ||
| 869 | // documented behaviour, so that is what this reads. Anything unexpected -- | ||
| 870 | // no output at all, ptp4l missing, popen failing -- is treated as "not | ||
| 871 | // supported", which falls back to the spelling that works on every release | ||
| 872 | // shipped so far rather than the one that only works on new ones. | ||
| 873 | 28 | static bool Ptp4lSupportsOption(const std::string& option) { | |
| 874 | struct PipeCloser { | ||
| 875 |
1/2✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
|
28 | void operator()(FILE* f) const { if (f) pclose(f); } |
| 876 | }; | ||
| 877 |
1/2✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
|
28 | const std::string probePath = "/tmp/fpp-ptp4l-probe.conf"; |
| 878 | { | ||
| 879 |
1/2✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
|
28 | std::ofstream probe(probePath); |
| 880 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 28 times.
|
28 | if (!probe.is_open()) { |
| 881 | ✗ | return false; | |
| 882 | } | ||
| 883 |
3/6✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 28 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 28 times.
✗ Branch 5 not taken.
|
28 | probe << "[global]\n" << option << "\t\t1\n"; |
| 884 |
1/2✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
|
28 | } |
| 885 |
4/8✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 28 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 28 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 28 times.
✗ Branch 7 not taken.
|
28 | std::string cmd = std::string(PTP4L_BINARY) + " -f " + probePath + " 2>&1"; |
| 886 | 28 | std::string output; | |
| 887 | { | ||
| 888 |
1/2✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
|
28 | std::unique_ptr<FILE, PipeCloser> pipe(popen(cmd.c_str(), "r")); |
| 889 |
1/2✓ Branch 0 taken 28 times.
✗ Branch 1 not taken.
|
28 | if (pipe) { |
| 890 | char buffer[256]; | ||
| 891 |
3/4✓ Branch 0 taken 55 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 27 times.
✓ Branch 3 taken 28 times.
|
55 | while (fgets(buffer, sizeof(buffer), pipe.get()) != nullptr) { |
| 892 |
1/2✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
|
27 | output += buffer; |
| 893 | } | ||
| 894 | } | ||
| 895 | 28 | } | |
| 896 | 28 | unlink(probePath.c_str()); | |
| 897 | |||
| 898 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 27 times.
|
28 | if (output.empty()) { |
| 899 | 1 | return false; | |
| 900 | } | ||
| 901 | 27 | return output.find("unknown option") == std::string::npos; | |
| 902 | 28 | } | |
| 903 | |||
| 904 | // The spelling of the follower-role key this ptp4l accepts. Probed once: the | ||
| 905 | // binary does not change under a running fppd, and InitPTP() writes the config | ||
| 906 | // up to three times per start. | ||
| 907 | 39 | static const char* Ptp4lClientOnlyKey() { | |
| 908 | static const char* key = | ||
| 909 |
7/12✓ Branch 0 taken 28 times.
✓ Branch 1 taken 11 times.
✓ Branch 2 taken 28 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 28 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 28 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 26 times.
✓ Branch 9 taken 2 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
|
95 | Ptp4lSupportsOption("clientOnly") ? "clientOnly" : "slaveOnly"; |
| 910 | 39 | return key; | |
| 911 | } | ||
| 912 | |||
| 913 | 39 | bool AES67Manager::WritePtpConf(const std::string& path, bool hwTimestamping, bool includeDscp) { | |
| 914 |
1/2✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
|
39 | std::ofstream conf(path); |
| 915 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 39 times.
|
39 | if (!conf.is_open()) { |
| 916 | ✗ | LogErr(VB_MEDIAOUT, "AES67Manager: Cannot write PTP config to %s\n", path.c_str()); | |
| 917 | ✗ | WarningHolder::AddWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_CONF_WRITE); | |
| 918 | ✗ | return false; | |
| 919 | } | ||
| 920 | |||
| 921 | // BMCA behaviour. "auto" deliberately runs at a worse priority1 than the | ||
| 922 | // 128 that professional gear ships with: a tie on priority is broken by | ||
| 923 | // clock identity (lowest MAC wins), which is how an FPP box ends up | ||
| 924 | // grandmastering a Q-SYS or Yamaha domain it should have been following. | ||
| 925 | 39 | int priority1 = AES67::PTP_PRIORITY_AUTO; | |
| 926 | 39 | bool slaveOnly = false; | |
| 927 |
3/4✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 38 times.
|
39 | if (m_config.ptpRole == "master") { |
| 928 | 1 | priority1 = AES67::PTP_PRIORITY_PREFER_MASTER; | |
| 929 |
3/4✓ Branch 0 taken 38 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 36 times.
|
38 | } else if (m_config.ptpRole == "follower") { |
| 930 | 2 | slaveOnly = true; | |
| 931 | } | ||
| 932 | |||
| 933 | // AES67 media profile: announce every 2s, sync 8/sec, delay req 1/sec. | ||
| 934 | conf << "[global]\n" | ||
| 935 |
3/6✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 39 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 39 times.
✗ Branch 5 not taken.
|
39 | << "domainNumber\t\t" << m_config.ptpDomain << "\n" |
| 936 | << "twoStepFlag\t\t1\n" | ||
| 937 |
8/14✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 39 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 39 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 39 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 39 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 2 times.
✓ Branch 11 taken 37 times.
✓ Branch 12 taken 39 times.
✗ Branch 13 not taken.
|
39 | << Ptp4lClientOnlyKey() << "\t\t" << (slaveOnly ? 1 : 0) << "\n" |
| 938 |
3/6✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 39 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 39 times.
✗ Branch 5 not taken.
|
39 | << "priority1\t\t" << priority1 << "\n" |
| 939 | << "priority2\t\t128\n" | ||
| 940 | << "clockClass\t\t248\n" | ||
| 941 | << "clockAccuracy\t\t0xFE\n" | ||
| 942 | << "offsetScaledLogVariance\t0xFFFF\n" | ||
| 943 | << "logAnnounceInterval\t1\n" // 1 announce/2s — matches announceReceiptTimeout cadence | ||
| 944 | << "logSyncInterval\t\t-3\n" // 8/sec (AES67 recommends -3) | ||
| 945 | << "logMinDelayReqInterval\t0\n" | ||
| 946 | << "announceReceiptTimeout\t3\n" | ||
| 947 | << "syncReceiptTimeout\t0\n" | ||
| 948 | // Step rather than slew a large offset. linuxptp defaults | ||
| 949 | // step_threshold to 0, meaning it only ever steps on the very first | ||
| 950 | // correction (first_step_threshold, 20us) and slews everything after. | ||
| 951 | // Initial lock is therefore fine -- a follower does converge on real | ||
| 952 | // hardware -- but a grandmaster changeover or a GM time jump after | ||
| 953 | // that leaves a large offset to be slewed out at the servo's frequency | ||
| 954 | // limit, which takes hours for anything past a second. The published | ||
| 955 | // AES67 profile sets this for the same reason ("converge faster when | ||
| 956 | // time jumps"). Harmless as grandmaster, where there is nothing to | ||
| 957 | // step towards. | ||
| 958 | << "step_threshold\t\t1\n" | ||
| 959 | << "transportSpecific\t0x0\n" | ||
| 960 | << "network_transport\tUDPv4\n" | ||
| 961 | << "delay_mechanism\t\tE2E\n" | ||
| 962 |
19/36✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 39 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 39 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 39 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 39 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 39 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 39 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 39 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 39 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 39 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 39 times.
✗ Branch 21 not taken.
✓ Branch 22 taken 39 times.
✗ Branch 23 not taken.
✓ Branch 24 taken 39 times.
✗ Branch 25 not taken.
✓ Branch 26 taken 39 times.
✗ Branch 27 not taken.
✓ Branch 28 taken 39 times.
✗ Branch 29 not taken.
✓ Branch 30 taken 37 times.
✓ Branch 31 taken 2 times.
✓ Branch 32 taken 39 times.
✗ Branch 33 not taken.
✓ Branch 34 taken 39 times.
✗ Branch 35 not taken.
|
39 | << "time_stamping\t\t" << (hwTimestamping ? "hardware" : "software") << "\n"; |
| 963 | |||
| 964 |
2/2✓ Branch 0 taken 38 times.
✓ Branch 1 taken 1 times.
|
39 | if (includeDscp) { |
| 965 |
2/4✓ Branch 0 taken 38 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 38 times.
✗ Branch 3 not taken.
|
38 | conf << "dscp_event\t\t" << AES67::PTP_DSCP << "\n" // EF (46) -- Sync/Delay_Req |
| 966 |
4/8✓ Branch 0 taken 38 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 38 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 38 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 38 times.
✗ Branch 7 not taken.
|
38 | << "dscp_general\t\t" << AES67::PTP_DSCP << "\n"; // EF (46) -- Announce/Follow_Up/etc. |
| 967 | } | ||
| 968 |
1/2✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
|
39 | conf << "# AES67 uses L2 multicast on 224.0.1.129/224.0.0.107\n"; |
| 969 |
1/2✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
|
39 | conf.close(); |
| 970 | 39 | return true; | |
| 971 | 39 | } | |
| 972 | |||
| 973 | // A pidfd for our own child, or -1 with errno set (ESRCH: already gone; | ||
| 974 | // ENOSYS: kernel or platform without pidfds, where callers fall back to | ||
| 975 | // the PID). | ||
| 976 | 39 | static int OpenPidfd(pid_t pid) { | |
| 977 | #if defined(__linux__) && defined(SYS_pidfd_open) | ||
| 978 | 39 | return (int)syscall(SYS_pidfd_open, pid, 0); // close-on-exec by default | |
| 979 | #else | ||
| 980 | (void)pid; | ||
| 981 | errno = ENOSYS; | ||
| 982 | return -1; | ||
| 983 | #endif | ||
| 984 | } | ||
| 985 | |||
| 986 | // Whether the child is alive. A pidfd becomes readable when its process | ||
| 987 | // exits. Without one, the PID has to belong to a process with the | ||
| 988 | // expected name: a dead child's PID may already be someone else's. | ||
| 989 | 479 | static bool ChildAlive(pid_t pid, int pidfd, const char* name) { | |
| 990 | #if defined(__linux__) | ||
| 991 |
1/2✓ Branch 0 taken 479 times.
✗ Branch 1 not taken.
|
479 | if (pidfd >= 0) { |
| 992 | 479 | struct pollfd pfd = { pidfd, POLLIN, 0 }; | |
| 993 |
1/2✓ Branch 0 taken 479 times.
✗ Branch 1 not taken.
|
479 | return poll(&pfd, 1, 0) == 0; |
| 994 | } | ||
| 995 | #endif | ||
| 996 | ✗ | if (pid <= 0 || kill(pid, 0) != 0) { | |
| 997 | ✗ | return false; | |
| 998 | } | ||
| 999 | ✗ | std::ifstream f("/proc/" + std::to_string((int)pid) + "/comm"); | |
| 1000 | ✗ | if (!f.is_open()) { | |
| 1001 | ✗ | return true; // no procfs (macOS): the signal probe is all there is | |
| 1002 | } | ||
| 1003 | ✗ | std::string comm; | |
| 1004 | ✗ | std::getline(f, comm); | |
| 1005 | ✗ | return comm == name; | |
| 1006 | ✗ | } | |
| 1007 | |||
| 1008 | 34 | static void SignalChild(pid_t pid, int pidfd, int sig, const char* name) { | |
| 1009 | #if defined(__linux__) && defined(SYS_pidfd_send_signal) | ||
| 1010 |
1/2✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
|
34 | if (pidfd >= 0) { |
| 1011 | 34 | syscall(SYS_pidfd_send_signal, pidfd, sig, nullptr, 0); | |
| 1012 | 34 | return; | |
| 1013 | } | ||
| 1014 | #endif | ||
| 1015 | ✗ | if (ChildAlive(pid, -1, name)) { | |
| 1016 | ✗ | kill(pid, sig); | |
| 1017 | } | ||
| 1018 | } | ||
| 1019 | |||
| 1020 | 4 | void AES67Manager::ForgetPtp4l() { | |
| 1021 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | std::lock_guard<std::mutex> lock(m_ptp4lProcMutex); |
| 1022 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 1 times.
|
4 | if (m_ptp4lPidfd >= 0) { |
| 1023 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | close(m_ptp4lPidfd); |
| 1024 | 3 | m_ptp4lPidfd = -1; | |
| 1025 | } | ||
| 1026 | 4 | m_ptp4lPid = -1; | |
| 1027 | 4 | } | |
| 1028 | |||
| 1029 | 39 | bool AES67Manager::StartPtp4l(bool hwTimestamping, bool includeDscp) { | |
| 1030 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 39 times.
|
39 | if (!WritePtpConf(m_ptpConfPath, hwTimestamping, includeDscp)) { |
| 1031 | ✗ | return false; | |
| 1032 | } | ||
| 1033 | |||
| 1034 | 39 | pid_t pid = fork(); | |
| 1035 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 78 times.
|
78 | if (pid < 0) { |
| 1036 | ✗ | LogErr(VB_MEDIAOUT, "AES67Manager: fork() failed for ptp4l: %s\n", FPPstrerror(errno)); | |
| 1037 | ✗ | WarningHolder::AddWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_SPAWN_FAILED); | |
| 1038 | ✗ | return false; | |
| 1039 | } | ||
| 1040 |
2/2✓ Branch 0 taken 39 times.
✓ Branch 1 taken 39 times.
|
78 | if (pid == 0) { |
| 1041 | // Child process — exec ptp4l (-m: log to stderr) | ||
| 1042 | 39 | execlp("ptp4l", "ptp4l", | |
| 1043 | "-i", m_config.ptpInterface.c_str(), | ||
| 1044 | "-f", m_ptpConfPath.c_str(), | ||
| 1045 | "-m", | ||
| 1046 | nullptr); | ||
| 1047 | // If exec fails | ||
| 1048 | 39 | _exit(127); | |
| 1049 | } | ||
| 1050 | 39 | int pidfd = OpenPidfd(pid); | |
| 1051 |
1/4✗ Branch 0 not taken.
✓ Branch 1 taken 39 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
|
39 | bool gone = pidfd < 0 && errno == ESRCH; |
| 1052 | { | ||
| 1053 |
1/2✓ Branch 0 taken 39 times.
✗ Branch 1 not taken.
|
39 | std::lock_guard<std::mutex> lock(m_ptp4lProcMutex); |
| 1054 | 39 | m_ptp4lPid = pid; | |
| 1055 | 39 | m_ptp4lPidfd = pidfd; | |
| 1056 | 39 | } | |
| 1057 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 39 times.
|
39 | if (gone) { |
| 1058 | // Exited and was reaped before we could even look. | ||
| 1059 | ✗ | ForgetPtp4l(); | |
| 1060 | ✗ | return false; | |
| 1061 | } | ||
| 1062 | |||
| 1063 | // Give ptp4l a moment to start, then check it did not exit immediately | ||
| 1064 | // (bad config key, no hardware timestamping, interface down, ...). | ||
| 1065 | 39 | usleep(500000); // 500ms | |
| 1066 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 36 times.
|
39 | if (!IsPtp4lRunning()) { |
| 1067 | 3 | ForgetPtp4l(); | |
| 1068 | 3 | return false; | |
| 1069 | } | ||
| 1070 | |||
| 1071 | 36 | m_ptpHwTimestamping.store(hwTimestamping); | |
| 1072 |
2/4✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✗ Branch 3 not taken.
|
36 | LogInfo(VB_MEDIAOUT, "AES67Manager: ptp4l started (PID %d) on %s — %s timestamping%s, domain %d, role %s\n", |
| 1073 | (int)m_ptp4lPid, m_config.ptpInterface.c_str(), | ||
| 1074 | hwTimestamping ? "hardware" : "software", | ||
| 1075 | includeDscp ? "" : ", DSCP disabled", | ||
| 1076 | m_config.ptpDomain, m_config.ptpRole.c_str()); | ||
| 1077 | 36 | return true; | |
| 1078 | } | ||
| 1079 | |||
| 1080 | 62 | bool AES67Manager::InitPTP() { | |
| 1081 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 62 times.
|
62 | if (m_ptpInitialized) { |
| 1082 | ✗ | return true; | |
| 1083 | } | ||
| 1084 | |||
| 1085 | // Check if ptp4l binary exists | ||
| 1086 |
2/2✓ Branch 0 taken 25 times.
✓ Branch 1 taken 37 times.
|
62 | if (!FileExists(PTP4L_BINARY)) { |
| 1087 | 25 | LogErr(VB_MEDIAOUT, "AES67Manager: ptp4l not found — install linuxptp package\n"); | |
| 1088 |
2/4✓ Branch 0 taken 25 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✗ Branch 3 not taken.
|
75 | WarningHolder::AddWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_NOT_INSTALLED); |
| 1089 | 25 | return false; | |
| 1090 | } | ||
| 1091 | |||
| 1092 | 37 | m_ptpConfPath = "/tmp/fpp-ptp4l.conf"; | |
| 1093 | |||
| 1094 | 37 | LogInfo(VB_MEDIAOUT, "AES67Manager: Starting ptp4l on %s (AES67 profile, domain %d, role %s)\n", | |
| 1095 | m_config.ptpInterface.c_str(), m_config.ptpDomain, m_config.ptpRole.c_str()); | ||
| 1096 | |||
| 1097 | // Start attempts, most capable first. The last one drops the DSCP keys: | ||
| 1098 | // they are only understood by linuxptp >= 2.0, and an unknown key is a | ||
| 1099 | // hard config-parse failure, which would otherwise take PTP down entirely | ||
| 1100 | // on an older install rather than just losing the QoS marking. | ||
| 1101 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 36 times.
|
37 | if (!StartPtp4l(true, true)) { |
| 1102 | 1 | LogErr(VB_MEDIAOUT, "AES67Manager: ptp4l exited immediately — " | |
| 1103 | "check hardware timestamping support on %s\n", | ||
| 1104 | m_config.ptpInterface.c_str()); | ||
| 1105 | 1 | LogInfo(VB_MEDIAOUT, "AES67Manager: Retrying ptp4l with software timestamping\n"); | |
| 1106 | |||
| 1107 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (!StartPtp4l(false, true)) { |
| 1108 | 1 | LogInfo(VB_MEDIAOUT, "AES67Manager: Retrying ptp4l without DSCP marking " | |
| 1109 | "(linuxptp may predate dscp_event/dscp_general)\n"); | ||
| 1110 | |||
| 1111 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (!StartPtp4l(false, false)) { |
| 1112 | 1 | LogErr(VB_MEDIAOUT, "AES67Manager: ptp4l failed to start\n"); | |
| 1113 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
3 | WarningHolder::AddWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_START_FAILED); |
| 1114 | 1 | ForgetPtp4l(); | |
| 1115 | 1 | return false; | |
| 1116 | } | ||
| 1117 | } | ||
| 1118 | } | ||
| 1119 | |||
| 1120 | // ptp4l is up, so none of the four "PTP could not start" warnings describes | ||
| 1121 | // anything current any more -- including one raised by an earlier attempt | ||
| 1122 | // on this same call, since the cascade above retries with weaker options. | ||
| 1123 | 36 | ClearAES67PtpStartWarnings(); | |
| 1124 | |||
| 1125 | // phc2sys is deliberately NOT started. | ||
| 1126 | // | ||
| 1127 | // Its only job was to copy PTP time onto the system clock, and nothing | ||
| 1128 | // needs that any more: the media clock reads the PTP time source directly | ||
| 1129 | // (the PHC with hardware timestamping, CLOCK_REALTIME with software), so | ||
| 1130 | // the pipeline no longer depends on the system clock tracking PTP. | ||
| 1131 | // | ||
| 1132 | // Running it is actively harmful against real grandmasters. PTP's | ||
| 1133 | // timescale is whatever the grandmaster distributes, and Dante/Brooklyn | ||
| 1134 | // devices commonly distribute an arbitrary epoch rather than wall time -- | ||
| 1135 | // one measured at ~4411 seconds, i.e. time since the device booted. | ||
| 1136 | // "phc2sys -a -r" faithfully slaved CLOCK_REALTIME to that and dragged the | ||
| 1137 | // Pi's clock back to 1970 (reported on issue #2848), taking the scheduler, | ||
| 1138 | // logs and anything else on wall time with it. | ||
| 1139 | // | ||
| 1140 | // A box that wants PTP-disciplined system time should run phc2sys from | ||
| 1141 | // systemd with an offset appropriate to its grandmaster; that is a | ||
| 1142 | // deliberate site decision, not something an audio stream should impose. | ||
| 1143 | |||
| 1144 | // Passive reader for the grandmaster's IP address -- see | ||
| 1145 | // PtpAnnounceListenLoop(). Started only once ptp4l is up, so it lives | ||
| 1146 | // exactly as long as the domain membership it is reporting on. | ||
| 1147 |
1/2✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
|
36 | if (!m_ptpAnnounceThread.joinable()) { |
| 1148 | 36 | m_ptpAnnounceRunning.store(true); | |
| 1149 |
1/2✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
|
36 | m_ptpAnnounceThread = std::thread(&AES67Manager::PtpAnnounceListenLoop, this); |
| 1150 | } | ||
| 1151 | |||
| 1152 | 36 | AnchorToPtpLock(); | |
| 1153 |
2/6✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 36 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
36 | m_ptpStartedWith = PtpParams{ m_config.ptpInterface, m_config.ptpDomain, m_config.ptpRole }; |
| 1154 | |||
| 1155 | 36 | m_ptpInitialized = true; | |
| 1156 | 36 | LogInfo(VB_MEDIAOUT, "AES67Manager: PTP initialized — ptp4l PID %d on %s\n", | |
| 1157 | (int)m_ptp4lPid, m_config.ptpInterface.c_str()); | ||
| 1158 | 36 | return true; | |
| 1159 | } | ||
| 1160 | |||
| 1161 | 43 | void AES67Manager::AnchorToPtpLock() { | |
| 1162 | // Do not let anything anchor to the clock until it has stopped moving. | ||
| 1163 | // | ||
| 1164 | // ptp4l being alive is not the same as the clock being right: as a | ||
| 1165 | // follower the PHC free-runs until BMCA picks a grandmaster and the first | ||
| 1166 | // measurement steps it, which on #2848 was 6.81 hours later than the value | ||
| 1167 | // four streams had already anchored to. See AES67::PTP_LOCK_OFFSET_NS. | ||
| 1168 | // | ||
| 1169 | // Timing out is not fatal -- the streams start anyway, because a box with | ||
| 1170 | // no grandmaster on the domain yet should still come up and play -- but it | ||
| 1171 | // does mean the anchors are provisional, so say so, and leave the step | ||
| 1172 | // detector in CheckPtpWatchdog() to rebuild them when the clock does lock. | ||
| 1173 | 43 | bool locked = false; | |
| 1174 |
2/2✓ Branch 0 taken 10 times.
✓ Branch 1 taken 33 times.
|
43 | if (m_config.ptpLockWaitMs > 0) { |
| 1175 | 10 | locked = WaitForPtpLock(m_config.ptpLockWaitMs); | |
| 1176 |
2/2✓ Branch 0 taken 7 times.
✓ Branch 1 taken 3 times.
|
10 | if (!locked) { |
| 1177 |
2/4✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
|
7 | LogWarn(VB_MEDIAOUT, |
| 1178 | "AES67Manager: PTP did not settle within %dms (state %s) — " | ||
| 1179 | "starting anyway; the RTP timelines will be re-anchored if " | ||
| 1180 | "the clock steps once it locks\n", | ||
| 1181 | m_config.ptpLockWaitMs, GetPtp4lState().c_str()); | ||
| 1182 | 7 | RaisePtpNoLockWarning(); | |
| 1183 | } else { | ||
| 1184 | 3 | ClearPtpNoLockWarning(); | |
| 1185 | } | ||
| 1186 | } else { | ||
| 1187 | 33 | LogInfo(VB_MEDIAOUT, | |
| 1188 | "AES67Manager: ptpLockWaitMs is 0 — not waiting for PTP to " | ||
| 1189 | "lock before anchoring the RTP timelines\n"); | ||
| 1190 | } | ||
| 1191 | // With ptpMediaClock off nothing is anchored to PTP at all, so there is | ||
| 1192 | // no anchor for a late lock to invalidate and the question does not apply. | ||
| 1193 |
3/4✓ Branch 0 taken 40 times.
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 40 times.
|
43 | m_ptpLockedAtStart.store(locked || !m_config.ptpMediaClock); |
| 1194 | 43 | } | |
| 1195 | |||
| 1196 | // SIGTERM then SIGKILL, polling until the process is really gone. | ||
| 1197 | // | ||
| 1198 | // fppd installs SIGCHLD with SA_NOCLDWAIT (see fppd.cpp), so children are | ||
| 1199 | // reaped by init and waitpid() here fails with ECHILD immediately -- it never | ||
| 1200 | // actually waited. That let ApplyConfig() launch a replacement ptp4l while | ||
| 1201 | // the old one still held /var/run/ptp4l and the PTP ports, leaving two | ||
| 1202 | // daemons briefly running BMCA against each other on one interface. | ||
| 1203 | // | ||
| 1204 | // Signals go through the pidfd where there is one: under SA_NOCLDWAIT a | ||
| 1205 | // child that already died has given up its PID, and kill(pid) -- SIGKILL | ||
| 1206 | // included -- would land on whatever process took it. | ||
| 1207 | 404 | static void StopChildProcess(pid_t& pid, int& pidfd, const char* name) { | |
| 1208 | 404 | auto forget = [&]() { | |
| 1209 |
2/2✓ Branch 0 taken 36 times.
✓ Branch 1 taken 368 times.
|
404 | if (pidfd >= 0) { |
| 1210 | 36 | close(pidfd); | |
| 1211 | 36 | pidfd = -1; | |
| 1212 | } | ||
| 1213 | 404 | pid = -1; | |
| 1214 | 808 | }; | |
| 1215 |
2/2✓ Branch 0 taken 368 times.
✓ Branch 1 taken 36 times.
|
404 | if (pid <= 0) { |
| 1216 |
1/2✓ Branch 0 taken 368 times.
✗ Branch 1 not taken.
|
368 | forget(); |
| 1217 | 404 | return; | |
| 1218 | } | ||
| 1219 |
3/4✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 34 times.
|
36 | if (!ChildAlive(pid, pidfd, name)) { |
| 1220 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | LogInfo(VB_MEDIAOUT, "AES67Manager: %s (PID %d) had already exited\n", name, (int)pid); |
| 1221 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | forget(); |
| 1222 | 2 | return; | |
| 1223 | } | ||
| 1224 |
1/2✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
|
34 | LogInfo(VB_MEDIAOUT, "AES67Manager: Stopping %s (PID %d)\n", name, (int)pid); |
| 1225 |
1/2✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
|
34 | SignalChild(pid, pidfd, SIGTERM, name); |
| 1226 | |||
| 1227 | // Up to ~3s for a clean exit, then insist. | ||
| 1228 |
1/2✓ Branch 0 taken 68 times.
✗ Branch 1 not taken.
|
68 | for (int i = 0; i < 60; i++) { |
| 1229 | // Harmless no-op under SA_NOCLDWAIT; reaps the child if a future | ||
| 1230 | // change turns that off. | ||
| 1231 | 68 | int status = 0; | |
| 1232 |
1/2✓ Branch 0 taken 68 times.
✗ Branch 1 not taken.
|
68 | waitpid(pid, &status, WNOHANG); |
| 1233 |
3/4✓ Branch 0 taken 68 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 34 times.
✓ Branch 3 taken 34 times.
|
68 | if (!ChildAlive(pid, pidfd, name)) { |
| 1234 |
1/2✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
|
34 | forget(); |
| 1235 | 34 | return; | |
| 1236 | } | ||
| 1237 |
1/2✓ Branch 0 taken 34 times.
✗ Branch 1 not taken.
|
34 | usleep(50000); |
| 1238 | } | ||
| 1239 | |||
| 1240 | ✗ | LogWarn(VB_MEDIAOUT, "AES67Manager: %s (PID %d) did not exit, sending SIGKILL\n", | |
| 1241 | name, (int)pid); | ||
| 1242 | ✗ | SignalChild(pid, pidfd, SIGKILL, name); | |
| 1243 | ✗ | for (int i = 0; i < 20; i++) { | |
| 1244 | ✗ | int status = 0; | |
| 1245 | ✗ | waitpid(pid, &status, WNOHANG); | |
| 1246 | ✗ | if (!ChildAlive(pid, pidfd, name)) { | |
| 1247 | ✗ | break; | |
| 1248 | } | ||
| 1249 | ✗ | usleep(50000); | |
| 1250 | } | ||
| 1251 | ✗ | forget(); | |
| 1252 | } | ||
| 1253 | |||
| 1254 | 202 | void AES67Manager::ShutdownPTP() { | |
| 1255 | 202 | m_ptpAnnounceRunning.store(false); | |
| 1256 |
2/2✓ Branch 0 taken 36 times.
✓ Branch 1 taken 166 times.
|
202 | if (m_ptpAnnounceThread.joinable()) { |
| 1257 |
1/2✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
|
36 | m_ptpAnnounceThread.join(); |
| 1258 | } | ||
| 1259 | { | ||
| 1260 |
1/2✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
|
202 | std::lock_guard<std::mutex> lock(m_ptpAnnounceMutex); |
| 1261 | 202 | m_ptpAnnounceSources.clear(); | |
| 1262 | 202 | } | |
| 1263 | |||
| 1264 |
1/2✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
|
202 | StopChildProcess(m_phc2sysPid, m_phc2sysPidfd, "phc2sys"); |
| 1265 | { | ||
| 1266 | // out of the members first, so a reader never polls the pidfd as it | ||
| 1267 | // is closed; the wait for the child is made without the lock | ||
| 1268 | pid_t pid; | ||
| 1269 | int pidfd; | ||
| 1270 | { | ||
| 1271 |
1/2✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
|
202 | std::lock_guard<std::mutex> lock(m_ptp4lProcMutex); |
| 1272 | 202 | pid = m_ptp4lPid; | |
| 1273 | 202 | pidfd = m_ptp4lPidfd; | |
| 1274 | 202 | m_ptp4lPid = -1; | |
| 1275 | 202 | m_ptp4lPidfd = -1; | |
| 1276 | 202 | } | |
| 1277 |
1/2✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
|
202 | StopChildProcess(pid, pidfd, "ptp4l"); |
| 1278 | } | ||
| 1279 | 202 | m_ptpHwTimestamping.store(false); | |
| 1280 | |||
| 1281 |
2/2✓ Branch 0 taken 36 times.
✓ Branch 1 taken 166 times.
|
202 | if (!m_ptpConfPath.empty()) { |
| 1282 | 36 | unlink(m_ptpConfPath.c_str()); | |
| 1283 | 36 | m_ptpConfPath.clear(); | |
| 1284 | } | ||
| 1285 | 202 | m_ptpInitialized = false; | |
| 1286 | 202 | m_ptpLockedAtStart.store(false); | |
| 1287 | |||
| 1288 | // Nothing is anchored to PTP any more, so a banner saying the anchors are | ||
| 1289 | // provisional describes streams that no longer exist. InitPTP() raises it | ||
| 1290 | // again if the next start still cannot reach a lock -- and likewise for the | ||
| 1291 | // start failures, which cannot be true of a daemon we have just stopped. | ||
| 1292 |
1/2✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
|
202 | ClearPtpNoLockWarning(); |
| 1293 |
1/2✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
|
202 | ClearAES67PtpStartWarnings(); |
| 1294 | |||
| 1295 | { | ||
| 1296 |
1/2✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
|
202 | std::lock_guard<std::mutex> lock(m_ptpStepMutex); |
| 1297 | 202 | m_ptpMonotonicDeltaValid = false; | |
| 1298 | 202 | } | |
| 1299 | |||
| 1300 |
1/2✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
|
202 | std::lock_guard<std::mutex> lock(m_ptpCacheMutex); |
| 1301 |
1/2✓ Branch 0 taken 202 times.
✗ Branch 1 not taken.
|
202 | m_ptpCache = PtpQueryCache(); |
| 1302 | 202 | } | |
| 1303 | |||
| 1304 | 1853 | bool AES67Manager::IsPtp4lRunning() const { | |
| 1305 | // SA_NOCLDWAIT means a dead ptp4l leaves no zombie holding its slot, so | ||
| 1306 | // the PID can be recycled by an unrelated process; see ChildAlive(). | ||
| 1307 |
1/2✓ Branch 0 taken 1853 times.
✗ Branch 1 not taken.
|
1853 | std::lock_guard<std::mutex> lock(m_ptp4lProcMutex); |
| 1308 |
5/6✓ Branch 0 taken 375 times.
✓ Branch 1 taken 1478 times.
✓ Branch 2 taken 375 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 371 times.
✓ Branch 5 taken 4 times.
|
3706 | return m_ptp4lPid > 0 && ChildAlive(m_ptp4lPid, m_ptp4lPidfd, "ptp4l"); |
| 1309 | 1853 | } | |
| 1310 | |||
| 1311 | // Runs a `pmc` management query against ptp4l's UDS socket and returns the | ||
| 1312 | // raw text output, or an empty string if ptp4l isn't running / pmc fails. | ||
| 1313 | 138 | static std::string RunPmcQuery(const std::string& tlv, int domain) { | |
| 1314 | struct PipeCloser { | ||
| 1315 | 138 | void operator()(FILE* f) const { | |
| 1316 | // pclose() returns -1 under fppd's SA_NOCLDWAIT (init already | ||
| 1317 | // reaped the child); the output has been read by then, so the | ||
| 1318 | // status is of no use to us either way. | ||
| 1319 |
1/2✓ Branch 0 taken 138 times.
✗ Branch 1 not taken.
|
138 | if (f) pclose(f); |
| 1320 | 138 | } | |
| 1321 | }; | ||
| 1322 | // ptp4l silently drops a management message whose domainNumber does not | ||
| 1323 | // match its own -- even over the UDS socket -- so -d is not optional once | ||
| 1324 | // the domain is configurable. | ||
| 1325 |
2/4✓ Branch 0 taken 138 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 138 times.
✗ Branch 3 not taken.
|
276 | std::string cmd = std::string(PMC_BINARY) + " -u -b 0 -d " + |
| 1326 |
4/8✓ Branch 0 taken 138 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 138 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 138 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 138 times.
✗ Branch 7 not taken.
|
414 | std::to_string(domain) + " '" + tlv + "' 2>/dev/null"; |
| 1327 |
1/2✓ Branch 0 taken 138 times.
✗ Branch 1 not taken.
|
138 | std::unique_ptr<FILE, PipeCloser> pipe(popen(cmd.c_str(), "r")); |
| 1328 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 138 times.
|
138 | if (!pipe) { |
| 1329 | ✗ | return ""; | |
| 1330 | } | ||
| 1331 | 138 | std::string output; | |
| 1332 | char buffer[256]; | ||
| 1333 |
3/4✓ Branch 0 taken 1072 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 934 times.
✓ Branch 3 taken 138 times.
|
1072 | while (fgets(buffer, sizeof(buffer), pipe.get()) != nullptr) { |
| 1334 |
1/2✓ Branch 0 taken 934 times.
✗ Branch 1 not taken.
|
934 | output += buffer; |
| 1335 | } | ||
| 1336 | 138 | return output; | |
| 1337 | 138 | } | |
| 1338 | |||
| 1339 | // Reformats a pmc clockIdentity like "aabbcc.fffe.ddeeff" into the dashed | ||
| 1340 | // EUI-64 form used elsewhere in this file ("AA-BB-CC-FF-FE-DD-EE-FF"). | ||
| 1341 | 69 | static std::string FormatPmcClockId(const std::string& raw) { | |
| 1342 | 69 | std::string hex; | |
| 1343 |
2/2✓ Branch 0 taken 1242 times.
✓ Branch 1 taken 69 times.
|
1311 | for (char c : raw) { |
| 1344 |
3/4✓ Branch 0 taken 1104 times.
✓ Branch 1 taken 138 times.
✓ Branch 2 taken 1104 times.
✗ Branch 3 not taken.
|
1242 | if (c != '.') hex += c; |
| 1345 | } | ||
| 1346 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 69 times.
|
69 | if (hex.length() != 16) { |
| 1347 | ✗ | return raw; | |
| 1348 | } | ||
| 1349 | 69 | std::string dashed; | |
| 1350 |
2/2✓ Branch 0 taken 552 times.
✓ Branch 1 taken 69 times.
|
621 | for (size_t i = 0; i < hex.length(); i += 2) { |
| 1351 |
3/4✓ Branch 0 taken 483 times.
✓ Branch 1 taken 69 times.
✓ Branch 2 taken 483 times.
✗ Branch 3 not taken.
|
552 | if (i) dashed += '-'; |
| 1352 |
1/2✓ Branch 0 taken 552 times.
✗ Branch 1 not taken.
|
552 | dashed += (char)toupper((unsigned char)hex[i]); |
| 1353 |
1/2✓ Branch 0 taken 552 times.
✗ Branch 1 not taken.
|
552 | dashed += (char)toupper((unsigned char)hex[i + 1]); |
| 1354 | } | ||
| 1355 | 69 | return dashed; | |
| 1356 | 69 | } | |
| 1357 | |||
| 1358 | // True if a ptp4l portState string means "this node is the domain master". | ||
| 1359 | // PRE_MASTER is deliberately excluded -- it is a transitional state on the | ||
| 1360 | // way to MASTER, not a settled BMCA outcome. | ||
| 1361 | 853 | static bool IsGrandmasterPortState(const std::string& portState) { | |
| 1362 |
4/4✓ Branch 0 taken 850 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 849 times.
|
853 | return portState == "MASTER" || portState == "GRAND_MASTER"; |
| 1363 | } | ||
| 1364 | |||
| 1365 | // Runs both management queries at most once per PTP_QUERY_CACHE_MS and keeps | ||
| 1366 | // the parsed results. /aes67/status is HTTP-facing and the SAP thread polls | ||
| 1367 | // once a second while BMCA settles; without this each of those forks a pmc. | ||
| 1368 | 264 | void AES67Manager::RefreshPtpCache(bool force) { | |
| 1369 |
1/2✓ Branch 0 taken 264 times.
✗ Branch 1 not taken.
|
264 | std::lock_guard<std::mutex> lock(m_ptpCacheMutex); |
| 1370 | |||
| 1371 | 264 | auto now = std::chrono::steady_clock::now(); | |
| 1372 |
4/4✓ Branch 0 taken 207 times.
✓ Branch 1 taken 57 times.
✓ Branch 2 taken 195 times.
✓ Branch 3 taken 12 times.
|
264 | if (!force && m_ptpCache.valid) { |
| 1373 |
2/4✓ Branch 0 taken 195 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 195 times.
✗ Branch 3 not taken.
|
195 | auto age = std::chrono::duration_cast<std::chrono::milliseconds>(now - m_ptpCache.when); |
| 1374 |
2/2✓ Branch 0 taken 194 times.
✓ Branch 1 taken 1 times.
|
195 | if (age.count() < AES67::PTP_QUERY_CACHE_MS) { |
| 1375 | 194 | return; | |
| 1376 | } | ||
| 1377 | } | ||
| 1378 | |||
| 1379 |
1/2✓ Branch 0 taken 70 times.
✗ Branch 1 not taken.
|
70 | PtpQueryCache fresh; |
| 1380 | 70 | fresh.when = now; | |
| 1381 | |||
| 1382 |
3/4✓ Branch 0 taken 70 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 69 times.
|
70 | if (!IsPtp4lRunning()) { |
| 1383 | 1 | fresh.valid = true; | |
| 1384 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | fresh.portState = "not running"; |
| 1385 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | m_ptpCache = fresh; |
| 1386 | 1 | return; | |
| 1387 | } | ||
| 1388 | |||
| 1389 | // Grandmaster / offset | ||
| 1390 |
1/2✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
|
69 | int domain = GetPtpDomain(); |
| 1391 |
2/4✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 69 times.
✗ Branch 3 not taken.
|
69 | std::string output = RunPmcQuery("GET TIME_STATUS_NP", domain); |
| 1392 |
1/2✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
|
69 | std::istringstream iss(output); |
| 1393 | 69 | std::string line; | |
| 1394 |
4/6✓ Branch 0 taken 589 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 589 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 520 times.
✓ Branch 5 taken 69 times.
|
589 | while (std::getline(iss, line)) { |
| 1395 |
1/2✓ Branch 0 taken 520 times.
✗ Branch 1 not taken.
|
520 | std::istringstream ls(line); |
| 1396 | 520 | std::string key; | |
| 1397 |
1/2✓ Branch 0 taken 520 times.
✗ Branch 1 not taken.
|
520 | ls >> key; |
| 1398 |
3/4✓ Branch 0 taken 520 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 62 times.
✓ Branch 3 taken 458 times.
|
520 | if (key == "gmPresent") { |
| 1399 | 62 | std::string val; | |
| 1400 |
1/2✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
|
62 | ls >> val; |
| 1401 |
1/2✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
|
62 | fresh.gmPresent = (val == "true"); |
| 1402 |
6/8✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25 times.
✓ Branch 3 taken 37 times.
✓ Branch 4 taken 25 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 18 times.
✓ Branch 7 taken 7 times.
|
62 | fresh.gmPresentValid = (val == "true" || val == "false"); |
| 1403 | // Only ever raise validity here. gmIdentity sets it too, and a | ||
| 1404 | // malformed gmPresent must not retract what another field has | ||
| 1405 | // already established -- pmc happens to print gmPresent first | ||
| 1406 | // today, which is the only reason a plain assignment has not | ||
| 1407 | // bitten anyone. | ||
| 1408 |
2/2✓ Branch 0 taken 55 times.
✓ Branch 1 taken 7 times.
|
62 | if (fresh.gmPresentValid) { |
| 1409 | 55 | fresh.valid = true; | |
| 1410 | } | ||
| 1411 |
3/4✓ Branch 0 taken 458 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 69 times.
✓ Branch 3 taken 389 times.
|
520 | } else if (key == "gmIdentity") { |
| 1412 | 69 | std::string val; | |
| 1413 |
1/2✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
|
69 | ls >> val; |
| 1414 |
1/2✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
|
69 | if (!val.empty()) { |
| 1415 |
1/2✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
|
69 | fresh.gmIdentity = FormatPmcClockId(val); |
| 1416 | 69 | fresh.valid = true; | |
| 1417 | } | ||
| 1418 |
3/4✓ Branch 0 taken 389 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 44 times.
✓ Branch 3 taken 345 times.
|
458 | } else if (key == "master_offset") { |
| 1419 | 44 | std::string val; | |
| 1420 |
1/2✓ Branch 0 taken 44 times.
✗ Branch 1 not taken.
|
44 | ls >> val; |
| 1421 | try { | ||
| 1422 | 44 | size_t consumed = 0; | |
| 1423 |
2/2✓ Branch 0 taken 37 times.
✓ Branch 1 taken 7 times.
|
44 | fresh.offsetNs = std::stoll(val, &consumed); |
| 1424 | 37 | fresh.offsetValid = (consumed == val.size()); | |
| 1425 | 7 | } catch (...) { | |
| 1426 | 7 | fresh.offsetValid = false; | |
| 1427 |
1/2✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
|
7 | } |
| 1428 | 44 | } | |
| 1429 | 520 | } | |
| 1430 | |||
| 1431 | // Port state | ||
| 1432 |
2/4✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 69 times.
✗ Branch 3 not taken.
|
69 | std::string portOutput = RunPmcQuery("GET PORT_DATA_SET", domain); |
| 1433 |
1/2✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
|
69 | std::istringstream pss(portOutput); |
| 1434 |
3/6✓ Branch 0 taken 276 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 276 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 276 times.
✗ Branch 5 not taken.
|
276 | while (std::getline(pss, line)) { |
| 1435 |
1/2✓ Branch 0 taken 276 times.
✗ Branch 1 not taken.
|
276 | std::istringstream ls(line); |
| 1436 | 276 | std::string key; | |
| 1437 |
1/2✓ Branch 0 taken 276 times.
✗ Branch 1 not taken.
|
276 | ls >> key; |
| 1438 |
3/4✓ Branch 0 taken 276 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 69 times.
✓ Branch 3 taken 207 times.
|
276 | if (key == "portState") { |
| 1439 | 69 | std::string val; | |
| 1440 |
1/2✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
|
69 | ls >> val; |
| 1441 |
1/2✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
|
69 | if (!val.empty()) { |
| 1442 |
1/2✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
|
69 | fresh.portState = val; |
| 1443 | 69 | break; | |
| 1444 | } | ||
| 1445 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 69 times.
|
69 | } |
| 1446 |
4/4✓ Branch 0 taken 207 times.
✓ Branch 1 taken 69 times.
✓ Branch 2 taken 207 times.
✓ Branch 3 taken 69 times.
|
345 | } |
| 1447 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 69 times.
|
69 | if (fresh.portState.empty()) { |
| 1448 | ✗ | fresh.portState = "running (state unknown)"; | |
| 1449 | } | ||
| 1450 | |||
| 1451 |
1/2✓ Branch 0 taken 69 times.
✗ Branch 1 not taken.
|
69 | m_ptpCache = fresh; |
| 1452 |
4/4✓ Branch 0 taken 69 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 69 times.
✓ Branch 3 taken 195 times.
|
265 | } |
| 1453 | |||
| 1454 | 108 | std::string AES67Manager::GetPtp4lState() { | |
| 1455 |
1/2✓ Branch 0 taken 108 times.
✗ Branch 1 not taken.
|
108 | RefreshPtpCache(); |
| 1456 |
1/2✓ Branch 0 taken 108 times.
✗ Branch 1 not taken.
|
108 | std::lock_guard<std::mutex> lock(m_ptpCacheMutex); |
| 1457 |
1/2✓ Branch 0 taken 108 times.
✗ Branch 1 not taken.
|
216 | return m_ptpCache.portState; |
| 1458 | 108 | } | |
| 1459 | |||
| 1460 | // Is the PTP clock one that will not step under the RTP anchors? See | ||
| 1461 | // AES67::PTP_LOCK_OFFSET_NS for what went wrong without asking this and why | ||
| 1462 | // the offset -- not the port state -- is the signal. | ||
| 1463 | // | ||
| 1464 | // Two callers: WaitForPtpLock() spins on it before the anchors are built, and | ||
| 1465 | // ReviewPtpNoLockWarning() asks it again afterwards, so that a clock which | ||
| 1466 | // settles late is noticed rather than left described by a warning nobody | ||
| 1467 | // retracts. One definition, so the two cannot disagree about what "settled" | ||
| 1468 | // means. | ||
| 1469 | // | ||
| 1470 | // Three outcomes count as settled: | ||
| 1471 | // | ||
| 1472 | // MASTER / GRAND_MASTER -- we won the BMCA, so the PHC *is* the domain's | ||
| 1473 | // time and there is nothing for ptp4l to step it towards. Returns as | ||
| 1474 | // soon as the election is decided rather than burning the whole cap, | ||
| 1475 | // which matters because declaring yourself master takes announceReceipt- | ||
| 1476 | // Timeout x the announce interval (3 x 2s here) of listening first. | ||
| 1477 | // | ||
| 1478 | // SLAVE with a small offset from master -- ptp4l has measured against the | ||
| 1479 | // grandmaster and the correction it needed has already been applied. | ||
| 1480 | // Both halves have to be evidence rather than a default: a gmPresent | ||
| 1481 | // that actually read "true" and a master_offset that actually parsed. | ||
| 1482 | // A missing or malformed management field leaves the offset at zero, | ||
| 1483 | // which is indistinguishable from a perfect lock -- and zero is exactly | ||
| 1484 | // what #2848 settled on before the clock stepped 6.81 hours. | ||
| 1485 | // | ||
| 1486 | // LISTENING with no grandmaster on the domain, once the cap has run out -- | ||
| 1487 | // there is nothing to be stepped towards, so the free-running PHC is as | ||
| 1488 | // settled as it is going to get. "No grandmaster" means gmPresent was | ||
| 1489 | // read and said false; a management query that failed outright says | ||
| 1490 | // nothing about the domain and is not an answer. This one is only | ||
| 1491 | // tested at the deadline: during the wait "no announce seen" and "no | ||
| 1492 | // announce yet" look identical, and returning on it early would defeat | ||
| 1493 | // the whole point on a follower whose grandmaster had not spoken yet. | ||
| 1494 | // It matters because a box that is about to become grandmaster spends | ||
| 1495 | // announceReceiptTimeout x the announce interval -- 3 x 2s here -- | ||
| 1496 | // listening first, so a solo FPP would otherwise time out and cry wolf | ||
| 1497 | // on every single boot. Measured on a Pi 5 with nothing else on the | ||
| 1498 | // domain: LISTENING throughout, gmPresent false throughout, and | ||
| 1499 | // "assuming the grand master role" at t=7.46s -- well past the cap. | ||
| 1500 | // UNCALIBRATED or a wide SLAVE offset at the deadline is the opposite | ||
| 1501 | // case: a grandmaster is there and we have not caught up with it yet, | ||
| 1502 | // which is exactly when the caller should worry. | ||
| 1503 | // | ||
| 1504 | // Everything else is not settled, and the caller decides what to do about it. | ||
| 1505 | // The third outcome is returned as its own value rather than folded into | ||
| 1506 | // "settled" precisely because of the deadline caveat above: only a caller that | ||
| 1507 | // knows it has waited long enough may accept it. | ||
| 1508 | 57 | AES67Manager::PtpSettleState AES67Manager::PtpSettledNow(std::string* portStateOut, | |
| 1509 | int64_t* offsetNsOut) { | ||
| 1510 | // force: the whole point is the live answer, and the cache is a second | ||
| 1511 | // deep -- four polls of the same stale reading would just burn the cap. | ||
| 1512 |
1/2✓ Branch 0 taken 57 times.
✗ Branch 1 not taken.
|
57 | RefreshPtpCache(true); |
| 1513 | 57 | std::string portState; | |
| 1514 | 57 | int64_t offsetNs = 0; | |
| 1515 | 57 | bool gmPresent = false; | |
| 1516 | 57 | bool gmPresentValid = false; | |
| 1517 | 57 | bool offsetValid = false; | |
| 1518 | { | ||
| 1519 |
1/2✓ Branch 0 taken 57 times.
✗ Branch 1 not taken.
|
57 | std::lock_guard<std::mutex> lock(m_ptpCacheMutex); |
| 1520 |
1/2✓ Branch 0 taken 57 times.
✗ Branch 1 not taken.
|
57 | portState = m_ptpCache.portState; |
| 1521 | 57 | offsetNs = m_ptpCache.offsetNs; | |
| 1522 | 57 | gmPresent = m_ptpCache.gmPresent; | |
| 1523 | 57 | gmPresentValid = m_ptpCache.gmPresentValid; | |
| 1524 | 57 | offsetValid = m_ptpCache.offsetValid; | |
| 1525 | 57 | } | |
| 1526 |
1/2✓ Branch 0 taken 57 times.
✗ Branch 1 not taken.
|
57 | if (portStateOut) { |
| 1527 |
1/2✓ Branch 0 taken 57 times.
✗ Branch 1 not taken.
|
57 | *portStateOut = portState; |
| 1528 | } | ||
| 1529 |
2/2✓ Branch 0 taken 56 times.
✓ Branch 1 taken 1 times.
|
57 | if (offsetNsOut) { |
| 1530 | 56 | *offsetNsOut = offsetNs; | |
| 1531 | } | ||
| 1532 | |||
| 1533 |
3/4✓ Branch 0 taken 57 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 56 times.
|
57 | if (IsGrandmasterPortState(portState)) { |
| 1534 | 1 | return PtpSettleState::Grandmaster; | |
| 1535 | } | ||
| 1536 | // Missing/malformed management data is not a zero offset. Compare | ||
| 1537 | // signed bounds directly so INT64_MIN cannot overflow on negation. | ||
| 1538 |
7/10✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 23 times.
✓ Branch 3 taken 14 times.
✓ Branch 4 taken 23 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 2 times.
✓ Branch 7 taken 21 times.
✓ Branch 8 taken 2 times.
✗ Branch 9 not taken.
|
93 | if (portState == "SLAVE" && gmPresentValid && gmPresent && offsetValid && |
| 1539 |
5/6✓ Branch 0 taken 37 times.
✓ Branch 1 taken 19 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✓ Branch 5 taken 54 times.
|
93 | offsetNs > -AES67::PTP_LOCK_OFFSET_NS && |
| 1540 | offsetNs < AES67::PTP_LOCK_OFFSET_NS) { | ||
| 1541 | 2 | return PtpSettleState::SlaveLocked; | |
| 1542 | } | ||
| 1543 |
7/10✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✓ Branch 3 taken 47 times.
✓ Branch 4 taken 7 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 7 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 7 times.
✓ Branch 9 taken 47 times.
|
54 | if (portState == "LISTENING" && gmPresentValid && !gmPresent) { |
| 1544 | 7 | return PtpSettleState::NoGrandmasterYet; | |
| 1545 | } | ||
| 1546 | 47 | return PtpSettleState::NotSettled; | |
| 1547 | 57 | } | |
| 1548 | |||
| 1549 | // Block on PtpSettledNow() above until it says the clock has settled, or until | ||
| 1550 | // the caller's patience runs out. Returns true if it settled. | ||
| 1551 | 10 | bool AES67Manager::WaitForPtpLock(int timeoutMs) { | |
| 1552 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 10 times.
|
10 | if (timeoutMs <= 0) { |
| 1553 | ✗ | return false; | |
| 1554 | } | ||
| 1555 | // Every answer here comes from pmc. Without it the wait can only ever | ||
| 1556 | // time out, so spending the cap to find that out helps nobody -- say so | ||
| 1557 | // once and carry on. (linuxptp ships both binaries, and InitPTP() has | ||
| 1558 | // already required ptp4l, so this is a broken install rather than an | ||
| 1559 | // ordinary one.) | ||
| 1560 |
2/4✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 10 times.
|
10 | if (!FileExists(PMC_BINARY)) { |
| 1561 | ✗ | LogWarn(VB_MEDIAOUT, | |
| 1562 | "AES67Manager: %s is missing — cannot tell whether PTP has " | ||
| 1563 | "locked, so not waiting for it\n", PMC_BINARY); | ||
| 1564 | ✗ | return false; | |
| 1565 | } | ||
| 1566 | 10 | const auto start = std::chrono::steady_clock::now(); | |
| 1567 |
1/2✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
|
10 | const auto deadline = start + std::chrono::milliseconds(timeoutMs); |
| 1568 | |||
| 1569 | 10 | std::string reported; | |
| 1570 | for (;;) { | ||
| 1571 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 56 times.
|
56 | if (m_stopping.load()) { |
| 1572 | ✗ | return false; | |
| 1573 | } | ||
| 1574 |
2/4✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 56 times.
|
56 | if (!IsPtp4lRunning()) { |
| 1575 | ✗ | LogWarn(VB_MEDIAOUT, | |
| 1576 | "AES67Manager: ptp4l exited while waiting for it to lock\n"); | ||
| 1577 | ✗ | return false; | |
| 1578 | } | ||
| 1579 | |||
| 1580 | 56 | std::string portState; | |
| 1581 | 56 | int64_t offsetNs = 0; | |
| 1582 |
1/2✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
|
56 | const PtpSettleState settled = PtpSettledNow(&portState, &offsetNs); |
| 1583 | |||
| 1584 | // One line per transition, not per poll: this runs four times a second | ||
| 1585 | // and the interesting thing is LISTENING -> UNCALIBRATED -> SLAVE. | ||
| 1586 |
2/2✓ Branch 0 taken 10 times.
✓ Branch 1 taken 46 times.
|
56 | if (portState != reported) { |
| 1587 |
1/2✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
|
10 | reported = portState; |
| 1588 |
1/2✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
|
10 | LogInfo(VB_MEDIAOUT, "AES67Manager: waiting for PTP — %s\n", |
| 1589 | portState.c_str()); | ||
| 1590 | } | ||
| 1591 | |||
| 1592 | ✗ | const double waitedS = std::chrono::duration<double>( | |
| 1593 |
2/4✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 56 times.
✗ Branch 3 not taken.
|
56 | std::chrono::steady_clock::now() - start).count(); |
| 1594 | |||
| 1595 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 55 times.
|
56 | if (settled == PtpSettleState::Grandmaster) { |
| 1596 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, |
| 1597 | "AES67Manager: PTP settled after %.1fs — this node is the " | ||
| 1598 | "grandmaster, so the clock is already the domain's time\n", | ||
| 1599 | waitedS); | ||
| 1600 | 1 | return true; | |
| 1601 | } | ||
| 1602 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 54 times.
|
55 | if (settled == PtpSettleState::SlaveLocked) { |
| 1603 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, |
| 1604 | "AES67Manager: PTP settled after %.1fs — SLAVE, offset " | ||
| 1605 | "%+lldns\n", waitedS, (long long)offsetNs); | ||
| 1606 | 1 | return true; | |
| 1607 | } | ||
| 1608 | |||
| 1609 | 54 | const auto now = std::chrono::steady_clock::now(); | |
| 1610 |
3/4✓ Branch 0 taken 54 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✓ Branch 3 taken 46 times.
|
54 | if (now >= deadline) { |
| 1611 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 7 times.
|
8 | if (settled == PtpSettleState::NoGrandmasterYet) { |
| 1612 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | LogInfo(VB_MEDIAOUT, |
| 1613 | "AES67Manager: no PTP grandmaster on domain %d after " | ||
| 1614 | "%.1fs — nothing to step this clock, carrying on\n", | ||
| 1615 | GetPtpDomain(), waitedS); | ||
| 1616 | 1 | return true; | |
| 1617 | } | ||
| 1618 | 7 | return false; | |
| 1619 | } | ||
| 1620 | 46 | auto slice = std::chrono::milliseconds(AES67::PTP_LOCK_POLL_MS); | |
| 1621 |
1/2✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
|
46 | const auto left = std::chrono::duration_cast<std::chrono::milliseconds>( |
| 1622 |
1/2✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
|
46 | deadline - now); |
| 1623 |
1/2✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
|
46 | std::this_thread::sleep_for(std::min(slice, left)); |
| 1624 |
2/2✓ Branch 0 taken 46 times.
✓ Branch 1 taken 10 times.
|
102 | } |
| 1625 | 10 | } | |
| 1626 | |||
| 1627 | // Queries the *actual* domain grandmaster via `pmc GET TIME_STATUS_NP` — | ||
| 1628 | // this is the remote/upstream clock ptp4l has selected via BMCA, which may | ||
| 1629 | // or may not be this node. GetPTPClockId() (below) only ever returns this | ||
| 1630 | // node's own identity and must not be used to report "who is the master". | ||
| 1631 | 797 | bool AES67Manager::QueryPtp4lTimeStatus(bool& gmPresent, std::string& gmIdentity, int64_t& offsetNs, | |
| 1632 | bool* offsetValid) { | ||
| 1633 |
3/4✓ Branch 0 taken 797 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 698 times.
✓ Branch 3 taken 99 times.
|
797 | if (!IsPtp4lRunning()) { |
| 1634 | 698 | return false; | |
| 1635 | } | ||
| 1636 |
1/2✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
|
99 | RefreshPtpCache(); |
| 1637 | |||
| 1638 |
1/2✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
|
99 | std::lock_guard<std::mutex> lock(m_ptpCacheMutex); |
| 1639 |
3/6✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 99 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 99 times.
|
99 | if (!m_ptpCache.valid || m_ptpCache.gmIdentity.empty()) { |
| 1640 | ✗ | return false; | |
| 1641 | } | ||
| 1642 | 99 | gmPresent = m_ptpCache.gmPresent; | |
| 1643 |
1/2✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
|
99 | gmIdentity = m_ptpCache.gmIdentity; |
| 1644 | 99 | offsetNs = m_ptpCache.offsetNs; | |
| 1645 |
2/2✓ Branch 0 taken 98 times.
✓ Branch 1 taken 1 times.
|
99 | if (offsetValid) { |
| 1646 | 98 | *offsetValid = m_ptpCache.offsetValid; | |
| 1647 | } | ||
| 1648 | 99 | return true; | |
| 1649 | 99 | } | |
| 1650 | |||
| 1651 | // The clock identity to advertise/report: the upstream grandmaster we follow, | ||
| 1652 | // or our own identity when we hold the role. Empty while ptp4l is still | ||
| 1653 | // converging, so callers can avoid announcing a refclk that is about to | ||
| 1654 | // change. | ||
| 1655 | 1531 | std::string AES67Manager::GetActiveGrandmasterId() { | |
| 1656 |
8/10✓ Branch 0 taken 1531 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 76 times.
✓ Branch 3 taken 1455 times.
✓ Branch 4 taken 76 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 75 times.
✓ Branch 7 taken 1 times.
✓ Branch 8 taken 1530 times.
✓ Branch 9 taken 1 times.
|
1531 | if (!IsPtpEnabled() || !IsPtp4lRunning()) { |
| 1657 |
1/2✓ Branch 0 taken 1530 times.
✗ Branch 1 not taken.
|
3060 | return ""; |
| 1658 | } | ||
| 1659 | |||
| 1660 | 1 | bool gmPresent = false; | |
| 1661 | 1 | std::string gmId; | |
| 1662 | 1 | int64_t offsetNs = 0; | |
| 1663 |
5/10✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1 times.
✗ Branch 9 not taken.
|
1 | if (QueryPtp4lTimeStatus(gmPresent, gmId, offsetNs) && gmPresent && !gmId.empty()) { |
| 1664 | 1 | return gmId; | |
| 1665 | } | ||
| 1666 | ✗ | if (IsGrandmasterPortState(GetPtp4lState())) { | |
| 1667 | // We won the BMCA -- we are the refclk. | ||
| 1668 | ✗ | return GetPTPClockId(); | |
| 1669 | } | ||
| 1670 | ✗ | return ""; | |
| 1671 | 1 | } | |
| 1672 | |||
| 1673 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 1674 | // PTP Announce listener — resolves a grandmaster identity to an IP address | ||
| 1675 | // | ||
| 1676 | // "Which box is the clock?" is the first question at a commissioning, and a | ||
| 1677 | // clock identity does not answer it: it is an EUI-64 built from *a* MAC on the | ||
| 1678 | // device, which need not be the interface carrying PTP and cannot be looked up | ||
| 1679 | // in ARP. Nothing in PTP management gives the address either -- TIME_STATUS_NP | ||
| 1680 | // names the grandmaster by identity only. | ||
| 1681 | // | ||
| 1682 | // The address is therefore taken from where the Announce messages arrive from. | ||
| 1683 | // This is a passive second reader on the group ptp4l is already joined to: | ||
| 1684 | // SO_REUSEADDR (never SO_REUSEPORT, which would load-balance the flow away | ||
| 1685 | // from ptp4l instead of duplicating it) means both sockets get every packet. | ||
| 1686 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 1687 | |||
| 1688 | // Parses one PTPv2 Announce and records the address it came from against the | ||
| 1689 | // grandmaster it advertises. Anything else on the group is ignored. | ||
| 1690 | 4 | void AES67Manager::HandlePtpAnnounce(const uint8_t* data, size_t len, | |
| 1691 | const std::string& senderAddr) { | ||
| 1692 | // PTPv2 common header is 34 bytes. Within the Announce body: | ||
| 1693 | // originTimestamp(10) currentUtcOffset(2) reserved(1) priority1(1) | ||
| 1694 | // clockQuality(4) priority2(1) grandmasterIdentity(8) stepsRemoved(2). | ||
| 1695 | 4 | constexpr size_t PTP_HEADER_LEN = 34; | |
| 1696 | 4 | constexpr size_t GM_ID_OFFSET = PTP_HEADER_LEN + 19; // 53 | |
| 1697 | 4 | constexpr size_t STEPS_OFFSET = PTP_HEADER_LEN + 27; // 61 | |
| 1698 | 4 | constexpr uint8_t MSG_ANNOUNCE = 0x0B; | |
| 1699 | |||
| 1700 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
|
4 | if (len < STEPS_OFFSET + 2) { |
| 1701 | 2 | return; | |
| 1702 | } | ||
| 1703 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
|
4 | if ((data[0] & 0x0F) != MSG_ANNOUNCE) { |
| 1704 | ✗ | return; | |
| 1705 | } | ||
| 1706 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
|
4 | if ((data[1] & 0x0F) != 2) { // versionPTP |
| 1707 | ✗ | return; | |
| 1708 | } | ||
| 1709 | // Other domains share the multicast group; their grandmaster is not ours. | ||
| 1710 |
3/4✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 3 times.
|
4 | if (data[4] != (uint8_t)GetPtpDomain()) { |
| 1711 | 1 | return; | |
| 1712 | } | ||
| 1713 | |||
| 1714 | char gmId[24]; | ||
| 1715 | 3 | snprintf(gmId, sizeof(gmId), "%02X-%02X-%02X-%02X-%02X-%02X-%02X-%02X", | |
| 1716 | 3 | data[GM_ID_OFFSET], data[GM_ID_OFFSET + 1], data[GM_ID_OFFSET + 2], | |
| 1717 | 3 | data[GM_ID_OFFSET + 3], data[GM_ID_OFFSET + 4], data[GM_ID_OFFSET + 5], | |
| 1718 | 3 | data[GM_ID_OFFSET + 6], data[GM_ID_OFFSET + 7]); | |
| 1719 | |||
| 1720 | 3 | uint16_t stepsRemoved = ((uint16_t)data[STEPS_OFFSET] << 8) | data[STEPS_OFFSET + 1]; | |
| 1721 | |||
| 1722 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | int64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>( |
| 1723 | 6 | std::chrono::steady_clock::now().time_since_epoch()).count(); | |
| 1724 | |||
| 1725 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | std::lock_guard<std::mutex> lock(m_ptpAnnounceMutex); |
| 1726 | |||
| 1727 | // A boundary clock relays the domain under the real grandmaster's | ||
| 1728 | // identity, so its address is the nearest master rather than the clock | ||
| 1729 | // itself. Never let one overwrite an address heard direct from the | ||
| 1730 | // grandmaster -- that is the one the user is looking for. | ||
| 1731 |
2/4✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
6 | auto it = m_ptpAnnounceSources.find(gmId); |
| 1732 |
7/8✓ Branch 0 taken 2 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 2 times.
|
4 | if (it != m_ptpAnnounceSources.end() && it->second.direct && stepsRemoved > 0 && |
| 1733 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | (nowMs - it->second.lastSeenMs) < AES67::PTP_ANNOUNCE_STALE_MS) { |
| 1734 | 1 | return; | |
| 1735 | } | ||
| 1736 | |||
| 1737 | 2 | PtpAnnounceSource src; | |
| 1738 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | src.address = senderAddr; |
| 1739 | 2 | src.direct = (stepsRemoved == 0); | |
| 1740 | 2 | src.lastSeenMs = nowMs; | |
| 1741 | |||
| 1742 | // Drop clocks that have stopped announcing. A domain holds a handful of | ||
| 1743 | // masters at most, but BMCA churn would otherwise accumulate them for the | ||
| 1744 | // life of the process. | ||
| 1745 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 2 times.
|
3 | for (auto i = m_ptpAnnounceSources.begin(); i != m_ptpAnnounceSources.end();) { |
| 1746 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if ((nowMs - i->second.lastSeenMs) > 10 * AES67::PTP_ANNOUNCE_STALE_MS) { |
| 1747 | ✗ | i = m_ptpAnnounceSources.erase(i); | |
| 1748 | } else { | ||
| 1749 | 1 | ++i; | |
| 1750 | } | ||
| 1751 | } | ||
| 1752 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
4 | it = m_ptpAnnounceSources.find(gmId); // the prune may have erased it |
| 1753 |
4/6✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 2 times.
|
3 | if (it == m_ptpAnnounceSources.end() && |
| 1754 | 1 | m_ptpAnnounceSources.size() >= AES67::MAX_PTP_ANNOUNCE_SOURCES) { | |
| 1755 | ✗ | auto oldest = std::min_element(m_ptpAnnounceSources.begin(), m_ptpAnnounceSources.end(), | |
| 1756 | ✗ | [](const auto& a, const auto& b) { | |
| 1757 | ✗ | return a.second.lastSeenMs < b.second.lastSeenMs; | |
| 1758 | }); | ||
| 1759 | ✗ | m_ptpAnnounceSources.erase(oldest); | |
| 1760 | } | ||
| 1761 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
4 | m_ptpAnnounceSources[gmId] = src; |
| 1762 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 1 times.
|
3 | } |
| 1763 | |||
| 1764 | 10 | bool AES67Manager::GetGrandmasterAddress(const std::string& gmId, std::string& address, | |
| 1765 | bool& viaBoundary) { | ||
| 1766 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 10 times.
|
10 | if (gmId.empty()) { |
| 1767 | ✗ | return false; | |
| 1768 | } | ||
| 1769 | |||
| 1770 |
1/2✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
|
10 | int64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>( |
| 1771 | 20 | std::chrono::steady_clock::now().time_since_epoch()).count(); | |
| 1772 | |||
| 1773 |
1/2✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
|
10 | std::lock_guard<std::mutex> lock(m_ptpAnnounceMutex); |
| 1774 |
1/2✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
|
10 | auto it = m_ptpAnnounceSources.find(gmId); |
| 1775 |
2/2✓ Branch 0 taken 7 times.
✓ Branch 1 taken 3 times.
|
10 | if (it == m_ptpAnnounceSources.end()) { |
| 1776 | 7 | return false; | |
| 1777 | } | ||
| 1778 | // Stale means the clock we are reporting is no longer announcing from | ||
| 1779 | // there -- a changeover, or the listener socket having been shut out. | ||
| 1780 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
|
3 | if ((nowMs - it->second.lastSeenMs) > AES67::PTP_ANNOUNCE_STALE_MS) { |
| 1781 | ✗ | return false; | |
| 1782 | } | ||
| 1783 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | address = it->second.address; |
| 1784 | 3 | viaBoundary = !it->second.direct; | |
| 1785 | 3 | return true; | |
| 1786 | 10 | } | |
| 1787 | |||
| 1788 | 36 | void AES67Manager::PtpAnnounceListenLoop() { | |
| 1789 |
1/2✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
|
36 | LogInfo(VB_MEDIAOUT, "AES67 PTP announce listener thread started\n"); |
| 1790 | |||
| 1791 | 36 | int sock = OpenSocket(AF_INET, SOCK_DGRAM, 0); | |
| 1792 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 36 times.
|
36 | if (sock < 0) { |
| 1793 | ✗ | LogWarn(VB_MEDIAOUT, "AES67 PTP announce: socket failed: %s\n", FPPstrerror(errno)); | |
| 1794 | ✗ | return; | |
| 1795 | } | ||
| 1796 | |||
| 1797 | // SO_REUSEADDR only: ptp4l holds this port and multicast is delivered to | ||
| 1798 | // every socket bound to it. SO_REUSEPORT would instead put us in a | ||
| 1799 | // load-balancing group with ptp4l and steal its Announce messages. | ||
| 1800 | 36 | int reuse = 1; | |
| 1801 | 36 | setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); | |
| 1802 | |||
| 1803 | struct sockaddr_in bindAddr; | ||
| 1804 | 36 | memset(&bindAddr, 0, sizeof(bindAddr)); | |
| 1805 | 36 | bindAddr.sin_family = AF_INET; | |
| 1806 | 36 | bindAddr.sin_port = htons(AES67::PTP_GENERAL_PORT); | |
| 1807 | 36 | bindAddr.sin_addr.s_addr = htonl(INADDR_ANY); | |
| 1808 | |||
| 1809 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 36 times.
|
36 | if (bind(sock, (struct sockaddr*)&bindAddr, sizeof(bindAddr)) < 0) { |
| 1810 | // Not fatal to anything: PTP itself is ptp4l's socket, so all that is | ||
| 1811 | // lost here is the grandmaster's address in the status display. | ||
| 1812 | ✗ | LogWarn(VB_MEDIAOUT, "AES67 PTP announce: bind to port %d failed (%s) — " | |
| 1813 | "grandmaster address will not be shown\n", | ||
| 1814 | AES67::PTP_GENERAL_PORT, FPPstrerror(errno)); | ||
| 1815 | ✗ | close(sock); | |
| 1816 | ✗ | return; | |
| 1817 | } | ||
| 1818 | |||
| 1819 | struct ip_mreq mreq; | ||
| 1820 | 36 | memset(&mreq, 0, sizeof(mreq)); | |
| 1821 | 36 | inet_pton(AF_INET, AES67::PTP_MCAST_ADDRESS, &mreq.imr_multiaddr); | |
| 1822 | |||
| 1823 |
1/2✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
|
36 | std::string iface = GetPtpInterface(); |
| 1824 |
1/2✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
|
36 | if (!iface.empty()) { |
| 1825 |
1/2✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
|
36 | std::string ifIP = GetInterfaceIP(iface); |
| 1826 | 36 | inet_pton(AF_INET, ifIP.c_str(), &mreq.imr_interface); | |
| 1827 | 36 | } else { | |
| 1828 | ✗ | mreq.imr_interface.s_addr = htonl(INADDR_ANY); | |
| 1829 | } | ||
| 1830 | |||
| 1831 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 36 times.
|
36 | if (setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) { |
| 1832 | ✗ | LogWarn(VB_MEDIAOUT, "AES67 PTP announce: join %s failed: %s\n", | |
| 1833 | AES67::PTP_MCAST_ADDRESS, FPPstrerror(errno)); | ||
| 1834 | ✗ | close(sock); | |
| 1835 | ✗ | return; | |
| 1836 | } | ||
| 1837 | |||
| 1838 | // Bounds how long ShutdownPTP() waits on the join. | ||
| 1839 | struct timeval tv; | ||
| 1840 | 36 | tv.tv_sec = 1; | |
| 1841 | 36 | tv.tv_usec = 0; | |
| 1842 | 36 | setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); | |
| 1843 | |||
| 1844 | uint8_t buf[512]; | ||
| 1845 |
2/2✓ Branch 0 taken 127 times.
✓ Branch 1 taken 36 times.
|
163 | while (m_ptpAnnounceRunning.load()) { |
| 1846 | struct sockaddr_in senderAddr; | ||
| 1847 | 127 | socklen_t addrLen = sizeof(senderAddr); | |
| 1848 | |||
| 1849 |
1/2✓ Branch 0 taken 127 times.
✗ Branch 1 not taken.
|
127 | ssize_t n = recvfrom(sock, buf, sizeof(buf), 0, |
| 1850 | (struct sockaddr*)&senderAddr, &addrLen); | ||
| 1851 |
2/2✓ Branch 0 taken 123 times.
✓ Branch 1 taken 4 times.
|
127 | if (n <= 0) continue; // timeout or error |
| 1852 | |||
| 1853 | char senderIP[INET_ADDRSTRLEN]; | ||
| 1854 | 4 | inet_ntop(AF_INET, &senderAddr.sin_addr, senderIP, sizeof(senderIP)); | |
| 1855 | |||
| 1856 |
2/4✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
|
8 | HandlePtpAnnounce(buf, (size_t)n, senderIP); |
| 1857 | } | ||
| 1858 | |||
| 1859 | 36 | setsockopt(sock, IPPROTO_IP, IP_DROP_MEMBERSHIP, &mreq, sizeof(mreq)); | |
| 1860 |
1/2✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
|
36 | close(sock); |
| 1861 |
1/2✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
|
36 | LogInfo(VB_MEDIAOUT, "AES67 PTP announce listener thread stopped\n"); |
| 1862 |
1/2✓ Branch 0 taken 36 times.
✗ Branch 1 not taken.
|
36 | } |
| 1863 | |||
| 1864 | 1533 | std::string AES67Manager::GetPTPClockId() { | |
| 1865 | // Derive EUI-64 clock ID from interface MAC address | ||
| 1866 | // Read /sys/class/net/<iface>/address → AA:BB:CC:DD:EE:FF | ||
| 1867 | // Insert FF:FE → AA-BB-CC-FF-FE-DD-EE-FF | ||
| 1868 |
3/6✓ Branch 0 taken 1533 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1533 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1533 times.
✗ Branch 5 not taken.
|
1533 | std::string macPath = "/sys/class/net/" + GetPtpInterface() + "/address"; |
| 1869 |
1/2✓ Branch 0 taken 1533 times.
✗ Branch 1 not taken.
|
1533 | std::ifstream macFile(macPath); |
| 1870 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1533 times.
|
1533 | if (!macFile.is_open()) { |
| 1871 | ✗ | LogWarn(VB_MEDIAOUT, "AES67Manager: Cannot read MAC from %s\n", macPath.c_str()); | |
| 1872 | ✗ | return "00-00-00-FF-FE-00-00-00"; | |
| 1873 | } | ||
| 1874 | |||
| 1875 | 1533 | std::string mac; | |
| 1876 |
1/2✓ Branch 0 taken 1533 times.
✗ Branch 1 not taken.
|
1533 | std::getline(macFile, mac); |
| 1877 |
1/2✓ Branch 0 taken 1533 times.
✗ Branch 1 not taken.
|
1533 | macFile.close(); |
| 1878 | |||
| 1879 | // Parse MAC: "aa:bb:cc:dd:ee:ff" | ||
| 1880 | 1533 | unsigned int m[6] = {0}; | |
| 1881 | 1533 | if (sscanf(mac.c_str(), "%x:%x:%x:%x:%x:%x", | |
| 1882 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1533 times.
|
1533 | &m[0], &m[1], &m[2], &m[3], &m[4], &m[5]) != 6) { |
| 1883 | ✗ | return "00-00-00-FF-FE-00-00-00"; | |
| 1884 | } | ||
| 1885 | |||
| 1886 | // EUI-64: m0-m1-m2-FF-FE-m3-m4-m5 | ||
| 1887 | char eui64[24]; | ||
| 1888 | 1533 | snprintf(eui64, sizeof(eui64), "%02X-%02X-%02X-FF-FE-%02X-%02X-%02X", | |
| 1889 | m[0], m[1], m[2], m[3], m[4], m[5]); | ||
| 1890 |
1/2✓ Branch 0 taken 1533 times.
✗ Branch 1 not taken.
|
3066 | return std::string(eui64); |
| 1891 | 1533 | } | |
| 1892 | |||
| 1893 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 1894 | // AES67 media clock | ||
| 1895 | // | ||
| 1896 | // AES67 requires the RTP timestamp to be the PTP media clock count, and the SDP | ||
| 1897 | // we publish asserts exactly that with "a=mediaclk:direct=0". Honouring it | ||
| 1898 | // means the pipeline has to be driven by PTP time itself, not by GStreamer's | ||
| 1899 | // default monotonic system clock. | ||
| 1900 | // | ||
| 1901 | // Which clock actually holds PTP time depends on how ptp4l is running, and it | ||
| 1902 | // has to be right in BOTH roles: | ||
| 1903 | // | ||
| 1904 | // hardware timestamping -- ptp4l's clock is the NIC's PHC. As a follower it | ||
| 1905 | // disciplines the PHC to the grandmaster; as grandmaster it distributes | ||
| 1906 | // the PHC's own time. Either way the PHC is the domain's time, so we | ||
| 1907 | // read the PHC. | ||
| 1908 | // | ||
| 1909 | // software timestamping -- there is no PHC; ptp4l uses CLOCK_REALTIME as its | ||
| 1910 | // clock, disciplining it as a follower and distributing it as | ||
| 1911 | // grandmaster. So CLOCK_REALTIME is the domain's time. | ||
| 1912 | // | ||
| 1913 | // Note what we deliberately do NOT use: | ||
| 1914 | // | ||
| 1915 | // CLOCK_TAI / GST_CLOCK_TYPE_TAI -- only correct while something is setting | ||
| 1916 | // the kernel TAI offset, which phc2sys does as a follower and not at all | ||
| 1917 | // as grandmaster. Measured on a grandmaster box: adjtimex offset 0 and | ||
| 1918 | // CLOCK_TAI == CLOCK_REALTIME, i.e. silently not PTP time. | ||
| 1919 | // | ||
| 1920 | // GstPtpClock (gst_ptp_init) -- would run gst-ptp-helper as a second PTP | ||
| 1921 | // client on the same domain alongside our ptp4l, adding a participant to | ||
| 1922 | // a network we just stopped FPP from disrupting. | ||
| 1923 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 1924 | |||
| 1925 | // Linux exposes a dynamic POSIX clock for an open /dev/ptpN fd. | ||
| 1926 | #define FPP_FD_TO_CLOCKID(fd) ((clockid_t)((((unsigned int)~(fd)) << 3) | 3)) | ||
| 1927 | |||
| 1928 | // Resolve the PHC backing an interface via ETHTOOL_GET_TS_INFO. Returns -1 | ||
| 1929 | // when the NIC has no PHC (software timestamping), which is not an error. | ||
| 1930 | 96 | static int PhcIndexForInterface(const std::string& iface) { | |
| 1931 | #ifndef FPP_AES67_HAS_ETHTOOL | ||
| 1932 | return -1; | ||
| 1933 | #else | ||
| 1934 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 96 times.
|
96 | if (iface.empty()) { |
| 1935 | ✗ | return -1; | |
| 1936 | } | ||
| 1937 | 96 | int sock = OpenSocket(AF_INET, SOCK_DGRAM, 0); | |
| 1938 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 96 times.
|
96 | if (sock < 0) { |
| 1939 | ✗ | return -1; | |
| 1940 | } | ||
| 1941 | struct ethtool_ts_info tsi; | ||
| 1942 | 96 | memset(&tsi, 0, sizeof(tsi)); | |
| 1943 | 96 | tsi.cmd = ETHTOOL_GET_TS_INFO; | |
| 1944 | |||
| 1945 | struct ifreq ifr; | ||
| 1946 | 96 | memset(&ifr, 0, sizeof(ifr)); | |
| 1947 | 96 | snprintf(ifr.ifr_name, sizeof(ifr.ifr_name), "%s", iface.c_str()); | |
| 1948 | 96 | ifr.ifr_data = (char*)&tsi; | |
| 1949 | |||
| 1950 | 96 | int idx = -1; | |
| 1951 |
1/2✓ Branch 0 taken 96 times.
✗ Branch 1 not taken.
|
96 | if (ioctl(sock, SIOCETHTOOL, &ifr) == 0) { |
| 1952 | 96 | idx = tsi.phc_index; | |
| 1953 | } | ||
| 1954 |
1/2✓ Branch 0 taken 96 times.
✗ Branch 1 not taken.
|
96 | close(sock); |
| 1955 | 96 | return idx; | |
| 1956 | #endif | ||
| 1957 | } | ||
| 1958 | |||
| 1959 | // GstClock reading PTP time. Subclasses GstSystemClock so that all of its | ||
| 1960 | // wait/scheduling machinery keeps working -- only the time source changes, | ||
| 1961 | // since GstSystemClock routes its waits through the virtual get_internal_time. | ||
| 1962 | struct FppPtpClock { | ||
| 1963 | GstSystemClock parent; | ||
| 1964 | clockid_t clkid; // PHC dynamic clockid, or CLOCK_REALTIME | ||
| 1965 | int phcFd; // open /dev/ptpN, or -1 when using CLOCK_REALTIME | ||
| 1966 | }; | ||
| 1967 | struct FppPtpClockClass { | ||
| 1968 | GstSystemClockClass parent_class; | ||
| 1969 | }; | ||
| 1970 | |||
| 1971 |
6/7✓ Branch 0 taken 43 times.
✓ Branch 1 taken 18 times.
✓ Branch 2 taken 43 times.
✓ Branch 3 taken 43 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 43 times.
✓ Branch 6 taken 18 times.
|
294 | G_DEFINE_TYPE(FppPtpClock, fpp_ptp_clock, GST_TYPE_SYSTEM_CLOCK) |
| 1972 | |||
| 1973 | 10086 | static GstClockTime fpp_ptp_clock_get_internal_time(GstClock* clock) { | |
| 1974 | 10086 | FppPtpClock* self = (FppPtpClock*)clock; | |
| 1975 | struct timespec ts; | ||
| 1976 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 10086 times.
|
10086 | if (clock_gettime(self->clkid, &ts) != 0) { |
| 1977 | // Losing the clock mid-stream would wedge every waiting element, so | ||
| 1978 | // fall back rather than returning an error the caller cannot act on. | ||
| 1979 | ✗ | clock_gettime(CLOCK_REALTIME, &ts); | |
| 1980 | } | ||
| 1981 | 10086 | return GST_TIMESPEC_TO_TIME(ts); | |
| 1982 | } | ||
| 1983 | |||
| 1984 | 61 | static void fpp_ptp_clock_finalize(GObject* object) { | |
| 1985 | 61 | FppPtpClock* self = (FppPtpClock*)object; | |
| 1986 |
2/2✓ Branch 0 taken 5 times.
✓ Branch 1 taken 56 times.
|
61 | if (self->phcFd >= 0) { |
| 1987 | 5 | close(self->phcFd); | |
| 1988 | 5 | self->phcFd = -1; | |
| 1989 | } | ||
| 1990 | 61 | G_OBJECT_CLASS(fpp_ptp_clock_parent_class)->finalize(object); | |
| 1991 | 61 | } | |
| 1992 | |||
| 1993 | 43 | static void fpp_ptp_clock_class_init(FppPtpClockClass* klass) { | |
| 1994 | 43 | GST_CLOCK_CLASS(klass)->get_internal_time = fpp_ptp_clock_get_internal_time; | |
| 1995 | 43 | G_OBJECT_CLASS(klass)->finalize = fpp_ptp_clock_finalize; | |
| 1996 | 43 | } | |
| 1997 | |||
| 1998 | 61 | static void fpp_ptp_clock_init(FppPtpClock* self) { | |
| 1999 | 61 | self->clkid = CLOCK_REALTIME; | |
| 2000 | 61 | self->phcFd = -1; | |
| 2001 | 61 | } | |
| 2002 | |||
| 2003 | |||
| 2004 | // Opens the PTP time source and wraps it in a GstClock. One clock is shared by | ||
| 2005 | // every send pipeline: they must all carry the same media timeline, and a | ||
| 2006 | // second clock object would mean a second set of rate estimates. | ||
| 2007 | 168 | GstClock* AES67Manager::GetOrCreateMediaClock() { | |
| 2008 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | std::lock_guard<std::mutex> lock(m_ptpClockMutex); |
| 2009 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 165 times.
|
168 | if (m_ptpClock) { |
| 2010 | 3 | return m_ptpClock; | |
| 2011 | } | ||
| 2012 |
3/4✓ Branch 0 taken 165 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 104 times.
✓ Branch 3 taken 61 times.
|
165 | if (!IsPtpEnabled()) { |
| 2013 | 104 | return nullptr; | |
| 2014 | } | ||
| 2015 | |||
| 2016 |
2/4✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 61 times.
✗ Branch 3 not taken.
|
61 | FppPtpClock* clock = (FppPtpClock*)g_object_new(fpp_ptp_clock_get_type(), |
| 2017 | "name", "fppaes67ptpclock", NULL); | ||
| 2018 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 61 times.
|
61 | if (!clock) { |
| 2019 | ✗ | return nullptr; | |
| 2020 | } | ||
| 2021 | // A GstClock is born floating, and m_ptpClock is our own reference, | ||
| 2022 | // released in ReleaseMediaClock(). The pipelines it goes to only ref it | ||
| 2023 | // (gst_object_replace), so the counts balance today, but anything that | ||
| 2024 | // ref_sinks it would take our reference over and our unref would then | ||
| 2025 | // free a clock still in use. Sink it here, as its owner. | ||
| 2026 |
1/2✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
|
61 | gst_object_ref_sink(clock); |
| 2027 | |||
| 2028 |
1/2✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
|
61 | std::string iface = GetPtpInterface(); |
| 2029 | // The PHC is the domain's time only while ptp4l disciplines it. A NIC | ||
| 2030 | // that has one but rejected hardware timestamping leaves ptp4l on | ||
| 2031 | // software timestamping (it steers CLOCK_REALTIME), and with ptp4l not | ||
| 2032 | // running at all CLOCK_REALTIME is at least NTP-steered -- a free-running | ||
| 2033 | // PHC is neither, and every box would drift on its own crystal. | ||
| 2034 |
3/4✓ Branch 0 taken 40 times.
✓ Branch 1 taken 21 times.
✓ Branch 2 taken 40 times.
✗ Branch 3 not taken.
|
61 | int phcIndex = m_ptpHwTimestamping.load() ? PhcIndexForInterface(iface) : -1; |
| 2035 |
7/8✓ Branch 0 taken 56 times.
✓ Branch 1 taken 5 times.
✓ Branch 2 taken 56 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 55 times.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 60 times.
|
61 | if (phcIndex < 0 && PhcIndexForInterface(iface) >= 0) { |
| 2036 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "AES67 media clock: %s has a PHC, but ptp4l is not running with " |
| 2037 | "hardware timestamping, so it is not disciplined\n", iface.c_str()); | ||
| 2038 | } | ||
| 2039 |
2/2✓ Branch 0 taken 5 times.
✓ Branch 1 taken 56 times.
|
61 | if (phcIndex >= 0) { |
| 2040 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | std::string dev = "/dev/ptp" + std::to_string(phcIndex); |
| 2041 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | int fd = open(dev.c_str(), O_RDONLY); |
| 2042 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | if (fd >= 0) { |
| 2043 | 5 | clock->phcFd = fd; | |
| 2044 | 5 | clock->clkid = FPP_FD_TO_CLOCKID(fd); | |
| 2045 | // Prove it is readable before we hand it to a pipeline -- a PHC | ||
| 2046 | // that exists but will not answer would stall every element | ||
| 2047 | // waiting on it. | ||
| 2048 | struct timespec ts; | ||
| 2049 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | if (clock_gettime(clock->clkid, &ts) == 0) { |
| 2050 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | LogInfo(VB_MEDIAOUT, "AES67 media clock: using PHC %s (interface %s)\n", |
| 2051 | dev.c_str(), iface.c_str()); | ||
| 2052 |
2/4✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
|
5 | m_ptpClock = GST_CLOCK(clock); |
| 2053 | 5 | return m_ptpClock; | |
| 2054 | } | ||
| 2055 | ✗ | LogWarn(VB_MEDIAOUT, "AES67 media clock: %s is not readable (%s), " | |
| 2056 | "falling back to CLOCK_REALTIME\n", dev.c_str(), FPPstrerror(errno)); | ||
| 2057 | ✗ | close(fd); | |
| 2058 | ✗ | clock->phcFd = -1; | |
| 2059 | } else { | ||
| 2060 | ✗ | LogWarn(VB_MEDIAOUT, "AES67 media clock: cannot open %s (%s), " | |
| 2061 | "falling back to CLOCK_REALTIME\n", dev.c_str(), FPPstrerror(errno)); | ||
| 2062 | } | ||
| 2063 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 5 times.
|
5 | } |
| 2064 | |||
| 2065 | // Software-timestamping path: ptp4l has no PHC and uses CLOCK_REALTIME as | ||
| 2066 | // its own clock, so that is the domain's time in both roles. | ||
| 2067 | 56 | clock->clkid = CLOCK_REALTIME; | |
| 2068 |
1/2✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
|
56 | LogInfo(VB_MEDIAOUT, "AES67 media clock: using CLOCK_REALTIME " |
| 2069 | "(no disciplined PHC on %s — software timestamping)\n", iface.c_str()); | ||
| 2070 |
2/4✓ Branch 0 taken 56 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 56 times.
✗ Branch 3 not taken.
|
56 | m_ptpClock = GST_CLOCK(clock); |
| 2071 | 56 | return m_ptpClock; | |
| 2072 | 168 | } | |
| 2073 | |||
| 2074 | 208 | void AES67Manager::ReleaseMediaClock() { | |
| 2075 |
1/2✓ Branch 0 taken 208 times.
✗ Branch 1 not taken.
|
208 | std::lock_guard<std::mutex> lock(m_ptpClockMutex); |
| 2076 |
2/2✓ Branch 0 taken 61 times.
✓ Branch 1 taken 147 times.
|
208 | if (m_ptpClock) { |
| 2077 |
1/2✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
|
61 | gst_object_unref(m_ptpClock); |
| 2078 | 61 | m_ptpClock = nullptr; | |
| 2079 | } | ||
| 2080 | 208 | } | |
| 2081 | |||
| 2082 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 2083 | // Pipeline creation — Send | ||
| 2084 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 2085 | |||
| 2086 | // Source-gap instrumentation, deliberately independent of every config switch. | ||
| 2087 | // | ||
| 2088 | // The same measurement used to live inside DriftResampleProbe, which meant it | ||
| 2089 | // only existed when ptpMediaClock and driftResample were both on -- and those | ||
| 2090 | // are exactly the flags a diagnostic needs to turn off. Testing whether the | ||
| 2091 | // PTP pipeline clock is what drains the source pool was impossible for that | ||
| 2092 | // reason: turning off the clock also turned off the instrument. | ||
| 2093 | // | ||
| 2094 | // Sits directly on pipewiresrc's src pad so it sees the source's own timeline | ||
| 2095 | // before any conversion, and reports each PTS discontinuity against the buffer | ||
| 2096 | // index it happened at. The pool empties after (N-1) quanta of accumulated | ||
| 2097 | // drift -- N with always-copy, which frees the slot a wrapped buffer would | ||
| 2098 | // hold through the chain -- and from then on the source skips one graph cycle | ||
| 2099 | // per rotation. Both the onset and the spacing are read off these lines. | ||
| 2100 | struct SourceGapState { | ||
| 2101 | int instanceId = 0; | ||
| 2102 | int rate = AES67::AUDIO_RATE; | ||
| 2103 | GstClockTime lastPts = 0; | ||
| 2104 | guint64 lastDur = 0; | ||
| 2105 | bool have = false; | ||
| 2106 | guint64 seen = 0; | ||
| 2107 | guint64 gaps = 0; | ||
| 2108 | }; | ||
| 2109 | |||
| 2110 | 168 | static void DestroySourceGapState(gpointer data) { | |
| 2111 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | delete static_cast<SourceGapState*>(data); |
| 2112 | 168 | } | |
| 2113 | |||
| 2114 | 9463 | static GstPadProbeReturn SourceGapProbe(GstPad* pad, GstPadProbeInfo* info, | |
| 2115 | gpointer user) { | ||
| 2116 | 9463 | auto* st = static_cast<SourceGapState*>(user); | |
| 2117 | 9463 | GstBuffer* buf = GST_PAD_PROBE_INFO_BUFFER(info); | |
| 2118 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 9463 times.
|
9463 | if (!buf) { |
| 2119 | ✗ | return GST_PAD_PROBE_OK; | |
| 2120 | } | ||
| 2121 | 9463 | st->seen++; | |
| 2122 |
1/2✓ Branch 0 taken 9463 times.
✗ Branch 1 not taken.
|
9463 | if (GST_BUFFER_PTS_IS_VALID(buf)) { |
| 2123 | 9463 | const GstClockTime pts = GST_BUFFER_PTS(buf); | |
| 2124 |
3/4✓ Branch 0 taken 9443 times.
✓ Branch 1 taken 20 times.
✓ Branch 2 taken 9443 times.
✗ Branch 3 not taken.
|
9463 | if (st->have && pts > st->lastPts) { |
| 2125 | 9443 | const gint64 jump = (gint64)(pts - st->lastPts) - (gint64)st->lastDur; | |
| 2126 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 9440 times.
|
9443 | if (jump > (gint64)GST_MSECOND) { |
| 2127 | 3 | st->gaps++; | |
| 2128 | 3 | LogInfo(VB_MEDIAOUT, | |
| 2129 | "AES67 source [%d]: gap %+.2f ms at buffer %llu " | ||
| 2130 | "(gap #%llu)\n", | ||
| 2131 | st->instanceId, (double)jump / (double)GST_MSECOND, | ||
| 2132 | (unsigned long long)st->seen, | ||
| 2133 | (unsigned long long)st->gaps); | ||
| 2134 | } | ||
| 2135 | } | ||
| 2136 | 9463 | st->lastPts = pts; | |
| 2137 | 18926 | st->lastDur = GST_BUFFER_DURATION_IS_VALID(buf) | |
| 2138 |
1/2✓ Branch 0 taken 9463 times.
✗ Branch 1 not taken.
|
9463 | ? GST_BUFFER_DURATION(buf) |
| 2139 | : 0; | ||
| 2140 | 9463 | st->have = GST_BUFFER_DURATION_IS_VALID(buf); | |
| 2141 | } | ||
| 2142 | 9463 | return GST_PAD_PROBE_OK; | |
| 2143 | } | ||
| 2144 | |||
| 2145 | #ifdef FPP_HAVE_SAMPLERATE | ||
| 2146 | // ───────────────────────────────────────────────────────────────────────────── | ||
| 2147 | // Media clock drift correction | ||
| 2148 | // | ||
| 2149 | // The audio is produced on the sound card's crystal and the RTP timeline runs | ||
| 2150 | // on the NIC's PHC; on the reference hardware those differ by ~56ppm, which is | ||
| 2151 | // 3.4ms per minute. That empties any receiver's buffer, and it empties the | ||
| 2152 | // sinkPacing queue, so it has to be corrected in the sender. | ||
| 2153 | // | ||
| 2154 | // Three GStreamer elements were tried first and all three are recorded as dead | ||
| 2155 | // ends in AES67Config: "speed" and "pitch" accept a rate property and never | ||
| 2156 | // apply it to a live stream, and "audiorate" reconciles per-buffer timestamp | ||
| 2157 | // rounding rather than drift and then runs away. libsamplerate is used | ||
| 2158 | // instead because it is built for exactly this: src_process() takes a ratio per | ||
| 2159 | // block, interpolates it across the block so there is no zipper, and keeps a | ||
| 2160 | // fractional read pointer across calls. | ||
| 2161 | // | ||
| 2162 | // The control law deliberately leads with a direct measurement rather than a | ||
| 2163 | // feedback loop, because every previous attempt here oscillated: | ||
| 2164 | // | ||
| 2165 | // feedforward -- the card's true rate is (input frames / PHC seconds), which | ||
| 2166 | // is two crystals and therefore almost perfectly constant. The ratio that | ||
| 2167 | // cancels it is just AUDIO_RATE / that. This carries essentially all of | ||
| 2168 | // the correction and needs no gain. | ||
| 2169 | // feedback -- a small proportional term bleeds off accumulated offset, so a | ||
| 2170 | // startup transient or a rounding residue does not persist. It is | ||
| 2171 | // deliberately weak; it is a trim, not the controller. | ||
| 2172 | // | ||
| 2173 | // The failure that killed audiorate is handled explicitly: on DISCONT the | ||
| 2174 | // counters re-anchor rather than treating the gap as drift to be made up. | ||
| 2175 | struct DriftResampleState { | ||
| 2176 | SRC_STATE* src = nullptr; | ||
| 2177 | int channels = 2; | ||
| 2178 | GstClock* clock = nullptr; | ||
| 2179 | int instanceId = 0; | ||
| 2180 | |||
| 2181 | // Output timeline: monotonic, only re-anchored on a real discontinuity. | ||
| 2182 | bool anchored = false; | ||
| 2183 | GstClockTime anchorPts = 0; | ||
| 2184 | guint64 ptsFrames = 0; | ||
| 2185 | |||
| 2186 | // Control counters: zeroed again once the pipeline is up to speed. Keeping | ||
| 2187 | // these separate is the point -- a pipeline takes a moment to start | ||
| 2188 | // flowing, and counting that startup gap as drift makes the loop chase a | ||
| 2189 | // deficit it did not cause (measured: -188ms, which pinned the trim at the | ||
| 2190 | // clamp and ran the stream 2000ppm fast for minutes). | ||
| 2191 | bool warmed = false; | ||
| 2192 | GstClockTime ctlClock = 0; | ||
| 2193 | guint64 ctlIn = 0; | ||
| 2194 | guint64 ctlOut = 0; | ||
| 2195 | double ratio = 1.0; | ||
| 2196 | |||
| 2197 | std::vector<float> out; | ||
| 2198 | guint64 buffers = 0; | ||
| 2199 | guint64 shortReads = 0; | ||
| 2200 | |||
| 2201 | // Onset instrumentation for the periodic under-delivery. The absorb path | ||
| 2202 | // below only reports gaps over 50ms, because that is the threshold that | ||
| 2203 | // matters to the control loop -- but that hides when a burst actually | ||
| 2204 | // starts, since the first gaps can be a single graph quantum. Measuring | ||
| 2205 | // from the >50ms line put the onset at 275k-284k buffers across seven | ||
| 2206 | // runs; the 3% spread is most likely this bias, not the mechanism. These | ||
| 2207 | // fields report every discontinuity against the buffer index it happened | ||
| 2208 | // at, which is what separates a fixed-count trigger from an accumulation. | ||
| 2209 | GstClockTime lastPts = 0; | ||
| 2210 | long lastFrames = 0; | ||
| 2211 | bool lastPtsValid = false; | ||
| 2212 | guint64 seen = 0; | ||
| 2213 | guint64 gapsSeen = 0; | ||
| 2214 | |||
| 2215 | // Buffer index the last re-anchor happened at. | ||
| 2216 | guint64 lastResyncAt = 0; | ||
| 2217 | bool everResynced = false; | ||
| 2218 | |||
| 2219 | // Frames of silence owed to the timeline, from a source gap not yet filled. | ||
| 2220 | // See AES67Config::sourceSilenceFloor. | ||
| 2221 | bool fillGaps = false; | ||
| 2222 | guint64 fillFrames = 0; | ||
| 2223 | |||
| 2224 | // Rate estimate that survives a re-anchor. The card's rate against PTP is | ||
| 2225 | // a property of two crystals: it does not change because a track did. The | ||
| 2226 | // sliding window that measures it does get discarded at every re-anchor, | ||
| 2227 | // so without somewhere to keep the answer the loop has to re-learn a 54ppm | ||
| 2228 | // figure from scratch after every track boundary. | ||
| 2229 | double cardRateEst = 0.0; | ||
| 2230 | |||
| 2231 | |||
| 2232 | // Sliding window of (clock, input frames, output frames) used to measure | ||
| 2233 | // the card's rate. A cumulative average was used here first and it is what | ||
| 2234 | // made the loop oscillate: it is itself an integrator, so with the phase | ||
| 2235 | // feedback -- which integrates too -- the loop had two in series. It also | ||
| 2236 | // grew steadily less responsive as the run went on, which showed up as a | ||
| 2237 | // trim still crawling towards its answer 26 minutes in. | ||
| 2238 | struct Sample { | ||
| 2239 | GstClockTime t; | ||
| 2240 | guint64 in; | ||
| 2241 | guint64 out; | ||
| 2242 | }; | ||
| 2243 | std::deque<Sample> window; | ||
| 2244 | }; | ||
| 2245 | |||
| 2246 | 63 | static void DestroyDriftResampleState(gpointer data) { | |
| 2247 | 63 | auto* st = static_cast<DriftResampleState*>(data); | |
| 2248 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | if (st->src) { |
| 2249 | 63 | src_delete(st->src); | |
| 2250 | } | ||
| 2251 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | if (st->clock) { |
| 2252 | 63 | gst_object_unref(st->clock); | |
| 2253 | } | ||
| 2254 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | delete st; |
| 2255 | 63 | } | |
| 2256 | |||
| 2257 | 9171 | static GstPadProbeReturn DriftResampleProbe(GstPad* pad, GstPadProbeInfo* info, | |
| 2258 | gpointer user) { | ||
| 2259 | 9171 | auto* st = static_cast<DriftResampleState*>(user); | |
| 2260 | 9171 | const bool fillGaps = st->fillGaps; | |
| 2261 | 9171 | GstBuffer* buf = GST_PAD_PROBE_INFO_BUFFER(info); | |
| 2262 |
3/6✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9171 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 9171 times.
|
9171 | if (!buf || !st->src || !st->clock) { |
| 2263 | ✗ | return GST_PAD_PROBE_OK; | |
| 2264 | } | ||
| 2265 | |||
| 2266 | // A gap is not drift. Re-anchor and let the resampler start clean, rather | ||
| 2267 | // than trying to make up time that was never ours to make up. | ||
| 2268 |
2/2✓ Branch 0 taken 19 times.
✓ Branch 1 taken 9152 times.
|
9171 | if (GST_BUFFER_FLAG_IS_SET(buf, GST_BUFFER_FLAG_DISCONT)) { |
| 2269 | // Worth logging: this resets the resampler and restarts the rate | ||
| 2270 | // estimate, and it was happening every few minutes with nothing in the | ||
| 2271 | // log to say so -- the only visible sign was the loop's own elapsed | ||
| 2272 | // counter starting over. | ||
| 2273 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | LogInfo(VB_MEDIAOUT, |
| 2274 | "AES67 drift [%d]: source discontinuity, resetting\n", | ||
| 2275 | st->instanceId); | ||
| 2276 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | src_reset(st->src); |
| 2277 | 19 | st->anchored = false; | |
| 2278 | } | ||
| 2279 | |||
| 2280 | GstMapInfo map; | ||
| 2281 |
2/4✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 9171 times.
|
9171 | if (!gst_buffer_map(buf, &map, GST_MAP_READ)) { |
| 2282 | ✗ | return GST_PAD_PROBE_OK; | |
| 2283 | } | ||
| 2284 | 9171 | const int ch = st->channels; | |
| 2285 | 9171 | const long inFrames = (long)(map.size / (sizeof(float) * ch)); | |
| 2286 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 9171 times.
|
9171 | if (inFrames <= 0) { |
| 2287 | ✗ | gst_buffer_unmap(buf, &map); | |
| 2288 | ✗ | return GST_PAD_PROBE_OK; | |
| 2289 | } | ||
| 2290 | |||
| 2291 | // Report source-side PTS discontinuities at full resolution, tagged with | ||
| 2292 | // the buffer index so the onset can be located exactly. 1ms is chosen to | ||
| 2293 | // sit far below one quantum (21.3ms at 48000, 23.2ms at 44100) while | ||
| 2294 | // staying above any rounding in the PTS arithmetic; every gap observed so | ||
| 2295 | // far has been an exact multiple of the quantum, most often 3x. | ||
| 2296 | 9171 | st->seen++; | |
| 2297 |
1/2✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
|
9171 | if (GST_BUFFER_PTS_IS_VALID(buf)) { |
| 2298 | 9171 | const GstClockTime pts = GST_BUFFER_PTS(buf); | |
| 2299 |
3/4✓ Branch 0 taken 9152 times.
✓ Branch 1 taken 19 times.
✓ Branch 2 taken 9152 times.
✗ Branch 3 not taken.
|
9171 | if (st->lastPtsValid && pts > st->lastPts) { |
| 2300 | 9152 | const gint64 expected = | |
| 2301 | 9152 | (gint64)st->lastFrames * GST_SECOND / AES67::AUDIO_RATE; | |
| 2302 | 9152 | const gint64 jump = (gint64)(pts - st->lastPts) - expected; | |
| 2303 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 9149 times.
|
9152 | if (jump > (gint64)GST_MSECOND) { |
| 2304 | 3 | st->gapsSeen++; | |
| 2305 | // Owe the timeline this much silence. The RTP timestamp is a | ||
| 2306 | // sample count, so audio that never arrives puts the media | ||
| 2307 | // clock permanently behind PTP -- it cannot be corrected | ||
| 2308 | // later, because the samples are not late, they are absent. | ||
| 2309 | // Filling keeps the count aligned and keeps AES67 in step with | ||
| 2310 | // the local sound card, which already plays the same pause. | ||
| 2311 | // Capped so a pathological jump cannot ask for a huge | ||
| 2312 | // allocation; anything longer is a stream restart, not a gap. | ||
| 2313 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | if (fillGaps) { |
| 2314 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | const gint64 f = gst_util_uint64_scale( |
| 2315 | 3 | (guint64)jump, AES67::AUDIO_RATE, GST_SECOND); | |
| 2316 | 6 | st->fillFrames = (guint64)std::min<gint64>( | |
| 2317 | 3 | f, (gint64)AES67::AUDIO_RATE); // <= 1 second | |
| 2318 | } | ||
| 2319 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | LogInfo(VB_MEDIAOUT, |
| 2320 | "AES67 drift [%d]: source gap %+.2f ms at buffer %llu " | ||
| 2321 | "(gap #%llu)\n", | ||
| 2322 | st->instanceId, (double)jump / (double)GST_MSECOND, | ||
| 2323 | (unsigned long long)st->seen, | ||
| 2324 | (unsigned long long)st->gapsSeen); | ||
| 2325 | } | ||
| 2326 | } | ||
| 2327 | 9171 | st->lastPts = pts; | |
| 2328 | 9171 | st->lastFrames = inFrames; | |
| 2329 | 9171 | st->lastPtsValid = true; | |
| 2330 | } | ||
| 2331 | |||
| 2332 |
1/2✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
|
9171 | const GstClockTime now = gst_clock_get_time(st->clock); |
| 2333 |
2/2✓ Branch 0 taken 19 times.
✓ Branch 1 taken 9152 times.
|
9171 | if (!st->anchored) { |
| 2334 | 19 | st->anchored = true; | |
| 2335 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | st->anchorPts = GST_BUFFER_PTS_IS_VALID(buf) ? GST_BUFFER_PTS(buf) : 0; |
| 2336 | 19 | st->ptsFrames = 0; | |
| 2337 | 19 | st->warmed = false; | |
| 2338 | 19 | st->ctlClock = now; | |
| 2339 | 19 | st->ctlIn = 0; | |
| 2340 | 19 | st->ctlOut = 0; | |
| 2341 | 19 | st->ratio = 1.0; | |
| 2342 | } | ||
| 2343 | |||
| 2344 | // A PTP clock can step backwards, which happens when ptp4l first locks to | ||
| 2345 | // a grandmaster -- i.e. only when this device is a follower, never when it | ||
| 2346 | // is the grandmaster itself. Without this the elapsed time pins at zero | ||
| 2347 | // until the clock catches back up to where it was, and for a large step | ||
| 2348 | // (a TAI/UTC correction is 37 seconds) the loop would sit idle that whole | ||
| 2349 | // time. Re-anchoring costs nothing: the learned trim is kept, and only | ||
| 2350 | // the measurement window restarts. | ||
| 2351 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 9171 times.
|
9171 | if (now < st->ctlClock) { |
| 2352 | ✗ | LogInfo(VB_MEDIAOUT, | |
| 2353 | "AES67 drift [%d]: clock stepped back, re-anchoring\n", | ||
| 2354 | st->instanceId); | ||
| 2355 | ✗ | st->ctlClock = now; | |
| 2356 | ✗ | st->ctlIn = 0; | |
| 2357 | ✗ | st->ctlOut = 0; | |
| 2358 | ✗ | st->warmed = false; | |
| 2359 | ✗ | st->window.clear(); | |
| 2360 | } | ||
| 2361 | |||
| 2362 | 9171 | double elapsed = | |
| 2363 |
2/2✓ Branch 0 taken 9152 times.
✓ Branch 1 taken 19 times.
|
9171 | (now > st->ctlClock) ? (double)(now - st->ctlClock) / GST_SECOND : 0.0; |
| 2364 | |||
| 2365 | // Start controlling from a steady state, not from the pipeline's first | ||
| 2366 | // gasp. Everything before this is discarded rather than corrected. | ||
| 2367 |
4/4✓ Branch 0 taken 2342 times.
✓ Branch 1 taken 6829 times.
✓ Branch 2 taken 7 times.
✓ Branch 3 taken 2335 times.
|
9171 | if (!st->warmed && elapsed > 5.0) { |
| 2368 | 7 | st->warmed = true; | |
| 2369 | 7 | st->ctlClock = now; | |
| 2370 | 7 | st->ctlIn = 0; | |
| 2371 | 7 | st->ctlOut = 0; | |
| 2372 | 7 | st->window.clear(); | |
| 2373 | 7 | elapsed = 0.0; | |
| 2374 | } | ||
| 2375 | |||
| 2376 | // A large offset is a gap, not drift, and chasing it is the exact mistake | ||
| 2377 | // that made audiorate unusable. Absorb it instead: realign the output | ||
| 2378 | // timeline to PTP, restart the rate estimate, and keep the trim already | ||
| 2379 | // learned. Measured before this was added: a ~200ms startup gap held the | ||
| 2380 | // trim at its clamp for minutes, audibly pitching the stream while it | ||
| 2381 | // clawed back time that was never lost to drift. | ||
| 2382 | 9171 | constexpr double MAX_OFFSET_S = 0.05; | |
| 2383 | 9171 | bool resync = false; | |
| 2384 | 18342 | double err = st->warmed | |
| 2385 |
2/2✓ Branch 0 taken 6836 times.
✓ Branch 1 taken 2335 times.
|
9171 | ? (double)st->ctlOut - (double)AES67::AUDIO_RATE * elapsed |
| 2386 | : 0.0; | ||
| 2387 | // Do not absorb a gap we are about to fill. Absorbing re-anchors the | ||
| 2388 | // timeline to skip the hole; filling puts the missing samples back. Doing | ||
| 2389 | // both means the loop re-anchors and then the fill pushes the timeline | ||
| 2390 | // again -- measured, 4 absorptions still fired alongside 8 successful fills | ||
| 2391 | // and the media clock stayed at -54.7 ppm. Filling is the correct response | ||
| 2392 | // when it is available, so it wins. | ||
| 2393 |
4/4✓ Branch 0 taken 6836 times.
✓ Branch 1 taken 2335 times.
✓ Branch 2 taken 6834 times.
✓ Branch 3 taken 2 times.
|
9171 | if (st->warmed && st->fillFrames == 0 && |
| 2394 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 6834 times.
|
6834 | std::fabs(err) > MAX_OFFSET_S * AES67::AUDIO_RATE) { |
| 2395 | ✗ | LogInfo(VB_MEDIAOUT, | |
| 2396 | "AES67 drift [%d]: %+.0f ms gap absorbed, re-anchoring " | ||
| 2397 | "at buffer %llu\n", | ||
| 2398 | st->instanceId, err * 1000.0 / AES67::AUDIO_RATE, | ||
| 2399 | (unsigned long long)st->seen); | ||
| 2400 | ✗ | st->ctlClock = now; | |
| 2401 | ✗ | st->ctlIn = 0; | |
| 2402 | ✗ | st->ctlOut = 0; | |
| 2403 | ✗ | st->window.clear(); | |
| 2404 | ✗ | if (GST_BUFFER_PTS_IS_VALID(buf)) { | |
| 2405 | ✗ | st->anchorPts = GST_BUFFER_PTS(buf); | |
| 2406 | } | ||
| 2407 | ✗ | st->ptsFrames = 0; | |
| 2408 | ✗ | elapsed = 0.0; | |
| 2409 | ✗ | err = 0.0; | |
| 2410 | ✗ | resync = true; | |
| 2411 | // Re-warm rather than resume controlling straight away. Re-anchoring | ||
| 2412 | // rewrites the output timeline, and the buffers right after it measure | ||
| 2413 | // that as a fresh offset -- so absorbing a gap and immediately | ||
| 2414 | // controlling again makes the loop re-trigger on itself. Measured | ||
| 2415 | // under a 25s file on repeat it re-anchored three times in four | ||
| 2416 | // buffers, and a fixed buffer hold-off only paced the oscillation | ||
| 2417 | // rather than stopping it: the spacing histogram came back with 526 | ||
| 2418 | // re-anchors at exactly the hold-off length. | ||
| 2419 | // | ||
| 2420 | // The warm-up path above already does the right thing -- it waits 5s, | ||
| 2421 | // discards what it saw meanwhile, and starts the measurement clean -- | ||
| 2422 | // and the learned trim is deliberately kept across it, so rate | ||
| 2423 | // correction continues at the last good value while adaptation pauses. | ||
| 2424 | ✗ | st->warmed = false; | |
| 2425 | ✗ | st->lastResyncAt = st->seen; | |
| 2426 | ✗ | st->everResynced = true; | |
| 2427 | } | ||
| 2428 | |||
| 2429 | // Hold the trim already learned rather than snapping back to 1.0 whenever | ||
| 2430 | // there is not yet enough data. The rate being corrected is a property of | ||
| 2431 | // two crystals, so the last good value is always a better guess than "no | ||
| 2432 | // correction" -- and resetting it is what turned a burst of absorbed gaps | ||
| 2433 | // into a death spiral: trim collapsed to 0, the pacing queue then drained | ||
| 2434 | // for want of correction, the drain starved the source into more gaps, and | ||
| 2435 | // those gaps absorbed away every chance to re-learn the rate. Measured: | ||
| 2436 | // 9 minutes of correct operation, then trim pinned at 0.0ppm with the send | ||
| 2437 | // rate falling from 250/s to 155/s and absorptions climbing past 3500. | ||
| 2438 | 9171 | double target = st->ratio; | |
| 2439 | 9171 | double cardRate = 0.0; | |
| 2440 |
2/2✓ Branch 0 taken 6836 times.
✓ Branch 1 taken 2335 times.
|
9171 | if (st->warmed) { |
| 2441 | 6836 | constexpr double WINDOW_S = 60.0; | |
| 2442 |
1/2✓ Branch 0 taken 6836 times.
✗ Branch 1 not taken.
|
6836 | st->window.push_back({now, st->ctlIn, st->ctlOut}); |
| 2443 |
3/4✓ Branch 0 taken 6829 times.
✓ Branch 1 taken 7 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 6836 times.
|
13665 | while (st->window.size() > 1 && |
| 2444 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 6829 times.
|
6829 | (double)(now - st->window.front().t) / GST_SECOND > WINDOW_S) { |
| 2445 | ✗ | st->window.pop_front(); | |
| 2446 | } | ||
| 2447 | |||
| 2448 | 6836 | const auto& a = st->window.front(); | |
| 2449 | 6836 | const double span = (double)(now - a.t) / GST_SECOND; | |
| 2450 | |||
| 2451 | // Long enough to actually resolve the figure being measured. 54ppm | ||
| 2452 | // over 5 seconds is 270us, which sample quantisation and normal jitter | ||
| 2453 | // swamp -- measured, a 5s minimum left trim thrashing between +59, +29 | ||
| 2454 | // and +22 ppm after every track boundary. Over 30s the same drift is | ||
| 2455 | // 1.6ms and stands clear of the noise. | ||
| 2456 | // Two thresholds, because one cannot serve both jobs. Updating a | ||
| 2457 | // settled estimate wants a long window: 54ppm over 5s is 270us, which | ||
| 2458 | // sample quantisation and jitter swamp, and a 5s minimum left trim | ||
| 2459 | // thrashing between +59, +29 and +22 ppm after every track boundary. | ||
| 2460 | // But bootstrapping wants a short one -- a re-anchor clears the window, | ||
| 2461 | // so if tracks change every 25s a 30s minimum is never reached at all | ||
| 2462 | // and the estimate stays unset forever. Measured with only the long | ||
| 2463 | // threshold: trim sat at +0.0 ppm with no feedforward and the media | ||
| 2464 | // clock ran to -4442 ppm. | ||
| 2465 |
2/2✓ Branch 0 taken 4371 times.
✓ Branch 1 taken 2465 times.
|
6836 | const double MIN_ESTIMATE_S = (st->cardRateEst > 1000.0) ? 30.0 : 10.0; |
| 2466 |
2/2✓ Branch 0 taken 2401 times.
✓ Branch 1 taken 4435 times.
|
6836 | if (span > MIN_ESTIMATE_S) { |
| 2467 | // Feedforward over the window, not since the anchor. The card's | ||
| 2468 | // rate against PTP is two crystals, so this is near-constant; the | ||
| 2469 | // window only keeps the estimate from being anchored to whatever | ||
| 2470 | // the first few seconds happened to look like. | ||
| 2471 | 2401 | const double raw = (double)(st->ctlIn - a.in) / span; | |
| 2472 |
1/2✓ Branch 0 taken 2401 times.
✗ Branch 1 not taken.
|
2401 | if (raw > 1000.0) { |
| 2473 | // Smooth into the persistent estimate rather than replacing it, | ||
| 2474 | // so one noisy window cannot move the trim far. | ||
| 2475 | 2401 | st->cardRateEst = (st->cardRateEst > 1000.0) | |
| 2476 |
2/2✓ Branch 0 taken 2398 times.
✓ Branch 1 taken 3 times.
|
2401 | ? st->cardRateEst * 0.8 + raw * 0.2 |
| 2477 | : raw; | ||
| 2478 | } | ||
| 2479 | } | ||
| 2480 | // Use the persistent estimate, which is still valid across the gap the | ||
| 2481 | // window just lost. A track change disturbs phase, not rate. | ||
| 2482 | 6836 | cardRate = st->cardRateEst; | |
| 2483 | } | ||
| 2484 | |||
| 2485 |
2/2✓ Branch 0 taken 4374 times.
✓ Branch 1 taken 4797 times.
|
9171 | if (cardRate > 1000.0) { |
| 2486 | 4374 | const double ff = (double)AES67::AUDIO_RATE / cardRate; | |
| 2487 | |||
| 2488 | // Phase feedback: pull the accumulated offset back towards zero with a | ||
| 2489 | // first-order lag. The plant is already an integrator (a rate error | ||
| 2490 | // accumulates into offset), so proportional is the right shape and a | ||
| 2491 | // second integrator here is what previously rang. Clamping it well | ||
| 2492 | // below the feedforward guarantees it can trim but never take over. | ||
| 2493 | // Gain set by measurement, not by theory. At 3000 the loop settled | ||
| 2494 | // with a standing 23ms offset it would not remove; 30000 drives it to | ||
| 2495 | // +/-0.05ms and holds there, with the trim still sitting on the | ||
| 2496 | // feedforward value. Offset then decays with a ~33s time constant. | ||
| 2497 | 4374 | constexpr double KP_PPM_PER_SEC = 30000.0; | |
| 2498 | // 100ppm is 0.17 cents -- inaudible -- and well under the ~55ppm | ||
| 2499 | // feedforward, so this can trim but never take over. | ||
| 2500 | 4374 | constexpr double MAX_FEEDBACK_PPM = 100.0; | |
| 2501 | 4374 | double fbPpm = -KP_PPM_PER_SEC * err / (double)AES67::AUDIO_RATE; | |
| 2502 |
1/2✓ Branch 0 taken 4374 times.
✗ Branch 1 not taken.
|
4374 | fbPpm = std::clamp(fbPpm, -MAX_FEEDBACK_PPM, MAX_FEEDBACK_PPM); |
| 2503 | |||
| 2504 | 4374 | target = ff * (1.0 + fbPpm * 1e-6); | |
| 2505 | } | ||
| 2506 | |||
| 2507 | // Hard clamp, then slew limit. 300ppm is far beyond any real crystal pair | ||
| 2508 | // (the reference hardware needs 56ppm), so hitting the clamp means the | ||
| 2509 | // estimate is wrong and the right response is to refuse to act on it | ||
| 2510 | // rather than to pitch-shift the audio chasing it. | ||
| 2511 | 9171 | constexpr double MAX_TRIM = 0.0003; // +/-300ppm | |
| 2512 | 9171 | constexpr double MAX_STEP = 0.000002; // 2ppm per buffer | |
| 2513 |
1/2✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
|
9171 | target = std::clamp(target, 1.0 - MAX_TRIM, 1.0 + MAX_TRIM); |
| 2514 |
1/2✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
|
9171 | st->ratio = std::clamp(target, st->ratio - MAX_STEP, st->ratio + MAX_STEP); |
| 2515 | |||
| 2516 | 9171 | const size_t capacity = (size_t)(inFrames / st->ratio) + 32; | |
| 2517 |
2/2✓ Branch 0 taken 20 times.
✓ Branch 1 taken 9151 times.
|
9171 | if (st->out.size() < capacity * ch) { |
| 2518 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | st->out.resize(capacity * ch); |
| 2519 | } | ||
| 2520 | |||
| 2521 | SRC_DATA d; | ||
| 2522 | 9171 | memset(&d, 0, sizeof(d)); | |
| 2523 | 9171 | d.data_in = reinterpret_cast<const float*>(map.data); | |
| 2524 | 9171 | d.input_frames = inFrames; | |
| 2525 | 9171 | d.data_out = st->out.data(); | |
| 2526 | 9171 | d.output_frames = (long)capacity; | |
| 2527 | 9171 | d.src_ratio = st->ratio; | |
| 2528 | 9171 | d.end_of_input = 0; | |
| 2529 | |||
| 2530 |
1/2✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
|
9171 | const int rc = src_process(st->src, &d); |
| 2531 |
1/2✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
|
9171 | gst_buffer_unmap(buf, &map); |
| 2532 | |||
| 2533 |
2/4✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 9171 times.
|
9171 | if (rc != 0 || d.output_frames_gen <= 0) { |
| 2534 | ✗ | if (rc != 0) { | |
| 2535 | ✗ | LogWarn(VB_MEDIAOUT, "AES67 drift [%d]: src_process: %s\n", | |
| 2536 | st->instanceId, src_strerror(rc)); | ||
| 2537 | } | ||
| 2538 | ✗ | return GST_PAD_PROBE_OK; | |
| 2539 | } | ||
| 2540 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 9171 times.
|
9171 | if (d.input_frames_used < inFrames) { |
| 2541 | // Would mean silently discarding input, so it is counted rather than | ||
| 2542 | // ignored; the output buffer is sized so this should never happen. | ||
| 2543 | ✗ | st->shortReads++; | |
| 2544 | } | ||
| 2545 | |||
| 2546 | // Any silence owed from a source gap goes ahead of this buffer's audio, so | ||
| 2547 | // the sample count downstream stays continuous across a track change. | ||
| 2548 | 9171 | const size_t fill = (size_t)st->fillFrames; | |
| 2549 | 9171 | const size_t fillBytes = fill * ch * sizeof(float); | |
| 2550 | 9171 | const size_t bytes = (size_t)d.output_frames_gen * ch * sizeof(float); | |
| 2551 |
1/2✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
|
9171 | GstBuffer* out = gst_buffer_new_allocate(nullptr, fillBytes + bytes, nullptr); |
| 2552 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 9171 times.
|
9171 | if (!out) { |
| 2553 | ✗ | return GST_PAD_PROBE_OK; | |
| 2554 | } | ||
| 2555 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 9168 times.
|
9171 | if (fill) { |
| 2556 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | gst_buffer_memset(out, 0, 0, fillBytes); |
| 2557 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | LogInfo(VB_MEDIAOUT, |
| 2558 | "AES67 drift [%d]: filled %.1f ms of source gap with silence\n", | ||
| 2559 | st->instanceId, | ||
| 2560 | (double)fill * 1000.0 / (double)AES67::AUDIO_RATE); | ||
| 2561 | 3 | st->fillFrames = 0; | |
| 2562 | } | ||
| 2563 |
1/2✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
|
9171 | gst_buffer_fill(out, fillBytes, st->out.data(), bytes); |
| 2564 | |||
| 2565 | // Timestamp from the output sample count, so the timeline downstream | ||
| 2566 | // advances at exactly AUDIO_RATE. Producing AUDIO_RATE samples per PHC | ||
| 2567 | // second is what the loop above enforces, so media time tracks PTP. | ||
| 2568 | 9171 | GST_BUFFER_PTS(out) = | |
| 2569 |
1/2✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
|
9171 | st->anchorPts + gst_util_uint64_scale(st->ptsFrames, GST_SECOND, |
| 2570 | AES67::AUDIO_RATE); | ||
| 2571 | 18342 | GST_BUFFER_DURATION(out) = gst_util_uint64_scale( | |
| 2572 |
1/2✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
|
9171 | (guint64)d.output_frames_gen, GST_SECOND, AES67::AUDIO_RATE); |
| 2573 |
3/4✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✓ Branch 3 taken 9152 times.
|
9171 | if (resync || GST_BUFFER_FLAG_IS_SET(buf, GST_BUFFER_FLAG_DISCONT)) { |
| 2574 | 19 | GST_BUFFER_FLAG_SET(out, GST_BUFFER_FLAG_DISCONT); | |
| 2575 | } | ||
| 2576 | |||
| 2577 | 9171 | st->ptsFrames += (guint64)fill + (guint64)d.output_frames_gen; | |
| 2578 | // Count the filled silence as input as well as output. It is synthetic | ||
| 2579 | // input standing in for audio that never arrived, so leaving it out of the | ||
| 2580 | // input side makes the loop measure a source that is slow by exactly the | ||
| 2581 | // gaps -- measured, cardRate read 47235/s (-15926 ppm) and the trim pinned | ||
| 2582 | // at its +300 ppm clamp with the offset growing 11.5 -> 22.9 ms, even | ||
| 2583 | // though the wire was correct at +1.2 ppm. Counting it on both sides keeps | ||
| 2584 | // the rate estimate honest: after filling, the effective input really is | ||
| 2585 | // aligned to real time. | ||
| 2586 | 9171 | st->ctlIn += (guint64)fill + (guint64)d.input_frames_used; | |
| 2587 | 9171 | st->ctlOut += (guint64)fill + (guint64)d.output_frames_gen; | |
| 2588 | |||
| 2589 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 9169 times.
|
9171 | if ((++st->buffers % 2000) == 0) { |
| 2590 |
3/6✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
2 | LogInfo(VB_MEDIAOUT, |
| 2591 | "AES67 drift [%d]: trim %+.1f ppm, card %.1f/s (%+.1f ppm), offset %+.2f ms over %.0fs%s [buffer %llu, %llu gaps]\n", | ||
| 2592 | st->instanceId, (st->ratio - 1.0) * 1e6, cardRate, | ||
| 2593 | cardRate > 1000.0 | ||
| 2594 | ? (cardRate / (double)AES67::AUDIO_RATE - 1.0) * 1e6 | ||
| 2595 | : 0.0, | ||
| 2596 | // err as the controller saw it. Recomputing here instead | ||
| 2597 | // reads one buffer high -- a whole graph quantum, 23.2ms on a | ||
| 2598 | // 1024/44100 graph -- which looked exactly like a stuck offset | ||
| 2599 | // the loop was failing to correct. | ||
| 2600 | err * 1000.0 / AES67::AUDIO_RATE, | ||
| 2601 | elapsed, | ||
| 2602 | st->shortReads ? " (SHORT READS)" : "", | ||
| 2603 | (unsigned long long)st->seen, | ||
| 2604 | (unsigned long long)st->gapsSeen); | ||
| 2605 | } | ||
| 2606 | |||
| 2607 |
1/2✓ Branch 0 taken 9171 times.
✗ Branch 1 not taken.
|
9171 | gst_buffer_unref(buf); |
| 2608 | 9171 | GST_PAD_PROBE_INFO_DATA(info) = out; | |
| 2609 | 9171 | return GST_PAD_PROBE_OK; | |
| 2610 | } | ||
| 2611 | #endif // FPP_HAVE_SAMPLERATE | ||
| 2612 | |||
| 2613 | |||
| 2614 | // Byte counters at both ends of the send pipeline. See | ||
| 2615 | // AES67Config::pipelineStats. | ||
| 2616 | static int PipeWireGraphRate(); | ||
| 2617 | |||
| 2618 | struct PipelineStatsState { | ||
| 2619 | int instanceId = 0; | ||
| 2620 | guint64 inBytes = 0; | ||
| 2621 | guint64 outBytes = 0; | ||
| 2622 | GstClockTime start = 0; | ||
| 2623 | GstClockTime lastLog = 0; | ||
| 2624 | guint64 inAtLast = 0; | ||
| 2625 | guint64 outAtLast = 0; | ||
| 2626 | }; | ||
| 2627 | |||
| 2628 | ✗ | static void DestroyPipelineStatsState(gpointer data) { | |
| 2629 | ✗ | delete static_cast<PipelineStatsState*>(data); | |
| 2630 | ✗ | } | |
| 2631 | |||
| 2632 | ✗ | static GstPadProbeReturn PipelineStatsIn(GstPad*, GstPadProbeInfo* info, | |
| 2633 | gpointer user) { | ||
| 2634 | ✗ | auto* st = static_cast<PipelineStatsState*>(user); | |
| 2635 | ✗ | GstBuffer* b = GST_PAD_PROBE_INFO_BUFFER(info); | |
| 2636 | ✗ | if (!b) { | |
| 2637 | ✗ | return GST_PAD_PROBE_OK; | |
| 2638 | } | ||
| 2639 | ✗ | st->inBytes += gst_buffer_get_size(b); | |
| 2640 | |||
| 2641 | ✗ | const GstClockTime now = gst_util_get_timestamp(); | |
| 2642 | ✗ | if (st->start == 0) { | |
| 2643 | ✗ | st->start = now; | |
| 2644 | ✗ | st->lastLog = now; | |
| 2645 | ✗ | return GST_PAD_PROBE_OK; | |
| 2646 | } | ||
| 2647 | ✗ | if (now - st->lastLog < 30 * GST_SECOND) { | |
| 2648 | ✗ | return GST_PAD_PROBE_OK; | |
| 2649 | } | ||
| 2650 | ✗ | const double dt = (double)(now - st->lastLog) / GST_SECOND; | |
| 2651 | ✗ | LogInfo(VB_MEDIAOUT, | |
| 2652 | "AES67 stats [%d]: pipewiresrc %.0f B/s, udpsink %.0f B/s, " | ||
| 2653 | "ratio %.3f, totals in %llu out %llu\n", | ||
| 2654 | st->instanceId, (double)(st->inBytes - st->inAtLast) / dt, | ||
| 2655 | (double)(st->outBytes - st->outAtLast) / dt, | ||
| 2656 | (st->inBytes > st->inAtLast) | ||
| 2657 | ? (double)(st->outBytes - st->outAtLast) / | ||
| 2658 | (double)(st->inBytes - st->inAtLast) | ||
| 2659 | : 0.0, | ||
| 2660 | (unsigned long long)st->inBytes, (unsigned long long)st->outBytes); | ||
| 2661 | ✗ | st->lastLog = now; | |
| 2662 | ✗ | st->inAtLast = st->inBytes; | |
| 2663 | ✗ | st->outAtLast = st->outBytes; | |
| 2664 | ✗ | return GST_PAD_PROBE_OK; | |
| 2665 | } | ||
| 2666 | |||
| 2667 | ✗ | static GstPadProbeReturn PipelineStatsOut(GstPad*, GstPadProbeInfo* info, | |
| 2668 | gpointer user) { | ||
| 2669 | ✗ | auto* st = static_cast<PipelineStatsState*>(user); | |
| 2670 | ✗ | GstBuffer* b = GST_PAD_PROBE_INFO_BUFFER(info); | |
| 2671 | ✗ | if (b) { | |
| 2672 | ✗ | st->outBytes += gst_buffer_get_size(b); | |
| 2673 | } | ||
| 2674 | ✗ | return GST_PAD_PROBE_OK; | |
| 2675 | } | ||
| 2676 | |||
| 2677 | 169 | bool AES67Manager::CreateSendPipeline(const AES67Instance& inst) { | |
| 2678 |
2/4✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
|
169 | std::string nodeName = SafeNodeName(inst.name) + "_send"; |
| 2679 |
1/2✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
|
169 | std::string sourceIP = GetInterfaceIP(inst.interface); |
| 2680 | |||
| 2681 | // Calculate ptime in nanoseconds for rtpL24pay | ||
| 2682 | 169 | int64_t ptimeNs = (int64_t)inst.ptime * 1000000LL; | |
| 2683 | |||
| 2684 | // Build pipeline string | ||
| 2685 | // pipewiresrc registers itself as a PipeWire node with the expected name | ||
| 2686 | // so that the audio group's filter-chain output (which has | ||
| 2687 | // node.target=<nodeName>) can connect to it. We do NOT use | ||
| 2688 | // target-object because this node is the DESTINATION, not the source. | ||
| 2689 | // | ||
| 2690 | // Pipeline: | ||
| 2691 | // pipewiresrc stream-properties="props,node.name=<node>" | ||
| 2692 | // ! audioconvert ! audioresample ! audioconvert | ||
| 2693 | // ! audio/x-raw,format=S24BE,rate=48000,channels=N | ||
| 2694 | // ! rtpL24pay pt=96 min-ptime=<ns> max-ptime=<ns> | ||
| 2695 | // ! application/x-rtp,clock-rate=48000 | ||
| 2696 | // ! udpsink host=<multicast> port=<port> multicast-iface=<iface> ttl-mc=4 auto-multicast=true sync=true | ||
| 2697 | // | ||
| 2698 | // The resampler is not optional. AES67 mandates 48 kHz on the wire, and the | ||
| 2699 | // PipeWire graph no longer runs there: since the graph clock started | ||
| 2700 | // following the output card it sits at whatever that card is configured for, | ||
| 2701 | // 44100 by default. pipewiresrc cannot satisfy a caps filter that asks for | ||
| 2702 | // a different rate AND a format PipeWire does not carry natively (S24BE, the | ||
| 2703 | // big-endian packing L24 needs) -- it does not fail, it simply never | ||
| 2704 | // negotiates, so set_state() blocks and eventually returns FAILURE, which is | ||
| 2705 | // the "AES67: audio send stream failed to start" warning. | ||
| 2706 | // | ||
| 2707 | // Measured against the live 44100 graph: | ||
| 2708 | // audioconvert ! S24BE@48000 hangs | ||
| 2709 | // audioconvert ! S24BE@44100 works (no rate change) | ||
| 2710 | // audioresample ! audioconvert ! S24BE@48000 hangs | ||
| 2711 | // audioconvert ! audioresample ! audioconvert ! S24BE@48000 works | ||
| 2712 | // | ||
| 2713 | // So the conversion has to be split: reach a format the resampler is happy | ||
| 2714 | // to work in, resample, and only then pack to S24BE. This costs nothing | ||
| 2715 | // when the graph already runs at 48000, where the resampler passes through. | ||
| 2716 | |||
| 2717 |
1/2✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
|
169 | std::ostringstream oss; |
| 2718 | // Media clock -- see GetOrCreateMediaClock(). Everything below that makes | ||
| 2719 | // the RTP timestamps mean something depends on having it. | ||
| 2720 |
3/4✓ Branch 0 taken 168 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
|
169 | GstClock* ptpClock = m_config.ptpMediaClock ? GetOrCreateMediaClock() : nullptr; |
| 2721 | // Correcting the rate only means anything when the timeline it is being | ||
| 2722 | // corrected against is PTP. | ||
| 2723 |
4/4✓ Branch 0 taken 64 times.
✓ Branch 1 taken 105 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 63 times.
|
169 | const bool driftControl = (ptpClock != nullptr) && m_config.adaptiveResample; |
| 2724 | 169 | const bool sinkPacing = m_config.sinkPacing; | |
| 2725 | |||
| 2726 | // Caps above stereo must name the channel layout or nothing negotiates -- | ||
| 2727 | // see ChannelMaskFor(). Emitted into both caps filters below. | ||
| 2728 | 169 | const guint64 mask = AES67::ChannelMaskFor(inst.channels); | |
| 2729 | const std::string chanMask = | ||
| 2730 | ✗ | mask ? (",channel-mask=(bitmask)0x" + [&] { | |
| 2731 | char b[32]; | ||
| 2732 | 169 | snprintf(b, sizeof(b), "%llx", (unsigned long long)mask); | |
| 2733 |
1/2✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
|
338 | return std::string(b); |
| 2734 |
2/6✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
338 | }()) |
| 2735 |
2/4✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
|
338 | : std::string(); |
| 2736 | 169 | const bool rateMatch = m_config.rateMatch; | |
| 2737 | #ifdef FPP_HAVE_SAMPLERATE | ||
| 2738 |
4/4✓ Branch 0 taken 168 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 64 times.
✓ Branch 3 taken 104 times.
|
169 | const bool driftResample = m_config.driftResample && (ptpClock != nullptr); |
| 2739 | // sourceSilenceFloor is applied by DriftResampleProbe, and that probe is | ||
| 2740 | // only installed when driftResample is on -- so the flag on its own does | ||
| 2741 | // nothing. Say so rather than accepting it into silence: a set flag that | ||
| 2742 | // no-ops reads as a fault in the sender when someone tests against real | ||
| 2743 | // receiver hardware. | ||
| 2744 |
3/4✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 105 times.
✓ Branch 3 taken 64 times.
|
169 | if (m_config.sourceSilenceFloor && !driftResample) { |
| 2745 |
2/4✗ Branch 0 not taken.
✓ Branch 1 taken 105 times.
✓ Branch 2 taken 105 times.
✗ Branch 3 not taken.
|
105 | LogWarn(VB_MEDIAOUT, |
| 2746 | "AES67 send [%d]: sourceSilenceFloor is set but has no effect " | ||
| 2747 | "without driftResample%s -- source gaps will not be filled\n", | ||
| 2748 | inst.id, ptpClock ? "" : " and a PTP clock"); | ||
| 2749 | } | ||
| 2750 | #else | ||
| 2751 | const bool driftResample = false; | ||
| 2752 | if (m_config.driftResample) { | ||
| 2753 | LogWarn(VB_MEDIAOUT, | ||
| 2754 | "AES67 send [%d]: driftResample requested but this build has " | ||
| 2755 | "no libsamplerate\n", inst.id); | ||
| 2756 | } | ||
| 2757 | if (m_config.sourceSilenceFloor) { | ||
| 2758 | LogWarn(VB_MEDIAOUT, | ||
| 2759 | "AES67 send [%d]: sourceSilenceFloor requested but this build " | ||
| 2760 | "has no libsamplerate -- source gaps will not be filled\n", | ||
| 2761 | inst.id); | ||
| 2762 | } | ||
| 2763 | #endif | ||
| 2764 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 168 times.
|
169 | if (!m_config.ptpMediaClock) { |
| 2765 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "AES67 send [%d]: PTP media clock disabled by config\n", inst.id); |
| 2766 | } | ||
| 2767 | |||
| 2768 | // Ask PipeWire for the graph's own rate. Without this the AES67 rate | ||
| 2769 | // propagates all the way up and PipeWire resamples for this node alone, | ||
| 2770 | // which is what degrades after ~11 minutes. See | ||
| 2771 | // AES67Config::nativeSourceRate. audioresample below then does the | ||
| 2772 | // conversion, which is what it is there for. | ||
| 2773 |
1/4✗ Branch 0 not taken.
✓ Branch 1 taken 169 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
|
169 | const int graphRate = m_config.nativeSourceRate ? PipeWireGraphRate() : 0; |
| 2774 | |||
| 2775 | // Multichannel needs the graph clocked at 48kHz. Above stereo, PipeWire | ||
| 2776 | // resampling every quantum into the 48kHz AES67 node delays buffer | ||
| 2777 | // delivery enough that they reach udpsink already past their PTS, and | ||
| 2778 | // sync=true cannot pace what is already late -- it sends back-to-back to | ||
| 2779 | // catch up. Measured at 8 channels on a 44.1kHz graph: 74% of packets | ||
| 2780 | // back-to-back even with always-copy on; at 48kHz, clean, with jitter | ||
| 2781 | // inside the 1ms AES67 recommends. Stereo is unaffected either way. | ||
| 2782 | // | ||
| 2783 | // The graph rate follows the selected playback card's achieved rate (see | ||
| 2784 | // FPPINIT_Audio), so this is not always the AudioFormat setting alone. | ||
| 2785 |
2/2✓ Branch 0 taken 8 times.
✓ Branch 1 taken 161 times.
|
169 | if (inst.channels > AES67::MULTICHANNEL_NEEDS_BUFFER_COPY) { |
| 2786 |
1/2✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
|
8 | const int actualRate = PipeWireGraphRate(); |
| 2787 |
1/4✗ Branch 0 not taken.
✓ Branch 1 taken 8 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
|
8 | if (actualRate > 0 && actualRate != AES67::AUDIO_RATE) { |
| 2788 | ✗ | LogWarn(VB_MEDIAOUT, | |
| 2789 | "AES67 send [%d]: %d channels on a %dHz graph clock. " | ||
| 2790 | "Above stereo this paces badly -- the graph has to be %dHz. " | ||
| 2791 | "Set AudioFormat to 48kHz and use a playback card that runs " | ||
| 2792 | "at 48kHz, or keep this stream at 2 channels.\n", | ||
| 2793 | inst.id, inst.channels, actualRate, AES67::AUDIO_RATE); | ||
| 2794 | } | ||
| 2795 | } | ||
| 2796 |
1/4✗ Branch 0 not taken.
✓ Branch 1 taken 169 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
|
169 | if (graphRate > 0 && graphRate != AES67::AUDIO_RATE) { |
| 2797 | ✗ | LogInfo(VB_MEDIAOUT, | |
| 2798 | "AES67 send [%d]: taking source at graph rate %d, converting " | ||
| 2799 | "to %d in the pipeline\n", | ||
| 2800 | inst.id, graphRate, AES67::AUDIO_RATE); | ||
| 2801 | } | ||
| 2802 | |||
| 2803 | oss << "pipewiresrc name=pwsrc" | ||
| 2804 |
3/6✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
|
169 | << " min-buffers=" << m_config.sourceMinBuffers |
| 2805 | << " always-copy=" | ||
| 2806 | 338 | << ((m_config.sourceBufferCopy || | |
| 2807 |
2/2✓ Branch 0 taken 8 times.
✓ Branch 1 taken 161 times.
|
169 | inst.channels > AES67::MULTICHANNEL_NEEDS_BUFFER_COPY) |
| 2808 | ? "true" : "false") | ||
| 2809 | << " " | ||
| 2810 | ✗ | << ((graphRate > 0 && graphRate != AES67::AUDIO_RATE) | |
| 2811 |
2/12✗ Branch 0 not taken.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 169 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
|
338 | ? ("! audio/x-raw,rate=" + std::to_string(graphRate) + " ") |
| 2812 |
2/6✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 169 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
338 | : "") |
| 2813 | << "! audioconvert " | ||
| 2814 | << "! audioresample " | ||
| 2815 | << "! audioconvert " | ||
| 2816 | // The master volume (see SetMasterVolume()). Passes buffers through | ||
| 2817 | // untouched at 100%. | ||
| 2818 |
13/26✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 169 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 169 times.
✓ Branch 10 taken 169 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 169 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 169 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 169 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 169 times.
✗ Branch 19 not taken.
✓ Branch 20 taken 169 times.
✗ Branch 21 not taken.
✓ Branch 22 taken 169 times.
✗ Branch 23 not taken.
✓ Branch 24 taken 169 times.
✗ Branch 25 not taken.
|
507 | << "! volume name=mastervol volume=" << masterVolumeGain(getVolume()) << " " |
| 2819 | // Rate trim for the drift control loop. Sits in the float domain | ||
| 2820 | // because that is all this element accepts, and ahead of the S24BE | ||
| 2821 | // conversion and the packet split so both still see a clean stream. | ||
| 2822 | // Pin the format AND channel count across the rate trim. "speed" | ||
| 2823 | // advertises channels [1, MAX] and nothing downstream forces a count | ||
| 2824 | // until the S24BE capsfilter, so negotiation happily settles on mono | ||
| 2825 | // and audioconvert quietly upmixes back to stereo afterwards: correct | ||
| 2826 | // packet sizes, correct timestamps, valid SDP, and a mono stream. | ||
| 2827 | // Nothing at the packet level catches it -- it showed up as a mono | ||
| 2828 | // feed in the PipeWire graph. | ||
| 2829 | << (driftControl | ||
| 2830 |
7/16✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 168 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 168 times.
✓ Branch 8 taken 1 times.
✓ Branch 9 taken 168 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
|
339 | ? ("! audio/x-raw,format=F32LE,channels=" + std::to_string(inst.channels) + |
| 2831 | " ! pitch name=drift " | ||
| 2832 |
6/12✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 168 times.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 168 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
|
340 | "! audio/x-raw,format=F32LE,channels=" + std::to_string(inst.channels) + " ") |
| 2833 |
4/6✓ Branch 0 taken 168 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 168 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
337 | : "") |
| 2834 | // Tap point for drift correction. identity does nothing itself; the | ||
| 2835 | // resampling happens in a pad probe on its src pad, in the float | ||
| 2836 | // domain and before the audio is cut into packets, so every packet | ||
| 2837 | // downstream is still exactly one ptime long. | ||
| 2838 | << (driftResample | ||
| 2839 |
2/6✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 105 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
233 | ? ("! audio/x-raw,format=F32LE,rate=" + |
| 2840 |
8/16✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 64 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 64 times.
✓ Branch 5 taken 105 times.
✓ Branch 6 taken 64 times.
✓ Branch 7 taken 105 times.
✓ Branch 8 taken 64 times.
✓ Branch 9 taken 105 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
|
297 | std::to_string(AES67::AUDIO_RATE) + ",channels=" + |
| 2841 |
6/14✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 64 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 64 times.
✓ Branch 5 taken 105 times.
✓ Branch 6 taken 64 times.
✓ Branch 7 taken 105 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
|
466 | std::to_string(inst.channels) + chanMask + |
| 2842 | " ! identity name=driftpoint ") | ||
| 2843 |
4/6✓ Branch 0 taken 105 times.
✓ Branch 1 taken 64 times.
✓ Branch 2 taken 64 times.
✓ Branch 3 taken 105 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
274 | : "") |
| 2844 | << "! audioconvert " | ||
| 2845 |
10/16✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 168 times.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 64 times.
✓ Branch 7 taken 105 times.
✓ Branch 8 taken 169 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 169 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 169 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 169 times.
✗ Branch 15 not taken.
|
507 | << "! audio/x-raw,format=S24BE,rate=" << AES67::AUDIO_RATE |
| 2846 |
2/4✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
|
169 | << ",channels=" << inst.channels << chanMask << " " |
| 2847 | // Re-block the audio into exactly one packet per buffer, on a timeline | ||
| 2848 | // aligned to sample boundaries. pipewiresrc hands us whatever the graph | ||
| 2849 | // quantum produced, with timestamps that do not land on packet | ||
| 2850 | // boundaries -- and the payloader then has to choose between following | ||
| 2851 | // those timestamps (RTP increments wobble +/-1 sample, audibly | ||
| 2852 | // distorted) or counting samples itself (increments exact, but the | ||
| 2853 | // timeline drifts away from PTP on every dropped buffer, measured at | ||
| 2854 | // -180ms). Splitting first removes the choice: buffers are exactly | ||
| 2855 | // ptime long and correctly timestamped, so the payloader can follow the | ||
| 2856 | // running time (= PTP time) and still step exactly one packet each time. | ||
| 2857 | // Reconcile the card's sample count with PTP time before the audio is | ||
| 2858 | // cut into packets, so the payloader sees a timeline that already | ||
| 2859 | // advances at the PTP rate. See AES67Config::rateMatch. | ||
| 2860 | << (rateMatch ? "! audiorate name=ratematch " : "") | ||
| 2861 | // Also required by sinkPacing, and for a second reason: the payloader | ||
| 2862 | // stamps every packet it emits from one input buffer with that | ||
| 2863 | // buffer's timestamp, so a whole quantum's worth of packets come out | ||
| 2864 | // sharing a PTS. udpsink then has nothing to pace against and sends | ||
| 2865 | // them together -- sync=true on its own leaves the burst untouched | ||
| 2866 | // (measured: 5.8 packets per 23.22ms, identical to sync=false). | ||
| 2867 | // Splitting first gives each packet its own send time. | ||
| 2868 |
2/2✓ Branch 0 taken 104 times.
✓ Branch 1 taken 1 times.
|
105 | << ((ptpClock || sinkPacing) |
| 2869 |
2/6✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
506 | ? ("! audiobuffersplit output-buffer-duration=" + |
| 2870 |
7/12✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 168 times.
✓ Branch 4 taken 168 times.
✓ Branch 5 taken 1 times.
✓ Branch 6 taken 168 times.
✓ Branch 7 taken 1 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
|
506 | std::to_string(inst.ptime) + "/1000 ") : "") |
| 2871 |
9/16✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 169 times.
✓ Branch 6 taken 169 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 105 times.
✓ Branch 9 taken 64 times.
✓ Branch 10 taken 169 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 169 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 169 times.
✗ Branch 15 not taken.
|
338 | << "! rtpL24pay name=pay pt=" << AES67::RTP_PAYLOAD_TYPE |
| 2872 | // Explicit, not GStreamer's 1400 default: the payloader silently | ||
| 2873 | // splits a payload larger than this, which would contradict the | ||
| 2874 | // min-ptime=max-ptime pair right below. ParseConfig has already | ||
| 2875 | // clamped ptime so one packet fits. | ||
| 2876 |
2/4✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
|
169 | << " mtu=" << AES67::MAX_RTP_PACKET_BYTES |
| 2877 |
2/4✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
|
169 | << " min-ptime=" << ptimeNs |
| 2878 |
2/4✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
|
169 | << " max-ptime=" << ptimeNs |
| 2879 | // timestamp-offset=0 anchors the RTP timeline to PTP (see the media | ||
| 2880 | // clock note below). perfect-rtptime stays TRUE -- deriving each | ||
| 2881 | // packet's timestamp from the buffer running time instead made the | ||
| 2882 | // increments wobble by +/-1 sample (191/193/194 rather than a clean | ||
| 2883 | // 192), and a receiver that places samples by RTP timestamp has to | ||
| 2884 | // absorb that wobble on every single packet. With it true the | ||
| 2885 | // payloader counts samples, so the timeline is anchored to PTP at | ||
| 2886 | // start and then advances exactly one packet at a time. | ||
| 2887 | // perfect-rtptime=false: follow the (now exactly aligned) running time | ||
| 2888 | // so the RTP timeline stays anchored to PTP instead of free-running | ||
| 2889 | // off a sample counter. See the audiobuffersplit note above. | ||
| 2890 | // With splitClockDomains the pipeline is no longer on PTP, so running | ||
| 2891 | // time is not PTP and perfect-rtptime=false would unanchor the RTP | ||
| 2892 | // timeline entirely (measured: -36,135,744 ms of presentation error). | ||
| 2893 | // perfect-rtptime=true makes the payloader count samples instead, and | ||
| 2894 | // driftResample already guarantees exactly 48000 of them per PTP | ||
| 2895 | // second -- so the counter runs at PTP rate whatever clock the pipeline | ||
| 2896 | // uses. The remaining job is anchoring its start via timestamp-offset, | ||
| 2897 | // which is why the absolute lead is still wrong here. | ||
| 2898 | << (ptpClock | ||
| 2899 | 64 | ? (m_config.splitClockDomains | |
| 2900 |
1/2✓ Branch 0 taken 64 times.
✗ Branch 1 not taken.
|
64 | ? " timestamp-offset=0 perfect-rtptime=true" |
| 2901 | : " timestamp-offset=0 perfect-rtptime=false") | ||
| 2902 | : "") | ||
| 2903 | << " " | ||
| 2904 |
6/10✓ Branch 0 taken 64 times.
✓ Branch 1 taken 105 times.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 169 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 169 times.
✗ Branch 9 not taken.
|
233 | << "! application/x-rtp,clock-rate=" << AES67::AUDIO_RATE << " " |
| 2905 | // A queue here is what makes sink pacing safe: it runs the sink on its | ||
| 2906 | // own thread, so the sink blocking until a packet's send time cannot | ||
| 2907 | // push back into the live pipewiresrc. Non-leaky on purpose -- in | ||
| 2908 | // steady state it drains exactly as fast as it fills, and dropping a | ||
| 2909 | // packet to keep up would defeat the point. See sinkPacing. | ||
| 2910 | // Bounded by time only. A buffer-count cap is a ptime trap: 64 buffers | ||
| 2911 | // is 256ms at 4ms ptime but only 64ms at 1ms, and the queue has to hold | ||
| 2912 | // the sink latency plus a whole graph quantum's burst -- about 61 | ||
| 2913 | // buffers at 1ms. That put the cap right on top of normal steady | ||
| 2914 | // state, the queue ran permanently full, and the backpressure silenced | ||
| 2915 | // the stream entirely on a Yamaha MRX7-D at 1ms ptime (reported on | ||
| 2916 | // #2848: "queue 64 buffers / 64ms" repeating, with no audio). | ||
| 2917 | << (sinkPacing ? "! queue name=sinkq max-size-bytes=0 " | ||
| 2918 | "max-size-buffers=0 max-size-time=200000000 " : "") | ||
| 2919 | 169 | << "! udpsink name=usink host=" << inst.multicastIP | |
| 2920 |
8/14✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 169 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 169 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 169 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 169 times.
✗ Branch 13 not taken.
|
169 | << " port=" << inst.port |
| 2921 |
2/4✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
|
169 | << " ttl-mc=" << AES67::AUDIO_RTP_TTL |
| 2922 |
2/4✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 169 times.
✗ Branch 3 not taken.
|
169 | << " qos-dscp=" << AES67::AUDIO_DSCP |
| 2923 |
4/6✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 169 times.
✗ Branch 5 not taken.
|
169 | << " auto-multicast=true sync=" << (sinkPacing ? "true" : "false"); |
| 2924 | |||
| 2925 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 166 times.
|
169 | if (!inst.interface.empty()) { |
| 2926 |
2/4✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
3 | oss << " multicast-iface=" << inst.interface; |
| 2927 | } | ||
| 2928 | |||
| 2929 |
1/2✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
|
169 | std::string pipelineStr = oss.str(); |
| 2930 |
1/2✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
|
169 | LogInfo(VB_MEDIAOUT, "AES67 send pipeline [%d] %s: %s\n", |
| 2931 | inst.id, inst.name.c_str(), pipelineStr.c_str()); | ||
| 2932 | |||
| 2933 | 169 | GError* error = nullptr; | |
| 2934 |
1/2✓ Branch 0 taken 169 times.
✗ Branch 1 not taken.
|
169 | GstElement* pipeline = gst_parse_launch(pipelineStr.c_str(), &error); |
| 2935 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 168 times.
|
169 | if (error) { |
| 2936 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogErr(VB_MEDIAOUT, "AES67 send pipeline error [%d]: %s\n", |
| 2937 | inst.id, error->message); | ||
| 2938 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | g_error_free(error); |
| 2939 | // gst_parse_launch() can return a non-null (partial) pipeline even | ||
| 2940 | // when it also reports an error -- release it so we don't leak. | ||
| 2941 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (pipeline) { |
| 2942 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | gst_object_unref(pipeline); |
| 2943 | } | ||
| 2944 | 1 | return false; | |
| 2945 | } | ||
| 2946 | |||
| 2947 | // Set stream-properties post-launch — inline GstStructure values in | ||
| 2948 | // gst_parse_launch can crash gst_value_deserialize on some platforms. | ||
| 2949 |
3/6✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 168 times.
✗ Branch 5 not taken.
|
168 | GstElement* pwsrc = gst_bin_get_by_name(GST_BIN(pipeline), "pwsrc"); |
| 2950 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | if (pwsrc) { |
| 2951 | // Ask PipeWire for a quantum no larger than one RTP packet. | ||
| 2952 | // | ||
| 2953 | // This is what makes the stream evenly paced. At the stock 1024-sample | ||
| 2954 | // quantum pipewiresrc hands us ~21ms of audio at once and the packets | ||
| 2955 | // for it leave back-to-back in microseconds, then nothing for 21ms -- | ||
| 2956 | // which is far outside the receive window of a Dante/AES67 receiver. | ||
| 2957 | // | ||
| 2958 | // Pacing it at the sink instead (udpsink sync=true) does not work here: | ||
| 2959 | // buffers arrive with a PTS already one quantum in the past, so the | ||
| 2960 | // sink blocks, backpressure reaches the live pipewiresrc, and it drops | ||
| 2961 | // audio rather than stalling. Measured during playback that cost ~36% | ||
| 2962 | // of the stream. Fixing the cadence at the source has no such failure | ||
| 2963 | // mode -- and it cuts sender latency, which the receiver has to absorb | ||
| 2964 | // as link offset. | ||
| 2965 | // | ||
| 2966 | // PipeWire clamps this to its min-quantum, and the graph quantum is the | ||
| 2967 | // smallest any node asks for, so this does raise CPU for the whole | ||
| 2968 | // graph. That is the trade for a stream that is actually usable. | ||
| 2969 | 336 | std::string nodeLatency = std::to_string(inst.ptime * AES67::AUDIO_RATE / 1000) + | |
| 2970 |
2/4✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
|
504 | "/" + std::to_string(AES67::AUDIO_RATE); |
| 2971 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | GstStructure* props = gst_structure_new("props", |
| 2972 | "node.name", G_TYPE_STRING, nodeName.c_str(), | ||
| 2973 | "node.autoconnect", G_TYPE_BOOLEAN, FALSE, | ||
| 2974 | // Keep this node scheduled even when nothing is feeding it, so the | ||
| 2975 | // AES67 side sees one continuous stream -- silence when idle, audio | ||
| 2976 | // when a track is playing -- instead of a hole at every transition. | ||
| 2977 | // | ||
| 2978 | // Idle already works: with no media at all the stream still runs at | ||
| 2979 | // 100% delivery with no gaps, because PipeWire hands us silence. | ||
| 2980 | // The hole appears only at the moment fppd_stream_1 disconnects and | ||
| 2981 | // the graph relinks, when this node is briefly unscheduled: measured | ||
| 2982 | // as a +232 ms PTS jump at every track change. That matters because | ||
| 2983 | // the RTP timestamp is a sample count, so samples that never arrive | ||
| 2984 | // put the media clock permanently behind PTP -- measured at | ||
| 2985 | // -6078 ppm under a 25s file on repeat, which is AES67 running ahead | ||
| 2986 | // of the local sound card by ~180 ms per track and accumulating. | ||
| 2987 | // | ||
| 2988 | // Upstream sets the same property on module-rtp-sink's stream for | ||
| 2989 | // the same reason (pipewire-aes67.conf). | ||
| 2990 | "node.always-process", G_TYPE_BOOLEAN, TRUE, | ||
| 2991 | NULL); | ||
| 2992 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 168 times.
|
168 | if (m_config.sourcePacing) { |
| 2993 | ✗ | gst_structure_set(props, "node.latency", G_TYPE_STRING, nodeLatency.c_str(), NULL); | |
| 2994 | } else { | ||
| 2995 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | LogInfo(VB_MEDIAOUT, "AES67 send [%d]: source pacing disabled by config\n", inst.id); |
| 2996 | } | ||
| 2997 | // Schedule this node from PTP rather than the card. See | ||
| 2998 | // AES67Config::sourcePtpGroup. This has to go in *this* structure -- | ||
| 2999 | // g_object_set replaces the property wholesale, so setting it in the | ||
| 3000 | // gst_parse_launch string is silently discarded here. | ||
| 3001 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 168 times.
|
168 | if (m_config.sourcePtpGroup) { |
| 3002 | ✗ | gst_structure_set(props, "node.group", G_TYPE_STRING, | |
| 3003 | m_config.sourcePtpGroupName.c_str(), NULL); | ||
| 3004 | // node.group alone stalls: joining the PTP group puts this node in | ||
| 3005 | // its own driver domain, and the link back to the card-driven graph | ||
| 3006 | // then has nothing to bridge it -- measured, delivery went to zero | ||
| 3007 | // within two minutes. node.async is the mechanism PipeWire 1.2 | ||
| 3008 | // added for exactly that link: output ports write to the | ||
| 3009 | // (cycle+1)&1 async buffer slot and input ports read (cycle&1), so | ||
| 3010 | // the two ends need not be in the same cycle. Costs one quantum of | ||
| 3011 | // latency and needs at least two buffers negotiated on the link. | ||
| 3012 | ✗ | if (m_config.sourceAsync) { | |
| 3013 | ✗ | gst_structure_set(props, "node.async", G_TYPE_BOOLEAN, TRUE, NULL); | |
| 3014 | } | ||
| 3015 | ✗ | LogInfo(VB_MEDIAOUT, | |
| 3016 | "AES67 send [%d]: source node joining PTP driver group %s%s\n", | ||
| 3017 | inst.id, m_config.sourcePtpGroupName.c_str(), | ||
| 3018 | m_config.sourceAsync ? " (async)" : ""); | ||
| 3019 | } | ||
| 3020 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | g_object_set(pwsrc, "stream-properties", props, NULL); |
| 3021 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | gst_structure_free(props); |
| 3022 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | gst_object_unref(pwsrc); |
| 3023 | 168 | } | |
| 3024 | |||
| 3025 | // Put the pipeline on PTP time, and line the RTP timeline up with it. | ||
| 3026 | // | ||
| 3027 | // "a=mediaclk:direct=0" in our SDP asserts that the RTP timestamp IS the | ||
| 3028 | // media clock count on the reference clock, with zero offset. Three things | ||
| 3029 | // have to be true together for that to hold: | ||
| 3030 | // | ||
| 3031 | // 1. the pipeline clock is PTP time (use_clock below) | ||
| 3032 | // 2. base time is zero, so a buffer's running time IS absolute PTP time | ||
| 3033 | // rather than time-since-this-pipeline-started | ||
| 3034 | // 3. the payloader adds no offset of its own (timestamp-offset=0) | ||
| 3035 | // | ||
| 3036 | // rtpL24pay computes RTP ts = timestamp-offset + running_time * 48000 / 1e9, | ||
| 3037 | // so with (2) and (3) that is exactly PTP nanoseconds scaled to 48 kHz | ||
| 3038 | // samples and wrapped at 2^32 -- which is what a receiver reconstructs from | ||
| 3039 | // its own PTP time. Previously the offset was random and the running time | ||
| 3040 | // was relative to pipeline start, so the mapping was wrong by an arbitrary | ||
| 3041 | // amount up to 2^32 samples (~24 hours) and no conformant receiver could | ||
| 3042 | // place the audio. | ||
| 3043 | // | ||
| 3044 | // Setting the start time to NONE stops GStreamer recalculating base time on | ||
| 3045 | // every PAUSED->PLAYING. That is what keeps one continuous media timeline | ||
| 3046 | // across track changes and flushes: RTP timestamps stay tied to wall-clock | ||
| 3047 | // PTP instead of jumping whenever the pipeline is disturbed, which is how a | ||
| 3048 | // Dante/AES67 receiver expects a transmitter to behave. | ||
| 3049 | // splitClockDomains: leave the pipeline on the graph's own clock and use | ||
| 3050 | // PTP only where it is actually required. Putting the whole pipeline on | ||
| 3051 | // PTP is what drains the source ring: pipewiresrc pulls on pipeline time | ||
| 3052 | // while PipeWire fills at the sound card's rate, so at ~54ppm the ring | ||
| 3053 | // empties after (min-buffers - 1) quanta and the source then skips a graph | ||
| 3054 | // cycle per rotation forever. Measured both ways, min-buffers=8: | ||
| 3055 | // | ||
| 3056 | // pipeline on PTP gap burst at buffer 145407 (~56 min) | ||
| 3057 | // pipeline on graph no burst in 74 min (~190000 buffers) | ||
| 3058 | // | ||
| 3059 | // driftResample keeps its own reference to ptpClock, so the output is still | ||
| 3060 | // converted to exactly 48000 samples per PTP second and its PTS are still | ||
| 3061 | // anchored to PTP -- the RTP timestamps stay conformant either way. What | ||
| 3062 | // this does give up is sink pacing against PTP, since udpsink syncs to the | ||
| 3063 | // pipeline clock; that is the ts-offset servo's job. | ||
| 3064 |
3/4✓ Branch 0 taken 63 times.
✓ Branch 1 taken 105 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 63 times.
|
168 | if (ptpClock && !m_config.splitClockDomains) { |
| 3065 | ✗ | gst_pipeline_use_clock(GST_PIPELINE(pipeline), ptpClock); | |
| 3066 | ✗ | gst_element_set_start_time(pipeline, GST_CLOCK_TIME_NONE); | |
| 3067 | ✗ | gst_element_set_base_time(pipeline, 0); | |
| 3068 |
2/2✓ Branch 0 taken 63 times.
✓ Branch 1 taken 105 times.
|
168 | } else if (ptpClock) { |
| 3069 | // Deliberately do NOT pin start time or force base time to 0 here. | ||
| 3070 | // Those exist so running time equals PTP time on the stock path, where | ||
| 3071 | // the pipeline clock *is* PTP. Carrying them over when the pipeline is | ||
| 3072 | // on the graph clock is what made the sink burst: base time 0 makes | ||
| 3073 | // running time equal raw clock time, which the source's PTS never match, | ||
| 3074 | // so udpsink judges every buffer already overdue and flushes the lot -- | ||
| 3075 | // measured, 83% of packets back-to-back in bursts of one graph quantum. | ||
| 3076 | // With normal base-time handling the sink can pace again, and RTP | ||
| 3077 | // timestamps no longer depend on running time anyway now that | ||
| 3078 | // perfect-rtptime counts samples. | ||
| 3079 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | LogInfo(VB_MEDIAOUT, |
| 3080 | "AES67 send [%d]: split clock domains -- pipeline on the graph " | ||
| 3081 | "clock, PTP for the media clock and RTP timestamps\n", inst.id); | ||
| 3082 | } | ||
| 3083 | |||
| 3084 | // splitClockDomains: anchor the RTP timeline to PTP, then hold the lead. | ||
| 3085 | // | ||
| 3086 | // The anchor sets where the timeline starts; the servo keeps it there. | ||
| 3087 | // Both are needed. perfect-rtptime makes the payloader count samples, so | ||
| 3088 | // the RTP timestamp no longer derives from the PTS and the sinkPacing PTS | ||
| 3089 | // shift no longer produces the transmit lead -- the offset below does. | ||
| 3090 | // It must be set before PLAYING: rtpL24pay latches its base on the segment, | ||
| 3091 | // and setting it from a probe on the first buffer was measured to do | ||
| 3092 | // nothing at all. | ||
| 3093 | // | ||
| 3094 | // That makes it provisional. It is taken when the pipeline is built, and | ||
| 3095 | // the first audio reaches the sink whenever the graph first feeds this node | ||
| 3096 | // -- 100 ms later on a Pi, a second or more when a group links it late. | ||
| 3097 | // Every packet then carries an RTP time that much earlier than it should, | ||
| 3098 | // and the servo below could only ever answer that by moving transmission | ||
| 3099 | // earlier, past the point where the audio has arrived at all: from then on | ||
| 3100 | // udpsink sent each buffer the moment it arrived, a quantum at a time, and | ||
| 3101 | // the lead it measured (its own projection) still read +3 ms while the | ||
| 3102 | // wire was tens of ms late. So the servo's probe re-anchors the timeline | ||
| 3103 | // on the first packet that actually reaches the sink, by shifting the RTP | ||
| 3104 | // timestamp it carries -- and every one after it by the same amount. | ||
| 3105 | // | ||
| 3106 | // On its own that anchor does not hold. Every source discontinuity makes | ||
| 3107 | // the drift loop re-anchor its PTS timeline to absorb the gap ("-231 ms gap | ||
| 3108 | // absorbed"), while the RTP sample counter does not move with it, so the | ||
| 3109 | // fixed relationship the anchor established is broken and the lead steps. | ||
| 3110 | // Measured across a night of media restarts it ratcheted one way only, | ||
| 3111 | // +23.2 -> +30.6 -> +34.1 -> +40.6 -> +44.5 ms, roughly 4-7ms per restart, | ||
| 3112 | // never recovering, with the pacing degrading alongside it. An open-loop | ||
| 3113 | // servo cannot fix that by construction: it corrects the clock difference | ||
| 3114 | // it is told about, not the error it actually produces. | ||
| 3115 | // | ||
| 3116 | // So the servo measures the lead and drives it to targetLeadMs. For each | ||
| 3117 | // outgoing packet it reads the RTP timestamp straight out of the header | ||
| 3118 | // (bytes 4-7; no gstreamer-rtp dependency for four bytes), works out when | ||
| 3119 | // the sink will actually transmit it, projects that onto the sample rate, | ||
| 3120 | // and compares. The subtraction is done in guint32 so the RTP wrap at | ||
| 3121 | // 2^32 handles itself. | ||
| 3122 |
3/4✓ Branch 0 taken 63 times.
✓ Branch 1 taken 105 times.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
|
168 | if (ptpClock && m_config.splitClockDomains) { |
| 3123 |
3/6✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 63 times.
✗ Branch 5 not taken.
|
63 | GstElement* payEl = gst_bin_get_by_name(GST_BIN(pipeline), "pay"); |
| 3124 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | if (payEl) { |
| 3125 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | const GstClockTime target = gst_clock_get_time(ptpClock) + |
| 3126 | 63 | (GstClockTime)m_config.sinkPacingMs * GST_MSECOND; | |
| 3127 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | const guint32 base = (guint32)gst_util_uint64_scale( |
| 3128 | 63 | target, AES67::AUDIO_RATE, GST_SECOND); | |
| 3129 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | g_object_set(payEl, "timestamp-offset", base, NULL); |
| 3130 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | LogInfo(VB_MEDIAOUT, |
| 3131 | "AES67 send [%d]: RTP timeline anchored to PTP " | ||
| 3132 | "(timestamp-offset %u, holding %dms lead)\n", | ||
| 3133 | inst.id, base, m_config.targetLeadMs); | ||
| 3134 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | gst_object_unref(payEl); |
| 3135 | } | ||
| 3136 | |||
| 3137 | // No reference to the sink here: the probe sits on the sink's own pad | ||
| 3138 | // and pads only drop their probes when their element is disposed, so a | ||
| 3139 | // ref held by the probe kept the sink -- and with it this struct, the | ||
| 3140 | // PTP clock and its /dev/ptp fd -- alive forever after the pipeline was | ||
| 3141 | // freed, one more on every rebuild. The probe reads the sink off the | ||
| 3142 | // pad instead. | ||
| 3143 | struct LeadServo { | ||
| 3144 | GstClock* ptp; | ||
| 3145 | GstClock* pipe; | ||
| 3146 | gint64 targetNs; | ||
| 3147 | guint64 tick; | ||
| 3148 | int instanceId; | ||
| 3149 | bool logged; | ||
| 3150 | gint64 latency; // sink render delay; -1 until queried | ||
| 3151 | gint64 anchorNs; // RTP time ahead of PTP at the first packet | ||
| 3152 | bool anchored; // rtpShift taken from the first packet | ||
| 3153 | guint32 rtpShift; // added to every RTP timestamp from then on | ||
| 3154 | }; | ||
| 3155 |
3/6✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 63 times.
✗ Branch 5 not taken.
|
63 | GstElement* sinkEl = gst_bin_get_by_name(GST_BIN(pipeline), "usink"); |
| 3156 |
2/4✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
|
63 | GstPad* sinkPad = sinkEl ? gst_element_get_static_pad(sinkEl, "sink") : nullptr; |
| 3157 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | if (sinkPad) { |
| 3158 |
2/4✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
|
63 | auto* sv = new LeadServo{ GST_CLOCK(gst_object_ref(ptpClock)), |
| 3159 |
2/4✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
|
63 | gst_pipeline_get_pipeline_clock(GST_PIPELINE(pipeline)), |
| 3160 | 63 | (gint64)m_config.targetLeadMs * GST_MSECOND, | |
| 3161 | 63 | 0, inst.id, false, -1, | |
| 3162 | 63 | (gint64)m_config.sinkPacingMs * GST_MSECOND, | |
| 3163 |
4/10✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 63 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 63 times.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
|
189 | false, 0 }; |
| 3164 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | gst_pad_add_probe( |
| 3165 | sinkPad, GST_PAD_PROBE_TYPE_BUFFER, | ||
| 3166 | ✗ | [](GstPad* pad, GstPadProbeInfo* info, gpointer user) -> GstPadProbeReturn { | |
| 3167 | 196169 | auto* v = static_cast<LeadServo*>(user); | |
| 3168 | // Valid for the call: the pad's element is the one running it. | ||
| 3169 |
2/4✓ Branch 0 taken 196169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 196169 times.
✗ Branch 3 not taken.
|
196169 | GstElement* sink = GST_ELEMENT(GST_OBJECT_PARENT(pad)); |
| 3170 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 196169 times.
|
196169 | if (!sink) { |
| 3171 | ✗ | return GST_PAD_PROBE_OK; | |
| 3172 | } | ||
| 3173 | 196169 | GstBuffer* b = GST_PAD_PROBE_INFO_BUFFER(info); | |
| 3174 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 196169 times.
|
196169 | if (!b) { |
| 3175 | ✗ | return GST_PAD_PROBE_OK; | |
| 3176 | } | ||
| 3177 | // Anchor the RTP timeline where the build-time anchor would | ||
| 3178 | // have put it had the first audio arrived at once -- see | ||
| 3179 | // above. The payloader counts samples from there, so one | ||
| 3180 | // fixed shift keeps every later timestamp contiguous. | ||
| 3181 |
3/4✓ Branch 0 taken 196150 times.
✓ Branch 1 taken 19 times.
✓ Branch 2 taken 196150 times.
✗ Branch 3 not taken.
|
196169 | if (!v->anchored || v->rtpShift != 0) { |
| 3182 |
1/2✓ Branch 0 taken 196169 times.
✗ Branch 1 not taken.
|
196169 | b = gst_buffer_make_writable(b); |
| 3183 | 196169 | GST_PAD_PROBE_INFO_DATA(info) = b; | |
| 3184 | GstMapInfo wm; | ||
| 3185 |
2/4✓ Branch 0 taken 196169 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 196169 times.
✗ Branch 3 not taken.
|
196169 | if (gst_buffer_map(b, &wm, GST_MAP_READWRITE)) { |
| 3186 |
1/2✓ Branch 0 taken 196169 times.
✗ Branch 1 not taken.
|
196169 | if (wm.size >= 12) { |
| 3187 | 196169 | guint32 ts = ((guint32)wm.data[4] << 24) | | |
| 3188 | 196169 | ((guint32)wm.data[5] << 16) | | |
| 3189 | 196169 | ((guint32)wm.data[6] << 8) | wm.data[7]; | |
| 3190 |
2/2✓ Branch 0 taken 19 times.
✓ Branch 1 taken 196150 times.
|
196169 | if (!v->anchored) { |
| 3191 | 19 | v->anchored = true; | |
| 3192 | 38 | const guint32 want = (guint32)gst_util_uint64_scale( | |
| 3193 |
2/4✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
|
19 | gst_clock_get_time(v->ptp) + (GstClockTime)v->anchorNs, |
| 3194 | 19 | AES67::AUDIO_RATE, GST_SECOND); | |
| 3195 | 19 | v->rtpShift = want - ts; | |
| 3196 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | LogInfo(VB_MEDIAOUT, |
| 3197 | "AES67 send [%d]: RTP timeline re-anchored at the " | ||
| 3198 | "first packet (%+.1f ms from the build-time anchor)\n", | ||
| 3199 | v->instanceId, | ||
| 3200 | (double)(gint32)v->rtpShift * 1000.0 / AES67::AUDIO_RATE); | ||
| 3201 | } | ||
| 3202 | 196169 | ts += v->rtpShift; | |
| 3203 | 196169 | wm.data[4] = (guint8)(ts >> 24); | |
| 3204 | 196169 | wm.data[5] = (guint8)(ts >> 16); | |
| 3205 | 196169 | wm.data[6] = (guint8)(ts >> 8); | |
| 3206 | 196169 | wm.data[7] = (guint8)ts; | |
| 3207 | } | ||
| 3208 |
1/2✓ Branch 0 taken 196169 times.
✗ Branch 1 not taken.
|
196169 | gst_buffer_unmap(b, &wm); |
| 3209 | } | ||
| 3210 | } | ||
| 3211 | // ~1Hz at any ptime; the correction is tens of microseconds | ||
| 3212 | // and does not need to be finer. | ||
| 3213 |
5/6✓ Branch 0 taken 770 times.
✓ Branch 1 taken 195399 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 770 times.
✓ Branch 4 taken 195399 times.
✓ Branch 5 taken 770 times.
|
196169 | if ((++v->tick % 250) != 0 || !GST_BUFFER_PTS_IS_VALID(b)) { |
| 3214 | 195399 | return GST_PAD_PROBE_OK; | |
| 3215 | } | ||
| 3216 | GstMapInfo m; | ||
| 3217 |
2/4✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 770 times.
|
770 | if (!gst_buffer_map(b, &m, GST_MAP_READ)) { |
| 3218 | ✗ | return GST_PAD_PROBE_OK; | |
| 3219 | } | ||
| 3220 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 770 times.
|
770 | if (m.size < 12) { |
| 3221 | ✗ | gst_buffer_unmap(b, &m); | |
| 3222 | ✗ | return GST_PAD_PROBE_OK; | |
| 3223 | } | ||
| 3224 | 770 | const guint32 rtpTs = ((guint32)m.data[4] << 24) | | |
| 3225 | 770 | ((guint32)m.data[5] << 16) | | |
| 3226 | 770 | ((guint32)m.data[6] << 8) | m.data[7]; | |
| 3227 |
1/2✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
|
770 | gst_buffer_unmap(b, &m); |
| 3228 | |||
| 3229 | // A sink renders at PTS + base + latency + ts-offset. The | ||
| 3230 | // latency term is not optional: leaving it out biases every | ||
| 3231 | // measurement by exactly the pipeline latency, and the servo | ||
| 3232 | // then drives the real lead that far past target -- measured, | ||
| 3233 | // it ran a 20ms target down to -4.10ms, which is the | ||
| 3234 | // receiver-discards-everything failure. | ||
| 3235 |
2/2✓ Branch 0 taken 19 times.
✓ Branch 1 taken 751 times.
|
770 | if (v->latency < 0) { |
| 3236 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | GstQuery* lq = gst_query_new_latency(); |
| 3237 |
2/4✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 19 times.
✗ Branch 3 not taken.
|
19 | if (gst_element_query(sink, lq)) { |
| 3238 | 19 | gboolean live = FALSE; | |
| 3239 | 19 | GstClockTime minL = 0, maxL = 0; | |
| 3240 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | gst_query_parse_latency(lq, &live, &minL, &maxL); |
| 3241 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | v->latency = GST_CLOCK_TIME_IS_VALID(minL) ? (gint64)minL : 0; |
| 3242 | } | ||
| 3243 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | gst_query_unref(lq); |
| 3244 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 19 times.
|
19 | if (v->latency < 0) { |
| 3245 | ✗ | v->latency = 0; | |
| 3246 | } | ||
| 3247 | } | ||
| 3248 | 770 | gint64 tsOff = 0; | |
| 3249 |
1/2✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
|
770 | g_object_get(sink, "ts-offset", &tsOff, NULL); |
| 3250 |
1/2✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
|
770 | const GstClockTime baseT = gst_element_get_base_time(sink); |
| 3251 |
1/2✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
|
770 | const GstClockTime pipeNow = gst_clock_get_time(v->pipe); |
| 3252 |
1/2✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
|
770 | const GstClockTime ptpNow = gst_clock_get_time(v->ptp); |
| 3253 | // When the sink will put this packet on the wire, expressed | ||
| 3254 | // in PTP time so it is comparable with the RTP timestamp. | ||
| 3255 | // A buffer whose render time has already passed is sent as it | ||
| 3256 | // arrives, now, so that is when it goes out. | ||
| 3257 | 770 | const gint64 txPipe = (gint64)GST_BUFFER_PTS(b) + (gint64)baseT + | |
| 3258 | 770 | v->latency + tsOff; | |
| 3259 | 770 | const bool late = txPipe < (gint64)pipeNow; | |
| 3260 | 770 | const gint64 txPtp = (gint64)ptpNow + | |
| 3261 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 767 times.
|
770 | (late ? 0 : txPipe - (gint64)pipeNow); |
| 3262 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 770 times.
|
770 | if (txPtp <= 0) { |
| 3263 | ✗ | return GST_PAD_PROBE_OK; | |
| 3264 | } | ||
| 3265 |
1/2✓ Branch 0 taken 770 times.
✗ Branch 1 not taken.
|
770 | const guint32 txRtp = (guint32)gst_util_uint64_scale( |
| 3266 | 770 | (guint64)txPtp, AES67::AUDIO_RATE, GST_SECOND); | |
| 3267 | 770 | const gint64 leadNs = (gint64)(gint32)(rtpTs - txRtp) * | |
| 3268 | GST_SECOND / AES67::AUDIO_RATE; | ||
| 3269 | |||
| 3270 | // Delaying transmission (larger ts-offset) reduces the lead. | ||
| 3271 | // | ||
| 3272 | // Step for a large error, slew for a small one -- the same | ||
| 3273 | // split linuxptp makes with step_threshold, and for the same | ||
| 3274 | // reason. Every source discontinuity re-anchors the drift | ||
| 3275 | // loop's PTS timeline while the RTP sample counter stays | ||
| 3276 | // put, so the lead takes a step; slew-only cannot survive | ||
| 3277 | // that if the steps arrive faster than the slew rate. | ||
| 3278 | // Measured with a 25s file on repeat (a restart every ~25s | ||
| 3279 | // against a 200us/s slew, i.e. 12ms/min of authority): the | ||
| 3280 | // lead ran away to +3614 ms in sawtooth cycles, each ending | ||
| 3281 | // in under-delivery and a watchdog rebuild. Stepping puts | ||
| 3282 | // it back at the moment of the disturbance, which is the | ||
| 3283 | // right moment -- the transmission timeline is already | ||
| 3284 | // discontinuous there. | ||
| 3285 | 770 | const gint64 err = leadNs - v->targetNs; | |
| 3286 | 770 | const gint64 kStepThreshold = 5 * GST_MSECOND; | |
| 3287 | 770 | const gint64 kSlew = 200 * GST_USECOND; | |
| 3288 | gint64 corr; | ||
| 3289 |
4/4✓ Branch 0 taken 751 times.
✓ Branch 1 taken 19 times.
✓ Branch 2 taken 2 times.
✓ Branch 3 taken 749 times.
|
770 | if (err > kStepThreshold || err < -kStepThreshold) { |
| 3290 | 21 | corr = err; | |
| 3291 | } else { | ||
| 3292 | 749 | corr = err / 4; | |
| 3293 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 749 times.
|
749 | if (corr > kSlew) corr = kSlew; |
| 3294 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 748 times.
|
749 | if (corr < -kSlew) corr = -kSlew; |
| 3295 | } | ||
| 3296 | // Transmitting earlier cannot help a packet that is already | ||
| 3297 | // late; it only moves every later one into the past too. | ||
| 3298 |
3/4✓ Branch 0 taken 3 times.
✓ Branch 1 taken 767 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
770 | if (late && corr < 0) { |
| 3299 | 3 | corr = 0; | |
| 3300 | } | ||
| 3301 |
2/2✓ Branch 0 taken 20 times.
✓ Branch 1 taken 750 times.
|
770 | if (corr != 0) { |
| 3302 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | g_object_set(sink, "ts-offset", tsOff + corr, NULL); |
| 3303 | } | ||
| 3304 | // Diagnostic: the servo drove the lead to -56ms once and | ||
| 3305 | // took 50 minutes to recover, which no combination of its | ||
| 3306 | // own gains explains. Log what it actually sees so the | ||
| 3307 | // measurement can be checked against aes67_verify rather | ||
| 3308 | // than reasoned about. | ||
| 3309 |
2/2✓ Branch 0 taken 9 times.
✓ Branch 1 taken 761 times.
|
770 | if ((v->tick % 15000) == 0) { |
| 3310 |
4/8✗ Branch 0 not taken.
✓ Branch 1 taken 9 times.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 9 times.
✓ Branch 6 taken 9 times.
✗ Branch 7 not taken.
|
9 | LogInfo(VB_MEDIAOUT, |
| 3311 | "AES67 send [%d]: servo lead %+.2f ms err %+.2f " | ||
| 3312 | "corr %+.3f ms ts-offset %+.2f ms%s%s\n", | ||
| 3313 | v->instanceId, | ||
| 3314 | (double)leadNs / GST_MSECOND, | ||
| 3315 | (double)err / GST_MSECOND, | ||
| 3316 | (double)corr / GST_MSECOND, | ||
| 3317 | (double)(tsOff + corr) / GST_MSECOND, | ||
| 3318 | (err > kStepThreshold || err < -kStepThreshold) | ||
| 3319 | ? " STEP" : "", | ||
| 3320 | late ? " LATE" : ""); | ||
| 3321 | } | ||
| 3322 |
2/2✓ Branch 0 taken 19 times.
✓ Branch 1 taken 751 times.
|
770 | if (!v->logged) { |
| 3323 | 19 | v->logged = true; | |
| 3324 |
1/2✓ Branch 0 taken 19 times.
✗ Branch 1 not taken.
|
19 | LogInfo(VB_MEDIAOUT, |
| 3325 | "AES67 send [%d]: lead servo active " | ||
| 3326 | "(lead %.2f ms, target %.0f ms, sink latency " | ||
| 3327 | "%.1f ms)\n", | ||
| 3328 | v->instanceId, (double)leadNs / GST_MSECOND, | ||
| 3329 | (double)v->targetNs / GST_MSECOND, | ||
| 3330 | (double)v->latency / GST_MSECOND); | ||
| 3331 | } | ||
| 3332 | 770 | return GST_PAD_PROBE_OK; | |
| 3333 | }, | ||
| 3334 | sv, | ||
| 3335 | ✗ | [](gpointer user) { | |
| 3336 | 63 | auto* v = static_cast<LeadServo*>(user); | |
| 3337 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | if (v->ptp) gst_object_unref(v->ptp); |
| 3338 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | if (v->pipe) gst_object_unref(v->pipe); |
| 3339 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | delete v; |
| 3340 | 63 | }); | |
| 3341 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | gst_object_unref(sinkPad); |
| 3342 | } | ||
| 3343 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | if (sinkEl) { |
| 3344 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | gst_object_unref(sinkEl); |
| 3345 | } | ||
| 3346 | } | ||
| 3347 | |||
| 3348 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 168 times.
|
168 | if (m_config.pipelineStats) { |
| 3349 | ✗ | GstElement* src = gst_bin_get_by_name(GST_BIN(pipeline), "pwsrc"); | |
| 3350 | ✗ | GstElement* snk = gst_bin_get_by_name(GST_BIN(pipeline), "usink"); | |
| 3351 | ✗ | GstPad* sp = src ? gst_element_get_static_pad(src, "src") : nullptr; | |
| 3352 | ✗ | GstPad* kp = snk ? gst_element_get_static_pad(snk, "sink") : nullptr; | |
| 3353 | ✗ | if (sp && kp) { | |
| 3354 | ✗ | auto* ps = new PipelineStatsState(); | |
| 3355 | ✗ | ps->instanceId = inst.id; | |
| 3356 | ✗ | gst_pad_add_probe(kp, GST_PAD_PROBE_TYPE_BUFFER, PipelineStatsOut, | |
| 3357 | ps, nullptr); | ||
| 3358 | ✗ | gst_pad_add_probe(sp, GST_PAD_PROBE_TYPE_BUFFER, PipelineStatsIn, | |
| 3359 | ps, DestroyPipelineStatsState); | ||
| 3360 | ✗ | LogInfo(VB_MEDIAOUT, "AES67 send [%d]: pipeline stats on\n", | |
| 3361 | inst.id); | ||
| 3362 | } | ||
| 3363 | ✗ | if (sp) gst_object_unref(sp); | |
| 3364 | ✗ | if (kp) gst_object_unref(kp); | |
| 3365 | ✗ | if (src) gst_object_unref(src); | |
| 3366 | ✗ | if (snk) gst_object_unref(snk); | |
| 3367 | } | ||
| 3368 | |||
| 3369 | // Raise the packet-pacing thread to SCHED_FIFO. GStreamer creates this | ||
| 3370 | // thread itself and offers no way to set its scheduling, so we take the | ||
| 3371 | // one opportunity we have to run code on it: a probe fires on the | ||
| 3372 | // streaming thread, so the first buffer through can elevate the thread | ||
| 3373 | // that carried it. The probe removes itself immediately afterwards and | ||
| 3374 | // costs nothing for the rest of the stream. | ||
| 3375 | // | ||
| 3376 | // This is not a splitClockDomains refinement -- an unpaced tail is worse | ||
| 3377 | // in every mode -- so it is unconditional. EPERM is expected and | ||
| 3378 | // survivable: fppd.service sets LimitRTPRIO, but a hand-started fppd or | ||
| 3379 | // a container without CAP_SYS_NICE will not have it, and the stream is | ||
| 3380 | // still conformant without it, just with a wider tail. | ||
| 3381 | // | ||
| 3382 | // The thread is GStreamer's, from a pool it shares with every pipeline, | ||
| 3383 | // so it has to be handed back as it was: left at SCHED_FIFO, each rebuild | ||
| 3384 | // (config apply, watchdog, PTP step, bus error) put one more pool thread | ||
| 3385 | // at FIFO 80 that later media decode or video pipelines could land on, | ||
| 3386 | // above the channel output thread (RT 10). The task that runs the thread | ||
| 3387 | // gets a leave callback that drops it back to SCHED_OTHER when the task | ||
| 3388 | // stops. | ||
| 3389 | { | ||
| 3390 |
3/6✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 168 times.
✗ Branch 5 not taken.
|
168 | GstElement* snk = gst_bin_get_by_name(GST_BIN(pipeline), "usink"); |
| 3391 |
2/4✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
|
168 | GstPad* kp = snk ? gst_element_get_static_pad(snk, "sink") : nullptr; |
| 3392 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | if (kp) { |
| 3393 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | gst_pad_add_probe( |
| 3394 | kp, GST_PAD_PROBE_TYPE_BUFFER, | ||
| 3395 | ✗ | [](GstPad* pad, GstPadProbeInfo*, gpointer user) -> GstPadProbeReturn { | |
| 3396 | 20 | const int id = GPOINTER_TO_INT(user); | |
| 3397 | // The task whose thread carried this buffer: the nearest | ||
| 3398 | // pad upstream that runs one (sinkq's, with sink pacing). | ||
| 3399 | 20 | GstTask* task = nullptr; | |
| 3400 | 20 | GstPad* up = gst_pad_get_peer(pad); | |
| 3401 |
3/6✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 20 times.
✗ Branch 5 not taken.
|
20 | for (int hop = 0; up && !task && hop < 16; hop++) { |
| 3402 | 20 | GST_OBJECT_LOCK(up); | |
| 3403 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | if (GST_PAD_TASK(up)) { |
| 3404 | 20 | task = GST_TASK(gst_object_ref(GST_PAD_TASK(up))); | |
| 3405 | } | ||
| 3406 | 20 | GST_OBJECT_UNLOCK(up); | |
| 3407 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | if (task) { |
| 3408 | 20 | break; | |
| 3409 | } | ||
| 3410 | ✗ | GstElement* el = gst_pad_get_parent_element(up); | |
| 3411 | ✗ | gst_object_unref(up); | |
| 3412 | ✗ | up = nullptr; | |
| 3413 | ✗ | GstPad* in = el ? gst_element_get_static_pad(el, "sink") : nullptr; | |
| 3414 | ✗ | if (el) gst_object_unref(el); | |
| 3415 | ✗ | if (in) { | |
| 3416 | ✗ | up = gst_pad_get_peer(in); | |
| 3417 | ✗ | gst_object_unref(in); | |
| 3418 | } | ||
| 3419 | } | ||
| 3420 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | if (up) gst_object_unref(up); |
| 3421 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 20 times.
|
20 | if (!task) { |
| 3422 | // Without a task to hand it back through, don't raise it. | ||
| 3423 | ✗ | LogWarn(VB_MEDIAOUT, "AES67 send [%d]: pacing thread not raised: " | |
| 3424 | "could not find its streaming task\n", id); | ||
| 3425 | ✗ | return GST_PAD_PROBE_REMOVE; | |
| 3426 | } | ||
| 3427 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | gst_task_set_leave_callback(task, [](GstTask*, GThread*, gpointer) { |
| 3428 | 20 | struct sched_param param = {}; | |
| 3429 | 20 | pthread_setschedparam(pthread_self(), SCHED_OTHER, ¶m); | |
| 3430 | 20 | }, nullptr, nullptr); | |
| 3431 | 20 | gst_object_unref(task); | |
| 3432 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | if (SetThreadRealtimePriority(AES67::SINK_RT_PRIORITY)) { |
| 3433 | 20 | LogInfo(VB_MEDIAOUT, | |
| 3434 | "AES67 send [%d]: pacing thread raised to " | ||
| 3435 | "SCHED_FIFO %d\n", id, AES67::SINK_RT_PRIORITY); | ||
| 3436 | } else { | ||
| 3437 | ✗ | LogWarn(VB_MEDIAOUT, | |
| 3438 | "AES67 send [%d]: could not raise pacing thread to " | ||
| 3439 | "SCHED_FIFO %d (%s); packets will occasionally " | ||
| 3440 | "transmit up to a ptime late\n", | ||
| 3441 | id, AES67::SINK_RT_PRIORITY, strerror(errno)); | ||
| 3442 | } | ||
| 3443 | 20 | return GST_PAD_PROBE_REMOVE; | |
| 3444 | }, | ||
| 3445 |
0/2✗ Branch 0 not taken.
✗ Branch 1 not taken.
|
168 | GINT_TO_POINTER(inst.id), nullptr); |
| 3446 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | gst_object_unref(kp); |
| 3447 | } | ||
| 3448 |
2/4✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
|
168 | if (snk) gst_object_unref(snk); |
| 3449 | } | ||
| 3450 | |||
| 3451 | // Always on: see SourceGapProbe. Costs one comparison per buffer and is | ||
| 3452 | // silent unless the source actually skips a cycle. | ||
| 3453 | { | ||
| 3454 |
3/6✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 168 times.
✗ Branch 5 not taken.
|
168 | GstElement* pw = gst_bin_get_by_name(GST_BIN(pipeline), "pwsrc"); |
| 3455 |
2/4✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
|
168 | GstPad* pwpad = pw ? gst_element_get_static_pad(pw, "src") : nullptr; |
| 3456 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | if (pwpad) { |
| 3457 |
2/4✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 168 times.
|
168 | auto* gs = new SourceGapState(); |
| 3458 | 168 | gs->instanceId = inst.id; | |
| 3459 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | gst_pad_add_probe(pwpad, GST_PAD_PROBE_TYPE_BUFFER, SourceGapProbe, |
| 3460 | gs, DestroySourceGapState); | ||
| 3461 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | gst_object_unref(pwpad); |
| 3462 | } | ||
| 3463 |
2/4✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
|
168 | if (pw) gst_object_unref(pw); |
| 3464 | } | ||
| 3465 | |||
| 3466 | #ifdef FPP_HAVE_SAMPLERATE | ||
| 3467 |
2/2✓ Branch 0 taken 63 times.
✓ Branch 1 taken 105 times.
|
168 | if (driftResample) { |
| 3468 |
3/6✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 63 times.
✗ Branch 5 not taken.
|
63 | GstElement* dp = gst_bin_get_by_name(GST_BIN(pipeline), "driftpoint"); |
| 3469 |
2/4✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
|
63 | GstPad* dpad = dp ? gst_element_get_static_pad(dp, "src") : nullptr; |
| 3470 | 63 | int err = 0; | |
| 3471 |
3/8✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 63 times.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
|
63 | auto* st = new DriftResampleState(); |
| 3472 | 63 | st->channels = inst.channels; | |
| 3473 | 63 | st->instanceId = inst.id; | |
| 3474 |
3/6✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 63 times.
✗ Branch 5 not taken.
|
63 | st->clock = GST_CLOCK(gst_object_ref(ptpClock)); |
| 3475 | // SINC_FASTEST is bandlimited and far above what a 56ppm correction | ||
| 3476 | // needs; the cost is a few percent of one core on a Pi. | ||
| 3477 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | st->src = src_new(SRC_SINC_FASTEST, inst.channels, &err); |
| 3478 | 63 | st->fillGaps = m_config.sourceSilenceFloor; | |
| 3479 | |||
| 3480 |
2/4✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
|
63 | if (dpad && st->src) { |
| 3481 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | gst_pad_add_probe(dpad, GST_PAD_PROBE_TYPE_BUFFER, |
| 3482 | DriftResampleProbe, st, | ||
| 3483 | DestroyDriftResampleState); | ||
| 3484 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | LogInfo(VB_MEDIAOUT, |
| 3485 | "AES67 send [%d]: drift correction on (libsamplerate)\n", | ||
| 3486 | inst.id); | ||
| 3487 | } else { | ||
| 3488 | ✗ | LogWarn(VB_MEDIAOUT, | |
| 3489 | "AES67 send [%d]: drift correction unavailable (%s)\n", | ||
| 3490 | inst.id, st->src ? "no driftpoint pad" : src_strerror(err)); | ||
| 3491 | ✗ | DestroyDriftResampleState(st); | |
| 3492 | } | ||
| 3493 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | if (dpad) { |
| 3494 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | gst_object_unref(dpad); |
| 3495 | } | ||
| 3496 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | if (dp) { |
| 3497 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | gst_object_unref(dp); |
| 3498 | } | ||
| 3499 | } | ||
| 3500 | #endif | ||
| 3501 | |||
| 3502 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 168 times.
|
168 | if (rateMatch) { |
| 3503 | ✗ | GstElement* rm = gst_bin_get_by_name(GST_BIN(pipeline), "ratematch"); | |
| 3504 | ✗ | if (rm) { | |
| 3505 | // skip-to-first is mandatory here, not a tuning choice. With the | ||
| 3506 | // PTP media clock the base time is 0, so a buffer's timestamp is | ||
| 3507 | // absolute PTP nanoseconds -- roughly 1.8e18 at present. Without | ||
| 3508 | // this audiorate treats that as a gap starting at zero and fills | ||
| 3509 | // it with silence: measured "in 1081, added 343632000" within a | ||
| 3510 | // minute, i.e. it was manufacturing hours of silence. | ||
| 3511 | ✗ | g_object_set(rm, | |
| 3512 | "tolerance", m_config.rateMatchToleranceNs, | ||
| 3513 | "skip-to-first", TRUE, | ||
| 3514 | NULL); | ||
| 3515 | ✗ | gst_object_unref(rm); | |
| 3516 | ✗ | LogInfo(VB_MEDIAOUT, | |
| 3517 | "AES67 send [%d]: rate matching on, tolerance %lluns\n", | ||
| 3518 | inst.id, | ||
| 3519 | (unsigned long long)m_config.rateMatchToleranceNs); | ||
| 3520 | } | ||
| 3521 | } | ||
| 3522 | |||
| 3523 |
2/2✓ Branch 0 taken 167 times.
✓ Branch 1 taken 1 times.
|
168 | if (sinkPacing) { |
| 3524 | // Hold each packet back so it is still in the future when the sink | ||
| 3525 | // gets it. Without the delay every buffer arrives past its running | ||
| 3526 | // time, udpsink renders each one immediately, and the burst is | ||
| 3527 | // unchanged -- pacing that never waits is not pacing. | ||
| 3528 | // | ||
| 3529 | // This has to be ts-offset on the sink rather than | ||
| 3530 | // gst_pipeline_set_latency(): the pipeline latency is recomputed from | ||
| 3531 | // a LATENCY query when the pipeline goes to PLAYING, so a value set | ||
| 3532 | // beforehand is replaced by the automatic one. That is what made an | ||
| 3533 | // earlier attempt pace for the first few seconds and then burst for | ||
| 3534 | // the rest of the run. ts-offset is applied per-buffer and survives. | ||
| 3535 | // Shift the buffer timestamps forward rather than offsetting the sink. | ||
| 3536 | // | ||
| 3537 | // ts-offset delays only the transmission; the RTP timestamps stay | ||
| 3538 | // where they were, so every packet goes out after the playout deadline | ||
| 3539 | // it declares and a conformant receiver drops the lot -- measured at | ||
| 3540 | // -23.7ms with pacing on against +7.8ms without it, and reported on | ||
| 3541 | // #2848 as a Yamaha MRX7-D sitting subscribed, green and silent on a | ||
| 3542 | // stream carrying perfectly paced, valid stereo L24. | ||
| 3543 | // | ||
| 3544 | // Moving the PTS instead carries both with it: the payloader derives | ||
| 3545 | // the RTP timestamp from the shifted PTS, and the sink renders at that | ||
| 3546 | // same shifted time. The lead over transmission therefore stays | ||
| 3547 | // whatever it was without pacing, whatever latency we choose, instead | ||
| 3548 | // of needing a correction factor fitted to one machine. | ||
| 3549 | // Held on the heap so the value can be corrected once the pipeline is | ||
| 3550 | // PLAYING and its real latency is known. See the retune below. | ||
| 3551 | // Reused rather than reallocated per rebuild. The watchdog rebuilds | ||
| 3552 | // the pipeline on sustained under-delivery, and a probe on the old | ||
| 3553 | // pipeline can still be in flight when the new one is built, so this | ||
| 3554 | // cell has to outlive both -- freeing it here would be a | ||
| 3555 | // use-after-free, and allocating a fresh one each time would leak on | ||
| 3556 | // every rebuild. One cell per instance id, alive for the process. | ||
| 3557 |
1/2✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
|
167 | auto& slot = m_sinkPacingShift[inst.id]; |
| 3558 |
2/2✓ Branch 0 taken 55 times.
✓ Branch 1 taken 112 times.
|
167 | if (!slot) { |
| 3559 |
2/4✓ Branch 0 taken 55 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 55 times.
|
55 | slot = new std::atomic<GstClockTime>(0); |
| 3560 | } | ||
| 3561 | 167 | slot->store((GstClockTime)m_config.sinkPacingMs * GST_MSECOND, | |
| 3562 | std::memory_order_relaxed); | ||
| 3563 | 167 | auto* shiftCell = slot; | |
| 3564 | |||
| 3565 |
3/6✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 167 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 167 times.
✗ Branch 5 not taken.
|
167 | GstElement* pay = gst_bin_get_by_name(GST_BIN(pipeline), "pay"); |
| 3566 |
2/4✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 167 times.
✗ Branch 3 not taken.
|
167 | GstPad* payPad = pay ? gst_element_get_static_pad(pay, "sink") : nullptr; |
| 3567 |
1/2✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
|
167 | if (payPad) { |
| 3568 |
1/2✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
|
167 | gst_pad_add_probe( |
| 3569 | payPad, GST_PAD_PROBE_TYPE_BUFFER, | ||
| 3570 | ✗ | [](GstPad*, GstPadProbeInfo* info, gpointer user) { | |
| 3571 | 202928 | GstBuffer* b = GST_PAD_PROBE_INFO_BUFFER(info); | |
| 3572 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 202928 times.
|
202928 | if (!b) { |
| 3573 | ✗ | return GST_PAD_PROBE_OK; | |
| 3574 | } | ||
| 3575 | const GstClockTime shift = | ||
| 3576 | 202928 | static_cast<std::atomic<GstClockTime>*>(user)->load( | |
| 3577 | std::memory_order_relaxed); | ||
| 3578 | 202928 | b = gst_buffer_make_writable(b); | |
| 3579 |
1/2✓ Branch 0 taken 202928 times.
✗ Branch 1 not taken.
|
202928 | if (GST_BUFFER_PTS_IS_VALID(b)) { |
| 3580 | 202928 | GST_BUFFER_PTS(b) += shift; | |
| 3581 | } | ||
| 3582 |
2/2✓ Branch 0 taken 98 times.
✓ Branch 1 taken 202830 times.
|
202928 | if (GST_BUFFER_DTS_IS_VALID(b)) { |
| 3583 | 98 | GST_BUFFER_DTS(b) += shift; | |
| 3584 | } | ||
| 3585 | 202928 | GST_PAD_PROBE_INFO_DATA(info) = b; | |
| 3586 | 202928 | return GST_PAD_PROBE_OK; | |
| 3587 | }, | ||
| 3588 | (gpointer)shiftCell, nullptr); | ||
| 3589 |
1/2✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
|
167 | gst_object_unref(payPad); |
| 3590 | |||
| 3591 | // Cancel the shift again at the sink. The payloader has already | ||
| 3592 | // taken its RTP timestamp from the shifted PTS, so undoing it here | ||
| 3593 | // moves only the transmission time back, leaving the timestamp | ||
| 3594 | // ahead of the wire by the shift. | ||
| 3595 | // | ||
| 3596 | // This is what the earlier attempts each got wrong. ts-offset | ||
| 3597 | // alone moved transmission but not the timestamp; shifting the PTS | ||
| 3598 | // alone moved both, so the lead did not change at all (-23.73ms | ||
| 3599 | // before, -23.73ms after -- identical, which is what gave it | ||
| 3600 | // away). The -23.2ms is GStreamer's own latency compensation: | ||
| 3601 | // sinks render at PTS + pipeline latency, and the pipeline latency | ||
| 3602 | // here is one graph quantum. | ||
| 3603 |
3/6✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 167 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 167 times.
✗ Branch 5 not taken.
|
167 | GstElement* usink2 = gst_bin_get_by_name(GST_BIN(pipeline), "usink"); |
| 3604 |
1/2✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
|
167 | if (usink2) { |
| 3605 | 167 | g_object_set(usink2, "ts-offset", | |
| 3606 |
1/2✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
|
167 | -((gint64)m_config.sinkPacingMs * GST_MSECOND), |
| 3607 | NULL); | ||
| 3608 |
1/2✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
|
167 | gst_object_unref(usink2); |
| 3609 | } | ||
| 3610 |
1/2✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
|
167 | LogInfo(VB_MEDIAOUT, |
| 3611 | "AES67 send [%d]: sink pacing on, %dms timestamp lead\n", | ||
| 3612 | inst.id, m_config.sinkPacingMs); | ||
| 3613 | } else { | ||
| 3614 | ✗ | LogWarn(VB_MEDIAOUT, | |
| 3615 | "AES67 send [%d]: sink pacing on but payloader not found\n", | ||
| 3616 | inst.id); | ||
| 3617 | } | ||
| 3618 |
1/2✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
|
167 | if (pay) { |
| 3619 |
1/2✓ Branch 0 taken 167 times.
✗ Branch 1 not taken.
|
167 | gst_object_unref(pay); |
| 3620 | } | ||
| 3621 | } | ||
| 3622 | |||
| 3623 | // Store the bus for polling in the watchdog thread. | ||
| 3624 | // gst_bus_add_watch() requires a running GLib main loop which fppd does | ||
| 3625 | // not have — use gst_bus_pop() polling instead. | ||
| 3626 |
3/6✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 168 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 168 times.
✗ Branch 5 not taken.
|
168 | GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline)); |
| 3627 | |||
| 3628 | // Start pipeline in PLAYING state OUTSIDE the mutex — this can block | ||
| 3629 | // waiting for PipeWire and must not hold m_pipelineMutex. | ||
| 3630 |
1/2✓ Branch 0 taken 168 times.
✗ Branch 1 not taken.
|
168 | GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING); |
| 3631 |
2/2✓ Branch 0 taken 148 times.
✓ Branch 1 taken 20 times.
|
168 | if (ret == GST_STATE_CHANGE_FAILURE) { |
| 3632 |
1/2✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
|
148 | LogErr(VB_MEDIAOUT, "AES67 send pipeline [%d] failed to start\n", inst.id); |
| 3633 |
2/4✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 148 times.
✗ Branch 3 not taken.
|
444 | WarningHolder::AddWarning(AES67::WARNING_ID_PIPELINE, AES67::WARNING_SEND_FAILED); |
| 3634 | // Drop back to NULL before unreffing -- elements may already hold | ||
| 3635 | // READY/PAUSED resources (sockets, threads, PipeWire connections) | ||
| 3636 | // that gst_object_unref() alone will not release. | ||
| 3637 |
1/2✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
|
148 | gst_element_set_state(pipeline, GST_STATE_NULL); |
| 3638 |
1/2✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
|
148 | gst_object_unref(bus); |
| 3639 |
1/2✓ Branch 0 taken 148 times.
✗ Branch 1 not taken.
|
148 | gst_object_unref(pipeline); |
| 3640 | 148 | return false; | |
| 3641 | } | ||
| 3642 | |||
| 3643 | // The latency-derived retune happens in the watchdog, not here: at this | ||
| 3644 | // point set_state(PLAYING) has only been *requested*, the pipeline has not | ||
| 3645 | // prerolled, and the latency query returns 0 -- which silently left the | ||
| 3646 | // shift at its configured default, defeating the whole point. | ||
| 3647 | |||
| 3648 | { | ||
| 3649 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | std::unique_lock<std::mutex> lock(m_pipelineMutex); |
| 3650 | // If a pipeline already exists for this instance id (e.g. two | ||
| 3651 | // ApplyConfig() runs interleaved), stop it properly instead of | ||
| 3652 | // erasing the map entry directly -- a raw erase() would destroy | ||
| 3653 | // the AES67Pipeline (dropping its GstElement* refs) without ever | ||
| 3654 | // calling gst_element_set_state(NULL), leaking a still-PLAYING | ||
| 3655 | // pipeline (refs, threads, sockets). | ||
| 3656 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | auto node = m_sendPipelines.extract(inst.id); |
| 3657 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 20 times.
|
20 | if (!node.empty()) { |
| 3658 | // StopPipeline() can block on a GStreamer state change to | ||
| 3659 | // PipeWire -- release the lock while it runs, matching the | ||
| 3660 | // no-block-while-locked policy used elsewhere in this file | ||
| 3661 | // (see StopAllPipelines()). | ||
| 3662 | ✗ | lock.unlock(); | |
| 3663 | ✗ | StopPipeline(node.mapped()); | |
| 3664 | ✗ | lock.lock(); | |
| 3665 | } | ||
| 3666 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | auto [it, ok] = m_sendPipelines.try_emplace(inst.id); |
| 3667 | 20 | it->second.instanceId = inst.id; | |
| 3668 | 20 | it->second.channels = inst.channels; | |
| 3669 | 20 | it->second.ptime = inst.ptime; | |
| 3670 | 20 | it->second.isSend = true; | |
| 3671 | 20 | it->second.pipeline = pipeline; | |
| 3672 | 20 | it->second.bus = bus; | |
| 3673 | 20 | it->second.running = true; | |
| 3674 | // The gain in the description is the one when it was built; a change | ||
| 3675 | // since then went to the pipelines already in this map, not this one. | ||
| 3676 |
2/4✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 20 times.
✗ Branch 3 not taken.
|
20 | SetPipelineMasterVolume(pipeline, getVolume()); |
| 3677 | 20 | } | |
| 3678 | |||
| 3679 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | LogInfo(VB_MEDIAOUT, "AES67 send pipeline [%d] %s started → %s:%d (%dch, %dms ptime)\n", |
| 3680 | inst.id, inst.name.c_str(), inst.multicastIP.c_str(), inst.port, | ||
| 3681 | inst.channels, inst.ptime); | ||
| 3682 | 20 | return true; | |
| 3683 | 169 | } | |
| 3684 | |||
| 3685 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 3686 | // Pipeline creation — Receive | ||
| 3687 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 3688 | 9 | bool AES67Manager::CreateRecvPipeline(const AES67Instance& inst) { | |
| 3689 |
2/4✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
|
9 | std::string nodeName = SafeNodeName(inst.name) + "_recv"; |
| 3690 | |||
| 3691 | // Build pipeline: | ||
| 3692 | // udpsrc multicast-group=<ip> port=<port> auto-multicast=true | ||
| 3693 | // ! application/x-rtp,media=audio,clock-rate=48000,encoding-name=L24,channels=N,payload=96 | ||
| 3694 | // ! rtpjitterbuffer latency=<ms> | ||
| 3695 | // ! rtpL24depay | ||
| 3696 | // ! audioconvert | ||
| 3697 | // ! pipewiresink target-object=<node> | ||
| 3698 | // stream-properties="props,media.class=Audio/Source,node.name=<node>" | ||
| 3699 | |||
| 3700 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | std::ostringstream oss; |
| 3701 | 9 | oss << "udpsrc multicast-group=" << inst.multicastIP | |
| 3702 |
4/8✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 9 times.
✗ Branch 7 not taken.
|
9 | << " port=" << inst.port |
| 3703 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | << " auto-multicast=true"; |
| 3704 | |||
| 3705 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 9 times.
|
9 | if (!inst.interface.empty()) { |
| 3706 | ✗ | oss << " multicast-iface=" << inst.interface; | |
| 3707 | } | ||
| 3708 | |||
| 3709 |
2/4✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
|
9 | oss << " ! application/x-rtp,media=audio,clock-rate=" << AES67::AUDIO_RATE |
| 3710 |
2/4✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
|
9 | << ",encoding-name=L24,channels=" << inst.channels |
| 3711 |
2/4✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
|
9 | << ",payload=" << AES67::RTP_PAYLOAD_TYPE << " " |
| 3712 |
3/6✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
|
9 | << "! rtpjitterbuffer latency=" << inst.latency << " " |
| 3713 | << "! rtpL24depay " | ||
| 3714 | << "! audioconvert " | ||
| 3715 |
4/8✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 9 times.
✗ Branch 7 not taken.
|
9 | << "! pipewiresink name=pwsink"; |
| 3716 | |||
| 3717 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | std::string pipelineStr = oss.str(); |
| 3718 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | LogInfo(VB_MEDIAOUT, "AES67 recv pipeline [%d] %s: %s\n", |
| 3719 | inst.id, inst.name.c_str(), pipelineStr.c_str()); | ||
| 3720 | |||
| 3721 | 9 | GError* error = nullptr; | |
| 3722 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | GstElement* pipeline = gst_parse_launch(pipelineStr.c_str(), &error); |
| 3723 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 9 times.
|
9 | if (error) { |
| 3724 | ✗ | LogErr(VB_MEDIAOUT, "AES67 recv pipeline error [%d]: %s\n", | |
| 3725 | inst.id, error->message); | ||
| 3726 | ✗ | g_error_free(error); | |
| 3727 | // gst_parse_launch() can return a non-null (partial) pipeline even | ||
| 3728 | // when it also reports an error -- release it so we don't leak. | ||
| 3729 | ✗ | if (pipeline) { | |
| 3730 | ✗ | gst_object_unref(pipeline); | |
| 3731 | } | ||
| 3732 | ✗ | return false; | |
| 3733 | } | ||
| 3734 | |||
| 3735 | // Set stream-properties post-launch — gst_parse_launch cannot | ||
| 3736 | // deserialize GstStructure values containing '/' (e.g. Audio/Source) | ||
| 3737 | // which crashes gst_value_deserialize. | ||
| 3738 |
2/4✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
|
9 | std::string recvNodeDesc = SafeNodeName(inst.name) + "_recv"; |
| 3739 |
3/6✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
|
9 | GstElement* pwsink = gst_bin_get_by_name(GST_BIN(pipeline), "pwsink"); |
| 3740 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | if (pwsink) { |
| 3741 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | GstStructure* props = gst_structure_new("props", |
| 3742 | "media.class", G_TYPE_STRING, "Audio/Source", | ||
| 3743 | "node.name", G_TYPE_STRING, nodeName.c_str(), | ||
| 3744 | "node.description", G_TYPE_STRING, recvNodeDesc.c_str(), | ||
| 3745 | NULL); | ||
| 3746 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | g_object_set(pwsink, "stream-properties", props, NULL); |
| 3747 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | gst_structure_free(props); |
| 3748 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | gst_object_unref(pwsink); |
| 3749 | } | ||
| 3750 | |||
| 3751 | // Store the bus for polling in the watchdog thread (no GLib main loop). | ||
| 3752 |
3/6✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 9 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 9 times.
✗ Branch 5 not taken.
|
9 | GstBus* bus = gst_pipeline_get_bus(GST_PIPELINE(pipeline)); |
| 3753 | |||
| 3754 | // Note: the receive path runs on GStreamer's default clock. Only the send | ||
| 3755 | // path is driven by PTP time (see CreateSendPipeline). | ||
| 3756 | |||
| 3757 | // Start pipeline OUTSIDE the mutex — GStreamer state changes can block | ||
| 3758 | // waiting for PipeWire. | ||
| 3759 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | GstStateChangeReturn ret = gst_element_set_state(pipeline, GST_STATE_PLAYING); |
| 3760 |
2/2✓ Branch 0 taken 6 times.
✓ Branch 1 taken 3 times.
|
9 | if (ret == GST_STATE_CHANGE_FAILURE) { |
| 3761 |
1/2✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
|
6 | LogErr(VB_MEDIAOUT, "AES67 recv pipeline [%d] failed to start\n", inst.id); |
| 3762 |
2/4✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
|
18 | WarningHolder::AddWarning(AES67::WARNING_ID_PIPELINE, AES67::WARNING_RECV_FAILED); |
| 3763 | // Drop back to NULL before unreffing -- elements may already hold | ||
| 3764 | // READY/PAUSED resources (sockets, threads, PipeWire connections) | ||
| 3765 | // that gst_object_unref() alone will not release. | ||
| 3766 |
1/2✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
|
6 | gst_element_set_state(pipeline, GST_STATE_NULL); |
| 3767 |
1/2✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
|
6 | gst_object_unref(bus); |
| 3768 |
1/2✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
|
6 | gst_object_unref(pipeline); |
| 3769 | 6 | return false; | |
| 3770 | } | ||
| 3771 | |||
| 3772 | { | ||
| 3773 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | std::unique_lock<std::mutex> lock(m_pipelineMutex); |
| 3774 | // See CreateSendPipeline() for why we stop any existing pipeline | ||
| 3775 | // for this id instead of erasing the map entry directly. | ||
| 3776 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | auto node = m_recvPipelines.extract(inst.id); |
| 3777 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
|
3 | if (!node.empty()) { |
| 3778 | ✗ | lock.unlock(); | |
| 3779 | ✗ | StopPipeline(node.mapped()); | |
| 3780 | ✗ | lock.lock(); | |
| 3781 | } | ||
| 3782 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | auto [it, ok] = m_recvPipelines.try_emplace(inst.id); |
| 3783 | 3 | it->second.instanceId = inst.id; | |
| 3784 | 3 | it->second.channels = inst.channels; | |
| 3785 | 3 | it->second.ptime = inst.ptime; | |
| 3786 | 3 | it->second.isSend = false; | |
| 3787 | 3 | it->second.pipeline = pipeline; | |
| 3788 | 3 | it->second.bus = bus; | |
| 3789 | 3 | it->second.running = true; | |
| 3790 | 3 | } | |
| 3791 | |||
| 3792 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | LogInfo(VB_MEDIAOUT, "AES67 recv pipeline [%d] %s started ← %s:%d (%dch, %dms latency)\n", |
| 3793 | inst.id, inst.name.c_str(), inst.multicastIP.c_str(), inst.port, | ||
| 3794 | inst.channels, inst.latency); | ||
| 3795 | 3 | return true; | |
| 3796 | 9 | } | |
| 3797 | |||
| 3798 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 3799 | // Pipeline management | ||
| 3800 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 3801 | 23 | void AES67Manager::StopPipeline(AES67Pipeline& p) { | |
| 3802 |
1/2✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
|
23 | if (p.pipeline) { |
| 3803 | 23 | gst_element_set_state(p.pipeline, GST_STATE_NULL); | |
| 3804 | |||
| 3805 | // Remove the buffer-drop probe installed by FlushSendPipelines() | ||
| 3806 | // (if any) and release our ref on the pad it was installed on. | ||
| 3807 | // Without this, the probe stays registered on the pad with | ||
| 3808 | // &p.dropCounter as userdata -- a dangling pointer once this | ||
| 3809 | // AES67Pipeline is erased from its owning map -- and the pad ref | ||
| 3810 | // taken in FlushSendPipelines() leaks. | ||
| 3811 |
3/4✓ Branch 0 taken 2 times.
✓ Branch 1 taken 21 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
23 | if (p.probeId != 0 && p.probePad) { |
| 3812 | 2 | gst_pad_remove_probe(p.probePad, p.probeId); | |
| 3813 | 2 | p.probeId = 0; | |
| 3814 | } | ||
| 3815 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 21 times.
|
23 | if (p.probePad) { |
| 3816 | 2 | gst_object_unref(p.probePad); | |
| 3817 | 2 | p.probePad = nullptr; | |
| 3818 | } | ||
| 3819 | |||
| 3820 | // Release the bus ref we stored at pipeline creation | ||
| 3821 |
1/2✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
|
23 | if (p.bus) { |
| 3822 | 23 | gst_object_unref(p.bus); | |
| 3823 | 23 | p.bus = nullptr; | |
| 3824 | } | ||
| 3825 | 23 | gst_object_unref(p.pipeline); | |
| 3826 | 23 | p.pipeline = nullptr; | |
| 3827 | 23 | p.running = false; | |
| 3828 | } | ||
| 3829 | 23 | } | |
| 3830 | |||
| 3831 | 208 | void AES67Manager::StopAllPipelines() { | |
| 3832 | // Extract pipelines from the maps under the lock, then release the lock | ||
| 3833 | // before calling gst_element_set_state(NULL) — state changes can block | ||
| 3834 | // on PipeWire and must not hold m_pipelineMutex. | ||
| 3835 | 208 | std::map<int, AES67Pipeline> sendCopy, recvCopy; | |
| 3836 | { | ||
| 3837 |
1/2✓ Branch 0 taken 208 times.
✗ Branch 1 not taken.
|
208 | std::lock_guard<std::mutex> lock(m_pipelineMutex); |
| 3838 | 208 | sendCopy.swap(m_sendPipelines); | |
| 3839 | 208 | recvCopy.swap(m_recvPipelines); | |
| 3840 | // Nothing is configured to be running any more, so no sender is | ||
| 3841 | // waiting for a source either. ApplyConfig() refills this after its | ||
| 3842 | // create pass; every other caller is a teardown. | ||
| 3843 | 208 | m_deferredSenders.clear(); | |
| 3844 | 208 | } | |
| 3845 | |||
| 3846 |
2/2✓ Branch 0 taken 20 times.
✓ Branch 1 taken 208 times.
|
228 | for (auto& [id, p] : sendCopy) { |
| 3847 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | LogDebug(VB_MEDIAOUT, "AES67Manager: Stopping send pipeline [%d]\n", id); |
| 3848 |
1/2✓ Branch 0 taken 20 times.
✗ Branch 1 not taken.
|
20 | StopPipeline(p); |
| 3849 | } | ||
| 3850 | |||
| 3851 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 208 times.
|
211 | for (auto& [id, p] : recvCopy) { |
| 3852 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | LogDebug(VB_MEDIAOUT, "AES67Manager: Stopping recv pipeline [%d]\n", id); |
| 3853 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | StopPipeline(p); |
| 3854 | } | ||
| 3855 | 208 | } | |
| 3856 | |||
| 3857 | // Pad probe callback: drops buffers while dropCounter > 0, passes through otherwise. | ||
| 3858 | // Installed once on pipewiresrc's src pad and stays active for the pipeline's lifetime. | ||
| 3859 | 632 | static GstPadProbeReturn DropBufferProbe(GstPad* pad, GstPadProbeInfo* info, gpointer userData) { | |
| 3860 | 632 | std::atomic<gint64>* remaining = static_cast<std::atomic<gint64>*>(userData); | |
| 3861 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 632 times.
|
632 | if (!(info->type & GST_PAD_PROBE_TYPE_BUFFER)) |
| 3862 | ✗ | return GST_PAD_PROBE_OK; | |
| 3863 |
1/2✓ Branch 0 taken 632 times.
✗ Branch 1 not taken.
|
1264 | if (remaining->load() <= 0) |
| 3864 | 632 | return GST_PAD_PROBE_OK; | |
| 3865 | |||
| 3866 | ✗ | GstBuffer* b = GST_PAD_PROBE_INFO_BUFFER(info); | |
| 3867 | ✗ | GstClockTime dur = b ? GST_BUFFER_DURATION(b) : GST_CLOCK_TIME_NONE; | |
| 3868 | ✗ | if (!GST_CLOCK_TIME_IS_VALID(dur)) { | |
| 3869 | // Nothing to subtract, so stop rather than drop indefinitely. Under- | ||
| 3870 | // dropping leaves a little stale audio; over-dropping is the bug this | ||
| 3871 | // whole change exists to fix. | ||
| 3872 | ✗ | remaining->store(0); | |
| 3873 | ✗ | return GST_PAD_PROBE_DROP; | |
| 3874 | } | ||
| 3875 | |||
| 3876 | // Only drop a buffer that FITS in what is left, and stop otherwise. | ||
| 3877 | // Buffers are whole graph quanta, so testing "is there budget left" and | ||
| 3878 | // subtracting afterwards overshoots by up to a quantum every time: at the | ||
| 3879 | // 21.333ms quantum of a Pi 5 at 48kHz, a 50ms budget took three buffers | ||
| 3880 | // and cut 64.00ms. A tester measured exactly that, and it put the gap | ||
| 3881 | // back over the sink queue's depth, which is the one thing this must not | ||
| 3882 | // do. Stopping short instead is bounded by construction -- 42.67ms at | ||
| 3883 | // that quantum, 46.44ms at 44.1kHz -- and never exceeds what was asked. | ||
| 3884 | ✗ | if ((GstClockTime)remaining->load() < dur) { | |
| 3885 | ✗ | remaining->store(0); | |
| 3886 | ✗ | return GST_PAD_PROBE_OK; | |
| 3887 | } | ||
| 3888 | ✗ | remaining->fetch_sub((gint64)dur); | |
| 3889 | ✗ | return GST_PAD_PROBE_DROP; | |
| 3890 | } | ||
| 3891 | |||
| 3892 | 3106 | void AES67Manager::SetMasterVolume(int volume) { | |
| 3893 |
1/2✓ Branch 0 taken 3106 times.
✗ Branch 1 not taken.
|
3106 | std::lock_guard<std::mutex> lock(m_pipelineMutex); |
| 3894 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 3106 times.
|
3107 | for (auto& kv : m_sendPipelines) { |
| 3895 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (kv.second.pipeline) { |
| 3896 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | SetPipelineMasterVolume(kv.second.pipeline, volume); |
| 3897 | } | ||
| 3898 | } | ||
| 3899 | 3106 | } | |
| 3900 | |||
| 3901 | 10 | void AES67Manager::FlushSendPipelines() { | |
| 3902 |
1/2✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
|
10 | std::lock_guard<std::mutex> lock(m_pipelineMutex); |
| 3903 |
2/2✓ Branch 0 taken 10 times.
✓ Branch 1 taken 10 times.
|
20 | for (auto& kv : m_sendPipelines) { |
| 3904 | 10 | AES67Pipeline& p = kv.second; | |
| 3905 |
2/4✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 10 times.
|
10 | if (!p.pipeline || !p.running) |
| 3906 | 8 | continue; | |
| 3907 | |||
| 3908 | // Drop SOURCE_FLUSH_MS of audio from pipewiresrc's src pad. This | ||
| 3909 | // discards whatever was queued in GStreamer elements between the old | ||
| 3910 | // track stopping and the new one starting, without disrupting the | ||
| 3911 | // pipeline's event flow (no flush-start/stop, no state change). | ||
| 3912 | // | ||
| 3913 | // Three call sites in GStreamerOut fire at a single track change, so | ||
| 3914 | // restart the window rather than adding to it -- the old counter was | ||
| 3915 | // reset to 10 buffers by each one, and only stayed bounded because | ||
| 3916 | // the calls happened to land within one window. Log just the first, | ||
| 3917 | // since three identical lines per transition made this look like | ||
| 3918 | // three separate events in the logs testers sent back. | ||
| 3919 | // Three call sites in GStreamerOut fire at a single track change. | ||
| 3920 | // Re-arming the budget on each one lets a later call land mid-sequence | ||
| 3921 | // and extend the drop: a tester saw transitions alternate between | ||
| 3922 | // three and four quanta (64.00ms and 85.33ms) for one 50ms request. | ||
| 3923 | // Ignore a call while a flush is still running, so one track change | ||
| 3924 | // discards one budget's worth however many code paths announce it. | ||
| 3925 | 10 | const gint64 target = (gint64)AES67::SOURCE_FLUSH_MS * GST_MSECOND; | |
| 3926 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 10 times.
|
20 | if (p.dropRemainingNs.load() > 0) { |
| 3927 | ✗ | continue; // already flushing; probe is installed | |
| 3928 | } | ||
| 3929 |
1/2✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
|
10 | LogInfo(VB_MEDIAOUT, |
| 3930 | "AES67 send pipeline [%d]: discarding up to %dms of stale audio\n", | ||
| 3931 | p.instanceId, AES67::SOURCE_FLUSH_MS); | ||
| 3932 | 10 | p.dropRemainingNs.store(target); | |
| 3933 | |||
| 3934 | // Install the probe once; subsequent calls just reset the counter. | ||
| 3935 |
2/2✓ Branch 0 taken 8 times.
✓ Branch 1 taken 2 times.
|
10 | if (p.probeId != 0) |
| 3936 | 8 | continue; | |
| 3937 | |||
| 3938 | // Find pipewiresrc's src pad and install the permanent probe | ||
| 3939 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
2 | GstIterator* it = gst_bin_iterate_sources(GST_BIN(p.pipeline)); |
| 3940 | 2 | GValue val = G_VALUE_INIT; | |
| 3941 | 2 | GstElement* srcElem = nullptr; | |
| 3942 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
2 | if (gst_iterator_next(it, &val) == GST_ITERATOR_OK) { |
| 3943 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
2 | srcElem = GST_ELEMENT(g_value_get_object(&val)); |
| 3944 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | gst_object_ref(srcElem); |
| 3945 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | g_value_unset(&val); |
| 3946 | } | ||
| 3947 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | gst_iterator_free(it); |
| 3948 | |||
| 3949 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | if (srcElem) { |
| 3950 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | GstPad* srcpad = gst_element_get_static_pad(srcElem, "src"); |
| 3951 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | if (srcpad) { |
| 3952 | 2 | p.probePad = srcpad; // takes ownership of ref | |
| 3953 | 2 | p.probeId = gst_pad_add_probe( | |
| 3954 | srcpad, | ||
| 3955 | GST_PAD_PROBE_TYPE_BUFFER, | ||
| 3956 | DropBufferProbe, | ||
| 3957 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | &p.dropRemainingNs, |
| 3958 | nullptr); | ||
| 3959 | } | ||
| 3960 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | gst_object_unref(srcElem); |
| 3961 | } | ||
| 3962 | } | ||
| 3963 | 10 | } | |
| 3964 | |||
| 3965 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 3966 | // GStreamer bus callback | ||
| 3967 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 3968 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 3969 | // Pipeline watchdog — polls bus messages and recovers crashed pipelines. | ||
| 3970 | // Called from WatchdogLoop() (every SAP_ANNOUNCE_INTERVAL_S seconds). | ||
| 3971 | // fppd does not run a GLib main loop, so gst_bus_add_watch() callbacks would | ||
| 3972 | // never fire. We poll the bus manually and promote any ERROR/WARNING messages. | ||
| 3973 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 3974 | // Restart ptp4l/phc2sys if they have died. Nothing else supervised them: a | ||
| 3975 | // link flap, an OOM kill or a stray `killall ptp4l` left FPP silently running | ||
| 3976 | // with no clock discipline at all, reporting "not running" forever. | ||
| 3977 | // | ||
| 3978 | // Called from WatchdogLoop() alongside the pipeline watchdog, so it shares | ||
| 3979 | // that thread's cadence. Note this means PTP is only supervised while the SAP | ||
| 3980 | // announcer runs (i.e. there is at least one SAP-enabled send instance). | ||
| 3981 | |||
| 3982 | // Trims the send stream's sample rate so the media timeline advances at exactly | ||
| 3983 | // PTP rate. See AES67Config::adaptiveResample for why this is not optional. | ||
| 3984 | // | ||
| 3985 | // The loop is deliberately slow and gentle. What it is correcting is a crystal | ||
| 3986 | // offset -- constant, order 100ppm -- not a fast disturbance, so it samples | ||
| 3987 | // every couple of seconds and moves in small steps. Two terms: | ||
| 3988 | // | ||
| 3989 | // rate the media clock's measured advance per unit of PTP time. Feeding | ||
| 3990 | // that straight back (speed *= measured ratio) nulls the offset in | ||
| 3991 | // rate, which is the bulk of the correction. | ||
| 3992 | // offset a slow pull that burns off however much lag accumulated before the | ||
| 3993 | // rate term settled, so the stream ends up at the right rate AND back | ||
| 3994 | // at the latency it started from. | ||
| 3995 | ✗ | void AES67Manager::DriftControlLoop() { | |
| 3996 | ✗ | LogInfo(VB_MEDIAOUT, "AES67 drift control thread started\n"); | |
| 3997 | |||
| 3998 | ✗ | constexpr int SAMPLE_INTERVAL_S = 2; | |
| 3999 | // The payloader's last-emitted RTP timestamp only advances when a burst of | ||
| 4000 | // packets goes out -- one graph quantum at a time -- so sampling it | ||
| 4001 | // asynchronously quantises the reading by ~21ms. Over a 30s endpoint | ||
| 4002 | // measurement that is +/-750ppm of noise on a signal of ~100ppm, and the | ||
| 4003 | // loop simply hunts. Fit a slope across the whole window instead, and make | ||
| 4004 | // the window long: noise falls with both the span and the sample count. | ||
| 4005 | ✗ | constexpr int WINDOW_S = 120; // measurement baseline | |
| 4006 | // Actuate far more slowly than we sample. The measurement is a 120s | ||
| 4007 | // window, so a correction takes ~60s to show up in it; correcting every 2s | ||
| 4008 | // means ~30 further corrections are applied before the first is visible, | ||
| 4009 | // and the loop oscillates (measured: trim swinging +140 to -377ppm, drift | ||
| 4010 | // swinging +236 to -99ppm). Classic dead-time instability -- the cure is | ||
| 4011 | // to make the control interval comparable to the lag, not to lower the | ||
| 4012 | // gain until the ringing is slow. | ||
| 4013 | ✗ | constexpr int ACTUATE_EVERY = 15; // iterations, i.e. every 30s | |
| 4014 | ✗ | constexpr double KP_RATE = 0.3; // fraction of the rate error to take per step | |
| 4015 | ✗ | constexpr double KP_OFFSET = 0.00002; // per second of accumulated lag | |
| 4016 | ✗ | constexpr double MAX_TRIM = 0.0005; // +/-500ppm, well beyond any crystal | |
| 4017 | ✗ | constexpr double MAX_STEP = 0.00002; // +/-20ppm per iteration | |
| 4018 | |||
| 4019 | struct Sample { | ||
| 4020 | GstClockTime clock; | ||
| 4021 | double samples; // cumulative RTP samples emitted | ||
| 4022 | }; | ||
| 4023 | struct PipelineState { | ||
| 4024 | std::deque<Sample> window; | ||
| 4025 | double cumulative = 0.0; | ||
| 4026 | guint lastTs = 0; | ||
| 4027 | GstClockTime lastClock = 0; | ||
| 4028 | bool primed = false; | ||
| 4029 | double speed = 1.0; | ||
| 4030 | int sinceActuate = 0; | ||
| 4031 | }; | ||
| 4032 | ✗ | std::map<int, PipelineState> state; | |
| 4033 | |||
| 4034 | ✗ | while (m_driftRunning.load()) { | |
| 4035 | ✗ | for (int i = 0; i < SAMPLE_INTERVAL_S * 10 && m_driftRunning.load(); i++) { | |
| 4036 | ✗ | std::this_thread::sleep_for(std::chrono::milliseconds(100)); | |
| 4037 | } | ||
| 4038 | ✗ | if (!m_driftRunning.load()) { | |
| 4039 | ✗ | break; | |
| 4040 | } | ||
| 4041 | |||
| 4042 | ✗ | std::unique_lock<std::mutex> lock(m_pipelineMutex, std::try_to_lock); | |
| 4043 | ✗ | if (!lock.owns_lock()) { | |
| 4044 | ✗ | continue; | |
| 4045 | } | ||
| 4046 | |||
| 4047 | ✗ | for (auto& [id, p] : m_sendPipelines) { | |
| 4048 | ✗ | if (!p.running || !p.pipeline) { | |
| 4049 | ✗ | continue; | |
| 4050 | } | ||
| 4051 | ✗ | GstElement* drift = gst_bin_get_by_name(GST_BIN(p.pipeline), "drift"); | |
| 4052 | ✗ | GstElement* pay = gst_bin_get_by_name(GST_BIN(p.pipeline), "pay"); | |
| 4053 | ✗ | if (!drift || !pay) { | |
| 4054 | ✗ | if (drift) gst_object_unref(drift); | |
| 4055 | ✗ | if (pay) gst_object_unref(pay); | |
| 4056 | ✗ | continue; | |
| 4057 | } | ||
| 4058 | ✗ | GstClock* clock = gst_element_get_clock(p.pipeline); | |
| 4059 | ✗ | if (!clock) { | |
| 4060 | ✗ | gst_object_unref(drift); | |
| 4061 | ✗ | gst_object_unref(pay); | |
| 4062 | ✗ | continue; | |
| 4063 | } | ||
| 4064 | |||
| 4065 | // Measure against the RTP timestamps we actually emitted. These | ||
| 4066 | // step by exactly one packet, so the only error is a single sample | ||
| 4067 | // (21us) over the whole window -- unlike a position query, which | ||
| 4068 | // reports the last buffer the sink handled and therefore jitters by | ||
| 4069 | // a full graph quantum (+/-10ms measured). That jitter is ~45x the | ||
| 4070 | // drift being corrected, and an earlier version of this loop spent | ||
| 4071 | // its life chasing it into the slew limit. | ||
| 4072 | ✗ | guint ts = 0; | |
| 4073 | ✗ | g_object_get(pay, "timestamp", &ts, NULL); | |
| 4074 | ✗ | GstClockTime now = gst_clock_get_time(clock); | |
| 4075 | ✗ | gst_object_unref(clock); | |
| 4076 | ✗ | gst_object_unref(pay); | |
| 4077 | |||
| 4078 | ✗ | PipelineState& st = state[id]; | |
| 4079 | ✗ | if (!st.primed) { | |
| 4080 | ✗ | st.lastTs = ts; | |
| 4081 | ✗ | st.lastClock = now; | |
| 4082 | ✗ | st.primed = true; | |
| 4083 | ✗ | st.window.push_back({now, 0.0}); | |
| 4084 | ✗ | gst_object_unref(drift); | |
| 4085 | ✗ | continue; | |
| 4086 | } | ||
| 4087 | |||
| 4088 | ✗ | double emitted = (double)((guint32)(ts - st.lastTs)); // wraps correctly | |
| 4089 | ✗ | st.lastTs = ts; | |
| 4090 | |||
| 4091 | // Reject discontinuities rather than measuring them as drift. | ||
| 4092 | // Between tracks, and whenever FlushSendPipelines() drops buffers, | ||
| 4093 | // the stream simply stops for a while; the media timeline then | ||
| 4094 | // legitimately loses time against the wall clock, which is | ||
| 4095 | // indistinguishable from a very slow clock if taken at face value. | ||
| 4096 | // Measured with five track restarts in a window it reported | ||
| 4097 | // -7500ppm and nearly a second of "lag" -- all of it gaps. | ||
| 4098 | ✗ | double expected = (double)(now - st.lastClock) / 1e9 * AES67::AUDIO_RATE; | |
| 4099 | ✗ | st.lastClock = now; | |
| 4100 | ✗ | if (expected > 0 && (emitted < expected * 0.9 || emitted > expected * 1.1)) { | |
| 4101 | ✗ | LogDebug(VB_MEDIAOUT, | |
| 4102 | "AES67 drift [%d]: stream discontinuity (%.0f of %.0f samples), " | ||
| 4103 | "restarting measurement\n", id, emitted, expected); | ||
| 4104 | ✗ | st.window.clear(); | |
| 4105 | ✗ | st.cumulative = 0.0; | |
| 4106 | ✗ | st.window.push_back({now, 0.0}); | |
| 4107 | ✗ | gst_object_unref(drift); | |
| 4108 | ✗ | continue; | |
| 4109 | } | ||
| 4110 | |||
| 4111 | ✗ | st.cumulative += emitted; | |
| 4112 | ✗ | st.window.push_back({now, st.cumulative}); | |
| 4113 | ✗ | while (st.window.size() > 2 && | |
| 4114 | ✗ | (now - st.window.front().clock) > (GstClockTime)WINDOW_S * GST_SECOND) { | |
| 4115 | ✗ | st.window.pop_front(); | |
| 4116 | } | ||
| 4117 | ✗ | if (st.window.size() < 3) { | |
| 4118 | ✗ | gst_object_unref(drift); | |
| 4119 | ✗ | continue; | |
| 4120 | } | ||
| 4121 | |||
| 4122 | ✗ | const Sample& a = st.window.front(); | |
| 4123 | ✗ | const Sample& b = st.window.back(); | |
| 4124 | ✗ | double dClockS = (double)(b.clock - a.clock) / 1e9; | |
| 4125 | ✗ | if (dClockS < (double)WINDOW_S * 0.5) { | |
| 4126 | // Not enough baseline yet to measure anything at this precision. | ||
| 4127 | ✗ | gst_object_unref(drift); | |
| 4128 | ✗ | continue; | |
| 4129 | } | ||
| 4130 | |||
| 4131 | // Least-squares slope of media seconds against PTP seconds across | ||
| 4132 | // the window. Averaging down the per-sample quantisation is the | ||
| 4133 | // whole point; endpoints alone are far too noisy. | ||
| 4134 | ✗ | double n = 0, sx = 0, sy = 0, sxx = 0, sxy = 0; | |
| 4135 | ✗ | for (const Sample& w : st.window) { | |
| 4136 | ✗ | double x = (double)(w.clock - a.clock) / 1e9; | |
| 4137 | ✗ | double y = (w.samples - a.samples) / (double)AES67::AUDIO_RATE; | |
| 4138 | ✗ | n += 1; sx += x; sy += y; sxx += x * x; sxy += x * y; | |
| 4139 | } | ||
| 4140 | ✗ | double denom = n * sxx - sx * sx; | |
| 4141 | ✗ | if (denom <= 0) { | |
| 4142 | ✗ | gst_object_unref(drift); | |
| 4143 | ✗ | continue; | |
| 4144 | } | ||
| 4145 | ✗ | double ratio = (n * sxy - sx * sy) / denom; // 1.0 = locked to PTP | |
| 4146 | ✗ | double lagS = dClockS - (b.samples - a.samples) / (double)AES67::AUDIO_RATE; | |
| 4147 | |||
| 4148 | // Sign: the "speed" element plays at `speed` x rate, so it emits | ||
| 4149 | // FEWER samples as speed rises -- media time advances as 1/speed. | ||
| 4150 | // Media running slow (ratio < 1) therefore needs speed to come | ||
| 4151 | // DOWN. Getting this backwards drives the loop straight into the | ||
| 4152 | // clamp and makes the drift worse, which is exactly what the first | ||
| 4153 | // rewrite of this loop did (-111ppm became -770ppm at +500ppm trim). | ||
| 4154 | // | ||
| 4155 | // Rate term takes a fraction of the error so the loop settles | ||
| 4156 | // rather than ringing; offset term is tiny and only bleeds off | ||
| 4157 | // accumulated lag. | ||
| 4158 | // Keep accumulating the window every iteration, but only move the | ||
| 4159 | // trim once the previous move has had time to show up. | ||
| 4160 | ✗ | if (++st.sinceActuate < ACTUATE_EVERY) { | |
| 4161 | ✗ | gst_object_unref(drift); | |
| 4162 | ✗ | continue; | |
| 4163 | } | ||
| 4164 | ✗ | st.sinceActuate = 0; | |
| 4165 | |||
| 4166 | ✗ | double next = st.speed * (1.0 + KP_RATE * (ratio - 1.0)) * (1.0 - KP_OFFSET * lagS); | |
| 4167 | ✗ | if (next > st.speed + MAX_STEP) next = st.speed + MAX_STEP; | |
| 4168 | ✗ | if (next < st.speed - MAX_STEP) next = st.speed - MAX_STEP; | |
| 4169 | ✗ | if (next < 1.0 - MAX_TRIM) next = 1.0 - MAX_TRIM; | |
| 4170 | ✗ | if (next > 1.0 + MAX_TRIM) next = 1.0 + MAX_TRIM; | |
| 4171 | |||
| 4172 | ✗ | if (fabs(next - st.speed) > 1e-9) { | |
| 4173 | ✗ | st.speed = next; | |
| 4174 | // "pitch" (soundtouch) exposes the rate as "rate". The | ||
| 4175 | // previous element, "speed", accepted its property and read it | ||
| 4176 | // back while doing nothing to a live source -- so verify any | ||
| 4177 | // replacement by watching the measured drift respond, never by | ||
| 4178 | // reading the property back. | ||
| 4179 | ✗ | g_object_set(drift, "rate", (gfloat)st.speed, NULL); | |
| 4180 | } | ||
| 4181 | |||
| 4182 | ✗ | LogDebug(VB_MEDIAOUT, | |
| 4183 | "AES67 drift [%d]: over %.0fs media/PTP %.7f (%+.1f ppm), lag %+.2f ms, trim %+.1f ppm\n", | ||
| 4184 | id, dClockS, ratio, (ratio - 1.0) * 1e6, lagS * 1000.0, | ||
| 4185 | (st.speed - 1.0) * 1e6); | ||
| 4186 | ✗ | gst_object_unref(drift); | |
| 4187 | } | ||
| 4188 | ✗ | } | |
| 4189 | ✗ | LogInfo(VB_MEDIAOUT, "AES67 drift control thread stopped\n"); | |
| 4190 | ✗ | } | |
| 4191 | |||
| 4192 | // PTP time minus CLOCK_MONOTONIC. This is the quantity a send pipeline's RTP | ||
| 4193 | // anchor depends on: the anchor is a PTP instant frozen at build time, and the | ||
| 4194 | // pipeline that has to hit it runs on the graph clock, which is a system clock | ||
| 4195 | // like this one. When the two move apart by more than drift, the anchor is | ||
| 4196 | // wrong by exactly that much. | ||
| 4197 | 146 | bool AES67Manager::PtpMonotonicDelta(int64_t& deltaNs) { | |
| 4198 |
1/2✓ Branch 0 taken 146 times.
✗ Branch 1 not taken.
|
146 | std::lock_guard<std::mutex> lock(m_ptpClockMutex); |
| 4199 |
2/2✓ Branch 0 taken 83 times.
✓ Branch 1 taken 63 times.
|
146 | if (!m_ptpClock) { |
| 4200 | 83 | return false; | |
| 4201 | } | ||
| 4202 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | const GstClockTime ptpNow = gst_clock_get_time(m_ptpClock); |
| 4203 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 63 times.
|
63 | if (!GST_CLOCK_TIME_IS_VALID(ptpNow)) { |
| 4204 | ✗ | return false; | |
| 4205 | } | ||
| 4206 | struct timespec ts; | ||
| 4207 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 63 times.
|
63 | if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) { |
| 4208 | ✗ | return false; | |
| 4209 | } | ||
| 4210 | 63 | deltaNs = (int64_t)ptpNow - (int64_t)GST_TIMESPEC_TO_TIME(ts); | |
| 4211 | 63 | return true; | |
| 4212 | 146 | } | |
| 4213 | |||
| 4214 | 144 | void AES67Manager::ResetPtpStepReference() { | |
| 4215 | 144 | int64_t delta = 0; | |
| 4216 |
1/2✓ Branch 0 taken 144 times.
✗ Branch 1 not taken.
|
144 | const bool have = PtpMonotonicDelta(delta); |
| 4217 |
1/2✓ Branch 0 taken 144 times.
✗ Branch 1 not taken.
|
144 | std::lock_guard<std::mutex> lock(m_ptpStepMutex); |
| 4218 | 144 | m_ptpMonotonicDeltaNs = delta; | |
| 4219 | 144 | m_ptpMonotonicDeltaValid = have; | |
| 4220 | |||
| 4221 | // Same sample, different job: this one is the fixed point | ||
| 4222 | // ReviewPtpNoLockWarning() measures total drift against, so it is taken | ||
| 4223 | // once here -- where the anchors it describes have just been built -- and | ||
| 4224 | // never re-taken while the warning stands. | ||
| 4225 |
2/2✓ Branch 0 taken 7 times.
✓ Branch 1 taken 137 times.
|
144 | if (m_ptpNoLockWarned.load()) { |
| 4226 | 7 | m_ptpNoLockBaselineNs = delta; | |
| 4227 | 7 | m_ptpNoLockBaselineValid = have; | |
| 4228 | } | ||
| 4229 | 144 | } | |
| 4230 | |||
| 4231 | // True when the PTP clock has moved out from under the anchors since the last | ||
| 4232 | // sample. Re-samples either way, so what it measures is always one watchdog | ||
| 4233 | // interval of movement rather than everything since the pipelines were built. | ||
| 4234 | 1 | bool AES67Manager::PtpClockStepped() { | |
| 4235 | 1 | int64_t delta = 0; | |
| 4236 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
|
1 | if (!PtpMonotonicDelta(delta)) { |
| 4237 | // No PTP media clock, so nothing is anchored to PTP and there is | ||
| 4238 | // nothing for a step to invalidate. | ||
| 4239 | ✗ | std::lock_guard<std::mutex> lock(m_ptpStepMutex); | |
| 4240 | ✗ | m_ptpMonotonicDeltaValid = false; | |
| 4241 | ✗ | return false; | |
| 4242 | ✗ | } | |
| 4243 | |||
| 4244 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::lock_guard<std::mutex> lock(m_ptpStepMutex); |
| 4245 | 1 | const bool had = m_ptpMonotonicDeltaValid; | |
| 4246 | 1 | const int64_t previous = m_ptpMonotonicDeltaNs; | |
| 4247 | 1 | m_ptpMonotonicDeltaNs = delta; | |
| 4248 | 1 | m_ptpMonotonicDeltaValid = true; | |
| 4249 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (!had) { |
| 4250 | ✗ | return false; | |
| 4251 | } | ||
| 4252 | 1 | const int64_t moved = delta - previous; | |
| 4253 | 1 | const int64_t absMoved = moved < 0 ? -moved : moved; | |
| 4254 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (absMoved < AES67::PTP_STEP_DETECT_NS) { |
| 4255 | 1 | return false; | |
| 4256 | } | ||
| 4257 | ✗ | LogWarn(VB_MEDIAOUT, | |
| 4258 | "AES67 watchdog: PTP clock stepped %+.3f s — the RTP timelines are " | ||
| 4259 | "anchored to where it used to be\n", | ||
| 4260 | (double)moved / 1e9); | ||
| 4261 | ✗ | return true; | |
| 4262 | 1 | } | |
| 4263 | |||
| 4264 | // The banner, the flag the watchdog gates on, and the baseline it measures | ||
| 4265 | // against all describe one fact, so they are set and cleared in one place. | ||
| 4266 | // | ||
| 4267 | // The baseline is not taken here. InitPTP() runs before any pipeline exists, | ||
| 4268 | // so the media clock has not been opened yet and PtpMonotonicDelta() has | ||
| 4269 | // nothing to read; ResetPtpStepReference() seeds it instead, at the point | ||
| 4270 | // ApplyConfig() has finished anchoring everything, which is the moment the | ||
| 4271 | // baseline is supposed to describe. | ||
| 4272 | 7 | void AES67Manager::RaisePtpNoLockWarning() { | |
| 4273 | { | ||
| 4274 |
1/2✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
|
7 | std::lock_guard<std::mutex> lock(m_ptpStepMutex); |
| 4275 | 7 | m_ptpNoLockBaselineValid = false; | |
| 4276 | 7 | } | |
| 4277 | 7 | m_ptpNoLockWarned.store(true); | |
| 4278 |
2/4✓ Branch 0 taken 7 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 7 times.
✗ Branch 3 not taken.
|
21 | WarningHolder::AddWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_NO_LOCK); |
| 4279 | 7 | } | |
| 4280 | |||
| 4281 | 206 | void AES67Manager::ClearPtpNoLockWarning() { | |
| 4282 | 206 | m_ptpNoLockWarned.store(false); | |
| 4283 | { | ||
| 4284 |
1/2✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
|
206 | std::lock_guard<std::mutex> lock(m_ptpStepMutex); |
| 4285 | 206 | m_ptpNoLockBaselineValid = false; | |
| 4286 | 206 | } | |
| 4287 |
3/6✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 206 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 206 times.
✗ Branch 5 not taken.
|
618 | WarningHolder::RemoveWarning(AES67::WARNING_ID_PTP, AES67::WARNING_PTP_NO_LOCK); |
| 4288 | 206 | } | |
| 4289 | |||
| 4290 | // WARNING_PTP_NO_LOCK says the RTP timelines were anchored to a clock that had | ||
| 4291 | // not settled yet. That stops being true on its own: ptp4l commonly reaches | ||
| 4292 | // SLAVE with a usable offset a few seconds after ptpLockWaitMs ran out. | ||
| 4293 | // Nothing used to notice -- the warning has no timeout, ShutdownPTP() was the | ||
| 4294 | // only thing that retracted it, and PtpClockStepped() below only asks for a | ||
| 4295 | // rebuild on a jump of PTP_STEP_DETECT_NS or more *within one watchdog | ||
| 4296 | // interval*. A clock that settles by slewing moves far less than that per | ||
| 4297 | // interval, so the banner and the "rebuilding when it does" badge outlived the | ||
| 4298 | // problem all the way to the next fppd restart. That is what the user sees on | ||
| 4299 | // an ordinary boot where the grandmaster is simply slower to appear than the | ||
| 4300 | // five-second cap allows. | ||
| 4301 | // | ||
| 4302 | // Settling is not on its own enough to retract it, though: the anchors are | ||
| 4303 | // still where InitPTP() left them, so what matters is how far the clock has | ||
| 4304 | // travelled since. Measured against the baseline taken when the warning went | ||
| 4305 | // up -- see m_ptpNoLockBaselineNs for why the step detector's own reference | ||
| 4306 | // cannot answer this. | ||
| 4307 | // | ||
| 4308 | // moved less than a step -> the anchors turned out fine; retract. | ||
| 4309 | // moved a step or more -> they did not; ask for the rebuild, which | ||
| 4310 | // retracts the warning via ShutdownPTP(). | ||
| 4311 | // | ||
| 4312 | // Returns true when the caller should rebuild. | ||
| 4313 | 1 | bool AES67Manager::ReviewPtpNoLockWarning() { | |
| 4314 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (!m_ptpNoLockWarned.load()) { |
| 4315 | ✗ | return false; | |
| 4316 | } | ||
| 4317 | |||
| 4318 | 1 | std::string portState; | |
| 4319 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | const PtpSettleState settled = PtpSettledNow(&portState); |
| 4320 | // NoGrandmasterYet counts as an answer here. Unlike WaitForPtpLock(), | ||
| 4321 | // this runs at least a watchdog interval after the wait gave up, which is | ||
| 4322 | // long past the point where "no announce seen" and "no announce yet" stop | ||
| 4323 | // looking the same. | ||
| 4324 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (settled == PtpSettleState::NotSettled) { |
| 4325 | ✗ | return false; | |
| 4326 | } | ||
| 4327 | |||
| 4328 | 1 | int64_t delta = 0; | |
| 4329 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | const bool haveDelta = PtpMonotonicDelta(delta); |
| 4330 | 1 | int64_t baseline = 0; | |
| 4331 | 1 | bool haveBaseline = false; | |
| 4332 | { | ||
| 4333 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::lock_guard<std::mutex> lock(m_ptpStepMutex); |
| 4334 | 1 | baseline = m_ptpNoLockBaselineNs; | |
| 4335 | 1 | haveBaseline = m_ptpNoLockBaselineValid; | |
| 4336 | 1 | } | |
| 4337 | |||
| 4338 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | if (haveDelta && haveBaseline) { |
| 4339 | 1 | const int64_t moved = delta - baseline; | |
| 4340 | 1 | const int64_t absMoved = moved < 0 ? -moved : moved; | |
| 4341 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (absMoved >= AES67::PTP_STEP_DETECT_NS) { |
| 4342 | ✗ | LogWarn(VB_MEDIAOUT, | |
| 4343 | "AES67 watchdog: PTP has settled (%s) but moved %+.3f s " | ||
| 4344 | "getting there — the RTP timelines are anchored to where it " | ||
| 4345 | "used to be\n", | ||
| 4346 | portState.c_str(), (double)moved / 1e9); | ||
| 4347 | ✗ | return true; | |
| 4348 | } | ||
| 4349 | } | ||
| 4350 | |||
| 4351 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, |
| 4352 | "AES67 watchdog: PTP has settled (%s) close to where the streams " | ||
| 4353 | "were anchored — clearing the provisional-anchor warning\n", | ||
| 4354 | portState.c_str()); | ||
| 4355 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | ClearPtpNoLockWarning(); |
| 4356 | // The badge this drives promises a rebuild that is no longer coming. | ||
| 4357 | 1 | m_ptpLockedAtStart.store(true); | |
| 4358 | 1 | return false; | |
| 4359 | 1 | } | |
| 4360 | |||
| 4361 | 9 | bool AES67Manager::CheckPtpWatchdog() { | |
| 4362 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 7 times.
|
9 | if (!m_config.ptpEnabled) { |
| 4363 | 2 | return false; | |
| 4364 | } | ||
| 4365 |
2/2✓ Branch 0 taken 5 times.
✓ Branch 1 taken 2 times.
|
7 | if (!m_ptpInitialized) { |
| 4366 | // Its start at apply time or a restart below failed (interface down, | ||
| 4367 | // the port briefly held). Everything after this used to return here, | ||
| 4368 | // so PTP stayed down until someone applied the config again. | ||
| 4369 | 5 | auto now = std::chrono::steady_clock::now(); | |
| 4370 |
5/8✓ Branch 0 taken 2 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 2 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 5 times.
|
5 | if (m_ptpRetryDelayS > 0 && now < m_ptpRetryAt) { |
| 4371 | ✗ | return false; | |
| 4372 | } | ||
| 4373 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 3 times.
|
5 | m_ptpRetryDelayS = m_ptpRetryDelayS == 0 ? 30 : std::min(m_ptpRetryDelayS * 2, 300); |
| 4374 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | m_ptpRetryAt = now + std::chrono::seconds(m_ptpRetryDelayS); |
| 4375 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | LogInfo(VB_MEDIAOUT, "AES67 watchdog: PTP is not running, starting it\n"); |
| 4376 |
3/4✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 1 times.
|
5 | if (!InitPTP()) { |
| 4377 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | LogWarn(VB_MEDIAOUT, "AES67 watchdog: PTP start failed, trying again in %ds\n", m_ptpRetryDelayS); |
| 4378 | 4 | return false; | |
| 4379 | } | ||
| 4380 | 1 | m_ptpRetryDelayS = 0; | |
| 4381 | // The streams were built without it (or against the ptp4l that died): | ||
| 4382 | // build them again on the clock that is disciplined now. | ||
| 4383 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "AES67 watchdog: PTP started, rebuilding the streams on it\n"); |
| 4384 | 1 | return true; | |
| 4385 | } | ||
| 4386 | |||
| 4387 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
|
2 | if (!IsPtp4lRunning()) { |
| 4388 | 1 | LogWarn(VB_MEDIAOUT, "AES67 watchdog: ptp4l is gone — restarting PTP\n"); | |
| 4389 | // Tear down first: InitPTP() is a no-op while m_ptpInitialized is set, | ||
| 4390 | // and phc2sys must not be left pointed at a dead daemon. | ||
| 4391 | 1 | ShutdownPTP(); | |
| 4392 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (InitPTP()) { |
| 4393 | 1 | LogInfo(VB_MEDIAOUT, "AES67 watchdog: ptp4l restarted\n"); | |
| 4394 | } else { | ||
| 4395 | // Retried on the next passes; see above. | ||
| 4396 | ✗ | m_ptpRetryDelayS = 0; | |
| 4397 | ✗ | LogErr(VB_MEDIAOUT, "AES67 watchdog: ptp4l restart failed\n"); | |
| 4398 | } | ||
| 4399 | // A fresh ptp4l may have stepped the clock on its way back to lock, and | ||
| 4400 | // the reference we were holding describes the old one either way. | ||
| 4401 | 1 | ResetPtpStepReference(); | |
| 4402 | 1 | return false; | |
| 4403 | } | ||
| 4404 | |||
| 4405 | // A clock that locks *after* the pipelines were built breaks them exactly | ||
| 4406 | // as the cold-boot race did (issue #2848): the grandmaster that boots | ||
| 4407 | // slower than the Pi, or a BMCA changeover to a device on a different | ||
| 4408 | // epoch. rtpL24pay latches timestamp-offset on the segment, so there is | ||
| 4409 | // no re-anchoring in place -- the only fix is to build them again, which | ||
| 4410 | // is cheap and is what the under-delivery path already does 90 seconds | ||
| 4411 | // later. This just gets there without waiting for the symptom. | ||
| 4412 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (PtpClockStepped()) { |
| 4413 | ✗ | return true; | |
| 4414 | } | ||
| 4415 | |||
| 4416 | // No step, but the clock may still have quietly settled since the streams | ||
| 4417 | // started -- in which case a warning is standing that nothing else will | ||
| 4418 | // ever take down. | ||
| 4419 | 1 | return ReviewPtpNoLockWarning(); | |
| 4420 | } | ||
| 4421 | |||
| 4422 | 9 | bool AES67Manager::PollPipelinesWatchdog() { | |
| 4423 | 9 | bool needsRebuild = false; | |
| 4424 | |||
| 4425 | { // scope for pipeline mutex | ||
| 4426 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | std::lock_guard<std::mutex> lock(m_pipelineMutex); |
| 4427 | |||
| 4428 | 18 | auto checkPipelines = [this, &needsRebuild](std::map<int, AES67Pipeline>& pipelines, const char* direction) { | |
| 4429 |
2/2✓ Branch 0 taken 5 times.
✓ Branch 1 taken 18 times.
|
23 | for (auto& kv : pipelines) { |
| 4430 | 5 | AES67Pipeline& p = kv.second; | |
| 4431 |
2/4✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 5 times.
|
6 | if (!p.pipeline || !p.running) continue; |
| 4432 | |||
| 4433 | // Drain bus messages (error, warning, state changes) | ||
| 4434 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | if (p.bus) { |
| 4435 | GstMessage* msg; | ||
| 4436 |
3/4✓ Branch 0 taken 64102 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 64097 times.
✓ Branch 3 taken 5 times.
|
64102 | while ((msg = gst_bus_pop(p.bus)) != nullptr) { |
| 4437 |
3/3✓ Branch 0 taken 1 times.
✓ Branch 1 taken 63959 times.
✓ Branch 2 taken 137 times.
|
64097 | switch (GST_MESSAGE_TYPE(msg)) { |
| 4438 | 1 | case GST_MESSAGE_ERROR: { | |
| 4439 | 1 | GError* err = nullptr; gchar* dbg = nullptr; | |
| 4440 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | gst_message_parse_error(msg, &err, &dbg); |
| 4441 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogErr(VB_MEDIAOUT, |
| 4442 | "AES67 %s pipeline [%d] bus error: %s\n", | ||
| 4443 | direction, p.instanceId, err->message); | ||
| 4444 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
2 | p.errorMessage = std::string("GStreamer error: ") + err->message; |
| 4445 | 1 | p.busError = true; | |
| 4446 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | g_error_free(err); g_free(dbg); |
| 4447 | 1 | break; | |
| 4448 | } | ||
| 4449 | 63959 | case GST_MESSAGE_WARNING: { | |
| 4450 | 63959 | GError* err = nullptr; gchar* dbg = nullptr; | |
| 4451 |
1/2✓ Branch 0 taken 63959 times.
✗ Branch 1 not taken.
|
63959 | gst_message_parse_warning(msg, &err, &dbg); |
| 4452 |
1/2✓ Branch 0 taken 63959 times.
✗ Branch 1 not taken.
|
63959 | LogWarn(VB_MEDIAOUT, |
| 4453 | "AES67 %s pipeline [%d] bus warning: %s\n", | ||
| 4454 | direction, p.instanceId, err->message); | ||
| 4455 |
2/4✓ Branch 0 taken 63959 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63959 times.
✗ Branch 3 not taken.
|
63959 | g_error_free(err); g_free(dbg); |
| 4456 | 63959 | break; | |
| 4457 | } | ||
| 4458 | 137 | default: break; | |
| 4459 | } | ||
| 4460 |
1/2✓ Branch 0 taken 64097 times.
✗ Branch 1 not taken.
|
64097 | gst_message_unref(msg); |
| 4461 | } | ||
| 4462 | } | ||
| 4463 | |||
| 4464 | // Already known dead; the bus-error backoff below decides when it | ||
| 4465 | // is rebuilt, and the stall checks would only bypass it. | ||
| 4466 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 4 times.
|
5 | if (p.busError) continue; |
| 4467 | |||
| 4468 | // Check current pipeline state | ||
| 4469 | 4 | GstState curState = GST_STATE_NULL, pendingState = GST_STATE_VOID_PENDING; | |
| 4470 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | gst_element_get_state(p.pipeline, &curState, &pendingState, 0 /* no blocking */); |
| 4471 | |||
| 4472 |
1/4✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
|
4 | if (curState != GST_STATE_PLAYING && pendingState != GST_STATE_PLAYING) { |
| 4473 | // Don't attempt recovery while holding m_pipelineMutex — | ||
| 4474 | // gst_element_set_state can block on PipeWire indefinitely. | ||
| 4475 | // Just flag for a full rebuild instead. | ||
| 4476 | ✗ | LogWarn(VB_MEDIAOUT, | |
| 4477 | "AES67 %s pipeline [%d] is in %s state — flagging for rebuild\n", | ||
| 4478 | direction, p.instanceId, | ||
| 4479 | gst_element_state_get_name(curState)); | ||
| 4480 | ✗ | p.running = false; | |
| 4481 | ✗ | p.errorMessage = "Watchdog: not in PLAYING state"; | |
| 4482 | ✗ | needsRebuild = true; | |
| 4483 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | } else if (p.isSend) { |
| 4484 | // Zombie detection for send pipelines: check if udpsink | ||
| 4485 | // is actually pushing bytes. pipewiresrc can lose its | ||
| 4486 | // PipeWire socket connection (e.g. PipeWire restart) while | ||
| 4487 | // GStreamer still reports PLAYING — producing no output. | ||
| 4488 |
3/6✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
|
4 | GstElement* usink = gst_bin_get_by_name(GST_BIN(p.pipeline), "usink"); |
| 4489 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | if (usink) { |
| 4490 | 4 | guint64 bytesSent = 0; | |
| 4491 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | g_object_get(usink, "bytes-served", &bytesSent, NULL); |
| 4492 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | gst_object_unref(usink); |
| 4493 | |||
| 4494 | // Retune the pacing shift once, when the pipeline has | ||
| 4495 | // actually prerolled and can answer a latency query. The | ||
| 4496 | // presentation lead is (shift - latency), and latency is | ||
| 4497 | // one graph quantum -- 21.3ms at 1024/48000 but 42.7ms at | ||
| 4498 | // 2048. A fixed 40ms shift is comfortably positive on the | ||
| 4499 | // first and negative on the second, and negative means a | ||
| 4500 | // conformant receiver discards every packet. | ||
| 4501 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
|
4 | if (!p.pacingTuned) { |
| 4502 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | auto sit = m_sinkPacingShift.find(p.instanceId); |
| 4503 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
2 | if (sit != m_sinkPacingShift.end() && sit->second) { |
| 4504 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | GstQuery* lq = gst_query_new_latency(); |
| 4505 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
2 | if (gst_element_query(p.pipeline, lq)) { |
| 4506 | 2 | gboolean live = FALSE; | |
| 4507 | 2 | GstClockTime minL = 0, maxL = 0; | |
| 4508 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | gst_query_parse_latency(lq, &live, &minL, &maxL); |
| 4509 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
2 | if (GST_CLOCK_TIME_IS_VALID(minL) && minL > 0) { |
| 4510 | 2 | const GstClockTime want = | |
| 4511 | 2 | minL + 16 * GST_MSECOND; | |
| 4512 | const GstClockTime cur = | ||
| 4513 | 2 | sit->second->load( | |
| 4514 | 2 | std::memory_order_relaxed); | |
| 4515 | const GstClockTime shift = | ||
| 4516 | 2 | std::max(want, cur); | |
| 4517 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
|
2 | if (shift != cur) { |
| 4518 | ✗ | sit->second->store( | |
| 4519 | shift, std::memory_order_relaxed); | ||
| 4520 | ✗ | GstElement* us2 = gst_bin_get_by_name( | |
| 4521 | ✗ | GST_BIN(p.pipeline), "usink"); | |
| 4522 | ✗ | if (us2) { | |
| 4523 | ✗ | g_object_set(us2, "ts-offset", | |
| 4524 | ✗ | -(gint64)shift, NULL); | |
| 4525 | ✗ | gst_object_unref(us2); | |
| 4526 | } | ||
| 4527 | } | ||
| 4528 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | LogInfo(VB_MEDIAOUT, |
| 4529 | "AES67 send [%d]: pipeline latency %.1fms, timestamp lead %.1fms\n", | ||
| 4530 | p.instanceId, | ||
| 4531 | (double)minL / GST_MSECOND, | ||
| 4532 | (double)shift / GST_MSECOND); | ||
| 4533 | 2 | p.pacingTuned = true; | |
| 4534 | } | ||
| 4535 | } | ||
| 4536 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | gst_query_unref(lq); |
| 4537 | } | ||
| 4538 | } | ||
| 4539 | |||
| 4540 | // Sink-pacing diagnostics. The queue is the whole reason | ||
| 4541 | // pacing can be done without starving the source, so its | ||
| 4542 | // depth is what explains a stream that paces correctly for | ||
| 4543 | // a minute and then reverts to bursting: empty means the | ||
| 4544 | // sink is starved and every buffer arrives past its send | ||
| 4545 | // time, full means backpressure has reached pipewiresrc and | ||
| 4546 | // audio is being dropped there. No element by this name | ||
| 4547 | // exists unless sink pacing is on, so this costs nothing | ||
| 4548 | // and needs no lock on the config. | ||
| 4549 | // Whether the rate matcher is actually correcting is a | ||
| 4550 | // readable fact here, not an inference -- which is the | ||
| 4551 | // whole reason it was chosen over "speed" and "pitch". | ||
| 4552 | // At ~56ppm this should climb by ~2.7 samples/s. | ||
| 4553 |
3/6✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
|
4 | GstElement* rm = gst_bin_get_by_name(GST_BIN(p.pipeline), |
| 4554 | "ratematch"); | ||
| 4555 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 4 times.
|
4 | if (rm) { |
| 4556 | ✗ | guint64 added = 0, dropped = 0, in = 0, out = 0; | |
| 4557 | ✗ | g_object_get(rm, "add", &added, "drop", &dropped, | |
| 4558 | "in", &in, "out", &out, NULL); | ||
| 4559 | ✗ | gst_object_unref(rm); | |
| 4560 | ✗ | LogInfo(VB_MEDIAOUT, | |
| 4561 | "AES67 send [%d] ratematch: in %llu out %llu, added %llu dropped %llu\n", | ||
| 4562 | p.instanceId, (unsigned long long)in, | ||
| 4563 | (unsigned long long)out, | ||
| 4564 | (unsigned long long)added, | ||
| 4565 | (unsigned long long)dropped); | ||
| 4566 | } | ||
| 4567 | |||
| 4568 |
3/6✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
|
4 | GstElement* sinkq = gst_bin_get_by_name(GST_BIN(p.pipeline), |
| 4569 | "sinkq"); | ||
| 4570 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | if (sinkq) { |
| 4571 | 4 | guint qBuffers = 0; | |
| 4572 | 4 | guint64 qTime = 0; | |
| 4573 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | g_object_get(sinkq, "current-level-buffers", &qBuffers, |
| 4574 | "current-level-time", &qTime, NULL); | ||
| 4575 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | gst_object_unref(sinkq); |
| 4576 |
1/2✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
|
4 | LogInfo(VB_MEDIAOUT, |
| 4577 | "AES67 send [%d] pacing: queue %u buffers / %llums, +%llu bytes since last check\n", | ||
| 4578 | p.instanceId, qBuffers, | ||
| 4579 | (unsigned long long)(qTime / GST_MSECOND), | ||
| 4580 | (unsigned long long)(bytesSent - p.lastByteCount)); | ||
| 4581 | } | ||
| 4582 | |||
| 4583 | // Compare throughput against what this instance should | ||
| 4584 | // be emitting. Payload is ptime worth of 24-bit frames, | ||
| 4585 | // and at nominal that is AUDIO_RATE * 3 * channels bytes a | ||
| 4586 | // second regardless of ptime. bytes-served counts whole | ||
| 4587 | // RTP packets, though, so each packet's 12-byte header is | ||
| 4588 | // in it too: leaving that out had a healthy stream read | ||
| 4589 | // 104% at 1ms ptime (101% at 4ms), and a stream delivering | ||
| 4590 | // 94% of its audio read 98% and never tripped the check. | ||
| 4591 | 4 | const auto nowT = std::chrono::steady_clock::now(); | |
| 4592 |
4/4✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 3 times.
|
6 | if (p.lastByteTime.time_since_epoch().count() != 0 && |
| 4593 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
|
2 | bytesSent > p.lastByteCount) { |
| 4594 | const double secs = | ||
| 4595 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | std::chrono::duration<double>(nowT - p.lastByteTime) |
| 4596 | 1 | .count(); | |
| 4597 | 1 | constexpr double RTP_HEADER_BYTES = 12.0; | |
| 4598 | 1 | const double expected = | |
| 4599 | 1 | (double)AES67::AUDIO_RATE * 3.0 * p.channels + | |
| 4600 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | (p.ptime > 0 ? RTP_HEADER_BYTES * 1000.0 / p.ptime : 0.0); |
| 4601 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | if (secs > 5.0 && expected > 0) { |
| 4602 | 1 | const double got = | |
| 4603 | 1 | (double)(bytesSent - p.lastByteCount) / secs; | |
| 4604 | // 95%, not something looser: the degradation | ||
| 4605 | // settles at 89-94% of nominal, so an 85% trigger | ||
| 4606 | // sat below every case actually observed and would | ||
| 4607 | // never have fired. A healthy stream measures | ||
| 4608 | // 100.0% consistently, so the margin is real, and | ||
| 4609 | // three consecutive checks means ~90s of sustained | ||
| 4610 | // under-delivery -- far longer than the brief dip a | ||
| 4611 | // track change produces. | ||
| 4612 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (got < expected * 0.95) { |
| 4613 | 1 | p.lowRateCount++; | |
| 4614 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogWarn(VB_MEDIAOUT, |
| 4615 | "AES67 %s pipeline [%d] under-delivering: %.0f of %.0f B/s (%.0f%%), check %d\n", | ||
| 4616 | direction, p.instanceId, got, expected, | ||
| 4617 | 100.0 * got / expected, p.lowRateCount); | ||
| 4618 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (p.lowRateCount >= 3) { |
| 4619 | ✗ | p.running = false; | |
| 4620 | p.errorMessage = | ||
| 4621 | ✗ | "Watchdog: sustained under-delivery"; | |
| 4622 | ✗ | needsRebuild = true; | |
| 4623 | } | ||
| 4624 | } else { | ||
| 4625 | ✗ | p.lowRateCount = 0; | |
| 4626 | } | ||
| 4627 | } | ||
| 4628 | } | ||
| 4629 | 4 | p.lastByteTime = nowT; | |
| 4630 | |||
| 4631 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 2 times.
|
4 | if (bytesSent == p.lastByteCount) { |
| 4632 | 2 | p.stallCount++; | |
| 4633 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
|
2 | if (p.stallCount >= 2) { |
| 4634 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogWarn(VB_MEDIAOUT, |
| 4635 | "AES67 %s pipeline [%d] stalled (bytes-sent=%llu for %d checks) — scheduling rebuild\n", | ||
| 4636 | direction, p.instanceId, (unsigned long long)bytesSent, p.stallCount); | ||
| 4637 | 1 | p.running = false; | |
| 4638 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | p.errorMessage = "Watchdog: pipeline stalled"; |
| 4639 | 1 | needsRebuild = true; | |
| 4640 | } | ||
| 4641 | } else { | ||
| 4642 | 2 | p.stallCount = 0; | |
| 4643 | } | ||
| 4644 | 4 | p.lastByteCount = bytesSent; | |
| 4645 | } | ||
| 4646 | } | ||
| 4647 | } | ||
| 4648 | 18 | }; | |
| 4649 | |||
| 4650 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | checkPipelines(m_sendPipelines, "send"); |
| 4651 |
1/2✓ Branch 0 taken 9 times.
✗ Branch 1 not taken.
|
9 | checkPipelines(m_recvPipelines, "recv"); |
| 4652 | |||
| 4653 | // An ERROR stops the pipeline's streaming for good, and a receive | ||
| 4654 | // pipeline has nothing else that would ever notice. The first one | ||
| 4655 | // rebuilds at once; one that keeps coming back waits 30s, 60s, ... up to | ||
| 4656 | // 8 minutes between rebuilds, and the count starts over after 10 quiet | ||
| 4657 | // minutes. A stream that failed to start at all is never stored, so | ||
| 4658 | // nothing above sees it; it is retried on the same backoff. | ||
| 4659 | { | ||
| 4660 | 9 | const auto now = std::chrono::steady_clock::now(); | |
| 4661 | 9 | std::vector<AES67Pipeline*> errored; | |
| 4662 |
2/2✓ Branch 0 taken 18 times.
✓ Branch 1 taken 9 times.
|
27 | for (auto* pipelines : { &m_sendPipelines, &m_recvPipelines }) { |
| 4663 |
2/2✓ Branch 0 taken 5 times.
✓ Branch 1 taken 18 times.
|
23 | for (auto& kv : *pipelines) { |
| 4664 |
3/4✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 4 times.
|
5 | if (kv.second.pipeline && kv.second.busError) { |
| 4665 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | errored.push_back(&kv.second); |
| 4666 | } | ||
| 4667 | } | ||
| 4668 | } | ||
| 4669 |
2/10✗ Branch 0 not taken.
✓ Branch 1 taken 9 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 9 times.
|
9 | if (m_errorRebuilds > 0 && now - m_lastErrorRebuild > std::chrono::minutes(10)) { |
| 4670 | ✗ | m_errorRebuilds = 0; | |
| 4671 | } | ||
| 4672 |
6/6✓ Branch 0 taken 8 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 4 times.
✓ Branch 4 taken 5 times.
✓ Branch 5 taken 4 times.
|
9 | if (!errored.empty() || m_failedStarts > 0) { |
| 4673 | 5 | const auto wait = std::chrono::seconds(30) * (1 << std::min(std::max(m_errorRebuilds - 1, 0), 4)); | |
| 4674 |
3/12✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 5 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✓ Branch 10 taken 5 times.
✗ Branch 11 not taken.
|
5 | if (needsRebuild || m_errorRebuilds == 0 || now - m_lastErrorRebuild >= wait) { |
| 4675 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | if (!needsRebuild) { |
| 4676 | 5 | m_errorRebuilds++; | |
| 4677 | 5 | m_lastErrorRebuild = now; | |
| 4678 | } | ||
| 4679 | 5 | needsRebuild = true; | |
| 4680 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 5 times.
|
6 | for (AES67Pipeline* p : errored) { |
| 4681 | 1 | p->running = false; | |
| 4682 | } | ||
| 4683 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | LogWarn(VB_MEDIAOUT, "AES67 watchdog: %d pipeline(s) stopped on a bus error, " |
| 4684 | "%d failed to start\n", | ||
| 4685 | (int)errored.size(), m_failedStarts); | ||
| 4686 | } else { | ||
| 4687 | ✗ | LogWarn(VB_MEDIAOUT, | |
| 4688 | "AES67 watchdog: %d pipeline(s) stopped on a bus error, %d failed to start, " | ||
| 4689 | "rebuilding in %llds (error rebuild %d)\n", | ||
| 4690 | (int)errored.size(), m_failedStarts, | ||
| 4691 | (long long)std::chrono::duration_cast<std::chrono::seconds>( | ||
| 4692 | wait - (now - m_lastErrorRebuild)).count(), | ||
| 4693 | m_errorRebuilds + 1); | ||
| 4694 | } | ||
| 4695 | } | ||
| 4696 | 9 | } | |
| 4697 | |||
| 4698 | // If any pipeline needs rebuilding, re-apply config. | ||
| 4699 | // ApplyConfig() tears down all pipelines and recreates them with fresh | ||
| 4700 | // PipeWire connections. | ||
| 4701 |
2/2✓ Branch 0 taken 6 times.
✓ Branch 1 taken 3 times.
|
9 | if (needsRebuild) { |
| 4702 |
1/2✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
|
6 | LogWarn(VB_MEDIAOUT, "AES67 watchdog: triggering full pipeline rebuild\n"); |
| 4703 | } | ||
| 4704 | 9 | } // end pipeline mutex scope | |
| 4705 | |||
| 4706 | 9 | return needsRebuild; | |
| 4707 | } | ||
| 4708 | |||
| 4709 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 4710 | // Network helpers | ||
| 4711 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 4712 | 2033 | std::string AES67Manager::GetInterfaceIP(const std::string& iface) { | |
| 4713 | 2033 | struct ifaddrs* addrs = nullptr; | |
| 4714 |
1/2✓ Branch 0 taken 2033 times.
✗ Branch 1 not taken.
|
2033 | std::string result = "0.0.0.0"; |
| 4715 | |||
| 4716 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 2033 times.
|
2033 | if (getifaddrs(&addrs) != 0) { |
| 4717 | ✗ | return result; | |
| 4718 | } | ||
| 4719 | |||
| 4720 |
2/2✓ Branch 0 taken 10175 times.
✓ Branch 1 taken 2023 times.
|
12198 | for (struct ifaddrs* ifa = addrs; ifa; ifa = ifa->ifa_next) { |
| 4721 |
3/4✓ Branch 0 taken 10175 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6101 times.
✓ Branch 3 taken 4074 times.
|
10175 | if (!ifa->ifa_addr || ifa->ifa_addr->sa_family != AF_INET) continue; |
| 4722 |
7/8✓ Branch 0 taken 3742 times.
✓ Branch 1 taken 332 times.
✓ Branch 2 taken 3742 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1867 times.
✓ Branch 5 taken 1875 times.
✓ Branch 6 taken 2199 times.
✓ Branch 7 taken 1875 times.
|
4074 | if (iface.empty() || iface == ifa->ifa_name) { |
| 4723 |
4/6✓ Branch 0 taken 2199 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2199 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2023 times.
✓ Branch 5 taken 176 times.
|
4398 | if (std::string(ifa->ifa_name) == "lo") continue; |
| 4724 | 176 | struct sockaddr_in* addr = (struct sockaddr_in*)ifa->ifa_addr; | |
| 4725 | char buf[INET_ADDRSTRLEN]; | ||
| 4726 | 176 | inet_ntop(AF_INET, &addr->sin_addr, buf, sizeof(buf)); | |
| 4727 |
1/2✓ Branch 0 taken 176 times.
✗ Branch 1 not taken.
|
176 | result = buf; |
| 4728 |
2/2✓ Branch 0 taken 10 times.
✓ Branch 1 taken 166 times.
|
176 | if (!iface.empty()) break; // found the specific interface |
| 4729 | } | ||
| 4730 | } | ||
| 4731 | |||
| 4732 | 2033 | freeifaddrs(addrs); | |
| 4733 | 2033 | return result; | |
| 4734 | ✗ | } | |
| 4735 | |||
| 4736 | 357 | std::string AES67Manager::SafeNodeName(const std::string& name) { | |
| 4737 |
1/2✓ Branch 0 taken 357 times.
✗ Branch 1 not taken.
|
357 | std::string result = "aes67_"; |
| 4738 |
2/2✓ Branch 0 taken 788 times.
✓ Branch 1 taken 357 times.
|
1145 | for (char c : name) { |
| 4739 |
3/4✓ Branch 0 taken 6 times.
✓ Branch 1 taken 782 times.
✗ Branch 2 not taken.
✓ Branch 3 taken 6 times.
|
788 | if (std::isalnum(c) || c == '_') { |
| 4740 |
1/2✓ Branch 0 taken 782 times.
✗ Branch 1 not taken.
|
782 | result += std::tolower(c); |
| 4741 | } else { | ||
| 4742 |
1/2✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
|
6 | result += '_'; |
| 4743 | } | ||
| 4744 | } | ||
| 4745 | 357 | return result; | |
| 4746 | ✗ | } | |
| 4747 | |||
| 4748 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 4749 | // SAP Announcer — RFC 2974 compliant | ||
| 4750 | // Replaces external fpp_aes67_sap Python daemon | ||
| 4751 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 4752 | 1 | uint16_t AES67Manager::ComputeSAPHash(const std::string& sdp) { | |
| 4753 | // RFC 2974 §6: the message id hash identifies "the precise version of this | ||
| 4754 | // announcement" and MUST change when the payload changes. It is therefore | ||
| 4755 | // computed over the SDP text, not over the instance: an announcement whose | ||
| 4756 | // refclk we corrected but whose hash stayed put is discarded as a repeat by | ||
| 4757 | // a compliant receiver, which is how a stale ts-refclk survives forever. | ||
| 4758 | // | ||
| 4759 | // Session *identity* is carried by the SDP o= line (stable sess-id, rising | ||
| 4760 | // sess-version), so a changed hash reads as "this session was modified", | ||
| 4761 | // not as a second session. | ||
| 4762 | 1 | uint32_t hash = 2166136261u; | |
| 4763 |
2/2✓ Branch 0 taken 233 times.
✓ Branch 1 taken 1 times.
|
234 | for (char c : sdp) { |
| 4764 | 233 | hash ^= (uint32_t)(unsigned char)c; | |
| 4765 | 233 | hash *= 16777619u; | |
| 4766 | } | ||
| 4767 | 1 | return (uint16_t)(hash & 0xFFFF); | |
| 4768 | } | ||
| 4769 | |||
| 4770 | // Monotonic SDP o= version. Seeded from the wall clock so it keeps rising | ||
| 4771 | // across fppd restarts -- a receiver that has cached version N ignores a | ||
| 4772 | // re-announcement numbered below it. | ||
| 4773 | 1 | void AES67Manager::LoadSDPVersion() { | |
| 4774 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (m_sdpVersionPath.empty()) { |
| 4775 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
2 | m_sdpVersionPath = getFPPMediaDir("/config/.aes67-sdp-version"); |
| 4776 | } | ||
| 4777 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::ifstream f(m_sdpVersionPath); |
| 4778 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (!f.is_open()) { |
| 4779 | 1 | return; | |
| 4780 | } | ||
| 4781 | ✗ | std::string key; | |
| 4782 | ✗ | uint64_t ver = 0; | |
| 4783 | ✗ | f >> key >> ver; | |
| 4784 | ✗ | if (!key.empty() && ver > 0) { | |
| 4785 | ✗ | m_sdpBodyKey = key; | |
| 4786 | ✗ | m_lastSdpVersion.store((uint32_t)ver, std::memory_order_relaxed); | |
| 4787 | } | ||
| 4788 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | } |
| 4789 | |||
| 4790 | 1 | void AES67Manager::SaveSDPVersion() { | |
| 4791 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (m_sdpVersionPath.empty()) { |
| 4792 | ✗ | return; | |
| 4793 | } | ||
| 4794 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::ofstream f(m_sdpVersionPath, std::ios::trunc); |
| 4795 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (f.is_open()) { |
| 4796 |
4/8✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✗ Branch 7 not taken.
|
2 | f << m_sdpBodyKey << " " << m_lastSdpVersion.load(std::memory_order_relaxed) << "\n"; |
| 4797 | } | ||
| 4798 | 1 | } | |
| 4799 | |||
| 4800 | // Returns the version to stamp on this announcement. Same body as last time = | ||
| 4801 | // same version, so the SDP (and therefore the SAP msg id hash derived from it) | ||
| 4802 | // is byte-identical and receivers see one continuing session rather than a new | ||
| 4803 | // one per restart. | ||
| 4804 | 1 | uint32_t AES67Manager::SDPVersionFor(const std::string& body) { | |
| 4805 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (m_sdpVersionPath.empty()) { |
| 4806 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LoadSDPVersion(); |
| 4807 | } | ||
| 4808 | |||
| 4809 | 1 | uint32_t h = 2166136261u; | |
| 4810 |
2/2✓ Branch 0 taken 224 times.
✓ Branch 1 taken 1 times.
|
225 | for (char c : body) { |
| 4811 | 224 | h ^= (uint32_t)(unsigned char)c; | |
| 4812 | 224 | h *= 16777619u; | |
| 4813 | } | ||
| 4814 | char keyBuf[16]; | ||
| 4815 | 1 | snprintf(keyBuf, sizeof(keyBuf), "%08x", h); | |
| 4816 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::string key(keyBuf); |
| 4817 | |||
| 4818 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (key == m_sdpBodyKey) { |
| 4819 | ✗ | uint32_t existing = m_lastSdpVersion.load(std::memory_order_relaxed); | |
| 4820 | ✗ | if (existing > 0) { | |
| 4821 | ✗ | return existing; | |
| 4822 | } | ||
| 4823 | } | ||
| 4824 | |||
| 4825 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | uint32_t v = NextSDPVersion(); |
| 4826 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | m_sdpBodyKey = key; |
| 4827 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | SaveSDPVersion(); |
| 4828 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "AES67 SAP: announcement content changed, SDP version now %u\n", v); |
| 4829 | 1 | return v; | |
| 4830 | 1 | } | |
| 4831 | |||
| 4832 | 1 | uint32_t AES67Manager::NextSDPVersion() { | |
| 4833 | 1 | uint32_t candidate = (uint32_t)time(nullptr); | |
| 4834 | 2 | uint32_t prev = m_lastSdpVersion.load(std::memory_order_relaxed); | |
| 4835 | uint32_t next; | ||
| 4836 | do { | ||
| 4837 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | next = (candidate > prev) ? candidate : prev + 1; |
| 4838 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
2 | } while (!m_lastSdpVersion.compare_exchange_weak(prev, next, std::memory_order_relaxed)); |
| 4839 | 1 | return next; | |
| 4840 | } | ||
| 4841 | |||
| 4842 | 1467 | std::string AES67Manager::BuildSDP(const AES67Instance& inst, | |
| 4843 | const std::string& sourceIP, | ||
| 4844 | const std::string& ptpClockId, | ||
| 4845 | uint32_t sdpVersion) { | ||
| 4846 | // AES67-compliant SDP — unique session ID per device + stream. | ||
| 4847 | // Combine source IP, stream name, multicast IP, and port so that | ||
| 4848 | // different FPP boxes (or different streams on the same box) always | ||
| 4849 | // produce distinct o= lines. | ||
| 4850 |
2/6✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
2934 | std::string key = sourceIP + ":" + inst.name + ":" + |
| 4851 |
4/8✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
|
4401 | inst.multicastIP + ":" + std::to_string(inst.port); |
| 4852 | // FNV-1a hash → deterministic, stable across restarts, unique per key | ||
| 4853 | 1467 | uint32_t h = 2166136261u; | |
| 4854 |
2/2✓ Branch 0 taken 36741 times.
✓ Branch 1 taken 1467 times.
|
38208 | for (char c : key) { |
| 4855 | 36741 | h ^= (uint32_t)(unsigned char)c; | |
| 4856 | 36741 | h *= 16777619u; | |
| 4857 | } | ||
| 4858 | 1467 | int sessionId = (int)(h & 0x3FFFFFFFu); // 30-bit positive value | |
| 4859 | |||
| 4860 | // o=<user> <sess-id> <sess-version> ... RFC 4566: sess-id identifies the | ||
| 4861 | // session and must stay put for its lifetime; sess-version rises each time | ||
| 4862 | // the description is modified, which is how a receiver knows to re-read a | ||
| 4863 | // session it already has (e.g. after BMCA changed the ts-refclk). | ||
| 4864 |
1/2✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
|
1467 | std::ostringstream sdp; |
| 4865 | sdp << "v=0\r\n" | ||
| 4866 |
5/10✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1467 times.
✗ Branch 9 not taken.
|
1467 | << "o=- " << sessionId << " " << sdpVersion << " IN IP4 " << sourceIP << "\r\n" |
| 4867 | 1467 | << "s=" << inst.sessionName << "\r\n" | |
| 4868 |
10/20✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1467 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 1467 times.
✗ Branch 11 not taken.
✓ Branch 12 taken 1467 times.
✗ Branch 13 not taken.
✓ Branch 14 taken 1467 times.
✗ Branch 15 not taken.
✓ Branch 16 taken 1467 times.
✗ Branch 17 not taken.
✓ Branch 18 taken 1467 times.
✗ Branch 19 not taken.
|
1467 | << "c=IN IP4 " << inst.multicastIP << "/" << AES67::AUDIO_RTP_TTL << "\r\n" |
| 4869 | << "t=0 0\r\n" | ||
| 4870 |
6/12✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1467 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 1467 times.
✗ Branch 11 not taken.
|
1467 | << "m=audio " << inst.port << " RTP/AVP " << AES67::RTP_PAYLOAD_TYPE << "\r\n" |
| 4871 |
4/8✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
|
1467 | << "a=rtpmap:" << AES67::RTP_PAYLOAD_TYPE << " L24/" |
| 4872 |
3/6✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
|
1467 | << AES67::AUDIO_RATE << "/" << inst.channels << "\r\n" |
| 4873 | << "a=sendonly\r\n" | ||
| 4874 |
4/8✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
|
1467 | << "a=ptime:" << inst.ptime << "\r\n" |
| 4875 | << "a=ts-refclk:ptp=IEEE1588-2008:" << ptpClockId << ":0\r\n" | ||
| 4876 |
5/10✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1467 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1467 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1467 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1467 times.
✗ Branch 9 not taken.
|
1467 | << "a=mediaclk:direct=0\r\n"; |
| 4877 | // NOTE: AES67 requires a=mediaclk:direct, and "direct=0" asserts that the | ||
| 4878 | // RTP timestamp is derived directly from the reference clock with zero | ||
| 4879 | // offset. That is not yet true here -- the payloader's timestamps come | ||
| 4880 | // from the pipeline's monotonic clock with its own start offset, so the | ||
| 4881 | // rate is PTP-locked but the phase is arbitrary. Making the assertion | ||
| 4882 | // true needs a GstPtpClock on the send pipeline plus an explicit | ||
| 4883 | // rtpL24pay timestamp-offset derived from PTP time. | ||
| 4884 | |||
| 4885 |
1/2✓ Branch 0 taken 1467 times.
✗ Branch 1 not taken.
|
2934 | return sdp.str(); |
| 4886 | 1467 | } | |
| 4887 | |||
| 4888 | // Filename to offer for a downloaded .sdp. Not SafeNodeName(): that prefixes | ||
| 4889 | // "aes67_" for the PipeWire node namespace, and instance names usually already | ||
| 4890 | // start with "AES67", which produced aes67_aes67_stream_1.sdp. | ||
| 4891 | 1465 | static std::string SDPFileName(const AES67Instance& inst) { | |
| 4892 | 1465 | std::string base; | |
| 4893 |
2/2✓ Branch 0 taken 2987 times.
✓ Branch 1 taken 1465 times.
|
4452 | for (char c : inst.name) { |
| 4894 |
4/6✓ Branch 0 taken 5 times.
✓ Branch 1 taken 2982 times.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 5 times.
|
2987 | if (std::isalnum((unsigned char)c) || c == '_' || c == '-') { |
| 4895 |
1/2✓ Branch 0 taken 2982 times.
✗ Branch 1 not taken.
|
2982 | base += (char)std::tolower((unsigned char)c); |
| 4896 |
3/6✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 5 times.
✗ Branch 5 not taken.
|
5 | } else if (!base.empty() && base.back() != '_') { |
| 4897 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | base += '_'; |
| 4898 | } | ||
| 4899 | } | ||
| 4900 |
3/6✓ Branch 0 taken 1465 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1465 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 1465 times.
|
1465 | while (!base.empty() && base.back() == '_') { |
| 4901 | ✗ | base.pop_back(); | |
| 4902 | } | ||
| 4903 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1465 times.
|
1465 | if (base.empty()) { |
| 4904 | ✗ | base = "aes67_" + std::to_string(inst.id); | |
| 4905 | } | ||
| 4906 |
1/2✓ Branch 0 taken 1465 times.
✗ Branch 1 not taken.
|
2930 | return base + ".sdp"; |
| 4907 | 1465 | } | |
| 4908 | |||
| 4909 | 1465 | std::string AES67Manager::ExportSDPFor(const AES67Instance& inst, | |
| 4910 | const AES67Config& cfg) { | ||
| 4911 | // Same refclk rule as the announcer: advertise the domain's grandmaster, | ||
| 4912 | // not our own identity, and fall back to our own only when BMCA has not | ||
| 4913 | // settled or PTP is off. | ||
| 4914 |
1/2✓ Branch 0 taken 1465 times.
✗ Branch 1 not taken.
|
1465 | std::string gm = GetActiveGrandmasterId(); |
| 4915 |
4/6✓ Branch 0 taken 1464 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
|
1465 | std::string ptpClockId = gm.empty() ? GetPTPClockId() : gm; |
| 4916 | 1465 | std::string sourceIP = GetInterfaceIP(inst.interface.empty() ? | |
| 4917 |
3/4✓ Branch 0 taken 1462 times.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 1465 times.
✗ Branch 3 not taken.
|
1465 | cfg.ptpInterface : inst.interface); |
| 4918 | |||
| 4919 | // Reuse whatever version the announcer last stamped so an exported file | ||
| 4920 | // and the SAP announcement describe the same revision of the session. | ||
| 4921 | // Not SDPVersionFor(): that mints a new version and rewrites the version | ||
| 4922 | // file, and a GET must not renumber the sessions being announced. With | ||
| 4923 | // SAP off, or before the first announcement, there is no version to match | ||
| 4924 | // and 1 will do -- sess-version only ever gets compared against an earlier | ||
| 4925 | // reading of the same session. | ||
| 4926 | 1465 | uint32_t version = m_lastSdpVersion.load(std::memory_order_relaxed); | |
| 4927 |
1/2✓ Branch 0 taken 1465 times.
✗ Branch 1 not taken.
|
1465 | if (version == 0) { |
| 4928 | 1465 | version = 1; | |
| 4929 | } | ||
| 4930 |
1/2✓ Branch 0 taken 1465 times.
✗ Branch 1 not taken.
|
2930 | return BuildSDP(inst, sourceIP, ptpClockId, version); |
| 4931 | 1465 | } | |
| 4932 | |||
| 4933 | 2 | std::vector<uint8_t> AES67Manager::BuildSAPPacket(const std::string& sourceIP, | |
| 4934 | uint16_t msgIdHash, | ||
| 4935 | const std::string& sdp, | ||
| 4936 | bool isDeletion) { | ||
| 4937 | // RFC 2974 SAP packet: | ||
| 4938 | // Byte 0: V=1 (bits 7-5), A=0 (bit 4), R=0 (bit 3), T=isDeletion (bit 2), | ||
| 4939 | // E=0 (bit 1), C=0 (bit 0) | ||
| 4940 | // Byte 1: Auth length = 0 | ||
| 4941 | // Bytes 2-3: Message ID hash (network byte order) | ||
| 4942 | // Bytes 4-7: Originating source (IPv4) | ||
| 4943 | // Payload type: "application/sdp\0" | ||
| 4944 | // SDP data | ||
| 4945 | |||
| 4946 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | std::string payloadType = "application/sdp"; |
| 4947 | |||
| 4948 | 2 | uint8_t header0 = (AES67::SAP_VERSION << 5); // V=1 | |
| 4949 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
|
2 | if (isDeletion) { |
| 4950 | 1 | header0 |= 0x04; // T=1 (deletion) | |
| 4951 | } | ||
| 4952 | |||
| 4953 | // Parse source IP | ||
| 4954 | struct in_addr srcAddr; | ||
| 4955 | 2 | inet_pton(AF_INET, sourceIP.c_str(), &srcAddr); | |
| 4956 | |||
| 4957 | 2 | std::vector<uint8_t> packet; | |
| 4958 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | packet.push_back(header0); |
| 4959 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | packet.push_back(0); // auth length |
| 4960 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | packet.push_back((msgIdHash >> 8) & 0xFF); |
| 4961 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | packet.push_back(msgIdHash & 0xFF); |
| 4962 | |||
| 4963 | // Source IP (4 bytes, network order) | ||
| 4964 | 2 | uint8_t* ipBytes = (uint8_t*)&srcAddr.s_addr; | |
| 4965 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | packet.push_back(ipBytes[0]); |
| 4966 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | packet.push_back(ipBytes[1]); |
| 4967 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | packet.push_back(ipBytes[2]); |
| 4968 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | packet.push_back(ipBytes[3]); |
| 4969 | |||
| 4970 | // Payload type string + NUL | ||
| 4971 |
2/2✓ Branch 0 taken 30 times.
✓ Branch 1 taken 2 times.
|
32 | for (char c : payloadType) { |
| 4972 |
1/2✓ Branch 0 taken 30 times.
✗ Branch 1 not taken.
|
30 | packet.push_back((uint8_t)c); |
| 4973 | } | ||
| 4974 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | packet.push_back(0); |
| 4975 | |||
| 4976 | // SDP data | ||
| 4977 |
2/2✓ Branch 0 taken 466 times.
✓ Branch 1 taken 2 times.
|
468 | for (char c : sdp) { |
| 4978 |
1/2✓ Branch 0 taken 466 times.
✗ Branch 1 not taken.
|
466 | packet.push_back((uint8_t)c); |
| 4979 | } | ||
| 4980 | |||
| 4981 | 2 | return packet; | |
| 4982 | 2 | } | |
| 4983 | |||
| 4984 | 1 | void AES67Manager::SAPAnnounceLoop() { | |
| 4985 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "AES67 SAP announcer thread started\n"); |
| 4986 | |||
| 4987 | // Create UDP socket for SAP multicast | ||
| 4988 | 1 | int sock = OpenSocket(AF_INET, SOCK_DGRAM, 0); | |
| 4989 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (sock < 0) { |
| 4990 | ✗ | LogErr(VB_MEDIAOUT, "AES67 SAP: Failed to create socket: %s\n", FPPstrerror(errno)); | |
| 4991 | ✗ | return; | |
| 4992 | } | ||
| 4993 | |||
| 4994 | // Set TTL | ||
| 4995 | 1 | int ttl = AES67::SAP_TTL; | |
| 4996 | 1 | setsockopt(sock, IPPROTO_IP, IP_MULTICAST_TTL, &ttl, sizeof(ttl)); | |
| 4997 | |||
| 4998 | // Set multicast interface if specified | ||
| 4999 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (!m_config.ptpInterface.empty()) { |
| 5000 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::string ifIP = GetInterfaceIP(m_config.ptpInterface); |
| 5001 | struct in_addr localAddr; | ||
| 5002 | 1 | inet_pton(AF_INET, ifIP.c_str(), &localAddr); | |
| 5003 | 1 | setsockopt(sock, IPPROTO_IP, IP_MULTICAST_IF, &localAddr, sizeof(localAddr)); | |
| 5004 | 1 | } | |
| 5005 | |||
| 5006 | struct sockaddr_in sapAddr; | ||
| 5007 | 1 | memset(&sapAddr, 0, sizeof(sapAddr)); | |
| 5008 | 1 | sapAddr.sin_family = AF_INET; | |
| 5009 | 1 | sapAddr.sin_port = htons(AES67::SAP_PORT); | |
| 5010 | 1 | inet_pton(AF_INET, AES67::SAP_MCAST_ADDRESS, &sapAddr.sin_addr); | |
| 5011 | |||
| 5012 | // SDP's ts-refclk must identify the domain's actual grandmaster (RFC 7273), | ||
| 5013 | // not this node's own identity — otherwise a follower incorrectly | ||
| 5014 | // advertises itself as the clock source. Fall back to our own derived | ||
| 5015 | // ID only if PTP is disabled or ptp4l hasn't selected a grandmaster yet. | ||
| 5016 | 63 | auto queryPtpClockId = [this]() -> std::string { | |
| 5017 |
1/2✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
|
63 | std::string gm = GetActiveGrandmasterId(); |
| 5018 |
2/6✓ Branch 0 taken 63 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 63 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
126 | return gm.empty() ? GetPTPClockId() : gm; |
| 5019 | 63 | }; | |
| 5020 | |||
| 5021 | // Build all SAP packets for send instances | ||
| 5022 | struct SAPEntry { | ||
| 5023 | uint16_t hash; | ||
| 5024 | std::vector<uint8_t> announcePacket; | ||
| 5025 | std::vector<uint8_t> deletePacket; | ||
| 5026 | }; | ||
| 5027 | // Senders held idle because nothing feeds them are not on the wire, so | ||
| 5028 | // they must not be announced -- a receiver that subscribes to an announced | ||
| 5029 | // stream carrying no packets has no way to tell that from a broken sender. | ||
| 5030 | // Snapshotted here rather than read per announce: ApplyConfig() restarts | ||
| 5031 | // this thread whenever the set can change. | ||
| 5032 | 1 | std::set<int> heldSenders; | |
| 5033 | { | ||
| 5034 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::lock_guard<std::mutex> lock(m_pipelineMutex); |
| 5035 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | for (const auto& [id, d] : m_deferredSenders) { |
| 5036 | ✗ | heldSenders.insert(id); | |
| 5037 | } | ||
| 5038 | 1 | } | |
| 5039 | |||
| 5040 | 1 | auto buildEntries = [this, &heldSenders](const std::string& ptpClockId) -> std::vector<SAPEntry> { | |
| 5041 | 1 | std::vector<SAPEntry> result; | |
| 5042 | |||
| 5043 | // Version the announcement by what is in it. Building the bodies with | ||
| 5044 | // a fixed placeholder version first gives a stable key to compare | ||
| 5045 | // against the last announcement -- see SDPVersionFor(). | ||
| 5046 | 1 | std::string bodyKey; | |
| 5047 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
|
2 | for (const auto& inst : m_config.instances) { |
| 5048 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
|
1 | if (!inst.enabled || !inst.sapEnabled) continue; |
| 5049 |
3/10✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 1 times.
|
1 | if (inst.mode != "send" && inst.mode != "both") continue; |
| 5050 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
|
1 | if (heldSenders.count(inst.id)) continue; |
| 5051 | 1 | std::string sourceIP = GetInterfaceIP(inst.interface.empty() ? | |
| 5052 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | m_config.ptpInterface : inst.interface); |
| 5053 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | bodyKey += BuildSDP(inst, sourceIP, ptpClockId, 0); |
| 5054 | 1 | } | |
| 5055 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | uint32_t sdpVersion = SDPVersionFor(bodyKey); |
| 5056 | |||
| 5057 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
|
2 | for (const auto& inst : m_config.instances) { |
| 5058 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (!inst.enabled) continue; |
| 5059 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (!inst.sapEnabled) continue; |
| 5060 |
3/10✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 1 times.
|
1 | if (inst.mode != "send" && inst.mode != "both") continue; |
| 5061 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
|
1 | if (heldSenders.count(inst.id)) continue; |
| 5062 | |||
| 5063 | 1 | std::string sourceIP = GetInterfaceIP(inst.interface.empty() ? | |
| 5064 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | m_config.ptpInterface : inst.interface); |
| 5065 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::string sdp = BuildSDP(inst, sourceIP, ptpClockId, sdpVersion); |
| 5066 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | uint16_t hash = ComputeSAPHash(sdp); |
| 5067 | |||
| 5068 | 1 | SAPEntry entry; | |
| 5069 | 1 | entry.hash = hash; | |
| 5070 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | entry.announcePacket = BuildSAPPacket(sourceIP, hash, sdp, false); |
| 5071 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | entry.deletePacket = BuildSAPPacket(sourceIP, hash, sdp, true); |
| 5072 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | result.push_back(entry); |
| 5073 | 1 | } | |
| 5074 | 1 | return result; | |
| 5075 | 1 | }; | |
| 5076 | |||
| 5077 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::string ptpClockId = queryPtpClockId(); |
| 5078 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::vector<SAPEntry> entries = buildEntries(ptpClockId); |
| 5079 | |||
| 5080 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
|
1 | if (entries.empty()) { |
| 5081 | ✗ | LogWarn(VB_MEDIAOUT, "AES67 SAP: No SAP-enabled send instances — announcer has nothing to send\n"); | |
| 5082 | } else { | ||
| 5083 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "AES67 SAP: Announcing %d stream(s) to %s:%d every %ds\n", |
| 5084 | (int)entries.size(), AES67::SAP_MCAST_ADDRESS, AES67::SAP_PORT, | ||
| 5085 | AES67::SAP_ANNOUNCE_INTERVAL_S); | ||
| 5086 | } | ||
| 5087 | |||
| 5088 | 3 | auto announceAll = [&]() { | |
| 5089 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 3 times.
|
6 | for (const auto& entry : entries) { |
| 5090 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | ssize_t sent = sendto(sock, entry.announcePacket.data(), entry.announcePacket.size(), 0, |
| 5091 | (struct sockaddr*)&sapAddr, sizeof(sapAddr)); | ||
| 5092 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
|
3 | if (sent < 0) { |
| 5093 | ✗ | LogErr(VB_MEDIAOUT, "AES67 SAP: sendto failed: %s\n", FPPstrerror(errno)); | |
| 5094 | } | ||
| 5095 | } | ||
| 5096 | 3 | }; | |
| 5097 | |||
| 5098 | // Rebuild and re-announce immediately when BMCA changes the refclk, rather | ||
| 5099 | // than letting a wrong ts-refclk stand for the rest of the announce cycle. | ||
| 5100 | 62 | auto refreshRefclk = [&]() -> bool { | |
| 5101 |
1/2✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
|
62 | std::string current = queryPtpClockId(); |
| 5102 |
1/2✓ Branch 0 taken 62 times.
✗ Branch 1 not taken.
|
62 | if (current == ptpClockId) { |
| 5103 | 62 | return false; | |
| 5104 | } | ||
| 5105 | ✗ | LogInfo(VB_MEDIAOUT, "AES67 SAP: PTP refclk changed (%s -> %s), rebuilding SDP\n", | |
| 5106 | ptpClockId.c_str(), current.c_str()); | ||
| 5107 | ✗ | ptpClockId = current; | |
| 5108 | // The rebuilt entries carry a new msg id hash (it is derived from the | ||
| 5109 | // SDP text). We deliberately do NOT delete the old hash first: the | ||
| 5110 | // o= sess-id is unchanged, so a receiver reads this as a modification | ||
| 5111 | // of a session it already has, whereas a deletion could make it tear | ||
| 5112 | // the stream down for the moment before the new announcement lands. | ||
| 5113 | ✗ | entries = buildEntries(ptpClockId); | |
| 5114 | ✗ | return true; | |
| 5115 | 62 | }; | |
| 5116 | |||
| 5117 | // Announce loop | ||
| 5118 | 1 | auto threadStart = std::chrono::steady_clock::now(); | |
| 5119 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 1 times.
|
4 | while (m_sapAnnounceRunning.load()) { |
| 5120 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | refreshRefclk(); |
| 5121 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | announceAll(); |
| 5122 | |||
| 5123 | // Sleep for SAP_ANNOUNCE_INTERVAL_S, checking shutdown flag every second. | ||
| 5124 | // | ||
| 5125 | // ptp4l needs several seconds after startup to finish BMCA and adopt an | ||
| 5126 | // upstream master; until then it reports itself as its own grandmaster. | ||
| 5127 | // Poll every second through that window so the corrected refclk goes out | ||
| 5128 | // within a second of the election settling instead of up to a full | ||
| 5129 | // announce interval later. Results are cached (PTP_QUERY_CACHE_MS), so | ||
| 5130 | // this is one pmc query per second at worst, and only for a minute. | ||
| 5131 |
6/6✓ Branch 0 taken 62 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 61 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 61 times.
✓ Branch 5 taken 3 times.
|
64 | for (int i = 0; i < AES67::SAP_ANNOUNCE_INTERVAL_S && m_sapAnnounceRunning.load(); i++) { |
| 5132 |
1/2✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
|
61 | std::this_thread::sleep_for(std::chrono::seconds(1)); |
| 5133 | |||
| 5134 |
1/2✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
|
61 | auto elapsed = std::chrono::duration_cast<std::chrono::seconds>( |
| 5135 |
1/2✓ Branch 0 taken 61 times.
✗ Branch 1 not taken.
|
122 | std::chrono::steady_clock::now() - threadStart).count(); |
| 5136 |
5/6✓ Branch 0 taken 59 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 59 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 59 times.
✓ Branch 5 taken 2 times.
|
61 | if (elapsed < AES67::PTP_CONVERGENCE_WINDOW_S && m_sapAnnounceRunning.load()) { |
| 5137 |
2/4✓ Branch 0 taken 59 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 59 times.
|
59 | if (refreshRefclk()) { |
| 5138 | ✗ | announceAll(); | |
| 5139 | } | ||
| 5140 | } | ||
| 5141 | } | ||
| 5142 | |||
| 5143 | // Pipeline recovery and PTP supervision run in WatchdogLoop(), which | ||
| 5144 | // doesn't depend on anything announcing. | ||
| 5145 | } | ||
| 5146 | |||
| 5147 | // Send deletion packets on shutdown | ||
| 5148 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
|
2 | for (const auto& entry : entries) { |
| 5149 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | sendto(sock, entry.deletePacket.data(), entry.deletePacket.size(), 0, |
| 5150 | (struct sockaddr*)&sapAddr, sizeof(sapAddr)); | ||
| 5151 | } | ||
| 5152 | |||
| 5153 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | close(sock); |
| 5154 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "AES67 SAP announcer thread stopped (deletion packets sent)\n"); |
| 5155 | 1 | } | |
| 5156 | |||
| 5157 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 5158 | // SAP Receiver — listens for remote AES67 announcements | ||
| 5159 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 5160 | 45 | void AES67Manager::SAPReceiveLoop() { | |
| 5161 |
1/2✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
|
45 | LogInfo(VB_MEDIAOUT, "AES67 SAP receiver thread started\n"); |
| 5162 | |||
| 5163 | 45 | int sock = OpenSocket(AF_INET, SOCK_DGRAM, 0); | |
| 5164 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 45 times.
|
45 | if (sock < 0) { |
| 5165 | ✗ | LogErr(VB_MEDIAOUT, "AES67 SAP recv: Failed to create socket: %s\n", FPPstrerror(errno)); | |
| 5166 | ✗ | return; | |
| 5167 | } | ||
| 5168 | |||
| 5169 | // Allow address reuse | ||
| 5170 | 45 | int reuse = 1; | |
| 5171 | 45 | setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); | |
| 5172 | |||
| 5173 | // Bind to SAP port | ||
| 5174 | struct sockaddr_in bindAddr; | ||
| 5175 | 45 | memset(&bindAddr, 0, sizeof(bindAddr)); | |
| 5176 | 45 | bindAddr.sin_family = AF_INET; | |
| 5177 | 45 | bindAddr.sin_port = htons(AES67::SAP_PORT); | |
| 5178 | 45 | bindAddr.sin_addr.s_addr = htonl(INADDR_ANY); | |
| 5179 | |||
| 5180 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 45 times.
|
45 | if (bind(sock, (struct sockaddr*)&bindAddr, sizeof(bindAddr)) < 0) { |
| 5181 | ✗ | LogErr(VB_MEDIAOUT, "AES67 SAP recv: bind failed: %s\n", FPPstrerror(errno)); | |
| 5182 | ✗ | close(sock); | |
| 5183 | ✗ | return; | |
| 5184 | } | ||
| 5185 | |||
| 5186 | // Join SAP multicast group | ||
| 5187 | struct ip_mreq mreq; | ||
| 5188 | 45 | inet_pton(AF_INET, AES67::SAP_MCAST_ADDRESS, &mreq.imr_multiaddr); | |
| 5189 | |||
| 5190 |
1/2✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
|
45 | if (!m_config.ptpInterface.empty()) { |
| 5191 |
1/2✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
|
45 | std::string ifIP = GetInterfaceIP(m_config.ptpInterface); |
| 5192 | 45 | inet_pton(AF_INET, ifIP.c_str(), &mreq.imr_interface); | |
| 5193 | 45 | } else { | |
| 5194 | ✗ | mreq.imr_interface.s_addr = htonl(INADDR_ANY); | |
| 5195 | } | ||
| 5196 | |||
| 5197 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 45 times.
|
45 | if (setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) < 0) { |
| 5198 | ✗ | LogWarn(VB_MEDIAOUT, "AES67 SAP recv: join multicast failed: %s\n", FPPstrerror(errno)); | |
| 5199 | } | ||
| 5200 | |||
| 5201 | // Set receive timeout so we can check the shutdown flag | ||
| 5202 | struct timeval tv; | ||
| 5203 | 45 | tv.tv_sec = 2; | |
| 5204 | 45 | tv.tv_usec = 0; | |
| 5205 | 45 | setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); | |
| 5206 | |||
| 5207 | uint8_t buf[4096]; | ||
| 5208 |
2/2✓ Branch 0 taken 351 times.
✓ Branch 1 taken 45 times.
|
396 | while (m_sapRecvRunning.load()) { |
| 5209 | struct sockaddr_in senderAddr; | ||
| 5210 | 351 | socklen_t addrLen = sizeof(senderAddr); | |
| 5211 | |||
| 5212 |
1/2✓ Branch 0 taken 351 times.
✗ Branch 1 not taken.
|
351 | ssize_t n = recvfrom(sock, buf, sizeof(buf), 0, |
| 5213 | (struct sockaddr*)&senderAddr, &addrLen); | ||
| 5214 |
2/2✓ Branch 0 taken 42 times.
✓ Branch 1 taken 309 times.
|
351 | if (n <= 0) continue; // timeout or error |
| 5215 | |||
| 5216 | char senderIP[INET_ADDRSTRLEN]; | ||
| 5217 | 309 | inet_ntop(AF_INET, &senderAddr.sin_addr, senderIP, sizeof(senderIP)); | |
| 5218 | |||
| 5219 |
2/4✓ Branch 0 taken 309 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 309 times.
✗ Branch 3 not taken.
|
618 | HandleSAPPacket(buf, (size_t)n, senderIP); |
| 5220 | } | ||
| 5221 | |||
| 5222 | // Leave multicast group | ||
| 5223 | 45 | setsockopt(sock, IPPROTO_IP, IP_DROP_MEMBERSHIP, &mreq, sizeof(mreq)); | |
| 5224 |
1/2✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
|
45 | close(sock); |
| 5225 |
1/2✓ Branch 0 taken 45 times.
✗ Branch 1 not taken.
|
45 | LogInfo(VB_MEDIAOUT, "AES67 SAP receiver thread stopped\n"); |
| 5226 | } | ||
| 5227 | |||
| 5228 | 309 | void AES67Manager::HandleSAPPacket(const uint8_t* data, size_t len, | |
| 5229 | const std::string& senderAddr) { | ||
| 5230 | // Minimum SAP header: 8 bytes (4 header + 4 source IP) | ||
| 5231 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 309 times.
|
310 | if (len < 8) return; |
| 5232 | |||
| 5233 | 309 | uint8_t header0 = data[0]; | |
| 5234 | 309 | int version = (header0 >> 5) & 0x07; | |
| 5235 | 309 | bool isDeletion = (header0 & 0x04) != 0; | |
| 5236 | // bool isIPv6 = (header0 & 0x10) != 0; // A bit — we only handle IPv4 | ||
| 5237 | |||
| 5238 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 309 times.
|
309 | if (version != AES67::SAP_VERSION) return; |
| 5239 | |||
| 5240 | 309 | uint16_t msgIdHash = ((uint16_t)data[2] << 8) | data[3]; | |
| 5241 | |||
| 5242 | // Skip auth data | ||
| 5243 | 309 | uint8_t authLen = data[1]; | |
| 5244 | 309 | size_t payloadStart = 8 + authLen * 4; | |
| 5245 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 309 times.
|
309 | if (payloadStart >= len) return; |
| 5246 | |||
| 5247 | // Find end of payload type string (NUL terminated) | ||
| 5248 | 309 | size_t sdpStart = payloadStart; | |
| 5249 |
3/4✓ Branch 0 taken 4944 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4635 times.
✓ Branch 3 taken 309 times.
|
4944 | while (sdpStart < len && data[sdpStart] != 0) { |
| 5250 | 4635 | sdpStart++; | |
| 5251 | } | ||
| 5252 | 309 | sdpStart++; // skip NUL | |
| 5253 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 309 times.
|
309 | if (sdpStart >= len) return; |
| 5254 | |||
| 5255 | // Ignore our own announcements | ||
| 5256 |
1/2✓ Branch 0 taken 309 times.
✗ Branch 1 not taken.
|
309 | std::string ourIP = GetInterfaceIP(m_config.ptpInterface); |
| 5257 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 309 times.
|
309 | if (senderAddr == ourIP) return; |
| 5258 | |||
| 5259 |
1/2✓ Branch 0 taken 309 times.
✗ Branch 1 not taken.
|
309 | const auto key = std::make_pair(senderAddr, msgIdHash); |
| 5260 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 308 times.
|
309 | if (isDeletion) { |
| 5261 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | std::lock_guard<std::mutex> lock(m_discoveredMutex); |
| 5262 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | auto it = m_discoveredStreams.find(key); |
| 5263 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (it != m_discoveredStreams.end()) { |
| 5264 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | LogInfo(VB_MEDIAOUT, "AES67 SAP: Stream deleted: %s\n", |
| 5265 | it->second.sessionName.c_str()); | ||
| 5266 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | m_discoveredStreams.erase(it); |
| 5267 | } | ||
| 5268 | 1 | return; | |
| 5269 | 1 | } | |
| 5270 | |||
| 5271 | // Parse SDP for stream info | ||
| 5272 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | std::string sdp((const char*)data + sdpStart, len - sdpStart); |
| 5273 | 924 | auto clamp = [](std::string v) { | |
| 5274 |
2/2✓ Branch 0 taken 300 times.
✓ Branch 1 taken 624 times.
|
924 | if (v.size() > AES67::MAX_SAP_FIELD_LEN) { |
| 5275 | 300 | v.resize(AES67::MAX_SAP_FIELD_LEN); | |
| 5276 | } | ||
| 5277 | 924 | return v; | |
| 5278 | }; | ||
| 5279 | |||
| 5280 | 308 | SAPDiscoveredStream stream; | |
| 5281 | 308 | stream.msgIdHash = msgIdHash; | |
| 5282 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | stream.originAddress = senderAddr; |
| 5283 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | stream.lastSeenMs = std::chrono::duration_cast<std::chrono::milliseconds>( |
| 5284 | 616 | std::chrono::steady_clock::now().time_since_epoch()).count(); | |
| 5285 | |||
| 5286 | // Parse SDP fields | ||
| 5287 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | std::istringstream sdpStream(sdp); |
| 5288 | 308 | std::string line; | |
| 5289 |
4/6✓ Branch 0 taken 3086 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3086 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2778 times.
✓ Branch 5 taken 308 times.
|
3086 | while (std::getline(sdpStream, line)) { |
| 5290 | // Remove \r | ||
| 5291 |
3/6✓ Branch 0 taken 2778 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2778 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2778 times.
✗ Branch 5 not taken.
|
2778 | if (!line.empty() && line.back() == '\r') line.pop_back(); |
| 5292 | |||
| 5293 |
4/6✓ Branch 0 taken 2778 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2778 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 308 times.
✓ Branch 5 taken 2470 times.
|
2778 | if (line.substr(0, 2) == "s=") { |
| 5294 |
2/4✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
|
308 | stream.sessionName = clamp(line.substr(2)); |
| 5295 |
4/6✓ Branch 0 taken 2470 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2470 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 308 times.
✓ Branch 5 taken 2162 times.
|
2470 | } else if (line.substr(0, 2) == "c=") { |
| 5296 | // c=IN IP4 239.69.0.1/255 | ||
| 5297 | 308 | size_t ip4Pos = line.find("IP4 "); | |
| 5298 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | if (ip4Pos != std::string::npos) { |
| 5299 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | std::string addr = line.substr(ip4Pos + 4); |
| 5300 | 308 | size_t slash = addr.find('/'); | |
| 5301 |
2/4✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
|
308 | if (slash != std::string::npos) addr = addr.substr(0, slash); |
| 5302 |
2/4✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
|
308 | stream.multicastIP = clamp(addr); |
| 5303 | 308 | } | |
| 5304 |
4/6✓ Branch 0 taken 2162 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2162 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 308 times.
✓ Branch 5 taken 1854 times.
|
2162 | } else if (line.substr(0, 8) == "m=audio ") { |
| 5305 | // m=audio 5004 RTP/AVP 96 | ||
| 5306 | 308 | int port = 0; | |
| 5307 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | if (sscanf(line.c_str(), "m=audio %d", &port) == 1) { |
| 5308 | 308 | stream.port = port; | |
| 5309 | } | ||
| 5310 |
4/6✓ Branch 0 taken 1854 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1854 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 308 times.
✓ Branch 5 taken 1546 times.
|
1854 | } else if (line.substr(0, 11) == "a=rtpmap:96") { |
| 5311 | // a=rtpmap:96 L24/48000/2 | ||
| 5312 | 308 | int ch = 2; | |
| 5313 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | if (sscanf(line.c_str(), "a=rtpmap:96 L24/%*d/%d", &ch) == 1) { |
| 5314 | 308 | stream.channels = ch; | |
| 5315 | } | ||
| 5316 |
4/6✓ Branch 0 taken 1546 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1546 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 308 times.
✓ Branch 5 taken 1238 times.
|
1546 | } else if (line.substr(0, 8) == "a=ptime:") { |
| 5317 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | stream.ptime = std::atoi(line.substr(8).c_str()); |
| 5318 |
3/4✓ Branch 0 taken 1238 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✓ Branch 3 taken 930 times.
|
1238 | } else if (line.rfind("a=ts-refclk:ptp=IEEE1588-", 0) == 0) { |
| 5319 | // a=ts-refclk:ptp=IEEE1588-2008:AA-BB-CC-FF-FE-DD-EE-FF:0 | ||
| 5320 | // (The domain suffix is optional.) This used to compare a 26-char | ||
| 5321 | // prefix against the 25-char literal, so it never matched and the | ||
| 5322 | // grandmaster of a discovered stream was never reported. | ||
| 5323 | 308 | size_t colon1 = line.find(':', 15); | |
| 5324 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | if (colon1 != std::string::npos) { |
| 5325 | 308 | size_t colon2 = line.find(':', colon1 + 1); | |
| 5326 | std::string id = line.substr(colon1 + 1, colon2 == std::string::npos ? std::string::npos | ||
| 5327 |
2/4✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
|
308 | : colon2 - colon1 - 1); |
| 5328 |
4/8✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 308 times.
✗ Branch 6 not taken.
✓ Branch 7 taken 308 times.
|
308 | while (!id.empty() && (id.back() == '\r' || id.back() == ' ')) { |
| 5329 | ✗ | id.pop_back(); | |
| 5330 | } | ||
| 5331 |
2/4✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
|
308 | stream.ptpClockId = clamp(id); |
| 5332 | 308 | } | |
| 5333 | } | ||
| 5334 | } | ||
| 5335 | |||
| 5336 | { | ||
| 5337 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | std::lock_guard<std::mutex> lock(m_discoveredMutex); |
| 5338 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | bool isNew = (m_discoveredStreams.find(key) == m_discoveredStreams.end()); |
| 5339 |
6/6✓ Branch 0 taken 306 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 48 times.
✓ Branch 3 taken 258 times.
✓ Branch 4 taken 48 times.
✓ Branch 5 taken 260 times.
|
308 | if (isNew && m_discoveredStreams.size() >= AES67::MAX_SAP_DISCOVERED) { |
| 5340 |
1/2✓ Branch 0 taken 48 times.
✗ Branch 1 not taken.
|
48 | auto oldest = std::min_element(m_discoveredStreams.begin(), m_discoveredStreams.end(), |
| 5341 | 12240 | [](const auto& a, const auto& b) { | |
| 5342 | 12240 | return a.second.lastSeenMs < b.second.lastSeenMs; | |
| 5343 | }); | ||
| 5344 |
1/2✓ Branch 0 taken 48 times.
✗ Branch 1 not taken.
|
48 | m_discoveredStreams.erase(oldest); |
| 5345 | } | ||
| 5346 |
2/4✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 308 times.
✗ Branch 3 not taken.
|
308 | m_discoveredStreams[key] = stream; |
| 5347 | |||
| 5348 |
2/2✓ Branch 0 taken 306 times.
✓ Branch 1 taken 2 times.
|
308 | if (isNew) { |
| 5349 |
1/2✓ Branch 0 taken 306 times.
✗ Branch 1 not taken.
|
306 | LogInfo(VB_MEDIAOUT, "AES67 SAP: Discovered stream '%s' from %s → %s:%d (%dch)\n", |
| 5350 | stream.sessionName.c_str(), senderAddr.c_str(), | ||
| 5351 | stream.multicastIP.c_str(), stream.port, stream.channels); | ||
| 5352 | } | ||
| 5353 | |||
| 5354 | // Prune stale entries. A remote sender that stops announcing | ||
| 5355 | // without ever transmitting an explicit SAP deletion packet (e.g. | ||
| 5356 | // it crashes, loses power, or drops off the network) would | ||
| 5357 | // otherwise leave its stream in m_discoveredStreams forever. Drop | ||
| 5358 | // anything not seen within ~10 announce intervals. | ||
| 5359 |
1/2✓ Branch 0 taken 308 times.
✗ Branch 1 not taken.
|
308 | uint64_t nowMs = std::chrono::duration_cast<std::chrono::milliseconds>( |
| 5360 | 616 | std::chrono::steady_clock::now().time_since_epoch()).count(); | |
| 5361 | 308 | constexpr uint64_t staleThresholdMs = | |
| 5362 | (uint64_t)AES67::SAP_ANNOUNCE_INTERVAL_S * 1000ULL * 10ULL; | ||
| 5363 |
2/2✓ Branch 0 taken 45188 times.
✓ Branch 1 taken 308 times.
|
45496 | for (auto sit = m_discoveredStreams.begin(); sit != m_discoveredStreams.end(); ) { |
| 5364 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 45188 times.
|
45188 | if (nowMs - sit->second.lastSeenMs > staleThresholdMs) { |
| 5365 | ✗ | LogInfo(VB_MEDIAOUT, "AES67 SAP: Stream '%s' timed out (no announcement in %llus) — removing\n", | |
| 5366 | sit->second.sessionName.c_str(), | ||
| 5367 | (unsigned long long)(staleThresholdMs / 1000)); | ||
| 5368 | ✗ | sit = m_discoveredStreams.erase(sit); | |
| 5369 | } else { | ||
| 5370 | 45188 | ++sit; | |
| 5371 | } | ||
| 5372 | } | ||
| 5373 | 308 | } | |
| 5374 |
4/4✓ Branch 0 taken 308 times.
✓ Branch 1 taken 1 times.
✓ Branch 2 taken 308 times.
✓ Branch 3 taken 1 times.
|
310 | } |
| 5375 | |||
| 5376 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 5377 | // Status reporting — for PHP API | ||
| 5378 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 5379 | // The rate the PipeWire graph is actually clocked at. Mirrors the fallback in | ||
| 5380 | // GStreamerOut.cpp's GetPipeWireGraphRate(), which is file-local there: the | ||
| 5381 | // per-card file carries the rate the hardware really achieved and sorts after | ||
| 5382 | // the defaults, so it wins wherever both exist. | ||
| 5383 | 804 | static int PipeWireGraphRate() { | |
| 5384 | static const char* confs[] = { | ||
| 5385 | "/etc/pipewire/pipewire.conf.d/95-fpp-alsa-sink.conf", | ||
| 5386 | "/etc/pipewire/pipewire.conf.d/90-fpp.conf" | ||
| 5387 | }; | ||
| 5388 |
2/2✓ Branch 0 taken 1608 times.
✓ Branch 1 taken 804 times.
|
2412 | for (const char* conf : confs) { |
| 5389 |
2/4✓ Branch 0 taken 1608 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1608 times.
✗ Branch 3 not taken.
|
1608 | std::string contents = GetFileContents(conf); |
| 5390 | 1608 | size_t p = contents.find("default.clock.rate"); | |
| 5391 |
1/2✓ Branch 0 taken 1608 times.
✗ Branch 1 not taken.
|
1608 | if (p == std::string::npos) |
| 5392 | 1608 | continue; | |
| 5393 | ✗ | p = contents.find('=', p); | |
| 5394 | ✗ | if (p == std::string::npos) | |
| 5395 | ✗ | continue; | |
| 5396 | ✗ | int rate = atoi(contents.c_str() + p + 1); | |
| 5397 | ✗ | if (rate > 0) | |
| 5398 | ✗ | return rate; | |
| 5399 |
1/3✗ Branch 0 not taken.
✓ Branch 1 taken 1608 times.
✗ Branch 2 not taken.
|
1608 | } |
| 5400 | 804 | return 0; | |
| 5401 | } | ||
| 5402 | |||
| 5403 | // The rate pipewiresrc negotiated with the graph for this pipeline, or 0 if it | ||
| 5404 | // has not negotiated yet. Read from the live pad rather than from any config | ||
| 5405 | // file: per-card and per-group rates sit between the graph clock and what this | ||
| 5406 | // stream is actually fed, so default.clock.rate can say 44100 while the stream | ||
| 5407 | // is getting a clean 48000 (or the reverse). | ||
| 5408 | ✗ | static int NegotiatedSourceRate(GstElement* pipeline) { | |
| 5409 | ✗ | if (!pipeline) { | |
| 5410 | ✗ | return 0; | |
| 5411 | } | ||
| 5412 | ✗ | GstElement* pwsrc = gst_bin_get_by_name(GST_BIN(pipeline), "pwsrc"); | |
| 5413 | ✗ | if (!pwsrc) { | |
| 5414 | ✗ | return 0; | |
| 5415 | } | ||
| 5416 | ✗ | int rate = 0; | |
| 5417 | ✗ | GstPad* pad = gst_element_get_static_pad(pwsrc, "src"); | |
| 5418 | ✗ | if (pad) { | |
| 5419 | ✗ | GstCaps* caps = gst_pad_get_current_caps(pad); | |
| 5420 | ✗ | if (caps) { | |
| 5421 | ✗ | const GstStructure* st = gst_caps_get_structure(caps, 0); | |
| 5422 | ✗ | if (st) { | |
| 5423 | ✗ | gst_structure_get_int(st, "rate", &rate); | |
| 5424 | } | ||
| 5425 | ✗ | gst_caps_unref(caps); | |
| 5426 | } | ||
| 5427 | ✗ | gst_object_unref(pad); | |
| 5428 | } | ||
| 5429 | ✗ | gst_object_unref(pwsrc); | |
| 5430 | ✗ | return rate; | |
| 5431 | } | ||
| 5432 | |||
| 5433 | 796 | AES67Manager::Status AES67Manager::GetStatus() { | |
| 5434 | 796 | Status status; | |
| 5435 | |||
| 5436 | // Snapshot the config BEFORE taking the pipeline lock, and use the copy | ||
| 5437 | // from here on. Reading m_config directly would race LoadConfig(), which | ||
| 5438 | // reallocates the instance vector on another thread -- and taking the two | ||
| 5439 | // locks together would create an ordering to get wrong. See m_configMutex. | ||
| 5440 |
1/2✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
|
796 | AES67Config config = GetConfigSnapshot(); |
| 5441 | 796 | status.ptpEnabled = config.ptpEnabled; | |
| 5442 | 796 | status.ptpDomain = config.ptpDomain; | |
| 5443 |
1/2✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
|
796 | status.ptpRole = config.ptpRole; |
| 5444 |
1/2✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
|
796 | status.graphSampleRate = PipeWireGraphRate(); |
| 5445 | |||
| 5446 | // Pipeline status — use try_lock to avoid blocking HTTP handlers | ||
| 5447 | // indefinitely if another thread holds m_pipelineMutex during a | ||
| 5448 | // long GStreamer state change. | ||
| 5449 | { | ||
| 5450 | 796 | std::unique_lock<std::mutex> lock(m_pipelineMutex, std::try_to_lock); | |
| 5451 |
2/2✓ Branch 0 taken 795 times.
✓ Branch 1 taken 1 times.
|
796 | if (lock.owns_lock()) { |
| 5452 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 795 times.
|
795 | for (const auto& [id, p] : m_sendPipelines) { |
| 5453 | ✗ | Status::PipelineStatus ps; | |
| 5454 | ✗ | ps.instanceId = id; | |
| 5455 | ✗ | ps.mode = "send"; | |
| 5456 | ✗ | ps.running = p.running; | |
| 5457 | ✗ | ps.error = p.errorMessage; | |
| 5458 | ✗ | ps.sourceRate = NegotiatedSourceRate(p.pipeline); | |
| 5459 | |||
| 5460 | ✗ | for (const auto& inst : config.instances) { | |
| 5461 | ✗ | if (inst.id == id) { | |
| 5462 | ✗ | ps.name = inst.name; | |
| 5463 | ✗ | break; | |
| 5464 | } | ||
| 5465 | } | ||
| 5466 | ✗ | status.pipelines.push_back(ps); | |
| 5467 | ✗ | } | |
| 5468 | |||
| 5469 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 795 times.
|
795 | for (const auto& [id, p] : m_recvPipelines) { |
| 5470 | ✗ | Status::PipelineStatus ps; | |
| 5471 | ✗ | ps.instanceId = id; | |
| 5472 | ✗ | ps.mode = "receive"; | |
| 5473 | ✗ | ps.running = p.running; | |
| 5474 | ✗ | ps.error = p.errorMessage; | |
| 5475 | |||
| 5476 | ✗ | for (const auto& inst : config.instances) { | |
| 5477 | ✗ | if (inst.id == id) { | |
| 5478 | ✗ | ps.name = inst.name; | |
| 5479 | ✗ | break; | |
| 5480 | } | ||
| 5481 | } | ||
| 5482 | ✗ | status.pipelines.push_back(ps); | |
| 5483 | ✗ | } | |
| 5484 | |||
| 5485 | // Senders deliberately held idle. Reported alongside the real | ||
| 5486 | // pipelines rather than omitted: a stream the user enabled and | ||
| 5487 | // cannot see anywhere reads as FPP having lost the config. | ||
| 5488 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 795 times.
|
796 | for (const auto& [id, reason] : m_deferredSenders) { |
| 5489 | 1 | Status::PipelineStatus ps; | |
| 5490 | 1 | ps.instanceId = id; | |
| 5491 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | ps.mode = "send"; |
| 5492 | 1 | ps.running = false; | |
| 5493 | 1 | ps.waitingForSource = true; | |
| 5494 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | ps.note = reason; |
| 5495 | |||
| 5496 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | for (const auto& inst : config.instances) { |
| 5497 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | if (inst.id == id) { |
| 5498 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | ps.name = inst.name; |
| 5499 | 1 | break; | |
| 5500 | } | ||
| 5501 | } | ||
| 5502 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | status.pipelines.push_back(ps); |
| 5503 | 1 | } | |
| 5504 | } else { | ||
| 5505 | // Could not acquire lock — return partial status | ||
| 5506 | 1 | Status::PipelineStatus ps; | |
| 5507 | 1 | ps.instanceId = -1; | |
| 5508 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | ps.name = "(status unavailable — pipeline operation in progress)"; |
| 5509 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | ps.mode = "unknown"; |
| 5510 | 1 | ps.running = false; | |
| 5511 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | status.pipelines.push_back(ps); |
| 5512 | 1 | } | |
| 5513 | 796 | } | |
| 5514 | // PTP status — query ptp4l for the actual selected grandmaster (may be a | ||
| 5515 | // remote/upstream clock) rather than assuming this node is the master. | ||
| 5516 | { | ||
| 5517 | 796 | bool gmPresent = false; | |
| 5518 | 796 | std::string gmId; | |
| 5519 | 796 | int64_t offsetNs = 0; | |
| 5520 | 796 | bool offsetValid = false; | |
| 5521 | |||
| 5522 |
7/12✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 98 times.
✓ Branch 3 taken 698 times.
✓ Branch 4 taken 98 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 698 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 698 times.
✓ Branch 9 taken 98 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
|
1494 | status.ptpPortState = IsPtp4lRunning() ? GetPtp4lState() : "not running"; |
| 5523 |
1/2✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
|
796 | status.ptpIsGrandmaster = IsGrandmasterPortState(status.ptpPortState); |
| 5524 | 796 | status.ptpLockedAtStart = m_ptpLockedAtStart.load(); | |
| 5525 | |||
| 5526 |
8/10✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 98 times.
✓ Branch 3 taken 698 times.
✓ Branch 4 taken 10 times.
✓ Branch 5 taken 88 times.
✓ Branch 6 taken 10 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 10 times.
✓ Branch 9 taken 786 times.
|
796 | if (QueryPtp4lTimeStatus(gmPresent, gmId, offsetNs, &offsetValid) && gmPresent && !gmId.empty()) { |
| 5527 | 10 | status.ptpSynced = true; | |
| 5528 |
1/2✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
|
10 | status.ptpGrandmasterId = gmId; |
| 5529 |
2/2✓ Branch 0 taken 7 times.
✓ Branch 1 taken 3 times.
|
10 | status.ptpOffsetNs = offsetValid ? offsetNs : 0; |
| 5530 | 10 | status.ptpOffsetValid = offsetValid; | |
| 5531 | 10 | GetGrandmasterAddress(gmId, status.ptpGrandmasterAddress, | |
| 5532 |
1/2✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
|
10 | status.ptpGrandmasterViaBoundary); |
| 5533 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 783 times.
|
786 | } else if (status.ptpIsGrandmaster) { |
| 5534 | // We won the BMCA and ARE the domain grandmaster. pmc reports | ||
| 5535 | // gmPresent=false in that case because there is no *remote* GM to | ||
| 5536 | // report -- which is not the same thing as "unsynced". Being the | ||
| 5537 | // clock source is a legitimate AES67 configuration (FPP driving | ||
| 5538 | // the show clock), so report ourselves as the grandmaster with a | ||
| 5539 | // zero offset rather than showing the user a broken PTP status. | ||
| 5540 | 3 | status.ptpSynced = true; | |
| 5541 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | status.ptpGrandmasterId = GetPTPClockId(); |
| 5542 | 3 | status.ptpOffsetNs = 0; | |
| 5543 | 3 | status.ptpOffsetValid = true; | |
| 5544 | // Taken from the interface rather than the Announce listener: we | ||
| 5545 | // are the one clock whose address is known without hearing it. | ||
| 5546 |
2/4✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
3 | status.ptpGrandmasterAddress = GetInterfaceIP(GetPtpInterface()); |
| 5547 | } else { | ||
| 5548 | // ptp4l not running, or no grandmaster selected yet (e.g. still | ||
| 5549 | // in LISTENING/PRE_MASTER) — do not claim we're synced or report | ||
| 5550 | // our own identity as if it were the grandmaster. | ||
| 5551 | 783 | status.ptpSynced = false; | |
| 5552 |
1/2✓ Branch 0 taken 783 times.
✗ Branch 1 not taken.
|
783 | status.ptpGrandmasterId = ""; |
| 5553 | 783 | status.ptpOffsetNs = 0; | |
| 5554 | 783 | status.ptpOffsetValid = false; | |
| 5555 | } | ||
| 5556 | 796 | } | |
| 5557 | |||
| 5558 | // Discovered streams | ||
| 5559 | { | ||
| 5560 |
1/2✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
|
796 | std::lock_guard<std::mutex> lock(m_discoveredMutex); |
| 5561 |
2/2✓ Branch 0 taken 259 times.
✓ Branch 1 taken 796 times.
|
1055 | for (const auto& [key, stream] : m_discoveredStreams) { |
| 5562 |
1/2✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
|
259 | status.discoveredStreams.push_back(stream); |
| 5563 | } | ||
| 5564 | 796 | } | |
| 5565 | |||
| 5566 | 796 | return status; | |
| 5567 | 796 | } | |
| 5568 | |||
| 5569 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 5570 | // Self-test — validates AES67 subsystem components (7.10) | ||
| 5571 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 5572 | 3 | std::vector<AES67Manager::TestResult> AES67Manager::RunSelfTest() { | |
| 5573 | // Snapshot once -- this runs on an HTTP/command thread and would otherwise | ||
| 5574 | // read m_config while ApplyConfig() reloads it. See m_configMutex. | ||
| 5575 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | AES67Config config = GetConfigSnapshot(); |
| 5576 | 3 | std::vector<TestResult> results; | |
| 5577 | |||
| 5578 | // Test 1: GStreamer initialization | ||
| 5579 | { | ||
| 5580 | 3 | TestResult r; | |
| 5581 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "gstreamer_init"; |
| 5582 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.passed = gst_is_initialized(); |
| 5583 |
2/4✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
3 | r.message = r.passed ? "GStreamer is initialized" : "GStreamer is NOT initialized"; |
| 5584 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5585 | 3 | } | |
| 5586 | |||
| 5587 | // Test 2: Required GStreamer elements available | ||
| 5588 | { | ||
| 5589 | 3 | const char* requiredElements[] = { | |
| 5590 | "rtpL24pay", "rtpL24depay", "rtpjitterbuffer", | ||
| 5591 | "udpsrc", "udpsink", "audioconvert", "audioresample", | ||
| 5592 | "pipewiresrc", "pipewiresink", nullptr | ||
| 5593 | }; | ||
| 5594 |
2/2✓ Branch 0 taken 27 times.
✓ Branch 1 taken 3 times.
|
30 | for (int i = 0; requiredElements[i]; i++) { |
| 5595 | 27 | TestResult r; | |
| 5596 |
2/4✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 27 times.
✗ Branch 3 not taken.
|
54 | r.testName = std::string("element_") + requiredElements[i]; |
| 5597 |
1/2✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
|
27 | GstElementFactory* factory = gst_element_factory_find(requiredElements[i]); |
| 5598 | 27 | r.passed = (factory != nullptr); | |
| 5599 | 27 | r.message = r.passed | |
| 5600 |
4/14✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 27 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 27 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✓ Branch 8 taken 27 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
|
108 | ? std::string(requiredElements[i]) + " element available" |
| 5601 |
3/12✗ Branch 0 not taken.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 27 times.
✗ Branch 4 not taken.
✓ Branch 5 taken 27 times.
✓ Branch 6 taken 27 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
|
54 | : std::string(requiredElements[i]) + " element NOT FOUND"; |
| 5602 |
2/4✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 27 times.
✗ Branch 3 not taken.
|
27 | if (factory) gst_object_unref(factory); |
| 5603 |
1/2✓ Branch 0 taken 27 times.
✗ Branch 1 not taken.
|
27 | results.push_back(r); |
| 5604 | 27 | } | |
| 5605 | } | ||
| 5606 | |||
| 5607 | // Test 3: PTP daemon (ptp4l) | ||
| 5608 | { | ||
| 5609 | 3 | TestResult r; | |
| 5610 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "ptp_initialized"; |
| 5611 | 3 | r.passed = m_ptpInitialized; | |
| 5612 |
2/4✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
3 | r.message = r.passed ? "PTP subsystem initialized" : "PTP subsystem not initialized"; |
| 5613 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5614 | 3 | } | |
| 5615 | { | ||
| 5616 | 3 | TestResult r; | |
| 5617 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "ptp4l_running"; |
| 5618 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.passed = IsPtp4lRunning(); |
| 5619 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | std::string state = GetPtp4lState(); |
| 5620 | 3 | r.message = r.passed | |
| 5621 |
4/20✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✓ Branch 8 taken 3 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✓ Branch 11 taken 3 times.
✗ Branch 12 not taken.
✓ Branch 13 taken 3 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
✗ Branch 18 not taken.
✗ Branch 19 not taken.
|
12 | ? "ptp4l is running (PID " + std::to_string(m_ptp4lPid) + ") — " + state |
| 5622 |
2/6✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
9 | : "ptp4l is NOT running"; |
| 5623 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5624 | 3 | } | |
| 5625 | { | ||
| 5626 | 3 | TestResult r; | |
| 5627 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "ptp4l_binary"; |
| 5628 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.passed = FileExists(PTP4L_BINARY); |
| 5629 |
2/4✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
3 | r.message = r.passed ? "ptp4l binary found at /usr/sbin/ptp4l" : "ptp4l binary NOT found — install linuxptp package"; |
| 5630 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5631 | 3 | } | |
| 5632 | { | ||
| 5633 | // Without pmc every grandmaster query fails, and PTP silently reports | ||
| 5634 | // "not synced" no matter how well it is actually locked. | ||
| 5635 | 3 | TestResult r; | |
| 5636 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "pmc_binary"; |
| 5637 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.passed = FileExists(PMC_BINARY); |
| 5638 | 3 | r.message = r.passed | |
| 5639 |
3/14✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✓ Branch 9 taken 3 times.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
|
12 | ? std::string("pmc binary found at ") + PMC_BINARY |
| 5640 |
6/18✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 3 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✓ Branch 11 taken 3 times.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
|
12 | : std::string("pmc binary NOT found at ") + PMC_BINARY + |
| 5641 | 3 | " — grandmaster status cannot be queried (install linuxptp)"; | |
| 5642 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5643 | 3 | } | |
| 5644 | { | ||
| 5645 | // Surfaces the two settings behind "why did my Pi become the master?" | ||
| 5646 | // and "why does the Q-SYS core not see us?". | ||
| 5647 | 3 | TestResult r; | |
| 5648 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "ptp_grandmaster"; |
| 5649 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | std::string gm = GetActiveGrandmasterId(); |
| 5650 |
4/12✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 3 times.
✗ Branch 9 not taken.
✗ Branch 10 not taken.
✗ Branch 11 not taken.
|
6 | std::string state = IsPtp4lRunning() ? GetPtp4lState() : "not running"; |
| 5651 |
2/4✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
|
3 | r.passed = !config.ptpEnabled || !gm.empty(); |
| 5652 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
|
3 | if (!config.ptpEnabled) { |
| 5653 | ✗ | r.message = "PTP is disabled — SDP advertises this node's own clock identity"; | |
| 5654 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | } else if (gm.empty()) { |
| 5655 |
2/4✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
6 | r.message = "No grandmaster selected yet (port state " + state + ") — domain " + |
| 5656 |
3/6✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
|
9 | std::to_string(config.ptpDomain) + ", role " + config.ptpRole; |
| 5657 | } else { | ||
| 5658 | ✗ | std::string where; | |
| 5659 | ✗ | std::string gmAddr; | |
| 5660 | ✗ | bool viaBoundary = false; | |
| 5661 | ✗ | if (IsGrandmasterPortState(state)) { | |
| 5662 | ✗ | where = " at " + GetInterfaceIP(config.ptpInterface); | |
| 5663 | ✗ | } else if (GetGrandmasterAddress(gm, gmAddr, viaBoundary)) { | |
| 5664 | ✗ | where = (viaBoundary ? " via boundary clock " : " at ") + gmAddr; | |
| 5665 | } | ||
| 5666 | ✗ | r.message = "Grandmaster " + gm + where + " (port state " + state + ") — domain " + | |
| 5667 | ✗ | std::to_string(config.ptpDomain) + ", role " + config.ptpRole + | |
| 5668 | ✗ | (IsGrandmasterPortState(state) ? " — this node holds the role" : ""); | |
| 5669 | ✗ | } | |
| 5670 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5671 | 3 | } | |
| 5672 | |||
| 5673 | // Test 4: Config file | ||
| 5674 | { | ||
| 5675 | 3 | TestResult r; | |
| 5676 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "config_file"; |
| 5677 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | std::ifstream test(m_configPath); |
| 5678 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.passed = test.good(); |
| 5679 | 3 | r.message = r.passed | |
| 5680 |
1/4✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
|
6 | ? "Config file found: " + m_configPath |
| 5681 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | : "Config file missing: " + m_configPath; |
| 5682 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5683 | 3 | } | |
| 5684 | |||
| 5685 | // Test 5: Config loaded and has instances | ||
| 5686 | { | ||
| 5687 | 3 | TestResult r; | |
| 5688 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "config_instances"; |
| 5689 | 3 | r.passed = !config.instances.empty(); | |
| 5690 | 3 | r.message = r.passed | |
| 5691 |
2/10✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
|
9 | ? std::to_string(config.instances.size()) + " instance(s) configured" |
| 5692 |
2/6✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
9 | : "No instances configured"; |
| 5693 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5694 | 3 | } | |
| 5695 | |||
| 5696 | // Test 6: Network interface availability | ||
| 5697 | { | ||
| 5698 | 3 | TestResult r; | |
| 5699 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "network_interface"; |
| 5700 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | std::string ip = GetInterfaceIP(config.ptpInterface); |
| 5701 | 3 | r.passed = !ip.empty(); | |
| 5702 | 3 | r.message = r.passed | |
| 5703 |
5/16✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
✓ Branch 10 taken 3 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✗ Branch 13 not taken.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
|
9 | ? "Interface " + config.ptpInterface + " has IP: " + ip |
| 5704 |
2/8✗ Branch 0 not taken.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 3 times.
✓ Branch 4 taken 3 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
|
6 | : "Interface " + config.ptpInterface + " not found or has no IP"; |
| 5705 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5706 | 3 | } | |
| 5707 | |||
| 5708 | // Test 7: PTP clock ID derivation (EUI-64 from MAC) | ||
| 5709 | { | ||
| 5710 | 3 | TestResult r; | |
| 5711 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "ptp_clock_id"; |
| 5712 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | std::string clockId = GetPTPClockId(); |
| 5713 |
2/4✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
3 | r.passed = !clockId.empty() && clockId.length() == 23; // XX-XX-XX-XX-XX-XX-XX-XX |
| 5714 | 3 | r.message = r.passed | |
| 5715 |
2/6✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
|
6 | ? "PTP Clock ID: " + clockId |
| 5716 |
1/4✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
|
6 | : "Could not derive PTP Clock ID from MAC address"; |
| 5717 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5718 | 3 | } | |
| 5719 | |||
| 5720 | // Test 8: Send pipeline status | ||
| 5721 | { | ||
| 5722 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | std::lock_guard<std::mutex> lock(m_pipelineMutex); |
| 5723 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
|
3 | for (const auto& [id, p] : m_sendPipelines) { |
| 5724 | ✗ | TestResult r; | |
| 5725 | ✗ | r.testName = "send_pipeline_" + std::to_string(id); | |
| 5726 | ✗ | r.passed = p.running; | |
| 5727 | ✗ | r.message = p.running | |
| 5728 | ✗ | ? "Send pipeline " + std::to_string(id) + " is running" | |
| 5729 | ✗ | : "Send pipeline " + std::to_string(id) + " is NOT running: " + p.errorMessage; | |
| 5730 | ✗ | results.push_back(r); | |
| 5731 | ✗ | } | |
| 5732 | |||
| 5733 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
|
3 | for (const auto& [id, p] : m_recvPipelines) { |
| 5734 | ✗ | TestResult r; | |
| 5735 | ✗ | r.testName = "recv_pipeline_" + std::to_string(id); | |
| 5736 | ✗ | r.passed = p.running; | |
| 5737 | ✗ | r.message = p.running | |
| 5738 | ✗ | ? "Receive pipeline " + std::to_string(id) + " is running" | |
| 5739 | ✗ | : "Receive pipeline " + std::to_string(id) + " is NOT running: " + p.errorMessage; | |
| 5740 | ✗ | results.push_back(r); | |
| 5741 | ✗ | } | |
| 5742 | |||
| 5743 | // Held senders have no pipeline to report on, and a configured stream | ||
| 5744 | // that appears in no test at all reads as a lost config. Passing: | ||
| 5745 | // this is the state FPP intends for a sender nothing feeds, not a | ||
| 5746 | // fault to chase. | ||
| 5747 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
|
3 | for (const auto& [id, reason] : m_deferredSenders) { |
| 5748 | ✗ | TestResult r; | |
| 5749 | ✗ | r.testName = "send_pipeline_" + std::to_string(id); | |
| 5750 | ✗ | r.passed = true; | |
| 5751 | ✗ | r.message = "Send stream " + std::to_string(id) + " is idle: " + reason; | |
| 5752 | ✗ | results.push_back(r); | |
| 5753 | ✗ | } | |
| 5754 | 3 | } | |
| 5755 | |||
| 5756 | // Test 9: SAP announcer running | ||
| 5757 | { | ||
| 5758 | // Nothing to announce is not a failure. The announcer only runs when | ||
| 5759 | // a sender is actually on the wire, so a receive-only box -- or one | ||
| 5760 | // whose senders are all held idle waiting for an Audio Output Group -- | ||
| 5761 | // correctly has no announcer thread, and reporting that as a failed | ||
| 5762 | // test sends the user looking for a fault that is not there. | ||
| 5763 | 3 | bool anySendRunning = false; | |
| 5764 | { | ||
| 5765 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | std::lock_guard<std::mutex> lock(m_pipelineMutex); |
| 5766 | 3 | anySendRunning = !m_sendPipelines.empty(); | |
| 5767 | 3 | } | |
| 5768 | 3 | const bool running = m_sapAnnounceRunning.load(); | |
| 5769 | |||
| 5770 | 3 | TestResult r; | |
| 5771 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "sap_announcer"; |
| 5772 |
2/4✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
3 | r.passed = running || !anySendRunning; |
| 5773 | r.message = running | ||
| 5774 | ? "SAP announcer thread running" | ||
| 5775 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
|
3 | : (anySendRunning ? "SAP announcer thread not running" |
| 5776 |
2/4✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
6 | : "No SAP announcer needed — no send stream is running"); |
| 5777 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5778 | 3 | } | |
| 5779 | |||
| 5780 | // Test 10: SAP receiver running | ||
| 5781 | { | ||
| 5782 | 3 | TestResult r; | |
| 5783 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "sap_receiver"; |
| 5784 | 3 | r.passed = m_sapRecvRunning.load(); | |
| 5785 |
2/4✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
3 | r.message = r.passed ? "SAP receiver thread running" : "SAP receiver thread not running"; |
| 5786 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5787 | 3 | } | |
| 5788 | |||
| 5789 | // Test 11: Multicast socket test — try binding to SAP port (quick check) | ||
| 5790 | { | ||
| 5791 | 3 | TestResult r; | |
| 5792 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "multicast_capability"; |
| 5793 | 3 | int sock = OpenSocket(AF_INET, SOCK_DGRAM, 0); | |
| 5794 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | if (sock >= 0) { |
| 5795 | 3 | int reuse = 1; | |
| 5796 | 3 | setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); | |
| 5797 | 3 | struct sockaddr_in addr{}; | |
| 5798 | 3 | addr.sin_family = AF_INET; | |
| 5799 | 3 | addr.sin_port = 0; // any port | |
| 5800 | 3 | addr.sin_addr.s_addr = INADDR_ANY; | |
| 5801 | 3 | r.passed = (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) == 0); | |
| 5802 |
2/4✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
|
3 | r.message = r.passed ? "UDP socket creation and bind OK" : "Failed to create/bind UDP socket"; |
| 5803 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | close(sock); |
| 5804 | } else { | ||
| 5805 | ✗ | r.passed = false; | |
| 5806 | ✗ | r.message = "Failed to create UDP socket"; | |
| 5807 | } | ||
| 5808 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5809 | 3 | } | |
| 5810 | |||
| 5811 | // Test 12: SDP generation test — verify we can build a valid SDP | ||
| 5812 | { | ||
| 5813 | 3 | TestResult r; | |
| 5814 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.testName = "sdp_generation"; |
| 5815 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 3 times.
|
3 | if (!config.instances.empty()) { |
| 5816 | ✗ | std::string sourceIP = GetInterfaceIP(config.ptpInterface); | |
| 5817 | ✗ | std::string clockId = GetPTPClockId(); | |
| 5818 | // Version 0: this is a rendering for the test output, not an | ||
| 5819 | // announcement, and must not advance the announced version. | ||
| 5820 | ✗ | std::string sdp = BuildSDP(config.instances[0], sourceIP, clockId, 0); | |
| 5821 | ✗ | r.passed = !sdp.empty() && sdp.find("v=0") != std::string::npos | |
| 5822 | ✗ | && sdp.find("ts-refclk") != std::string::npos | |
| 5823 | ✗ | && sdp.find("mediaclk") != std::string::npos | |
| 5824 | ✗ | && sdp.find("L24") != std::string::npos; | |
| 5825 | ✗ | r.message = r.passed | |
| 5826 | ✗ | ? "SDP generation OK (" + std::to_string(sdp.size()) + " bytes)" | |
| 5827 | ✗ | : "SDP generation failed or incomplete"; | |
| 5828 | ✗ | } else { | |
| 5829 | 3 | r.passed = false; | |
| 5830 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | r.message = "No instances configured — cannot test SDP generation"; |
| 5831 | } | ||
| 5832 |
1/2✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
|
3 | results.push_back(r); |
| 5833 | 3 | } | |
| 5834 | |||
| 5835 | 3 | return results; | |
| 5836 | 3 | } | |
| 5837 | |||
| 5838 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 5839 | // HTTP API endpoint — registered at /aes67 | ||
| 5840 | // ────────────────────────────────────────────────────────────────────────────── | ||
| 5841 | 1382 | HttpResponsePtr AES67Manager::render_GET(const HttpRequestPtr& req) { | |
| 5842 | |||
| 5843 |
2/4✓ Branch 0 taken 1382 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1382 times.
✗ Branch 3 not taken.
|
1382 | std::string url(req->path()); |
| 5844 | |||
| 5845 | // Strip leading /aes67/ | ||
| 5846 |
2/2✓ Branch 0 taken 1377 times.
✓ Branch 1 taken 5 times.
|
1382 | if (url.find("/aes67/") == 0) { |
| 5847 |
1/2✓ Branch 0 taken 1377 times.
✗ Branch 1 not taken.
|
1377 | url = url.substr(7); |
| 5848 |
2/4✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
|
5 | } else if (url == "/aes67") { |
| 5849 |
1/2✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
|
5 | url = "status"; |
| 5850 | } | ||
| 5851 | |||
| 5852 |
3/4✓ Branch 0 taken 1382 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✓ Branch 3 taken 586 times.
|
1382 | if (url == "status") { |
| 5853 |
1/2✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
|
796 | Status st = GetStatus(); |
| 5854 |
1/2✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
|
796 | Json::Value result; |
| 5855 | |||
| 5856 | // Pipelines | ||
| 5857 |
1/2✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
|
796 | Json::Value pipelines(Json::arrayValue); |
| 5858 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 796 times.
|
798 | for (const auto& p : st.pipelines) { |
| 5859 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | Json::Value pj; |
| 5860 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
2 | pj["instanceId"] = p.instanceId; |
| 5861 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
2 | pj["name"] = p.name; |
| 5862 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
2 | pj["mode"] = p.mode; |
| 5863 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
2 | pj["running"] = p.running; |
| 5864 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
|
2 | if (p.sourceRate > 0) { |
| 5865 | ✗ | pj["sourceRate"] = p.sourceRate; | |
| 5866 | } | ||
| 5867 |
1/2✗ Branch 0 not taken.
✓ Branch 1 taken 2 times.
|
2 | if (!p.error.empty()) { |
| 5868 | ✗ | pj["error"] = p.error; | |
| 5869 | } | ||
| 5870 | // Distinct from "error" on purpose: the UI renders a failure in | ||
| 5871 | // red and this as guidance, so collapsing the two would put a | ||
| 5872 | // brand new instance back in the alarming state this replaced. | ||
| 5873 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
|
2 | if (p.waitingForSource) { |
| 5874 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | pj["waitingForSource"] = true; |
| 5875 | } | ||
| 5876 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1 times.
|
2 | if (!p.note.empty()) { |
| 5877 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | pj["note"] = p.note; |
| 5878 | } | ||
| 5879 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | pipelines.append(pj); |
| 5880 | 2 | } | |
| 5881 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | result["pipelines"] = pipelines; |
| 5882 | |||
| 5883 | // PTP | ||
| 5884 |
1/2✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
|
796 | Json::Value ptp; |
| 5885 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | ptp["synced"] = st.ptpSynced; |
| 5886 | // null, not 0, when pmc gave nothing usable -- a zero here is the | ||
| 5887 | // same lie WaitForPtpLock() used to believe. The UI already tests | ||
| 5888 | // `typeof offsetNs === 'number'` and falls back to an em dash. | ||
| 5889 |
2/2✓ Branch 0 taken 10 times.
✓ Branch 1 taken 786 times.
|
796 | if (st.ptpOffsetValid) { |
| 5890 |
2/4✓ Branch 0 taken 10 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 10 times.
✗ Branch 3 not taken.
|
10 | ptp["offsetNs"] = (Json::Int64)st.ptpOffsetNs; |
| 5891 | } else { | ||
| 5892 |
2/4✓ Branch 0 taken 786 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 786 times.
✗ Branch 3 not taken.
|
786 | ptp["offsetNs"] = Json::Value::null; |
| 5893 | } | ||
| 5894 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | ptp["grandmasterId"] = st.ptpGrandmasterId; |
| 5895 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | ptp["grandmasterAddress"] = st.ptpGrandmasterAddress; |
| 5896 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | ptp["grandmasterViaBoundary"] = st.ptpGrandmasterViaBoundary; |
| 5897 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | ptp["portState"] = st.ptpPortState; |
| 5898 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | ptp["isGrandmaster"] = st.ptpIsGrandmaster; |
| 5899 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | ptp["lockedAtStart"] = st.ptpLockedAtStart; |
| 5900 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | ptp["enabled"] = st.ptpEnabled; |
| 5901 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | ptp["domain"] = st.ptpDomain; |
| 5902 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | ptp["role"] = st.ptpRole; |
| 5903 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | result["ptp"] = ptp; |
| 5904 | |||
| 5905 | // Discovered streams | ||
| 5906 |
1/2✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
|
796 | Json::Value discovered(Json::arrayValue); |
| 5907 |
2/2✓ Branch 0 taken 259 times.
✓ Branch 1 taken 796 times.
|
1055 | for (const auto& s : st.discoveredStreams) { |
| 5908 |
1/2✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
|
259 | Json::Value sj; |
| 5909 |
2/4✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
|
259 | sj["sessionName"] = s.sessionName; |
| 5910 |
2/4✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
|
259 | sj["originAddress"] = s.originAddress; |
| 5911 |
2/4✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
|
259 | sj["multicastIP"] = s.multicastIP; |
| 5912 |
2/4✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
|
259 | sj["port"] = s.port; |
| 5913 |
2/4✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
|
259 | sj["channels"] = s.channels; |
| 5914 |
2/4✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
|
259 | sj["ptime"] = s.ptime; |
| 5915 |
2/4✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 259 times.
✗ Branch 3 not taken.
|
259 | sj["ptpClockId"] = s.ptpClockId; |
| 5916 |
1/2✓ Branch 0 taken 259 times.
✗ Branch 1 not taken.
|
259 | discovered.append(sj); |
| 5917 | 259 | } | |
| 5918 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | result["discoveredStreams"] = discovered; |
| 5919 | |||
| 5920 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | result["graphSampleRate"] = st.graphSampleRate; |
| 5921 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | result["active"] = m_active.load(); |
| 5922 | |||
| 5923 |
1/2✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
|
796 | Json::StreamWriterBuilder wbuilder; |
| 5924 |
3/6✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 796 times.
✗ Branch 5 not taken.
|
2388 | wbuilder["indentation"] = ""; |
| 5925 |
1/2✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
|
796 | std::string resultStr = Json::writeString(wbuilder, result); |
| 5926 | |||
| 5927 |
2/4✓ Branch 0 taken 796 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 796 times.
✗ Branch 3 not taken.
|
796 | return makeStringResponse(resultStr, 200, "application/json"); |
| 5928 | 796 | } | |
| 5929 | |||
| 5930 | // SDP export. SAP only reaches receivers on the same subnet with a tool | ||
| 5931 | // that speaks it; anything else -- Stream Monitor on a laptop, VLC, a | ||
| 5932 | // scope on another VLAN -- needs the session description handed to it as a | ||
| 5933 | // file. "sdp" lists every send instance as JSON for the UI; "sdp/<id>" | ||
| 5934 | // returns one instance as a raw .sdp for curl and direct download. | ||
| 5935 |
8/10✓ Branch 0 taken 586 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✓ Branch 3 taken 581 times.
✓ Branch 4 taken 5 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 3 times.
✓ Branch 7 taken 2 times.
✓ Branch 8 taken 584 times.
✓ Branch 9 taken 2 times.
|
586 | if (url == "sdp" || url.compare(0, 4, "sdp/") == 0) { |
| 5936 |
1/2✓ Branch 0 taken 584 times.
✗ Branch 1 not taken.
|
584 | AES67Config cfg = GetConfigSnapshot(); |
| 5937 |
1/2✓ Branch 0 taken 584 times.
✗ Branch 1 not taken.
|
584 | bool single = (url != "sdp"); |
| 5938 |
5/8✓ Branch 0 taken 3 times.
✓ Branch 1 taken 581 times.
✓ Branch 2 taken 3 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 3 times.
✓ Branch 5 taken 581 times.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
|
584 | int wantId = single ? atoi(url.substr(4).c_str()) : 0; |
| 5939 | |||
| 5940 |
1/2✓ Branch 0 taken 584 times.
✗ Branch 1 not taken.
|
584 | Json::Value streams(Json::arrayValue); |
| 5941 | 584 | std::string singleSDP; | |
| 5942 | 584 | std::string singleName; | |
| 5943 |
2/2✓ Branch 0 taken 1475 times.
✓ Branch 1 taken 583 times.
|
2058 | for (const auto& inst : cfg.instances) { |
| 5944 | // Only a sender has an SDP to publish; a receive-only instance is | ||
| 5945 | // described by whoever is transmitting to it. | ||
| 5946 |
7/10✓ Branch 0 taken 1475 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✓ Branch 3 taken 1469 times.
✓ Branch 4 taken 6 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 6 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 6 times.
✓ Branch 9 taken 1469 times.
|
1475 | if (inst.mode != "send" && inst.mode != "both") { |
| 5947 | 10 | continue; | |
| 5948 | } | ||
| 5949 |
4/4✓ Branch 0 taken 5 times.
✓ Branch 1 taken 1464 times.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 1 times.
|
1469 | if (single && inst.id != wantId) { |
| 5950 | 4 | continue; | |
| 5951 | } | ||
| 5952 |
1/2✓ Branch 0 taken 1465 times.
✗ Branch 1 not taken.
|
1465 | std::string sdp = ExportSDPFor(inst, cfg); |
| 5953 |
2/2✓ Branch 0 taken 1 times.
✓ Branch 1 taken 1464 times.
|
1465 | if (single) { |
| 5954 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | singleSDP = sdp; |
| 5955 |
1/2✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
|
1 | singleName = SDPFileName(inst); |
| 5956 | 1 | break; | |
| 5957 | } | ||
| 5958 |
1/2✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
|
1464 | Json::Value sj; |
| 5959 |
2/4✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
|
1464 | sj["instanceId"] = inst.id; |
| 5960 |
2/4✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
|
1464 | sj["name"] = inst.name; |
| 5961 |
2/4✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
|
1464 | sj["sessionName"] = inst.sessionName; |
| 5962 | // Exported even while disabled or with SAP off -- that is exactly | ||
| 5963 | // when an external monitor is the only way to look at the stream | ||
| 5964 | // -- so say which it is and let the UI warn. | ||
| 5965 |
2/4✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
|
1464 | sj["enabled"] = inst.enabled; |
| 5966 |
2/4✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
|
1464 | sj["sapEnabled"] = inst.sapEnabled; |
| 5967 |
2/4✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
|
1464 | sj["multicastIP"] = inst.multicastIP; |
| 5968 |
2/4✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
|
1464 | sj["port"] = inst.port; |
| 5969 |
2/4✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
|
1464 | sj["channels"] = inst.channels; |
| 5970 |
2/4✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
|
1464 | sj["ptime"] = inst.ptime; |
| 5971 |
3/6✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1464 times.
✗ Branch 5 not taken.
|
1464 | sj["filename"] = SDPFileName(inst); |
| 5972 |
2/4✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1464 times.
✗ Branch 3 not taken.
|
1464 | sj["sdp"] = sdp; |
| 5973 |
1/2✓ Branch 0 taken 1464 times.
✗ Branch 1 not taken.
|
1464 | streams.append(sj); |
| 5974 |
2/2✓ Branch 0 taken 1464 times.
✓ Branch 1 taken 1 times.
|
1465 | } |
| 5975 | |||
| 5976 |
2/2✓ Branch 0 taken 3 times.
✓ Branch 1 taken 581 times.
|
584 | if (single) { |
| 5977 |
2/2✓ Branch 0 taken 2 times.
✓ Branch 1 taken 1 times.
|
3 | if (singleSDP.empty()) { |
| 5978 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
10 | return makeStringResponse("{\"error\":\"no such send instance\"}", |
| 5979 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | 404, "application/json"); |
| 5980 | } | ||
| 5981 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
1 | auto resp = makeStringResponse(singleSDP, 200, "application/sdp"); |
| 5982 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
2 | resp->addHeader("Content-Disposition", |
| 5983 |
2/4✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
|
2 | "attachment; filename=\"" + singleName + "\""); |
| 5984 | 1 | return resp; | |
| 5985 | 1 | } | |
| 5986 | |||
| 5987 |
1/2✓ Branch 0 taken 581 times.
✗ Branch 1 not taken.
|
581 | Json::Value result; |
| 5988 |
2/4✓ Branch 0 taken 581 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 581 times.
✗ Branch 3 not taken.
|
581 | result["streams"] = streams; |
| 5989 |
1/2✓ Branch 0 taken 581 times.
✗ Branch 1 not taken.
|
581 | Json::StreamWriterBuilder wbuilder; |
| 5990 |
3/6✓ Branch 0 taken 581 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 581 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 581 times.
✗ Branch 5 not taken.
|
1743 | wbuilder["indentation"] = ""; |
| 5991 |
2/4✓ Branch 0 taken 581 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 581 times.
✗ Branch 3 not taken.
|
1162 | return makeStringResponse(Json::writeString(wbuilder, result), 200, |
| 5992 |
1/2✓ Branch 0 taken 581 times.
✗ Branch 1 not taken.
|
581 | "application/json"); |
| 5993 | 584 | } | |
| 5994 | |||
| 5995 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
2 | if (url == "test") { |
| 5996 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | auto tests = RunSelfTest(); |
| 5997 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | Json::Value result; |
| 5998 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | Json::Value testArray(Json::arrayValue); |
| 5999 | 2 | int passed = 0, failed = 0; | |
| 6000 | |||
| 6001 |
2/2✓ Branch 0 taken 46 times.
✓ Branch 1 taken 2 times.
|
48 | for (const auto& t : tests) { |
| 6002 |
1/2✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
|
46 | Json::Value tj; |
| 6003 |
2/4✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 46 times.
✗ Branch 3 not taken.
|
46 | tj["test"] = t.testName; |
| 6004 |
2/4✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 46 times.
✗ Branch 3 not taken.
|
46 | tj["passed"] = t.passed; |
| 6005 |
2/4✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 46 times.
✗ Branch 3 not taken.
|
46 | tj["message"] = t.message; |
| 6006 |
1/2✓ Branch 0 taken 46 times.
✗ Branch 1 not taken.
|
46 | testArray.append(tj); |
| 6007 |
2/2✓ Branch 0 taken 28 times.
✓ Branch 1 taken 18 times.
|
46 | if (t.passed) passed++; else failed++; |
| 6008 | 46 | } | |
| 6009 | |||
| 6010 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
2 | result["tests"] = testArray; |
| 6011 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
2 | result["summary"]["total"] = (int)tests.size(); |
| 6012 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
2 | result["summary"]["passed"] = passed; |
| 6013 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
2 | result["summary"]["failed"] = failed; |
| 6014 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
2 | result["summary"]["allPassed"] = (failed == 0); |
| 6015 | |||
| 6016 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | Json::StreamWriterBuilder wbuilder; |
| 6017 |
3/6✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 2 times.
✗ Branch 5 not taken.
|
6 | wbuilder["indentation"] = " "; |
| 6018 |
1/2✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
|
2 | std::string resultStr = Json::writeString(wbuilder, result); |
| 6019 | |||
| 6020 |
2/4✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
|
2 | return makeStringResponse(resultStr, 200, "application/json"); |
| 6021 | 2 | } | |
| 6022 | |||
| 6023 | ✗ | return makeStringResponse("{\"error\":\"unknown endpoint\"}", 404, "application/json"); | |
| 6024 | 1382 | } | |
| 6025 | |||
| 6026 | #endif // HAS_AES67_GSTREAMER | ||
| 6027 |