GCC Code Coverage Report


Directory: ./
File: StatusWebSocket.cpp
Date: 2026-10-29 08:11:58
Exec Total Coverage
Lines: 102 107 95.3%
Functions: 13 13 100.0%
Branches: 112 176 63.6%

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 // WebSocket push of the fppd status.
14 //
15 // Historically every open web page polled api/system/status on a timer
16 // (default 5s). That PHP endpoint fetches /fppd/status from fppd and then
17 // augments it with slow-moving host data (wifi, interfaces, advancedView,
18 // plugin indicators, ...). On a BeagleBone that augmentation is ~180ms of
19 // PHP per request; multiplied by every open tab every few seconds it is a
20 // meaningful, continuous load on the exact devices that can least afford it.
21 //
22 // This endpoint splits the fast-moving half off: fppd pushes its own
23 // /fppd/status payload over a WebSocket the instant it changes (and at most
24 // once per second while a playlist's clock ticks), so pages no longer poll
25 // for it at all. The slow host augmentation stays in PHP but can be polled
26 // far less often. fppd already has this data in hand, so producing it costs
27 // ~0.7ms and nothing when nobody is connected.
28 //
29 // Wire format (server -> client):
30 // {"type":"snapshot","data":{"status":{...}}}
31 // `data` carries the keys that changed this round: "status" every second, and
32 // "levels" (audio meters) ten times a second while anything is subscribed to
33 // them. The envelope leaves room to add more later without a protocol change. Each snapshot is complete for the
34 // keys it contains -- there are no deltas to miss, so there is no sequence
35 // number to compare: a client that drops the socket re-syncs by doing one full
36 // status poll when it reconnects, which it must do anyway to pick up the PHP
37 // augmentation.
38 //
39 // A client that only cares about state -- a monitor watching every box on the
40 // network, rather than a page showing this one -- can connect to
41 // /fppdws?quiet=1. An idle fppd's payload still changes every second, but only
42 // in its clock and uptime fields (and sensor readings, which jitter), so a
43 // plain client gets a frame a second from a box where nothing is happening. A
44 // quiet client gets a frame only when something else changed, plus one every
45 // QUIET_KEEPALIVE_MS so the clock fields stay roughly current and the client
46 // can still tell a live feed from a dead one. Playback and warnings count as
47 // changes, so a playing box still sends once a second.
48 //
49 // Client -> server: only "pong" is expected (drogon answers pings itself);
50 // anything else is ignored. The endpoint is read-only — it never accepts
51 // commands — so proxying it to the LAN carries no more authority than the
52 // existing status poll did.
53
54 #include "mediaoutput/AudioLevelMonitor.h"
55
56 #include <drogon/HttpAppFramework.h>
57 #include <drogon/WebSocketController.h>
58 // trantor (drogon's net layer) defines LOG_* macros that collide with FPP's
59 // LogLevel enum; drop them before pulling in the FPP headers, same as httpAPI.cpp.
60 #undef LOG_WARN
61 #undef LOG_INFO
62 #undef LOG_DEBUG
63
64 #include "fpp-pch.h"
65
66 #include "StatusWebSocket.h"
67 #include "Warnings.h"
68 #include "common.h" // GetCurrentFPPDStatus, SaveJsonToString
69 #include "fpp-json.h"
70
71 #include <mutex>
72 #include <set>
73 #include <string>
74 #include <vector>
75
76 using namespace drogon;
77
78 namespace {
79
80 std::mutex g_mutex;
81 std::set<WebSocketConnectionPtr> g_conns;
82 std::set<WebSocketConnectionPtr> g_quietConns; // the subset that asked for ?quiet=1
83 std::string g_lastStatusJson; // last pushed status payload, for change detection
84 // The quiet clients' view: the payload minus kTickingKeys, and when they were
85 // last sent anything.
86 std::string g_lastQuietJson;
87 uint64_t g_lastQuietSendMs = 0;
88 constexpr uint64_t QUIET_KEEPALIVE_MS = 30000;
89 // Status keys that change on their own with nothing having happened.
90 const char* const kTickingKeys[] = {
91 "time", "timeStr", "timeStrFull", "dateStr",
92 "uptime", "uptimeStr", "uptimeTotalSeconds", "uptimeSeconds",
93 "uptimeMinutes", "uptimeHours", "uptimeDays",
94 "systemUptimeTotalSeconds", "sensors"
95 };
96 // Set by StatusWebSocketShutdown() under g_mutex. Once set, no producer builds
97 // or sends a payload and no new connection is tracked, so a connection that
98 // arrives while fppd is tearing down can't re-populate g_conns after the clear.
99 bool g_shutdown = false;
100
101 // Build the current /fppd/status payload as a compact JSON string. This is
102 // the identical data the GET /fppd/status handler returns.
103 206 std::string buildStatusJson() {
104
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 Json::Value status;
105
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 GetCurrentFPPDStatus(status);
106
2/4
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 206 times.
✗ Branch 3 not taken.
412 return SaveJsonToString(status, ""); // "" indentation == compact
107 206 }
108
109 // Wrap an already-serialized data payload for one key in the snapshot
110 // envelope. dataJson is spliced in verbatim so we don't parse-then-reserialize.
111 471 std::string makeSnapshot(const char* key, const std::string& dataJson) {
112 471 std::string out;
113
1/2
✓ Branch 0 taken 471 times.
✗ Branch 1 not taken.
471 out.reserve(dataJson.size() + 64);
114
1/2
✓ Branch 0 taken 471 times.
✗ Branch 1 not taken.
471 out += "{\"type\":\"snapshot\",\"data\":{\"";
115
1/2
✓ Branch 0 taken 471 times.
✗ Branch 1 not taken.
471 out += key;
116
1/2
✓ Branch 0 taken 471 times.
✗ Branch 1 not taken.
471 out += "\":";
117
1/2
✓ Branch 0 taken 471 times.
✗ Branch 1 not taken.
471 out += dataJson;
118
1/2
✓ Branch 0 taken 471 times.
✗ Branch 1 not taken.
471 out += "}}";
119 471 return out;
120 ✗ }
121
122 // Produce the current status, and if it differs from what was last sent (or a
123 // new client needs it), push it to all connected clients. Cheap and a no-op
124 // when nobody is connected, so the 1s timer costs nothing on an idle system.
125 11969 void broadcastStatusIfChanged() {
126 11969 std::string msg;
127 11969 std::vector<WebSocketConnectionPtr> targets;
128 {
129
1/2
✓ Branch 0 taken 11969 times.
✗ Branch 1 not taken.
11969 std::lock_guard<std::mutex> lk(g_mutex);
130 // Check for connections before doing any work: with nobody listening
131 // the 1s timer must cost nothing, so an idle system pays no price for
132 // this endpoint existing. (A client can appear right after this check;
133 // it gets its own snapshot in handleNewConnection, so a missed
134 // broadcast here is harmless.)
135
5/6
✓ Branch 0 taken 11969 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 11870 times.
✓ Branch 3 taken 99 times.
✓ Branch 4 taken 11870 times.
✓ Branch 5 taken 99 times.
11969 if (g_shutdown || g_conns.empty())
136 11870 return;
137 // The build is deliberately inside the lock. Three thread contexts
138 // reach this -- the drogon timer, the FPP-Warnings notify thread, and
139 // an IO thread via handleNewConnection -- and building outside it let a
140 // slower thread win the lock after a faster one and publish an older
141 // payload as the newest state (a warning could blink away for a second).
142 // g_mutex is file-static and only ever taken from those entry points,
143 // none of which hold any of the locks buildStatusJson() reaches, so the
144 // nesting is one-directional and this cannot deadlock. The hold is the
145 // ~0.7ms build.
146
1/2
✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
99 Json::Value status;
147
1/2
✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
99 GetCurrentFPPDStatus(status);
148
2/4
✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 99 times.
✗ Branch 3 not taken.
99 std::string js = SaveJsonToString(status, "");
149
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 99 times.
99 if (js == g_lastStatusJson)
150 ✗ return;
151 99 g_lastStatusJson = std::move(js);
152
1/2
✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
99 msg = makeSnapshot("status", g_lastStatusJson);
153 99 bool quietDue = false;
154
2/2
✓ Branch 0 taken 35 times.
✓ Branch 1 taken 64 times.
99 if (!g_quietConns.empty()) {
155
2/2
✓ Branch 0 taken 455 times.
✓ Branch 1 taken 35 times.
490 for (const char* key : kTickingKeys) {
156
1/2
✓ Branch 0 taken 455 times.
✗ Branch 1 not taken.
455 status.removeMember(key);
157 }
158
2/4
✓ Branch 0 taken 35 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 35 times.
✗ Branch 3 not taken.
35 std::string stable = SaveJsonToString(status, "");
159
1/2
✓ Branch 0 taken 35 times.
✗ Branch 1 not taken.
35 uint64_t now = GetTimeMS();
160
6/6
✓ Branch 0 taken 31 times.
✓ Branch 1 taken 4 times.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 30 times.
✓ Branch 4 taken 5 times.
✓ Branch 5 taken 30 times.
35 if (stable != g_lastQuietJson || now - g_lastQuietSendMs >= QUIET_KEEPALIVE_MS) {
161 5 g_lastQuietJson = std::move(stable);
162 5 g_lastQuietSendMs = now;
163 5 quietDue = true;
164 }
165 35 }
166
2/2
✓ Branch 0 taken 406 times.
✓ Branch 1 taken 99 times.
505 for (const auto& c : g_conns) {
167
7/8
✓ Branch 0 taken 397 times.
✓ Branch 1 taken 9 times.
✓ Branch 2 taken 397 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 367 times.
✓ Branch 5 taken 30 times.
✓ Branch 6 taken 376 times.
✓ Branch 7 taken 30 times.
406 if (quietDue || !g_quietConns.count(c)) {
168
1/2
✓ Branch 0 taken 376 times.
✗ Branch 1 not taken.
376 targets.push_back(c);
169 }
170 }
171
4/6
✓ Branch 0 taken 99 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 99 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 99 times.
✓ Branch 5 taken 11870 times.
11969 }
172
2/2
✓ Branch 0 taken 376 times.
✓ Branch 1 taken 99 times.
475 for (auto& c : targets) {
173
2/4
✓ Branch 0 taken 376 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 376 times.
✗ Branch 3 not taken.
376 if (c->connected())
174
1/2
✓ Branch 0 taken 376 times.
✗ Branch 1 not taken.
376 c->send(msg);
175 }
176
4/4
✓ Branch 0 taken 99 times.
✓ Branch 1 taken 11870 times.
✓ Branch 2 taken 99 times.
✓ Branch 3 taken 11870 times.
23839 }
177
178 #ifdef HAS_AUDIO_LEVEL_MONITOR
179 // Push signal levels to anyone watching meters.
180 //
181 // Separate from the status broadcast and much faster: a meter that updates once
182 // a second does not read as a meter. It is also unconditional rather than
183 // change-gated -- levels differ on essentially every tick, so diffing them
184 // would cost more than it saved -- which is why this only runs while something
185 // is actually subscribed. With no meters up, AudioLevelMonitor has no pipelines
186 // and this returns immediately.
187 102491 void broadcastLevels() {
188 102491 std::string msg;
189 102491 std::vector<WebSocketConnectionPtr> targets;
190 {
191
1/2
✓ Branch 0 taken 102491 times.
✗ Branch 1 not taken.
102491 std::lock_guard<std::mutex> lk(g_mutex);
192
8/8
✓ Branch 0 taken 102486 times.
✓ Branch 1 taken 5 times.
✓ Branch 2 taken 959 times.
✓ Branch 3 taken 101527 times.
✓ Branch 4 taken 793 times.
✓ Branch 5 taken 166 times.
✓ Branch 6 taken 102325 times.
✓ Branch 7 taken 166 times.
102491 if (g_shutdown || g_conns.empty() || !AudioLevelMonitor::INSTANCE.Active())
193 102325 return;
194
4/8
✓ Branch 0 taken 166 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 166 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 166 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 166 times.
✗ Branch 7 not taken.
166 msg = makeSnapshot("levels", SaveJsonToString(AudioLevelMonitor::INSTANCE.GetLevels(), ""));
195
1/2
✓ Branch 0 taken 166 times.
✗ Branch 1 not taken.
166 targets.assign(g_conns.begin(), g_conns.end());
196
2/2
✓ Branch 0 taken 166 times.
✓ Branch 1 taken 102325 times.
102491 }
197
2/2
✓ Branch 0 taken 264 times.
✓ Branch 1 taken 166 times.
430 for (auto& c : targets) {
198
2/4
✓ Branch 0 taken 264 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 264 times.
✗ Branch 3 not taken.
264 if (c->connected())
199
1/2
✓ Branch 0 taken 264 times.
✗ Branch 1 not taken.
264 c->send(msg);
200 }
201
4/4
✓ Branch 0 taken 166 times.
✓ Branch 1 taken 102325 times.
✓ Branch 2 taken 166 times.
✓ Branch 3 taken 102325 times.
204816 }
202 #endif
203
204 // Warnings are part of the /fppd/status payload, so a new or cleared warning
205 // changes that payload. Rather than wait up to a second for the timer, push
206 // immediately when WarningHolder notifies us.
207 class StatusWarningListener : public WarningListener {
208 public:
209 2303 void handleWarnings(const std::list<FPPWarning>& /*warnings*/) override {
210 2303 broadcastStatusIfChanged();
211 2303 }
212 };
213 StatusWarningListener* g_warningListener = nullptr;
214
215 } // namespace
216
217 // WebSocket controller bound to /fppdws. Instances are thin: all shared state
218 // lives in the file-static structures above so the timer and warning callbacks
219 // (which have no controller instance) can reach it too.
220 class StatusWebSocket : public drogon::WebSocketController<StatusWebSocket> {
221 public:
222 206 void handleNewConnection(const HttpRequestPtr& req,
223 const WebSocketConnectionPtr& conn) override {
224 206 std::string msg;
225 {
226 // A connection can still land after shutdown has begun (drogon's
227 // quit() only queues teardown). Building a payload then would read
228 // producers that are being destroyed, so answer nothing at all.
229
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 std::lock_guard<std::mutex> lk(g_mutex);
230
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 206 times.
206 if (g_shutdown)
231 ✗ return;
232 // Send this client a full snapshot immediately so it doesn't have
233 // to wait for the next change. Force a build (bypass the change
234 // check), inside the lock for the same reason
235 // broadcastStatusIfChanged() builds inside it: this runs on an IO
236 // thread alongside the other two producers, and a build outside the
237 // lock could overwrite g_lastStatusJson with an older payload.
238
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 std::string js = buildStatusJson();
239
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 g_conns.insert(conn);
240 // Its own first snapshot doesn't advance the quiet cache: the
241 // other quiet clients haven't seen this state yet.
242
5/8
✓ 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.
✓ Branch 6 taken 3 times.
✓ Branch 7 taken 203 times.
618 if (req->getParameter("quiet") == "1") {
243
1/2
✓ Branch 0 taken 3 times.
✗ Branch 1 not taken.
3 g_quietConns.insert(conn);
244 }
245 // Advance the shared cache so the timer doesn't immediately
246 // re-broadcast identical data to everyone.
247 206 g_lastStatusJson = std::move(js);
248
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 msg = makeSnapshot("status", g_lastStatusJson);
249
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 }
250
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 conn->send(msg);
251
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 }
252
253 32 void handleNewMessage(const WebSocketConnectionPtr& /*conn*/,
254 std::string&& /*message*/,
255 const WebSocketMessageType& /*type*/) override {
256 // Read-only endpoint: ignore anything the client sends. drogon
257 // handles ping/pong framing internally.
258 32 }
259
260 206 void handleConnectionClosed(const WebSocketConnectionPtr& conn) override {
261
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 std::lock_guard<std::mutex> lk(g_mutex);
262
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 g_conns.erase(conn);
263
1/2
✓ Branch 0 taken 206 times.
✗ Branch 1 not taken.
206 g_quietConns.erase(conn);
264 206 }
265
266 2346 WS_PATH_LIST_BEGIN
267
2/4
✓ Branch 0 taken 2346 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2346 times.
✗ Branch 3 not taken.
6646 WS_PATH_ADD("/fppdws");
268 2346 WS_PATH_LIST_END
269 };
270
271 1885 void StatusWebSocketInit() {
272 static bool initialized = false;
273
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1885 times.
1885 if (initialized)
274 ✗ return;
275 1885 initialized = true;
276
277
1/2
✓ Branch 0 taken 1885 times.
✗ Branch 1 not taken.
1885 if (!g_warningListener) {
278
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1885 times.
1885 g_warningListener = new StatusWarningListener();
279 1885 WarningHolder::AddWarningListener(g_warningListener);
280 }
281
282 // A once-per-second tick on drogon's own event loop -- independent of the
283 // fppd main loop, which is epoll-gated and doesn't tick reliably during
284 // playback. broadcastStatusIfChanged() is a no-op with no clients, so
285 // this is effectively free until a page connects.
286
1/2
✓ Branch 0 taken 1885 times.
✗ Branch 1 not taken.
11551 drogon::app().getLoop()->runEvery(1.0, []() { broadcastStatusIfChanged(); });
287
288 #ifdef HAS_AUDIO_LEVEL_MONITOR
289 // Meters need a much faster tick than status. This costs nothing until
290 // someone subscribes -- see broadcastLevels().
291
1/2
✓ Branch 0 taken 1885 times.
✗ Branch 1 not taken.
104376 drogon::app().getLoop()->runEvery(0.1, []() { broadcastLevels(); });
292 #endif
293 }
294
295 1867 void StatusWebSocketShutdown() {
296 static bool shutdownDone = false;
297
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1867 times.
1867 if (shutdownDone)
298 ✗ return;
299 1867 shutdownDone = true;
300
301 // Order matters here. Removing the listener first is what makes the rest
302 // safe: WarningHolder holds its listener lock across the whole notify pass,
303 // so once RemoveWarningListener() returns, no handleWarnings() call on our
304 // listener is running or can start, and the warning thread can no longer
305 // drive a status build. The listener object itself is deliberately leaked --
306 // the notify thread is the only user and it is done with it by now, and a
307 // delete buys nothing on a process that _exit()s.
308
1/2
✓ Branch 0 taken 1867 times.
✗ Branch 1 not taken.
1867 if (g_warningListener) {
309
1/2
✓ Branch 0 taken 1867 times.
✗ Branch 1 not taken.
1867 WarningHolder::RemoveWarningListener(g_warningListener);
310 }
311
312 // The remaining producer is the drogon timer, which is still ticking (this
313 // runs before drogon is stopped). g_shutdown stops it building, and clearing
314 // g_conns drops the connection references drogon's quit() is not guaranteed
315 // to release through the close callbacks.
316
1/2
✓ Branch 0 taken 1867 times.
✗ Branch 1 not taken.
1867 std::lock_guard<std::mutex> lk(g_mutex);
317 1867 g_shutdown = true;
318 1867 g_conns.clear();
319 1867 g_quietConns.clear();
320 1867 }
321