diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index d5c859d8c3..e5000292c9 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -2740,6 +2740,14 @@ int MachineObject::publish_json(const json& json_item, int qos, int flag) BOOST_LOG_TRIVIAL(error) << "publish_json: " << json_item.dump() << " code: " << rtn; } + // why: the agent is the only thing that knows what it can translate, so it reports + // not-supported in its return value and this - the single funnel every command_* builder + // passes through - is the one place that turns it into something the user sees. No list of + // unsupported commands is needed anywhere: an agent that has no case for a command says so. + if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE) { + show_unsupported_dlg(rtn); + } + return rtn; } @@ -3821,6 +3829,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ has_ipcam = true; } else { has_ipcam = false; + webcam_stream_url.clear(); } } if (ipcam.contains("resolution")) { @@ -3855,6 +3864,9 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ liveview_local = local_rtsp_url.empty() ? LVL_None : local_rtsp_url == "disable" ? LVL_Disable : boost::algorithm::starts_with(local_rtsp_url, "rtsps") ? LVL_Rtsps : LVL_Rtsp; } + if (ipcam.contains("stream_url") && ipcam["stream_url"].is_string()) { + webcam_stream_url = ipcam["stream_url"].get(); + } if (ipcam.contains("tutk_server")) { tutk_state = ipcam["tutk_server"].get(); } @@ -4650,6 +4662,40 @@ void MachineObject::set_ctt_dlg( wxString text){ } } +void MachineObject::show_unsupported_dlg(int code) +{ + // why: a dead control invites repeat clicks, and the frame is modeless - without the guard + // every click stacks another one. Same shape as set_ctt_dlg above, including the reset on + // both hide and close so a dismissed dialog can reappear on the next attempt. + if (m_unsupported_dlg_shown) { + return; + } + m_unsupported_dlg_shown = true; + + // why: two codes so the user learns which kind of dead end this is - the slicer having no + // translation for the command, or the printer's own config lacking the hardware to run it. + const wxString text = (code == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE) ? + _L("This printer is not configured with the hardware this control needs.") : + _L("This control is not supported on this printer."); + + // note: constructed directly rather than through CallAfter because every publish_json caller + // is on the UI thread - clicks come from wx handlers, and the agent marshals its own push + // callbacks back to main before parse_json runs. set_ctt_dlg relies on the same property. + auto unsupported_dlg = new GUI::SecondaryCheckDialog(nullptr, wxID_ANY, _L("Warning"), + GUI::SecondaryCheckDialog::VisibleButtons::ONLY_CONFIRM); + unsupported_dlg->update_text(text); + unsupported_dlg->Bind(wxEVT_SHOW, [this](auto& e) { + if (!e.IsShown()) { + m_unsupported_dlg_shown = false; + } + }); + unsupported_dlg->Bind(wxEVT_CLOSE_WINDOW, [this](auto& e) { + e.Skip(); + m_unsupported_dlg_shown = false; + }); + unsupported_dlg->on_show(); +} + int MachineObject::publish_gcode(std::string gcode_str) { json j; diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index 90dc50674c..40909ef912 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -272,9 +272,11 @@ public: bool m_is_online; bool m_lan_mode_connection_state{false}; bool m_set_ctt_dlg{ false }; + bool m_unsupported_dlg_shown{ false }; void set_lan_mode_connection_state(bool state) {m_lan_mode_connection_state = state;}; bool get_lan_mode_connection_state() {return m_lan_mode_connection_state;}; void set_ctt_dlg( wxString text); + void show_unsupported_dlg(int code); int parse_msg_count = 0; int keep_alive_count = 0; std::chrono::system_clock::time_point last_update_time; /* last received print data from machine */ @@ -541,6 +543,7 @@ public: bool xcam_first_layer_inspector { false }; time_t xcam_first_layer_hold_start = 0; std::string local_rtsp_url; + std::string webcam_stream_url; std::string tutk_state; enum LiveviewLocal { LVL_None, diff --git a/src/slic3r/GUI/StatusPanel.cpp b/src/slic3r/GUI/StatusPanel.cpp index 4fcc82152a..f81c79269d 100644 --- a/src/slic3r/GUI/StatusPanel.cpp +++ b/src/slic3r/GUI/StatusPanel.cpp @@ -2311,6 +2311,27 @@ void StatusPanel::update_camera_state(MachineObject* obj) { if (!obj) return; + const bool has_printer_webcam = !obj->webcam_stream_url.empty(); + if (has_printer_webcam) { + if (m_printer_webcam_url != obj->webcam_stream_url) { + m_custom_camera_view->LoadURL(obj->webcam_stream_url); + m_custom_camera_view->Show(); + m_media_ctrl->Hide(); + m_media_play_ctrl->Hide(); + m_printer_webcam_url = obj->webcam_stream_url; + } + m_camera_switch_button->Hide(); + if (!m_custom_camera_view->IsShown()) { + // why: do not compare or reload the WebView URL per tick, or redirects can cause a reload loop. + m_custom_camera_view->Show(); + m_media_ctrl->Hide(); + m_media_play_ctrl->Hide(); + } + } else if (!m_printer_webcam_url.empty()) { + handle_camera_source_change(); + m_printer_webcam_url.clear(); + } + //sdcard auto sdcard_state = obj->GetStorage()->get_sdcard_state(); if (m_last_sdcard != sdcard_state) { @@ -2342,7 +2363,12 @@ void StatusPanel::update_camera_state(MachineObject* obj) m_last_recording = obj->is_recording() ? 1 : 0; } - if (!m_bitmap_recording_img->IsShown()) { + if (has_printer_webcam) { + if (m_bitmap_recording_img->IsShown()) { + m_bitmap_recording_img->Hide(); + m_panel_monitoring_title->Layout(); + } + } else if (!m_bitmap_recording_img->IsShown()) { m_bitmap_recording_img->Show(); m_panel_monitoring_title->Layout(); } @@ -2399,6 +2425,8 @@ void StatusPanel::update_camera_state(MachineObject* obj) bool show_vcamera = m_media_play_ctrl->IsStreaming(); m_camera_popup->update(show_vcamera); } + + m_setting_button->Show(!has_printer_webcam); } StatusPanel::StatusPanel(wxWindow *parent, wxWindowID id, const wxPoint &pos, const wxSize &size, long style, const wxString &name) @@ -5194,6 +5222,10 @@ bool StatusPanel::is_stage_list_info_changed(MachineObject *obj) void StatusPanel::set_default() { BOOST_LOG_TRIVIAL(trace) << "status_panel: set_default"; + if (!m_printer_webcam_url.empty()) { + handle_camera_source_change(); + m_printer_webcam_url.clear(); + } obj = nullptr; last_subtask = nullptr; last_tray_exist_bits = -1; diff --git a/src/slic3r/GUI/StatusPanel.hpp b/src/slic3r/GUI/StatusPanel.hpp index 150ef68de9..04c32e472e 100644 --- a/src/slic3r/GUI/StatusPanel.hpp +++ b/src/slic3r/GUI/StatusPanel.hpp @@ -664,6 +664,7 @@ protected: int m_last_timelapse = -1; int m_last_extrusion = -1; int m_last_vcamera = -1; + std::string m_printer_webcam_url; int m_model_mall_request_count = 0; bool m_is_load_with_temp = false; json m_rating_result; diff --git a/src/slic3r/Utils/IPrinterAgent.hpp b/src/slic3r/Utils/IPrinterAgent.hpp index 91d271316e..0fa3616344 100644 --- a/src/slic3r/Utils/IPrinterAgent.hpp +++ b/src/slic3r/Utils/IPrinterAgent.hpp @@ -2,6 +2,13 @@ #define __I_PRINTER_AGENT_HPP__ #include "bambu_networking.hpp" +// why: these extend the BAMBU_NETWORK_* return space rather than opening a new one - the value +// flows through the same int domain callers already compare against BAMBU_NETWORK_SUCCESS. +// They live here and not in bambu_networking.hpp because that file is a vendor header replaced +// wholesale by header-sync commits (see c09252ce11), which would silently clobber them. +// -70xx is free: the vendor occupies -1..-25 and -10xx through -60xx. +#define ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED -7010 // no translation exists for this command +#define ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE -7020 // a translation exists; this printer lacks the capability #include #include diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp index c760ab5194..31e6953bc5 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp @@ -1089,9 +1089,8 @@ int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std:: } } if (gcode.empty()) { - // why: match the other unmapped commands - the UI treats a failure code as a printer error dialog. BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: ledctrl - no light object found, dropping"; - return BAMBU_NETWORK_SUCCESS; + return ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE; } if (!send_gcode(dev_id, gcode)) { return BAMBU_NETWORK_ERR_CONNECTION_TO_PRINTER_FAILED; @@ -1104,6 +1103,15 @@ int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std:: } } + // why: the whole "pushing" namespace asks the printer to (re)send status - pushall, start, stop. + // The ws status stream already pushes unsolicited, so every member of it is genuinely satisfied + // rather than dropped. Accepting the namespace instead of naming its members keeps this a + // handled case rather than a suppression list; it also fires from the DevManager keepalive + // timer roughly once a second, which the not-supported default below would otherwise warn on. + if (json.contains("pushing") && json["pushing"].contains("command")) { + return BAMBU_NETWORK_SUCCESS; + } + // Handle print commands if (json.contains("print") && json["print"].contains("command")) { const auto& command = json["print"]["command"]; @@ -1188,7 +1196,29 @@ int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std:: } } - return BAMBU_NETWORK_SUCCESS; + std::string command_namespace = "unknown"; + std::string command_name = "unknown"; + if (json.is_object()) { + for (const char* namespace_name : {"info", "system", "print", "camera", "xcam", "upgrade", "pushing"}) { + if (!json.contains(namespace_name) || !json[namespace_name].is_object()) { + continue; + } + const auto& namespace_object = json[namespace_name]; + if (!namespace_object.contains("command") || !namespace_object["command"].is_string()) { + continue; + } + command_namespace = namespace_name; + command_name = namespace_object["command"].get(); + break; + } + } + + // why: reaching here means no case claimed the command, which is the honest verdict for + // every control Klipper has no equivalent for. Returning SUCCESS instead made all of them + // look like they worked. Nothing surfaces this code to the user yet. + BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: no translation for " << command_namespace << "." << command_name + << ", dropping"; + return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; } bool MoonrakerPrinterAgent::init_device_info(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) @@ -1204,6 +1234,7 @@ bool MoonrakerPrinterAgent::init_device_info(std::string dev_id, std::string dev device_info.dev_ip = dev_ip; device_info.api_key = password; + device_info.use_ssl = use_ssl; device_info.model_name = printer_cfg.opt_string("printer_model"); device_info.model_id = preset.get_printer_type(preset_bundle); device_info.base_url = use_ssl ? "https://" + dev_ip : "http://" + dev_ip; @@ -1326,6 +1357,104 @@ bool MoonrakerPrinterAgent::query_printer_status(const std::string& base_url, return true; } +bool MoonrakerPrinterAgent::fetch_webcam_info(const std::string& base_url, const std::string& api_key, uint64_t generation) +{ + std::string stream_url; + std::string webcam_name; + std::string error; + try { + std::string response_body; + bool success = false; + std::string http_error; + + auto http = Http::get(join_url(base_url, "/server/webcams/list")); + if (!api_key.empty()) { + http.header("X-Api-Key", api_key); + } + http.timeout_connect(5) + .timeout_max(10) + .on_complete([&](std::string body, unsigned status_code) { + if (status_code == 200) { + response_body = body; + success = true; + } else { + http_error = "HTTP error: " + std::to_string(status_code); + } + }) + .on_error([&](std::string body, std::string err, unsigned status_code) { + http_error = err; + if (status_code > 0) { + http_error += " (HTTP " + std::to_string(status_code) + ")"; + } + }) + .perform_sync(); + + if (!success) { + error = http_error.empty() ? "Connection failed" : http_error; + } else { + auto json = nlohmann::json::parse(response_body, nullptr, false, true); + 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"; + } else { + for (const auto& webcam : result["webcams"]) { + if (webcam.is_object() && webcam.value("enabled", false) && webcam.contains("stream_url") && + webcam["stream_url"].is_string()) { + stream_url = webcam["stream_url"].get(); + if (webcam.contains("name") && webcam["name"].is_string()) { + webcam_name = webcam["name"].get(); + } + break; + } + } + if (stream_url.empty()) { + error = "No enabled webcam"; + } + } + } + } + + if (error.empty()) { + if (stream_url.rfind("http", 0) != 0 && !stream_url.empty() && stream_url.front() == '/') { + // why: Moonraker's API port serves a JSON 404 for /webcam; relative streams 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); + } + stream_url = scheme + authority + stream_url; + } else if (stream_url.rfind("http", 0) != 0) { + error = "Unsupported webcam stream URL"; + } + } + } catch (const std::exception& e) { + error = e.what(); + } catch (...) { + error = "Unknown webcam discovery error"; + } + + { + std::lock_guard lock(payload_mutex); + if (generation == connect_generation.load()) { + webcam_stream_url = error.empty() ? stream_url : ""; + } + } + if (!error.empty()) { + BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: webcam discovery failed: " << error; + return false; + } + BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: selected webcam '" << webcam_name << "' with stream URL " << stream_url; + return true; +} + bool MoonrakerPrinterAgent::post_print_action(const std::string& action) const { // why: /printer/print/{pause,resume,cancel} map to Klipper's pause_resume @@ -2079,6 +2208,12 @@ nlohmann::json MoonrakerPrinterAgent::build_print_payload_locked() const payload["print"]["bed_temp_range"] = {0, 120}; // Typical bed range payload["print"]["support_send_to_sd"] = true; + if (!webcam_stream_url.empty()) { + payload["print"]["ipcam"]["ipcam_dev"] = "1"; + payload["print"]["ipcam"]["stream_url"] = webcam_stream_url; + } else { + payload["print"]["ipcam"]["ipcam_dev"] = "0"; + } // Detect bed_leveling support from available objects (bed_mesh or probe) // Default to 0 (not supported) if neither object exists bool has_bed_leveling = (available_objects.count("bed_mesh") != 0 || available_objects.count("probe") != 0); @@ -2442,6 +2577,8 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id, BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: Initial status query failed: " << error_msg; } + fetch_webcam_info(base_url, api_key, generation); + // Start WebSocket status stream start_status_stream(dev_id, base_url, api_key); #endif diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp index d9c2cde7ab..70e734eea6 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp @@ -134,6 +134,7 @@ private: bool fetch_object_list(const std::string& base_url, const std::string& api_key, std::set& objects, std::string& error) const; bool query_printer_status(const std::string& base_url, const std::string& api_key, nlohmann::json& status, std::string& error) const; + bool fetch_webcam_info(const std::string& base_url, const std::string& api_key, uint64_t generation); void announce_printhost_device(); void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg); @@ -199,6 +200,7 @@ private: // note: guarded by payload_mutex; filled by refresh_thumbnail_url(), empty url = looked up, none found std::string thumbnail_filename; std::string thumbnail_url; + std::string webcam_stream_url; unsigned thumbnail_lookup_attempts = 0; std::atomic next_jsonrpc_id{1}; diff --git a/tests/slic3rutils/test_printer_agent.cpp b/tests/slic3rutils/test_printer_agent.cpp index a9f04fe05c..0254db2266 100644 --- a/tests/slic3rutils/test_printer_agent.cpp +++ b/tests/slic3rutils/test_printer_agent.cpp @@ -22,6 +22,34 @@ TEST_CASE("unit: Moonraker light name matching", "[unit][moonraker]") CHECK(moonraker_is_light_name("MODLELIGHT_SWITCH")); } +// =========================================================================== +// UNIT - handle_request's not-supported default. +// The agent is the only thing that knows what it can translate, so an untranslated +// command has to say so instead of returning success and letting the UI believe the +// control worked. Guards the inverse too: the pushing namespace is genuinely +// satisfied by the websocket status stream, and it re-fires from the keepalive timer +// roughly once a second, so it must stay a success or it would raise a dialog on a +// timer. Only branches that touch neither the network nor wx are exercised. +// =========================================================================== +TEST_CASE("unit: Moonraker reports untranslated commands as not supported", "[unit][moonraker]") +{ + MoonrakerPrinterAgent agent(""); + + CHECK(agent.send_message("dev", R"({"print":{"command":"ams_change_filament"}})", 0, 0) == + ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); + CHECK(agent.send_message("dev", R"({"system":{"command":"set_door_stat"}})", 0, 0) == + ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); + CHECK(agent.send_message("dev", R"({"xcam":{"command":"xcam_control_set"}})", 0, 0) == + ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED); + + CHECK(agent.send_message("dev", R"({"pushing":{"command":"pushall"}})", 0, 0) == BAMBU_NETWORK_SUCCESS); + CHECK(agent.send_message("dev", R"({"pushing":{"command":"start"}})", 0, 0) == BAMBU_NETWORK_SUCCESS); + + // why: malformed input is a different failure than an untranslated command, and the + // default must not swallow it into a misleading not-supported verdict. + CHECK(agent.send_message("dev", "{not json", 0, 0) == BAMBU_NETWORK_ERR_INVALID_RESULT); +} + // =========================================================================== // UNIT - printer-agent registry duplicate handling. // Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in