From 376ad61a5e34488d75388d5e102696d115f29a73 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Wed, 7 Oct 2026 18:11:44 +0800 Subject: [PATCH] fix: fetch webcam info in ws loop --- src/slic3r/Utils/MoonrakerPrinterAgent.cpp | 162 +++++++++++---------- src/slic3r/Utils/MoonrakerPrinterAgent.hpp | 21 ++- tests/slic3rutils/test_printer_agent.cpp | 64 ++++++++ 3 files changed, 166 insertions(+), 81 deletions(-) diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp index 398369ffef..9697d8c34e 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp @@ -930,55 +930,40 @@ bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync CameraStreamMode MoonrakerPrinterAgent::get_camera_stream_mode() const { - refresh_webcam_info(); + // Cache read only: the status loop refreshes this off the GUI thread (see + // refresh_webcam_info), so a getter must never block on an HTTP request. std::lock_guard lock(payload_mutex); return webcam_stream_mode; } std::string MoonrakerPrinterAgent::get_camera_url() const { - refresh_webcam_info(); std::lock_guard lock(payload_mutex); return webcam_stream_url; } void MoonrakerPrinterAgent::refresh_webcam_info() const { - ConnectionSettings connection; - uint64_t generation; - { - std::lock_guard lock(connect_mutex); - connection.dev_id = device_info.dev_id; - connection.base_url = device_info.base_url; - connection.api_key = device_info.api_key; - connection.use_ssl = device_info.use_ssl; - connection.ca_file = device_info.ca_file; - generation = connect_generation.load(); - } - + // Called every status-loop iteration; the gate below keeps the HTTP lookup rare. + // Only the ws thread calls this, so scheduling needs no extra locking. const uint64_t now_ms = static_cast( std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count()); + if (now_ms < webcam_info_next_attempt_ms.load(std::memory_order_relaxed)) { + return; + } + const ConnectionSettings connection = get_connection_settings(); if (connection.base_url.empty()) { std::lock_guard lock(payload_mutex); webcam_stream_url.clear(); webcam_stream_mode = CameraStreamMode::none; - webcam_info_last_lookup_ms = 0; - webcam_info_generation = generation; + webcam_info_next_attempt_ms.store(now_ms + WEBCAM_INFO_FAILURE_BACKOFF_MS); return; } - { - std::lock_guard lock(payload_mutex); - if (webcam_info_generation == generation && - now_ms - webcam_info_last_lookup_ms < WEBCAM_INFO_REFRESH_INTERVAL_MS) { - return; - } - webcam_info_generation = generation; - webcam_info_last_lookup_ms = now_ms; - } - - fetch_webcam_info(connection, generation); + const bool ok = fetch_webcam_info(connection, connect_generation.load()); + webcam_info_next_attempt_ms.store(now_ms + (ok ? WEBCAM_INFO_REFRESH_INTERVAL_MS + : WEBCAM_INFO_FAILURE_BACKOFF_MS)); } std::string MoonrakerPrinterAgent::trim_and_upper(const std::string& input) @@ -1892,6 +1877,68 @@ bool MoonrakerPrinterAgent::send_ws_rpc(const std::string& method, const nlohman return false; } +bool moonraker_parse_webcam_list(const nlohmann::json& response, const std::string& base_url, + MoonrakerWebcamSelection& out) +{ + const auto result = response.contains("result") ? response["result"] : response; + if (!result.contains("webcams") || !result["webcams"].is_array()) { + out.error = "Unexpected JSON structure"; + return false; + } + + for (const auto& webcam : result["webcams"]) { + if (!webcam.is_object()) { + continue; + } + // /server/webcams/list returns disabled webcams too; skip them. + if (webcam.contains("enabled") && webcam["enabled"].is_boolean() && !webcam["enabled"].get()) { + continue; + } + if (webcam.contains("stream_url") && webcam["stream_url"].is_string() && + !webcam["stream_url"].get().empty()) { + out.url = webcam["stream_url"].get(); + out.mode = CameraStreamMode::http; + } else if (webcam.contains("snapshot_url") && webcam["snapshot_url"].is_string() && + !webcam["snapshot_url"].get().empty()) { + out.url = webcam["snapshot_url"].get(); + out.mode = CameraStreamMode::http_snapshot; + } + if (webcam.contains("name") && webcam["name"].is_string()) { + out.name = webcam["name"].get(); + } + if (!out.url.empty()) { + break; + } + } + + if (out.url.empty()) { + out.error = "No enabled webcam"; + return false; + } + + if (out.url.rfind("rtsp://", 0) == 0 || out.url.rfind("rtsps://", 0) == 0) { + out.mode = CameraStreamMode::rtsp; + } else if (out.url.rfind("http", 0) != 0 && out.url.front() == '/') { + // why: Moonraker's API port serves a JSON 404 for /webcam; relative camera URLs use the printer web root. + const size_t scheme_end = base_url.find("://"); + const size_t authority_start = scheme_end == std::string::npos ? 0 : scheme_end + 3; + const size_t authority_end = base_url.find('/', authority_start); + const std::string scheme = scheme_end == std::string::npos ? "" : base_url.substr(0, scheme_end + 3); + std::string authority = base_url.substr(authority_start, authority_end - authority_start); + const size_t port_start = authority.rfind(':'); + if (port_start != std::string::npos && port_start + 1 < authority.size() && + std::all_of(authority.begin() + port_start + 1, authority.end(), [](char c) { return c >= '0' && c <= '9'; })) { + authority.erase(port_start); + } + out.url = scheme + authority + out.url; + } else if (out.url.rfind("http", 0) != 0) { + out.error = "Unsupported webcam URL"; + return false; + } + + return true; +} + bool MoonrakerPrinterAgent::fetch_webcam_info(const ConnectionSettings& connection, uint64_t generation) const { std::string camera_url; @@ -1934,61 +1981,16 @@ bool MoonrakerPrinterAgent::fetch_webcam_info(const ConnectionSettings& connecti if (json.is_discarded()) { error = "Invalid JSON response"; } else { - const auto result = json.contains("result") ? json["result"] : json; - if (!result.contains("webcams") || !result["webcams"].is_array()) { - error = "Unexpected JSON structure"; + MoonrakerWebcamSelection selection; + if (moonraker_parse_webcam_list(json, connection.base_url, selection)) { + camera_url = selection.url; + stream_mode = selection.mode; + webcam_name = selection.name; } else { - for (const auto& webcam : result["webcams"]) { - if (webcam.is_object()) - { - // /server/webcams/list returns disabled webcams too; skip them. - if (webcam.contains("enabled") && webcam["enabled"].is_boolean() && - !webcam["enabled"].get()) { - continue; - } - if (webcam.contains("stream_url") && webcam["stream_url"].is_string() && - !webcam["stream_url"].get().empty()) { - camera_url = webcam["stream_url"].get(); - stream_mode = CameraStreamMode::http; - } else if (webcam.contains("snapshot_url") && webcam["snapshot_url"].is_string() && - !webcam["snapshot_url"].get().empty()) { - camera_url = webcam["snapshot_url"].get(); - stream_mode = CameraStreamMode::http_snapshot; - } - if (webcam.contains("name") && webcam["name"].is_string()) { - webcam_name = webcam["name"].get(); - } - if (!camera_url.empty()) - break; - } - } - if (camera_url.empty()) { - error = "No enabled webcam"; - } + error = selection.error; } } } - - if (error.empty()) { - if (camera_url.rfind("rtsp://", 0) == 0 || camera_url.rfind("rtsps://", 0) == 0) { - stream_mode = CameraStreamMode::rtsp; - } else if (camera_url.rfind("http", 0) != 0 && !camera_url.empty() && camera_url.front() == '/') { - // why: Moonraker's API port serves a JSON 404 for /webcam; relative camera URLs use the printer web root. - const size_t scheme_end = connection.base_url.find("://"); - const size_t authority_start = scheme_end == std::string::npos ? 0 : scheme_end + 3; - const size_t authority_end = connection.base_url.find('/', authority_start); - const std::string scheme = scheme_end == std::string::npos ? "" : connection.base_url.substr(0, scheme_end + 3); - std::string authority = connection.base_url.substr(authority_start, authority_end - authority_start); - const size_t port_start = authority.rfind(':'); - if (port_start != std::string::npos && port_start + 1 < authority.size() && - std::all_of(authority.begin() + port_start + 1, authority.end(), [](char c) { return c >= '0' && c <= '9'; })) { - authority.erase(port_start); - } - camera_url = scheme + authority + camera_url; - } else if (camera_url.rfind("http", 0) != 0) { - error = "Unsupported webcam URL"; - } - } } catch (const std::exception& e) { error = e.what(); } catch (...) { @@ -2351,6 +2353,7 @@ void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, Conne } ws_stop.store(false); + webcam_info_next_attempt_ms.store(0); // new connection: refresh the webcam promptly ws_thread = std::thread([this, dev_id, connection = std::move(connection)]() mutable { run_status_stream(dev_id, std::move(connection)); }); @@ -2492,6 +2495,9 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, ConnectionSett // Read loop while (!ws_stop.load()) { on_status_loop_tick(dev_id); + // Webcam discovery is self-gated (WEBCAM_INFO_REFRESH_INTERVAL_MS) and runs + // here so the GUI-thread getters stay non-blocking. + refresh_webcam_info(); ws.expires_after(std::chrono::seconds(2)); std::string payload; diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp index f06e268a49..8ca087d293 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp @@ -31,6 +31,20 @@ bool moonraker_is_light_name(const std::string& name); // e.g. LIGHT_ON -> +1, LIGHT_OFF -> -1, LIGHT -> 0. int moonraker_light_name_direction(const std::string& name); +struct MoonrakerWebcamSelection +{ + std::string url; + CameraStreamMode mode = CameraStreamMode::none; + std::string name; + std::string error; // set when no selectable webcam was found +}; + +// Selects a webcam from a parsed /server/webcams/list response (the top-level JSON or its +// "result" value). Returns true and fills url/mode/name on success; on failure returns +// false and sets error. +bool moonraker_parse_webcam_list(const nlohmann::json& response, const std::string& base_url, + MoonrakerWebcamSelection& out); + class MoonrakerWebsocket { public: @@ -302,11 +316,12 @@ private: std::string thumbnail_url; mutable std::string webcam_stream_url; mutable CameraStreamMode webcam_stream_mode = CameraStreamMode::none; - mutable uint64_t webcam_info_last_lookup_ms = 0; - mutable uint64_t webcam_info_generation = 0; + // Next time the status loop may look the webcam up again (steady_clock ms). + mutable std::atomic webcam_info_next_attempt_ms{0}; unsigned thumbnail_lookup_attempts = 0; - static constexpr uint64_t WEBCAM_INFO_REFRESH_INTERVAL_MS = 1000; + static constexpr uint64_t WEBCAM_INFO_REFRESH_INTERVAL_MS = 30000; + static constexpr uint64_t WEBCAM_INFO_FAILURE_BACKOFF_MS = 60000; std::atomic next_jsonrpc_id{1}; std::set available_objects; // Track for feature detection diff --git a/tests/slic3rutils/test_printer_agent.cpp b/tests/slic3rutils/test_printer_agent.cpp index c19e6caf63..2b1ab97816 100644 --- a/tests/slic3rutils/test_printer_agent.cpp +++ b/tests/slic3rutils/test_printer_agent.cpp @@ -98,6 +98,70 @@ TEST_CASE("unit: Moonraker light name matching", "[unit][moonraker]") CHECK(moonraker_is_light_name("MODLELIGHT_SWITCH")); } +TEST_CASE("Moonraker webcam selection skips disabled webcams and prefers the first enabled one", + "[unit][moonraker]") +{ + const auto response = nlohmann::json::parse(R"({ + "result": { "webcams": [ + { "name": "disabled", "enabled": false, "stream_url": "http://192.168.1.9:8080/stream" }, + { "name": "enabled", "enabled": true, "stream_url": "http://192.168.1.9:8080/stream" } + ]} + })"); + + MoonrakerWebcamSelection selection; + REQUIRE(moonraker_parse_webcam_list(response, "http://192.168.1.9:7125", selection)); + CHECK(selection.name == "enabled"); + CHECK(selection.url == "http://192.168.1.9:8080/stream"); + CHECK(selection.mode == CameraStreamMode::http); + CHECK(selection.error.empty()); +} + +TEST_CASE("Moonraker webcam selection resolves relative URLs, maps rtsp, and rejects other schemes", + "[unit][moonraker]") +{ + const auto relative = nlohmann::json::parse(R"({ + "result": { "webcams": [ { "name": "cam", "snapshot_url": "/webcam/?action=snapshot" } ] } + })"); + MoonrakerWebcamSelection rel; + REQUIRE(moonraker_parse_webcam_list(relative, "http://192.168.1.9:7125", rel)); + // Relative URLs use the printer web root, without the Moonraker API port. + CHECK(rel.url == "http://192.168.1.9/webcam/?action=snapshot"); + CHECK(rel.mode == CameraStreamMode::http_snapshot); + + const auto rtsp = nlohmann::json::parse(R"({ + "result": { "webcams": [ { "name": "cam", "stream_url": "rtsp://192.168.1.9:554/live" } ] } + })"); + MoonrakerWebcamSelection rt; + REQUIRE(moonraker_parse_webcam_list(rtsp, "http://192.168.1.9:7125", rt)); + CHECK(rt.mode == CameraStreamMode::rtsp); + + const auto unsupported = nlohmann::json::parse(R"({ + "result": { "webcams": [ { "name": "cam", "stream_url": "weird://host/x" } ] } + })"); + MoonrakerWebcamSelection bad; + CHECK_FALSE(moonraker_parse_webcam_list(unsupported, "http://192.168.1.9:7125", bad)); + CHECK(bad.error == "Unsupported webcam URL"); +} + +TEST_CASE("Moonraker webcam selection reports no webcam and malformed structure", "[unit][moonraker]") +{ + const auto empty = nlohmann::json::parse(R"({ "result": { "webcams": [] } })"); + MoonrakerWebcamSelection none; + CHECK_FALSE(moonraker_parse_webcam_list(empty, "http://host:7125", none)); + CHECK(none.error == "No enabled webcam"); + + const auto disabled_only = nlohmann::json::parse(R"({ + "result": { "webcams": [ { "name": "disabled", "enabled": false, "stream_url": "http://host/stream" } ] } + })"); + MoonrakerWebcamSelection off; + CHECK_FALSE(moonraker_parse_webcam_list(disabled_only, "http://host:7125", off)); + + const auto malformed = nlohmann::json::parse(R"({ "result": { "nope": 1 } })"); + MoonrakerWebcamSelection shape; + CHECK_FALSE(moonraker_parse_webcam_list(malformed, "http://host:7125", shape)); + CHECK(shape.error == "Unexpected JSON structure"); +} + // =========================================================================== // UNIT - handle_request's not-supported default. // The agent is the only thing that knows what it can translate, so an untranslated