From 1b3e206f34af0bf5d039ee95fe503dc6ec7ee350 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 28 Aug 2026 18:10:31 +0800 Subject: [PATCH 01/24] feat: connect to cloud printer and monitor --- src/slic3r/GUI/DeviceCore/DevManager.cpp | 50 +- src/slic3r/GUI/GUI_App.cpp | 9 +- src/slic3r/GUI/GUI_App.hpp | 2 +- src/slic3r/GUI/Monitor.cpp | 12 +- src/slic3r/Utils/NetworkAgent.cpp | 30 +- src/slic3r/Utils/OrcaCloudServiceAgent.cpp | 722 ++++++++++++++++++++- src/slic3r/Utils/OrcaCloudServiceAgent.hpp | 95 +++ src/slic3r/Utils/OrcaPrinterAgent.cpp | 124 +++- src/slic3r/Utils/OrcaPrinterAgent.hpp | 8 + 9 files changed, 1020 insertions(+), 32 deletions(-) diff --git a/src/slic3r/GUI/DeviceCore/DevManager.cpp b/src/slic3r/GUI/DeviceCore/DevManager.cpp index 8844303793..36df12afaa 100644 --- a/src/slic3r/GUI/DeviceCore/DevManager.cpp +++ b/src/slic3r/GUI/DeviceCore/DevManager.cpp @@ -570,6 +570,19 @@ namespace Slic3r << " cur_selected=" << selected_machine; auto my_machine_list = get_my_machine_list(); auto it = my_machine_list.find(dev_id); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: set_selected_machine lookup dev_id=" << dev_id + << " found=" << (it != my_machine_list.end()) + << " my_machine_count=" << my_machine_list.size() + << " current_agent=" << get_current_printer_agent_id() + << " provider=" << GUI::wxGetApp().get_printer_cloud_provider(); + if (it != my_machine_list.end() && it->second) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: target machine dev_id=" << it->second->get_dev_id() + << " printer_agent_id=" << it->second->printer_agent_id + << " connection_type=" << it->second->connection_type() + << " dev_connection_type=" << it->second->dev_connection_type; + } else { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: target machine was not found in the current agent's machine list"; + } // disconnect last if dev_id difference from previous one auto last_selected = my_machine_list.find(selected_machine); @@ -580,7 +593,9 @@ namespace Slic3r m_agent->disconnect_printer(); } else if (last_selected->second->connection_type() == "cloud") { - m_agent->set_user_selected_machine(""); + const int result = m_agent->set_user_selected_machine(""); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cleared previous cloud selection dev_id=" + << selected_machine << " result=" << result; } } @@ -632,7 +647,9 @@ namespace Slic3r { // diff dev_id, cloud => set_user_selected_machine(new) BOOST_LOG_TRIVIAL(info) << "set_selected_machine: select new cloud machine, dev_id =" << dev_id; - m_agent->set_user_selected_machine(dev_id); + const int result = m_agent->set_user_selected_machine(dev_id); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: set new cloud selection dev_id=" + << dev_id << " result=" << result; it->second->reset(); } else @@ -660,6 +677,8 @@ namespace Slic3r selected_machine = dev_id; record_user_last_machine(selected_machine); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: DeviceManager selection complete selected_machine=" + << selected_machine; return true; } @@ -690,7 +709,9 @@ namespace Slic3r dev_list.push_back(it->first); BOOST_LOG_TRIVIAL(trace) << "add_user_subscribe: " << it->first; } - m_agent->add_subscribe(dev_list); + const int result = m_agent->add_subscribe(dev_list); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: add_user_subscribe count=" << dev_list.size() + << " result=" << result; } @@ -703,7 +724,9 @@ namespace Slic3r dev_list.push_back(it->first); BOOST_LOG_TRIVIAL(trace) << "del_user_subscribe: " << it->first; } - m_agent->del_subscribe(dev_list); + const int result = m_agent->del_subscribe(dev_list); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: del_user_subscribe count=" << dev_list.size() + << " result=" << result; } void DeviceManager::subscribe_device_list(std::vector dev_list) @@ -864,6 +887,12 @@ namespace Slic3r if (!obj) continue; + // Orca cloud printers are only ever delivered through this REST + // account list; tag them so DeviceManager's cloud/lan branches + // (subscribe + deselect in set_selected_machine) treat them right. + if (provider == "orca") + obj->dev_connection_type = "cloud"; + if (!elem["dev_id"].is_null()) obj->set_dev_id(elem["dev_id"].get()); if (!elem["dev_name"].is_null()) @@ -895,6 +924,12 @@ namespace Slic3r acc_code.erase(std::remove(acc_code.begin(), acc_code.end(), '\n'), acc_code.end()); obj->set_access_code(acc_code); } + + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: parsed cloud machine dev_id=" << dev_id + << " name=" << obj->get_dev_name() + << " agent_id=" << obj->printer_agent_id + << " connection_type=" << obj->connection_type() + << " online=" << obj->m_is_online; } //remove MachineObject from userMachineList @@ -910,6 +945,9 @@ namespace Slic3r iterat++; } } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: parse_user_print_info complete provider=" << provider + << " parsed_count=" << new_list.size() + << " stored_count=" << userMachineList.size(); } } catch (std::exception& e) @@ -926,10 +964,14 @@ namespace Slic3r unsigned int http_code; std::string body; int result = m_agent->get_user_print_info(&http_code, &body, provider); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: get_user_print_info provider=" << provider + << " result=" << result << " http_code=" << http_code + << " body_bytes=" << body.size(); if (result == 0) { // parse_user_print_info and on_machine_alive (SSDP for discovery) both mutate the same userMachineList map. // on_machine_alive mutates the map on the UI thread, do the same for parse_user_print_info. + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: queueing parse_user_print_info on UI thread"; Slic3r::GUI::wxGetApp().CallAfter([this, body]() { parse_user_print_info(body); }); } } diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 223829f435..7d2d20f082 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -4973,11 +4973,12 @@ bool GUI_App::is_user_login(const std::string& provider/* = ORCA_CLOUD_PROVIDER* return false; } -const std::string& GUI_App::get_printer_cloud_provider() const +std::string GUI_App::get_printer_cloud_provider() const { - // Orca todo: this need to be revisted. currently it is mainly used for device manager and related clausses and only bambu machines use them. - // - return BBL_CLOUD_PROVIDER; + std::string provider = preset_bundle->printers.get_edited_preset().config.opt_string("printer_agent"); + if (provider.empty()) + provider = ORCA_CLOUD_PROVIDER; + return provider; } diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 8bf32df64c..67c82129c8 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -494,7 +494,7 @@ public: bool check_login(const std::string& provider = ORCA_CLOUD_PROVIDER); void get_login_info(const std::string& provider = ORCA_CLOUD_PROVIDER); bool is_user_login(const std::string& provider = ORCA_CLOUD_PROVIDER); - const std::string& get_printer_cloud_provider() const; + std::string get_printer_cloud_provider() const; void request_user_login(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER); void request_user_handle(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER); diff --git a/src/slic3r/GUI/Monitor.cpp b/src/slic3r/GUI/Monitor.cpp index 1a6d969988..dd0350ae0e 100644 --- a/src/slic3r/GUI/Monitor.cpp +++ b/src/slic3r/GUI/Monitor.cpp @@ -34,6 +34,8 @@ #include "DeviceCore/DevManager.h" +#include + namespace Slic3r { namespace GUI { @@ -259,6 +261,7 @@ void MonitorPanel::msw_rescale() void MonitorPanel::select_machine(std::string machine_sn) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MonitorPanel::select_machine queueing machine_sn=" << machine_sn; wxCommandEvent *event = new wxCommandEvent(wxEVT_COMMAND_CHOICE_SELECTED); event->SetString(machine_sn); wxQueueEvent(this, event); @@ -276,13 +279,20 @@ void MonitorPanel::on_timer(wxTimerEvent& event) void MonitorPanel::on_select_printer(wxCommandEvent& event) { Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager(); + const std::string requested_dev_id = event.GetString().ToStdString(); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MonitorPanel::on_select_printer requested_dev_id=" + << requested_dev_id << " device_manager=" << (dev ? "set" : "null"); if (!dev) return; if ( dev->get_selected_machine() && (dev->get_selected_machine()->get_dev_id() != event.GetString().ToStdString()) && m_hms_panel) { m_hms_panel->clear_hms_tag(); } - if (!dev->set_selected_machine(event.GetString().ToStdString())) + const bool selected = dev->set_selected_machine(requested_dev_id); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MonitorPanel::on_select_printer set_selected_machine result=" + << selected << " selected_dev_id=" + << (dev->get_selected_machine() ? dev->get_selected_machine()->get_dev_id() : ""); + if (!selected) return; set_default(); diff --git a/src/slic3r/Utils/NetworkAgent.cpp b/src/slic3r/Utils/NetworkAgent.cpp index 0d77e5e660..e12ca2f7d1 100644 --- a/src/slic3r/Utils/NetworkAgent.cpp +++ b/src/slic3r/Utils/NetworkAgent.cpp @@ -846,8 +846,14 @@ std::string NetworkAgent::get_user_selected_machine() int NetworkAgent::set_user_selected_machine(std::string dev_id) { - if (m_printer_agent) - return m_printer_agent->set_user_selected_machine(dev_id); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::set_user_selected_machine: dev_id=" << dev_id + << " printer_agent=" << (m_printer_agent ? m_printer_agent->get_agent_info().id : ""); + if (m_printer_agent) { + const int result = m_printer_agent->set_user_selected_machine(dev_id); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::set_user_selected_machine: result=" << result; + return result; + } + BOOST_LOG_TRIVIAL(warning) << "NetworkAgent::set_user_selected_machine: no printer agent"; return -1; } @@ -867,15 +873,27 @@ int NetworkAgent::stop_subscribe(std::string module) int NetworkAgent::add_subscribe(std::vector dev_list) { - if (m_printer_agent) - return m_printer_agent->add_subscribe(std::move(dev_list)); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::add_subscribe: count=" << dev_list.size() + << " printer_agent=" << (m_printer_agent ? m_printer_agent->get_agent_info().id : ""); + if (m_printer_agent) { + const int result = m_printer_agent->add_subscribe(std::move(dev_list)); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::add_subscribe: result=" << result; + return result; + } + BOOST_LOG_TRIVIAL(warning) << "NetworkAgent::add_subscribe: no printer agent"; return -1; } int NetworkAgent::del_subscribe(std::vector dev_list) { - if (m_printer_agent) - return m_printer_agent->del_subscribe(std::move(dev_list)); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::del_subscribe: count=" << dev_list.size() + << " printer_agent=" << (m_printer_agent ? m_printer_agent->get_agent_info().id : ""); + if (m_printer_agent) { + const int result = m_printer_agent->del_subscribe(std::move(dev_list)); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::del_subscribe: result=" << result; + return result; + } + BOOST_LOG_TRIVIAL(warning) << "NetworkAgent::del_subscribe: no printer agent"; return -1; } diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index 4419395b4d..4a6c545a82 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -6,6 +6,8 @@ #include #include +#include +#include #include #include #include @@ -20,14 +22,18 @@ #include #include #include +#include #include #include +#include +#include #include #include #include #include #include +#include #include #include @@ -59,6 +65,522 @@ using json = nlohmann::json; namespace Slic3r { +struct OrcaCloudMqttConnection::Connection { + boost::asio::io_context io_context; + boost::asio::ssl::context ssl_context; + WebSocket websocket; + boost::asio::ip::tcp::resolver resolver; + + Connection() + : ssl_context(boost::asio::ssl::context::tls_client) + , websocket(io_context, ssl_context) + , resolver(io_context) + {} +}; + +OrcaCloudMqttConnection::~OrcaCloudMqttConnection() { stop(); } + +bool OrcaCloudMqttConnection::start(const std::string& endpoint, TokenProvider token_provider, MessageHandler message_handler, StateHandler state_handler) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start endpoint=" << endpoint + << " token_callback=" << (token_provider ? "set" : "null") + << " message_callback=" << (message_handler ? "set" : "null") + << " state_callback=" << (state_handler ? "set" : "null"); + stop(); + { + std::lock_guard lock(mutex); + endpoint_url = endpoint; + get_token = std::move(token_provider); + on_message = std::move(message_handler); + on_state = std::move(state_handler); + initial_result = false; + initial_completed = false; + connected = false; + } + stopping.store(false); + worker = std::thread(&OrcaCloudMqttConnection::run, this); + + std::unique_lock lock(mutex); + if (!initial_cv.wait_for(lock, std::chrono::seconds(10), [this] { return initial_completed; })) { + initial_completed = true; + initial_result = false; + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT initial connection timed out after 10 seconds; worker will retry"; + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start initial_result=" << initial_result + << " initial_completed=" << initial_completed; + return initial_result; +} + +void OrcaCloudMqttConnection::stop() { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT stop requested"; + stopping.store(true); + state_cv.notify_all(); + { + std::lock_guard lock(connection_mutex); + if (active_connection) { + auto& socket = boost::beast::get_lowest_layer(active_connection->websocket).socket(); + boost::system::error_code socket_error; + socket.cancel(socket_error); + socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, socket_error); + socket.close(socket_error); + active_connection->resolver.cancel(); + } + } + if (worker.joinable()) + worker.join(); + + { + std::lock_guard lock(mutex); + connected = false; + if (!initial_completed) { + initial_completed = true; + initial_result = false; + } + } + initial_cv.notify_all(); +} + +bool OrcaCloudMqttConnection::is_running() const { + return worker.joinable() && !stopping.load(); +} + +void OrcaCloudMqttConnection::flush_subscription_change() { + std::shared_ptr conn; + { + std::lock_guard lock(connection_mutex); + conn = active_connection; + } + bool connacked; + { + std::lock_guard lock(mutex); + connacked = connected; + } + if (!conn || !connacked) + return; // no live MQTT session yet — the worker sends the set on CONNACK + + // beast permits a concurrent writer while the worker is blocked in + // websocket.read(); every write is serialised by write_mutex inside send(). + try { + send_pending_subscriptions(conn->websocket); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: direct subscription write failed (" << e.what() + << "); worker will resend the full set on reconnect"; + } +} + +bool OrcaCloudMqttConnection::subscribe(const std::vector& device_ids) { + { + std::lock_guard lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe requested count=" << device_ids.size() + << " connected=" << connected.load(); + for (const std::string& device_id : device_ids) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe requested dev_id=" << device_id; + if (device_id.empty() || report_topic(device_id).size() > 96) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT subscribe rejected invalid dev_id=" << device_id; + return false; + } + } + for (const std::string& device_id : device_ids) { + subscriptions.insert(device_id); + pending_subscriptions.insert(device_id); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe queued total_subscriptions=" << subscriptions.size() + << " pending_subscriptions=" << pending_subscriptions.size(); + } + state_cv.notify_all(); + flush_subscription_change(); // emit SUBSCRIBE now on the live socket (no reconnect) + return true; +} + +bool OrcaCloudMqttConnection::unsubscribe(const std::vector& device_ids) { + { + std::lock_guard lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe requested count=" << device_ids.size() + << " connected=" << connected.load(); + for (const std::string& device_id : device_ids) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe requested dev_id=" << device_id; + subscriptions.erase(device_id); + pending_subscriptions.erase(device_id); + pending_unsubscriptions.insert(device_id); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe queued total_subscriptions=" << subscriptions.size() + << " pending_unsubscriptions=" << pending_unsubscriptions.size(); + } + state_cv.notify_all(); + flush_subscription_change(); // emit UNSUBSCRIBE now on the live socket (no reconnect) + return true; +} + +void OrcaCloudMqttConnection::clear_subscriptions() { + std::lock_guard lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT clear subscriptions count=" << subscriptions.size(); + subscriptions.clear(); + pending_subscriptions.clear(); + pending_unsubscriptions.clear(); +} + +bool OrcaCloudMqttConnection::parse_endpoint(const std::string& url, Endpoint& endpoint) { + constexpr const char* scheme = "wss://"; + constexpr size_t scheme_length = 6; + if (url.compare(0, scheme_length, scheme) != 0) + return false; + + const size_t authority_start = scheme_length; + const size_t path_start = url.find('/', authority_start); + const std::string authority = url.substr(authority_start, path_start - authority_start); + if (authority.empty()) + return false; + + const size_t port_start = authority.rfind(':'); + if (port_start != std::string::npos && authority.find(']') == std::string::npos) { + endpoint.host = authority.substr(0, port_start); + endpoint.port = authority.substr(port_start + 1); + } else { + endpoint.host = authority; + endpoint.port = "443"; + } + endpoint.target = path_start == std::string::npos ? "/" : url.substr(path_start); + return !endpoint.host.empty() && !endpoint.port.empty() && !endpoint.target.empty(); +} + +void OrcaCloudMqttConnection::append_string(std::vector& packet, const std::string& value) { + if (value.size() > 0xffff) + throw std::runtime_error("MQTT string is too long"); + packet.push_back(static_cast(value.size() >> 8)); + packet.push_back(static_cast(value.size() & 0xff)); + packet.insert(packet.end(), value.begin(), value.end()); +} + +void OrcaCloudMqttConnection::prepend_remaining_length(std::vector& packet, size_t length) { + std::vector encoded; + do { + uint8_t byte = static_cast(length % 128); + length /= 128; + if (length != 0) + byte |= 0x80; + encoded.push_back(byte); + } while (length != 0); + packet.insert(packet.begin() + 1, encoded.begin(), encoded.end()); +} + +std::vector OrcaCloudMqttConnection::make_connect_packet() { + std::vector packet{0x10}; + append_string(packet, "MQTT"); + packet.insert(packet.end(), {4, 2, 0, 60}); // level 4, clean session, 60 s keepalive + append_string(packet, "OrcaSlicer"); + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} + +std::string OrcaCloudMqttConnection::report_topic(const std::string& device_id) { return "device/" + device_id + "/report"; } + +std::vector OrcaCloudMqttConnection::make_topic_packet(uint8_t type, uint16_t packet_id, const std::vector& device_ids) { + std::vector packet{type}; + packet.push_back(static_cast(packet_id >> 8)); + packet.push_back(static_cast(packet_id & 0xff)); + for (const std::string& device_id : device_ids) { + append_string(packet, report_topic(device_id)); + if (type == 0x82) // SUBSCRIBE, QoS 0 is sufficient for printer reports. + packet.push_back(0); + } + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} + +std::vector OrcaCloudMqttConnection::make_ping_packet() { return {0xc0, 0}; } + +void OrcaCloudMqttConnection::send(WebSocket& websocket, const std::vector& packet) { + if (packet.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: attempted to send empty MQTT packet"; + return; + } + // Writes come from the worker thread AND, for dynamic (un)subscribes, the + // caller thread. Serialise them; the worker's concurrent read is fine (beast + // allows one reader + one writer). + std::lock_guard lock(write_mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending MQTT packet type=0x" << std::hex + << static_cast(packet[0] >> 4) << std::dec + << " bytes=" << packet.size(); + websocket.binary(true); + websocket.write(boost::asio::buffer(packet)); +} + +void OrcaCloudMqttConnection::connect_and_read() { + auto connection = std::make_shared(); + { + std::lock_guard lock(connection_mutex); + active_connection = connection; + if (stopping.load()) + return; + } + + Endpoint endpoint; + if (!parse_endpoint(endpoint_url, endpoint)) { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: invalid MQTT endpoint=" << endpoint_url; + throw std::runtime_error("invalid Orca Cloud WebSocket endpoint"); + } + + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connecting host=" << endpoint.host + << " port=" << endpoint.port << " target=" << endpoint.target; + + auto& websocket = connection->websocket; + const auto results = connection->resolver.resolve(endpoint.host, endpoint.port); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT DNS resolution succeeded host=" << endpoint.host; + auto& stream = boost::beast::get_lowest_layer(websocket); + stream.expires_after(std::chrono::seconds(10)); + stream.connect(results); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TCP connection established host=" << endpoint.host + << " port=" << endpoint.port; + + // The aggregate viewer is a TLS WebSocket endpoint. Set SNI before the + // TLS handshake so the cloud edge selects the correct certificate. + auto& tls_stream = websocket.next_layer(); + if (!SSL_set_tlsext_host_name(tls_stream.native_handle(), endpoint.host.c_str())) + throw std::runtime_error("failed to set Orca Cloud TLS server name"); + connection->ssl_context.set_default_verify_paths(); + tls_stream.set_verify_mode(boost::asio::ssl::verify_peer); + tls_stream.set_verify_callback(boost::asio::ssl::host_name_verification(endpoint.host)); + tls_stream.handshake(boost::asio::ssl::stream_base::client); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TLS handshake completed host=" << endpoint.host; + + const std::string token = get_token ? get_token() : std::string(); + if (token.empty()) { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: MQTT token callback returned an empty token"; + throw std::runtime_error("no access token for Orca Cloud WebSocket"); + } + + websocket.set_option(boost::beast::websocket::stream_base::decorator( + [token](boost::beast::websocket::request_type& request) { + request.set(boost::beast::http::field::user_agent, "OrcaSlicer"); + request.set(boost::beast::http::field::authorization, "Bearer " + token); + request.set("Sec-WebSocket-Protocol", "mqtt"); + })); + boost::beast::http::response response; + boost::system::error_code handshake_error; + websocket.handshake(response, endpoint.host, endpoint.target, handshake_error); + if (handshake_error) { + // Surface the server's HTTP status so a persistent rejection (stale token, + // missing api key, wrong route) is diagnosable from the log rather than an + // opaque "handshake declined". + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudMqttConnection: handshake rejected, http=" + << response.result_int() << " (" << response.reason() << "), " + << handshake_error.message(); + throw boost::system::system_error(handshake_error, "Orca Cloud WebSocket handshake"); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: WebSocket handshake completed http=" << response.result_int() + << " negotiated_protocol=" << response["Sec-WebSocket-Protocol"]; + if (response["Sec-WebSocket-Protocol"] != "mqtt") { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: WebSocket handshake did not negotiate MQTT"; + throw std::runtime_error("Orca Cloud WebSocket did not negotiate MQTT"); + } + + stream.expires_never(); + send(websocket, make_connect_packet()); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNECT packet sent"; + + boost::beast::flat_buffer buffer; + stream.expires_after(std::chrono::seconds(10)); + websocket.read(buffer); + const std::string connack = boost::beast::buffers_to_string(buffer.data()); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNACK received bytes=" << connack.size() + << " header=" << (connack.empty() ? -1 : static_cast(static_cast(connack[0]))) + << " return_code=" << (connack.size() > 3 ? static_cast(static_cast(connack[3])) : -1); + if (connack.size() != 4 || static_cast(connack[0]) != 0x20 || + static_cast(connack[2]) != 0x00 || static_cast(connack[3]) != 0x00) { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: MQTT CONNECT was refused or malformed"; + throw std::runtime_error("Orca Cloud MQTT CONNECT was refused"); + } + + notify_state(true); + reconnect_delay_seconds.store(1); // a fresh CONNACK resets the backoff + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection is ready; sending current subscriptions"; + send_current_subscriptions(websocket); + std::chrono::steady_clock::time_point next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30); + + while (!stopping.load()) { + send_pending_subscriptions(websocket); + buffer.consume(buffer.size()); + stream.expires_after(std::chrono::seconds(1)); + boost::system::error_code error; + websocket.read(buffer, error); + if (error == boost::beast::error::timeout) { + if (std::chrono::steady_clock::now() >= next_ping) { + send(websocket, make_ping_packet()); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT PINGREQ sent"; + next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30); + } + continue; + } + if (error) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT WebSocket read failed code=" << error.value() + << " message=" << error.message(); + throw boost::system::system_error(error, "read Orca Cloud MQTT message"); + } + handle_packet(boost::beast::buffers_to_string(buffer.data())); + } + + boost::system::error_code close_error; + websocket.close(boost::beast::websocket::close_code::normal, close_error); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection closed code=" << close_error.value() + << " message=" << close_error.message(); + if (!stopping.load()) + notify_state(false); +} + +void OrcaCloudMqttConnection::send_current_subscriptions(WebSocket& websocket) { + std::vector devices; + { + std::lock_guard lock(mutex); + devices.assign(subscriptions.begin(), subscriptions.end()); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending current MQTT subscriptions count=" << devices.size(); + if (!devices.empty()) { + const uint16_t packet_id = next_packet_id++; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending SUBSCRIBE packet_id=" << packet_id; + send(websocket, make_topic_packet(0x82, packet_id, devices)); + } +} + +void OrcaCloudMqttConnection::send_pending_subscriptions(WebSocket& websocket) { + std::vector subscribe_ids; + std::vector unsubscribe_ids; + { + std::lock_guard lock(mutex); + subscribe_ids.assign(pending_subscriptions.begin(), pending_subscriptions.end()); + unsubscribe_ids.assign(pending_unsubscriptions.begin(), pending_unsubscriptions.end()); + pending_subscriptions.clear(); + pending_unsubscriptions.clear(); + } + if (!subscribe_ids.empty()) { + const uint16_t packet_id = next_packet_id++; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending SUBSCRIBE count=" << subscribe_ids.size() + << " packet_id=" << packet_id; + for (const std::string& device_id : subscribe_ids) + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: SUBSCRIBE topic=" << report_topic(device_id); + send(websocket, make_topic_packet(0x82, packet_id, subscribe_ids)); + } + if (!unsubscribe_ids.empty()) { + const uint16_t packet_id = next_packet_id++; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending UNSUBSCRIBE count=" << unsubscribe_ids.size() + << " packet_id=" << packet_id; + for (const std::string& device_id : unsubscribe_ids) + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: UNSUBSCRIBE topic=" << report_topic(device_id); + send(websocket, make_topic_packet(0xa2, packet_id, unsubscribe_ids)); + } +} + +void OrcaCloudMqttConnection::handle_packet(const std::string& packet) { + if (packet.size() < 2) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: received undersized MQTT packet bytes=" << packet.size(); + return; + } + const uint8_t header = static_cast(packet[0]); + const uint8_t packet_type = header >> 4; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received MQTT packet type=" << static_cast(packet_type) + << " header=0x" << std::hex << static_cast(header) << std::dec + << " bytes=" << packet.size(); + if (packet_type != 3) { // Only QoS 0 PUBLISH carries printer status. + if (packet_type == 9 && packet.size() >= 5) { + std::ostringstream result_codes; + for (size_t index = 4; index < packet.size(); ++index) { + if (index != 4) + result_codes << ','; + result_codes << "0x" << std::hex << static_cast(static_cast(packet[index])); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received SUBACK packet_id=" + << ((static_cast(static_cast(packet[2])) << 8) | + static_cast(static_cast(packet[3]))) + << " result_codes=" << result_codes.str(); + } + return; + } + size_t index = 1; + size_t multiplier = 1; + size_t remaining = 0; + uint8_t encoded = 0; + do { + if (index >= packet.size() || multiplier > 128 * 128 * 128) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH remaining length"; + return; + } + encoded = static_cast(packet[index++]); + remaining += (encoded & 0x7f) * multiplier; + multiplier *= 128; + } while ((encoded & 0x80) != 0); + const size_t remaining_end = index + remaining; + if (remaining_end > packet.size() || remaining < 2 || index + 2 > remaining_end) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH body remaining=" << remaining + << " packet_bytes=" << packet.size(); + return; + } + const uint16_t topic_length = (static_cast(packet[index]) << 8) | + static_cast(packet[index + 1]); + index += 2; + if (topic_length > packet.size() - index) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH topic length=" << topic_length; + return; + } + const std::string topic(packet.data() + index, topic_length); + index += topic_length; + if (((header >> 1) & 0x03) != 0) { + if (index + 2 > remaining_end) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH packet identifier"; + return; + } + index += 2; // QoS 1/2 packet identifier; the service currently sends QoS 0. + } + const size_t payload_size = remaining_end - index; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received PUBLISH topic=" << topic + << " payload_bytes=" << payload_size + << " message_callback=" << (on_message ? "set" : "null"); + if (on_message) + on_message(topic, packet.substr(index, remaining_end - index)); + else + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping PUBLISH because message callback is not set"; +} + +void OrcaCloudMqttConnection::notify_state(bool is_now_connected) { + StateHandler callback; + bool initial = false; + { + std::lock_guard lock(mutex); + connected = is_now_connected; + initial = !initial_completed; + if (initial) { + initial_result = is_now_connected; + initial_completed = true; + } + callback = on_state; + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT state changed connected=" << is_now_connected + << " initial=" << initial << " state_callback=" << (callback ? "set" : "null"); + if (initial) + initial_cv.notify_all(); + else if (callback) + callback(is_now_connected, false); +} + +void OrcaCloudMqttConnection::run() { + while (!stopping.load()) { + const int retry_seconds = reconnect_delay_seconds.load(); + try { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection attempt retry_delay=" << retry_seconds; + connect_and_read(); + } catch (const std::exception& error) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT connection attempt failed: " << error.what(); + if (!stopping.load()) + notify_state(false); + } + if (stopping.load()) + break; + // Grow the backoff only across attempts that never reached CONNACK; a + // successful connection resets reconnect_delay_seconds to 1 (connect_and_read). + reconnect_delay_seconds.store(std::min(retry_seconds * 2, 30)); + std::unique_lock lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT waiting before reconnect seconds=" << retry_seconds; + state_cv.wait_for(lock, std::chrono::seconds(retry_seconds), [this] { return stopping.load(); }); + } +} + namespace { constexpr const char* ORCA_DEFAULT_API_URL = "api.orcaslicer.com"; constexpr const char* ORCA_DEFAULT_AUTH_URL = "https://auth.orcaslicer.com"; @@ -82,6 +604,7 @@ constexpr const char* ORCA_UNSUBSCRIBE_PLUGINS = "/api/v1/plugins/subscriptions" constexpr const char* ORCA_PLUGINS_MINE = "/api/v1/plugins/mine"; constexpr const char* ORCA_PLUGINS_BASE = "/api/v1/plugins"; constexpr const char* ORCA_PLUGIN_DOWNLOAD_URL = "/api/v1/plugins/download"; +constexpr const char* ORCA_CLOUD_PRINTER = "/api/v1/printers"; constexpr const char* ORCA_CLOUD_LOGIN_PATH = "/orcaslicer-login"; @@ -490,6 +1013,7 @@ OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir) , api_base_url(ORCA_DEFAULT_API_URL) , auth_base_url(ORCA_DEFAULT_AUTH_URL) , cloud_base_url(ORCA_DEFAULT_CLOUD_URL) + , mqtt_connection(std::make_unique()) { auth_headers["apikey"] = ORCA_DEFAULT_PUB_KEY; pkce_bundle.loopback_port = choose_loopback_port(); @@ -500,6 +1024,8 @@ OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir) OrcaCloudServiceAgent::~OrcaCloudServiceAgent() { + if (mqtt_connection) + mqtt_connection->stop(); if (refresh_thread.joinable()) { refresh_thread.join(); } @@ -938,22 +1464,102 @@ bool OrcaCloudServiceAgent::ensure_token_fresh(const std::string& reason) { retu int OrcaCloudServiceAgent::connect_server() { + const bool logged_in = is_user_login(); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::connect_server logged_in=" << logged_in + << " api_base_url=" << api_base_url; + if (!logged_in) { + if (mqtt_connection) + mqtt_connection->stop(); + { + std::lock_guard lock(state_mutex); + is_connected = false; + } + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: connect_server requires a logged-in user"; + invoke_server_connected_callback(-1, 401); + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + std::string response; unsigned int http_code = 0; int result = http_get(ORCA_HEALTH_PATH, &response, &http_code); bool connected = (result == BAMBU_NETWORK_SUCCESS && http_code >= 200 && http_code < 300); - { - std::lock_guard lock(state_mutex); - is_connected = connected; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health result=" << result << " http_code=" << http_code + << " connected=" << connected << " response_bytes=" << response.size(); + + if (connected && mqtt_connection && !mqtt_connection->is_running()) { + // Only (re)start when the worker isn't already alive. connect_server() is + // also called every ~5s by DeviceManagerRefresher::on_timer via + // refresh_connection(); start() begins with stop(), so calling it + // unconditionally tears down and rebuilds a healthy socket every tick. + const std::string endpoint = "wss://" + api_base_url + "/api/v1/printers/mqtt"; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: starting aggregate MQTT endpoint=" << endpoint; + // Fire-and-forget: start() spawns a worker that reconnects with exponential + // backoff. A failed *initial* attempt (token/network not ready yet during a + // startup gap) must NOT gate the socket's lifetime here — folding it into + // `connected` trips the stop() below and kills the retry loop for the whole + // session. The socket is torn down only on logout / clear_session. + const bool mqtt_started = mqtt_connection->start( + endpoint, + [this] { return get_access_token(); }, + [this](const std::string& topic, const std::string& message) { + constexpr const char* prefix = "device/"; + constexpr const char* suffix = "/report"; + if (topic.compare(0, 7, prefix) != 0 || topic.size() <= 14 || + topic.compare(topic.size() - 7, 7, suffix) != 0) + return; + const std::string device_id = topic.substr(7, topic.size() - 14); + OnMessageFn callback; + { + std::lock_guard lock(callback_mutex); + callback = printer_status_callback; + } + if (callback) + callback(device_id, message); + }, + [this](bool socket_connected, bool initial) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: aggregate MQTT state callback connected=" + << socket_connected << " initial=" << initial; + if (initial) + return; + { + std::lock_guard lock(state_mutex); + is_connected = socket_connected; + } + invoke_server_connected_callback(socket_connected ? 0 : -1, 0); + }); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: aggregate MQTT start returned=" << mqtt_started; + } + if (!connected) { + // Transient health-check failure (DNS blip / brief 5xx). Do NOT stop the + // MQTT worker — it owns its own reconnect loop, and connect_server() runs + // on the 5s refresher tick. The socket is torn down only on logout (the + // !logged_in branch above) and clear_session(). + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health check failed; leaving aggregate MQTT running"; } - invoke_server_connected_callback(connected ? 0 : -1, http_code); - return connected ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + // While the aggregate MQTT worker is alive it owns is_connected via its + // StateHandler. Don't let the 5s health probe overwrite it (a DNS blip would + // otherwise flap the "server connected" state and the Device tab). + const bool mqtt_alive = mqtt_connection && mqtt_connection->is_running(); + if (!mqtt_alive) { + { + std::lock_guard lock(state_mutex); + is_connected = connected; + } + invoke_server_connected_callback(connected ? 0 : -1, http_code); + } + + return (connected || mqtt_alive) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; } bool OrcaCloudServiceAgent::is_server_connected() { + // The aggregate MQTT socket is the real signal. While its worker is alive, + // report its actual CONNACK state — immune to the 5s health probe's DNS blips. + // Fall back to the last health-check result only when there is no socket. + if (mqtt_connection && mqtt_connection->is_running()) + return mqtt_connection->is_connected(); std::lock_guard lock(state_mutex); return is_connected; } @@ -974,16 +1580,59 @@ int OrcaCloudServiceAgent::stop_subscribe(std::string module) int OrcaCloudServiceAgent::add_subscribe(std::vector dev_list) { - (void) dev_list; - return BAMBU_NETWORK_SUCCESS; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::add_subscribe count=" << dev_list.size() + << " logged_in=" << is_user_login() << " mqtt_connection=" << (mqtt_connection ? "set" : "null"); + if (!is_user_login() || !mqtt_connection) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: add_subscribe rejected because cloud is not ready"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + const bool queued = mqtt_connection->subscribe(dev_list); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: add_subscribe queued=" << queued; + return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; } int OrcaCloudServiceAgent::del_subscribe(std::vector dev_list) { - (void) dev_list; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::del_subscribe count=" << dev_list.size() + << " logged_in=" << is_user_login() << " mqtt_connection=" << (mqtt_connection ? "set" : "null"); + if (!is_user_login() || !mqtt_connection) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: del_subscribe rejected because cloud is not ready"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + const bool queued = mqtt_connection->unsubscribe(dev_list); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: del_subscribe queued=" << queued; + return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; +} + +int OrcaCloudServiceAgent::set_printer_status_callback(OnMessageFn fn) +{ + std::lock_guard lock(callback_mutex); + printer_status_callback = std::move(fn); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: printer status callback=" << (printer_status_callback ? "set" : "clear"); return BAMBU_NETWORK_SUCCESS; } +int OrcaCloudServiceAgent::send_printer_command(const std::string& dev_id, const std::string& body) +{ + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: send_printer_command dev_id=" << dev_id + << " body_bytes=" << body.size() << " logged_in=" << is_user_login(); + if (dev_id.empty() || !is_user_login()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: send_printer_command rejected"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + + const std::string path = std::string(ORCA_CLOUD_PRINTER) + "/" + dev_id + "/commands"; + std::string response; + unsigned int http_code = 0; + int result = http_post(path, body, &response, &http_code); + BOOST_LOG_TRIVIAL(info) << "OrcaCloudServiceAgent: command dev=" << dev_id + << " http=" << http_code << " result=" << result + << " response_bytes=" << response.size(); + return (result == BAMBU_NETWORK_SUCCESS && http_code >= 200 && http_code < 300) + ? BAMBU_NETWORK_SUCCESS + : BAMBU_NETWORK_ERR_CONNECT_FAILED; +} + void OrcaCloudServiceAgent::enable_multi_machine(bool enable) { std::lock_guard lock(state_mutex); @@ -2021,6 +2670,10 @@ bool OrcaCloudServiceAgent::set_user_session(const json& session_json, bool noti void OrcaCloudServiceAgent::clear_session() { + if (mqtt_connection) { + mqtt_connection->stop(); + mqtt_connection->clear_subscriptions(); + } { std::lock_guard lock(session_mutex); session = SessionInfo{}; @@ -2620,11 +3273,56 @@ int OrcaCloudServiceAgent::check_user_task_report(int* task_id, bool* printable) int OrcaCloudServiceAgent::get_user_print_info(unsigned int* http_code, std::string* http_body) { - BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_user_print_info (stub)"; + std::string response; + unsigned int code = 0; + int result = http_get(ORCA_CLOUD_PRINTER, &response, &code); + if (http_code) - *http_code = 200; - if (http_body) - *http_body = "{}"; + *http_code = code; + + if (result != 0 || code != 200) { + BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: get_user_print_info failed - http_code=" << code << ", response=" << response; + return result != 0 ? result : BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED; + } + + BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: get_user_print_info fetched - http_code=" << code << ", response=" << response; + + try { + auto resp_json = nlohmann::json::parse(response); + nlohmann::json devices = nlohmann::json::array(); + + for (const auto& printer : resp_json.value("data", nlohmann::json::array())) { + nlohmann::json device; + device["dev_id"] = printer.value("id", ""); + device["dev_name"] = printer.value("name", ""); + if (printer.contains("model") && printer["model"].is_string()) + device["dev_model_name"] = printer["model"].get(); + + bool online = false; + if (printer.contains("status_snapshot") && printer["status_snapshot"].is_object()) { + const auto& status = printer["status_snapshot"].value("status", nlohmann::json::object()); + online = status.value("connection", nlohmann::json::object()).value("state", "") == "online"; + if (status.contains("job") && status["job"].is_object()) + device["task_status"] = status["job"].value("state", ""); + } + device["dev_online"] = online; + + devices.push_back(device); + } + + if (http_body) { + nlohmann::json out; + out["devices"] = devices; + *http_body = out.dump(); + } + + BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_user_print_info parsed - device_count=" << devices.size() + << ", devices=" << devices.dump(); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: get_user_print_info parse exception - " << e.what(); + return BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED; + } + return BAMBU_NETWORK_SUCCESS; } diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp index 3ae86ec27c..95f95b7d92 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp @@ -2,6 +2,12 @@ #define __ORCA_CLOUD_SERVICE_AGENT_HPP__ #include "ICloudServiceAgent.hpp" + +#include +#include +#include +#include +#include #include #include #include @@ -9,6 +15,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -21,6 +30,78 @@ namespace Slic3r { // Forward declarations class AppConfig; +// MQTT 3.1.1 over the aggregate WebSocket is deliberately kept here instead +// of using the printer SDK. The endpoint is a read-only status stream; MQTT +// PUBLISH must never be sent on it because the cloud closes such sessions. +class OrcaCloudMqttConnection +{ +public: + using TokenProvider = std::function; + using MessageHandler = std::function; + using StateHandler = std::function; + + ~OrcaCloudMqttConnection(); + + bool start(const std::string& endpoint, TokenProvider token_provider, MessageHandler message_handler, StateHandler state_handler); + void stop(); + // True while the worker thread is alive (connected OR retrying). Lets callers + // avoid restarting a healthy connection. + bool is_running() const; + // True once CONNACK has been received and the socket has not since dropped. + bool is_connected() const { return connected.load(); } + bool subscribe(const std::vector& device_ids); + bool unsubscribe(const std::vector& device_ids); + void clear_subscriptions(); + +private: + struct Endpoint { std::string host; std::string port; std::string target; }; + using WebSocket = boost::beast::websocket::stream< + boost::asio::ssl::stream>; + struct Connection; + + static bool parse_endpoint(const std::string& url, Endpoint& endpoint); + static void append_string(std::vector& packet, const std::string& value); + static void prepend_remaining_length(std::vector& packet, size_t length); + static std::vector make_connect_packet(); + static std::string report_topic(const std::string& device_id); + static std::vector make_topic_packet(uint8_t type, uint16_t packet_id, const std::vector& device_ids); + static std::vector make_ping_packet(); + + void send(WebSocket& websocket, const std::vector& packet); + // Emit a queued SUBSCRIBE/UNSUBSCRIBE on the live socket right now (from the + // caller thread), so a selection change is applied without waiting for the + // blocking read loop to next return. No-op if no CONNACKed socket exists yet + // (the worker sends the set on connect). The aggregate viewer is dynamic — the + // WebSocket is never dropped for a subscription change. + void flush_subscription_change(); + void connect_and_read(); + void send_current_subscriptions(WebSocket& websocket); + void send_pending_subscriptions(WebSocket& websocket); + void handle_packet(const std::string& packet); + void notify_state(bool is_now_connected); + void run(); + + std::atomic_bool stopping{true}; + std::atomic_int reconnect_delay_seconds{1}; + std::thread worker; + std::mutex mutex; + std::mutex connection_mutex; + std::mutex write_mutex; // serialises every websocket write (worker + caller threads) + std::shared_ptr active_connection; + std::condition_variable initial_cv; + std::condition_variable state_cv; + std::string endpoint_url; + TokenProvider get_token; + MessageHandler on_message; + StateHandler on_state; + std::set subscriptions; + std::set pending_subscriptions; + std::set pending_unsubscriptions; + std::atomic next_packet_id{1}; + bool initial_result{false}; + bool initial_completed{false}; + std::atomic_bool connected{false}; +}; struct BundleMetadata; struct PluginDescriptor; struct PluginChangelog; @@ -207,6 +288,18 @@ public: int del_subscribe(std::vector dev_list) override; void enable_multi_machine(bool enable) override; + // The aggregate printer socket is status-only. OrcaPrinterAgent registers + // its normal message callback here and adds/removes device report topics + // through add_subscribe()/del_subscribe(). Printer commands continue to + // use the REST commands endpoint; they must never be published here. + int set_printer_status_callback(OnMessageFn fn); + + // Send a Bambu-dialect command to one printer via the cloud relay's REST + // endpoint (POST /api/v1/printers//commands). Synchronous - wraps http_post, + // so it carries the standard apikey + bearer headers and token refresh. Callers + // that need non-blocking behaviour run it on their own thread. + int send_printer_command(const std::string& dev_id, const std::string& body); + // ======================================================================== // ICloudServiceAgent Interface Implementation - Settings Synchronization // ======================================================================== @@ -423,6 +516,7 @@ private: std::chrono::system_clock::now().time_since_epoch()).count()}; // Member variables - connection state + std::unique_ptr mqtt_connection; bool is_connected{false}; bool enable_track{false}; bool multi_machine_enabled{false}; @@ -436,6 +530,7 @@ private: AppOnHttpErrorFn on_http_error_fn; GetCountryCodeFn get_country_code_fn; QueueOnMainFn queue_on_main_fn; + OnMessageFn printer_status_callback; mutable std::mutex callback_mutex; // Thread safety diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index cb70dafcca..93cadd9abd 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -1,5 +1,8 @@ #include "OrcaPrinterAgent.hpp" #include "NetworkAgentFactory.hpp" +#include "OrcaCloudServiceAgent.hpp" +#include +#include namespace Slic3r { @@ -13,8 +16,35 @@ OrcaPrinterAgent::~OrcaPrinterAgent() = default; void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud) { - std::lock_guard lock(state_mutex); - m_cloud_agent = cloud; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_cloud_agent: cloud=" << (cloud ? cloud->get_id() : ""); + { + std::lock_guard lock(state_mutex); + m_cloud_agent = cloud; + m_orca_cloud = dynamic_cast(cloud.get()); + } + if (!m_orca_cloud) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_cloud_agent: cloud is not OrcaCloudServiceAgent"; + return; // BBL provider active - nothing to bridge + } + + // OrcaCloudServiceAgent owns the aggregate MQTT socket; it already strips the + // device//report topic and hands us (dev_id, raw_json). Forward to the + // standard sink. message_arrive_fn self-marshals to the UI thread via CallAfter, + // so being called from the MQTT worker thread is fine. + const int callback_result = m_orca_cloud->set_printer_status_callback([this](std::string dev_id, std::string payload) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: received cloud status dev_id=" << dev_id + << " payload_bytes=" << payload.size(); + OnMessageFn fn; + { + std::lock_guard lock(state_mutex); + fn = on_message_fn; + } + if (fn) + fn(std::move(dev_id), std::move(payload)); + else + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: cloud status has no registered on_message callback"; + }); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_cloud_agent: status callback result=" << callback_result; } // ============================================================================ @@ -23,6 +53,34 @@ void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud int OrcaPrinterAgent::send_message(std::string dev_id, std::string json_str, int qos, int flag) { + (void) qos; + (void) flag; // MQTT concepts; N/A for the REST command endpoint + + std::shared_ptr cloud; + { + std::lock_guard lock(state_mutex); + cloud = m_cloud_agent; + } + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::send_message: dev_id=" << dev_id + << " payload_bytes=" << json_str.size() << " qos=" << qos << " flag=" << flag + << " cloud=" << (cloud ? cloud->get_id() : ""); + if (!cloud || dev_id.empty()) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::send_message: rejected due to missing cloud or device ID"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + + // Detached worker so the UI thread is never blocked on HTTP. Capture a shared_ptr + // copy (keeps the cloud agent alive) - never `this`. + std::thread([cloud, dev_id, body = std::move(json_str)]() { + if (auto* orca = dynamic_cast(cloud.get())) { + const int result = orca->send_printer_command(dev_id, body); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::send_message: cloud command result=" << result + << " dev_id=" << dev_id; + } else { + BOOST_LOG_TRIVIAL(error) << "OrcaPrinterAgent::send_message: cloud agent is not OrcaCloudServiceAgent"; + } + }).detach(); + return BAMBU_NETWORK_SUCCESS; } @@ -123,11 +181,68 @@ std::string OrcaPrinterAgent::get_user_selected_machine() int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) { - std::lock_guard lock(state_mutex); - selected_machine = dev_id; + std::shared_ptr cloud; + std::string previous; + { + std::lock_guard lock(state_mutex); + if (dev_id == selected_machine) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: unchanged dev_id=" << dev_id; + return BAMBU_NETWORK_SUCCESS; + } + previous = selected_machine; + selected_machine = dev_id; + cloud = m_cloud_agent; + } + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: previous=" << previous + << " new=" << dev_id << " cloud=" << (cloud ? cloud->get_id() : ""); + if (!cloud) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_user_selected_machine: no cloud agent"; + return BAMBU_NETWORK_SUCCESS; + } + + // One report topic at a time. add_subscribe/del_subscribe only mutate a set and + // wake the MQTT worker, so they are safe to call synchronously on the UI thread. + if (!previous.empty()) { + const int result = cloud->del_subscribe({previous}); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: unsubscribe dev_id=" << previous + << " result=" << result; + } + if (!dev_id.empty()) { + const int result = cloud->add_subscribe({dev_id}); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: subscribe dev_id=" << dev_id + << " result=" << result; + // Relay retains nothing: ask the printer for a full snapshot. Async inside + // send_message; returns immediately. + send_message(dev_id, + R"({"pushing":{"command":"pushall","sequence_id":"20001","version":1,"push_target":1}})", + 0, 0); + deliver_mock_get_version(dev_id); + } return BAMBU_NETWORK_SUCCESS; } +void OrcaPrinterAgent::deliver_mock_get_version(const std::string& dev_id) +{ + // The printer would answer an info.get_version request with its firmware/module + // list; OrcaCloud does not relay that yet, so MachineObject::module_vers stays + // empty and is_info_ready(check_version) never passes (StatusPanel bails, every + // field renders N/A). Synthesize the reply and push it through the same sink as + // real report messages so parse_json handles it identically. Remove once the + // backend answers info.get_version on device//report. + OnMessageFn fn; + { + std::lock_guard lock(state_mutex); + fn = on_message_fn; + } + if (!fn) + return; + static const std::string kMockGetVersion = + R"({"info":{"command":"get_version","sequence_id":"0","module":[)" + R"({"name":"ota","product_name":"OrcaCloud Printer","hw_ver":"","sw_ver":"01.00.00.00","sn":""}]}})"; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: delivering mock info.get_version for dev_id=" << dev_id; + fn(dev_id, kMockGetVersion); +} + // ============================================================================ // Agent Information // ============================================================================ @@ -197,6 +312,7 @@ int OrcaPrinterAgent::set_on_message_fn(OnMessageFn fn) { std::lock_guard lock(state_mutex); on_message_fn = fn; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_on_message_fn: callback=" << (fn ? "set" : "clear"); return BAMBU_NETWORK_SUCCESS; } diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index a1613420a5..9cf2b29638 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -9,6 +9,8 @@ namespace Slic3r { +class OrcaCloudServiceAgent; + /** * OrcaPrinterAgent - Stub implementation for printer operations. * @@ -81,6 +83,12 @@ private: std::string log_dir; std::string selected_machine; std::shared_ptr m_cloud_agent; + OrcaCloudServiceAgent* m_orca_cloud = nullptr; // == m_cloud_agent.get() when the Orca provider is active + + // MOCK: OrcaCloud does not yet relay the printer's info.get_version reply, so + // synthesize it and feed it through on_message_fn (same sink as real report + // messages). Delete once the backend answers info.get_version. + void deliver_mock_get_version(const std::string& dev_id); // Callbacks OnMsgArrivedFn on_ssdp_msg_fn; From 6a12aca495d0d8a78fcb14898e1627724b551141 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 28 Aug 2026 18:10:31 +0800 Subject: [PATCH 02/24] feat: connect to cloud printer and monitor --- src/slic3r/GUI/DeviceCore/DevManager.cpp | 50 +- src/slic3r/GUI/GUI_App.cpp | 9 +- src/slic3r/GUI/GUI_App.hpp | 2 +- src/slic3r/GUI/Monitor.cpp | 12 +- src/slic3r/Utils/NetworkAgent.cpp | 30 +- src/slic3r/Utils/OrcaCloudServiceAgent.cpp | 722 ++++++++++++++++++++- src/slic3r/Utils/OrcaCloudServiceAgent.hpp | 95 +++ src/slic3r/Utils/OrcaPrinterAgent.cpp | 124 +++- src/slic3r/Utils/OrcaPrinterAgent.hpp | 8 + 9 files changed, 1020 insertions(+), 32 deletions(-) diff --git a/src/slic3r/GUI/DeviceCore/DevManager.cpp b/src/slic3r/GUI/DeviceCore/DevManager.cpp index 8f7953005a..1046377e9f 100644 --- a/src/slic3r/GUI/DeviceCore/DevManager.cpp +++ b/src/slic3r/GUI/DeviceCore/DevManager.cpp @@ -572,6 +572,19 @@ namespace Slic3r << " cur_selected=" << selected_machine; auto my_machine_list = get_my_machine_list(); auto it = my_machine_list.find(dev_id); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: set_selected_machine lookup dev_id=" << dev_id + << " found=" << (it != my_machine_list.end()) + << " my_machine_count=" << my_machine_list.size() + << " current_agent=" << get_current_printer_agent_id() + << " provider=" << GUI::wxGetApp().get_printer_cloud_provider(); + if (it != my_machine_list.end() && it->second) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: target machine dev_id=" << it->second->get_dev_id() + << " printer_agent_id=" << it->second->printer_agent_id + << " connection_type=" << it->second->connection_type() + << " dev_connection_type=" << it->second->dev_connection_type; + } else { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: target machine was not found in the current agent's machine list"; + } // disconnect last if dev_id difference from previous one auto last_selected = my_machine_list.find(selected_machine); @@ -582,7 +595,9 @@ namespace Slic3r m_agent->disconnect_printer(); } else if (last_selected->second->connection_type() == "cloud") { - m_agent->set_user_selected_machine(""); + const int result = m_agent->set_user_selected_machine(""); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cleared previous cloud selection dev_id=" + << selected_machine << " result=" << result; } } @@ -634,7 +649,9 @@ namespace Slic3r { // diff dev_id, cloud => set_user_selected_machine(new) BOOST_LOG_TRIVIAL(info) << "set_selected_machine: select new cloud machine, dev_id =" << dev_id; - m_agent->set_user_selected_machine(dev_id); + const int result = m_agent->set_user_selected_machine(dev_id); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: set new cloud selection dev_id=" + << dev_id << " result=" << result; it->second->reset(); } else @@ -662,6 +679,8 @@ namespace Slic3r selected_machine = dev_id; record_user_last_machine(selected_machine); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: DeviceManager selection complete selected_machine=" + << selected_machine; return true; } @@ -692,7 +711,9 @@ namespace Slic3r dev_list.push_back(it->first); BOOST_LOG_TRIVIAL(trace) << "add_user_subscribe: " << it->first; } - m_agent->add_subscribe(dev_list); + const int result = m_agent->add_subscribe(dev_list); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: add_user_subscribe count=" << dev_list.size() + << " result=" << result; } @@ -705,7 +726,9 @@ namespace Slic3r dev_list.push_back(it->first); BOOST_LOG_TRIVIAL(trace) << "del_user_subscribe: " << it->first; } - m_agent->del_subscribe(dev_list); + const int result = m_agent->del_subscribe(dev_list); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: del_user_subscribe count=" << dev_list.size() + << " result=" << result; } void DeviceManager::subscribe_device_list(std::vector dev_list) @@ -869,6 +892,12 @@ namespace Slic3r if (!obj) continue; + // Orca cloud printers are only ever delivered through this REST + // account list; tag them so DeviceManager's cloud/lan branches + // (subscribe + deselect in set_selected_machine) treat them right. + if (provider == "orca") + obj->dev_connection_type = "cloud"; + if (!elem["dev_id"].is_null()) obj->set_dev_id(elem["dev_id"].get()); if (!elem["dev_name"].is_null()) @@ -900,6 +929,12 @@ namespace Slic3r acc_code.erase(std::remove(acc_code.begin(), acc_code.end(), '\n'), acc_code.end()); obj->set_access_code(acc_code); } + + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: parsed cloud machine dev_id=" << dev_id + << " name=" << obj->get_dev_name() + << " agent_id=" << obj->printer_agent_id + << " connection_type=" << obj->connection_type() + << " online=" << obj->m_is_online; } //remove MachineObject from userMachineList @@ -915,6 +950,9 @@ namespace Slic3r iterat++; } } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: parse_user_print_info complete provider=" << provider + << " parsed_count=" << new_list.size() + << " stored_count=" << userMachineList.size(); } } catch (std::exception& e) @@ -931,10 +969,14 @@ namespace Slic3r unsigned int http_code; std::string body; int result = m_agent->get_user_print_info(&http_code, &body, provider); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: get_user_print_info provider=" << provider + << " result=" << result << " http_code=" << http_code + << " body_bytes=" << body.size(); if (result == 0) { // parse_user_print_info and on_machine_alive (SSDP for discovery) both mutate the same userMachineList map. // on_machine_alive mutates the map on the UI thread, do the same for parse_user_print_info. + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: queueing parse_user_print_info on UI thread"; Slic3r::GUI::wxGetApp().CallAfter([this, body]() { parse_user_print_info(body); }); } } diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index eb536d93df..4165b071cb 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -4971,11 +4971,12 @@ bool GUI_App::is_user_login(const std::string& provider/* = ORCA_CLOUD_PROVIDER* return false; } -const std::string& GUI_App::get_printer_cloud_provider() const +std::string GUI_App::get_printer_cloud_provider() const { - // Orca todo: this need to be revisted. currently it is mainly used for device manager and related clausses and only bambu machines use them. - // - return BBL_CLOUD_PROVIDER; + std::string provider = preset_bundle->printers.get_edited_preset().config.opt_string("printer_agent"); + if (provider.empty()) + provider = ORCA_CLOUD_PROVIDER; + return provider; } diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 8bf32df64c..67c82129c8 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -494,7 +494,7 @@ public: bool check_login(const std::string& provider = ORCA_CLOUD_PROVIDER); void get_login_info(const std::string& provider = ORCA_CLOUD_PROVIDER); bool is_user_login(const std::string& provider = ORCA_CLOUD_PROVIDER); - const std::string& get_printer_cloud_provider() const; + std::string get_printer_cloud_provider() const; void request_user_login(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER); void request_user_handle(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER); diff --git a/src/slic3r/GUI/Monitor.cpp b/src/slic3r/GUI/Monitor.cpp index 1a6d969988..dd0350ae0e 100644 --- a/src/slic3r/GUI/Monitor.cpp +++ b/src/slic3r/GUI/Monitor.cpp @@ -34,6 +34,8 @@ #include "DeviceCore/DevManager.h" +#include + namespace Slic3r { namespace GUI { @@ -259,6 +261,7 @@ void MonitorPanel::msw_rescale() void MonitorPanel::select_machine(std::string machine_sn) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MonitorPanel::select_machine queueing machine_sn=" << machine_sn; wxCommandEvent *event = new wxCommandEvent(wxEVT_COMMAND_CHOICE_SELECTED); event->SetString(machine_sn); wxQueueEvent(this, event); @@ -276,13 +279,20 @@ void MonitorPanel::on_timer(wxTimerEvent& event) void MonitorPanel::on_select_printer(wxCommandEvent& event) { Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager(); + const std::string requested_dev_id = event.GetString().ToStdString(); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MonitorPanel::on_select_printer requested_dev_id=" + << requested_dev_id << " device_manager=" << (dev ? "set" : "null"); if (!dev) return; if ( dev->get_selected_machine() && (dev->get_selected_machine()->get_dev_id() != event.GetString().ToStdString()) && m_hms_panel) { m_hms_panel->clear_hms_tag(); } - if (!dev->set_selected_machine(event.GetString().ToStdString())) + const bool selected = dev->set_selected_machine(requested_dev_id); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MonitorPanel::on_select_printer set_selected_machine result=" + << selected << " selected_dev_id=" + << (dev->get_selected_machine() ? dev->get_selected_machine()->get_dev_id() : ""); + if (!selected) return; set_default(); diff --git a/src/slic3r/Utils/NetworkAgent.cpp b/src/slic3r/Utils/NetworkAgent.cpp index 776a759a00..eca59d30ce 100644 --- a/src/slic3r/Utils/NetworkAgent.cpp +++ b/src/slic3r/Utils/NetworkAgent.cpp @@ -925,8 +925,14 @@ std::string NetworkAgent::get_user_selected_machine() int NetworkAgent::set_user_selected_machine(std::string dev_id) { - if (m_printer_agent) - return m_printer_agent->set_user_selected_machine(dev_id); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::set_user_selected_machine: dev_id=" << dev_id + << " printer_agent=" << (m_printer_agent ? m_printer_agent->get_agent_info().id : ""); + if (m_printer_agent) { + const int result = m_printer_agent->set_user_selected_machine(dev_id); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::set_user_selected_machine: result=" << result; + return result; + } + BOOST_LOG_TRIVIAL(warning) << "NetworkAgent::set_user_selected_machine: no printer agent"; return -1; } @@ -946,15 +952,27 @@ int NetworkAgent::stop_subscribe(std::string module) int NetworkAgent::add_subscribe(std::vector dev_list) { - if (m_printer_agent) - return m_printer_agent->add_subscribe(std::move(dev_list)); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::add_subscribe: count=" << dev_list.size() + << " printer_agent=" << (m_printer_agent ? m_printer_agent->get_agent_info().id : ""); + if (m_printer_agent) { + const int result = m_printer_agent->add_subscribe(std::move(dev_list)); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::add_subscribe: result=" << result; + return result; + } + BOOST_LOG_TRIVIAL(warning) << "NetworkAgent::add_subscribe: no printer agent"; return -1; } int NetworkAgent::del_subscribe(std::vector dev_list) { - if (m_printer_agent) - return m_printer_agent->del_subscribe(std::move(dev_list)); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::del_subscribe: count=" << dev_list.size() + << " printer_agent=" << (m_printer_agent ? m_printer_agent->get_agent_info().id : ""); + if (m_printer_agent) { + const int result = m_printer_agent->del_subscribe(std::move(dev_list)); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::del_subscribe: result=" << result; + return result; + } + BOOST_LOG_TRIVIAL(warning) << "NetworkAgent::del_subscribe: no printer agent"; return -1; } diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index 4419395b4d..4a6c545a82 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -6,6 +6,8 @@ #include #include +#include +#include #include #include #include @@ -20,14 +22,18 @@ #include #include #include +#include #include #include +#include +#include #include #include #include #include #include +#include #include #include @@ -59,6 +65,522 @@ using json = nlohmann::json; namespace Slic3r { +struct OrcaCloudMqttConnection::Connection { + boost::asio::io_context io_context; + boost::asio::ssl::context ssl_context; + WebSocket websocket; + boost::asio::ip::tcp::resolver resolver; + + Connection() + : ssl_context(boost::asio::ssl::context::tls_client) + , websocket(io_context, ssl_context) + , resolver(io_context) + {} +}; + +OrcaCloudMqttConnection::~OrcaCloudMqttConnection() { stop(); } + +bool OrcaCloudMqttConnection::start(const std::string& endpoint, TokenProvider token_provider, MessageHandler message_handler, StateHandler state_handler) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start endpoint=" << endpoint + << " token_callback=" << (token_provider ? "set" : "null") + << " message_callback=" << (message_handler ? "set" : "null") + << " state_callback=" << (state_handler ? "set" : "null"); + stop(); + { + std::lock_guard lock(mutex); + endpoint_url = endpoint; + get_token = std::move(token_provider); + on_message = std::move(message_handler); + on_state = std::move(state_handler); + initial_result = false; + initial_completed = false; + connected = false; + } + stopping.store(false); + worker = std::thread(&OrcaCloudMqttConnection::run, this); + + std::unique_lock lock(mutex); + if (!initial_cv.wait_for(lock, std::chrono::seconds(10), [this] { return initial_completed; })) { + initial_completed = true; + initial_result = false; + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT initial connection timed out after 10 seconds; worker will retry"; + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start initial_result=" << initial_result + << " initial_completed=" << initial_completed; + return initial_result; +} + +void OrcaCloudMqttConnection::stop() { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT stop requested"; + stopping.store(true); + state_cv.notify_all(); + { + std::lock_guard lock(connection_mutex); + if (active_connection) { + auto& socket = boost::beast::get_lowest_layer(active_connection->websocket).socket(); + boost::system::error_code socket_error; + socket.cancel(socket_error); + socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, socket_error); + socket.close(socket_error); + active_connection->resolver.cancel(); + } + } + if (worker.joinable()) + worker.join(); + + { + std::lock_guard lock(mutex); + connected = false; + if (!initial_completed) { + initial_completed = true; + initial_result = false; + } + } + initial_cv.notify_all(); +} + +bool OrcaCloudMqttConnection::is_running() const { + return worker.joinable() && !stopping.load(); +} + +void OrcaCloudMqttConnection::flush_subscription_change() { + std::shared_ptr conn; + { + std::lock_guard lock(connection_mutex); + conn = active_connection; + } + bool connacked; + { + std::lock_guard lock(mutex); + connacked = connected; + } + if (!conn || !connacked) + return; // no live MQTT session yet — the worker sends the set on CONNACK + + // beast permits a concurrent writer while the worker is blocked in + // websocket.read(); every write is serialised by write_mutex inside send(). + try { + send_pending_subscriptions(conn->websocket); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: direct subscription write failed (" << e.what() + << "); worker will resend the full set on reconnect"; + } +} + +bool OrcaCloudMqttConnection::subscribe(const std::vector& device_ids) { + { + std::lock_guard lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe requested count=" << device_ids.size() + << " connected=" << connected.load(); + for (const std::string& device_id : device_ids) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe requested dev_id=" << device_id; + if (device_id.empty() || report_topic(device_id).size() > 96) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT subscribe rejected invalid dev_id=" << device_id; + return false; + } + } + for (const std::string& device_id : device_ids) { + subscriptions.insert(device_id); + pending_subscriptions.insert(device_id); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe queued total_subscriptions=" << subscriptions.size() + << " pending_subscriptions=" << pending_subscriptions.size(); + } + state_cv.notify_all(); + flush_subscription_change(); // emit SUBSCRIBE now on the live socket (no reconnect) + return true; +} + +bool OrcaCloudMqttConnection::unsubscribe(const std::vector& device_ids) { + { + std::lock_guard lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe requested count=" << device_ids.size() + << " connected=" << connected.load(); + for (const std::string& device_id : device_ids) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe requested dev_id=" << device_id; + subscriptions.erase(device_id); + pending_subscriptions.erase(device_id); + pending_unsubscriptions.insert(device_id); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe queued total_subscriptions=" << subscriptions.size() + << " pending_unsubscriptions=" << pending_unsubscriptions.size(); + } + state_cv.notify_all(); + flush_subscription_change(); // emit UNSUBSCRIBE now on the live socket (no reconnect) + return true; +} + +void OrcaCloudMqttConnection::clear_subscriptions() { + std::lock_guard lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT clear subscriptions count=" << subscriptions.size(); + subscriptions.clear(); + pending_subscriptions.clear(); + pending_unsubscriptions.clear(); +} + +bool OrcaCloudMqttConnection::parse_endpoint(const std::string& url, Endpoint& endpoint) { + constexpr const char* scheme = "wss://"; + constexpr size_t scheme_length = 6; + if (url.compare(0, scheme_length, scheme) != 0) + return false; + + const size_t authority_start = scheme_length; + const size_t path_start = url.find('/', authority_start); + const std::string authority = url.substr(authority_start, path_start - authority_start); + if (authority.empty()) + return false; + + const size_t port_start = authority.rfind(':'); + if (port_start != std::string::npos && authority.find(']') == std::string::npos) { + endpoint.host = authority.substr(0, port_start); + endpoint.port = authority.substr(port_start + 1); + } else { + endpoint.host = authority; + endpoint.port = "443"; + } + endpoint.target = path_start == std::string::npos ? "/" : url.substr(path_start); + return !endpoint.host.empty() && !endpoint.port.empty() && !endpoint.target.empty(); +} + +void OrcaCloudMqttConnection::append_string(std::vector& packet, const std::string& value) { + if (value.size() > 0xffff) + throw std::runtime_error("MQTT string is too long"); + packet.push_back(static_cast(value.size() >> 8)); + packet.push_back(static_cast(value.size() & 0xff)); + packet.insert(packet.end(), value.begin(), value.end()); +} + +void OrcaCloudMqttConnection::prepend_remaining_length(std::vector& packet, size_t length) { + std::vector encoded; + do { + uint8_t byte = static_cast(length % 128); + length /= 128; + if (length != 0) + byte |= 0x80; + encoded.push_back(byte); + } while (length != 0); + packet.insert(packet.begin() + 1, encoded.begin(), encoded.end()); +} + +std::vector OrcaCloudMqttConnection::make_connect_packet() { + std::vector packet{0x10}; + append_string(packet, "MQTT"); + packet.insert(packet.end(), {4, 2, 0, 60}); // level 4, clean session, 60 s keepalive + append_string(packet, "OrcaSlicer"); + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} + +std::string OrcaCloudMqttConnection::report_topic(const std::string& device_id) { return "device/" + device_id + "/report"; } + +std::vector OrcaCloudMqttConnection::make_topic_packet(uint8_t type, uint16_t packet_id, const std::vector& device_ids) { + std::vector packet{type}; + packet.push_back(static_cast(packet_id >> 8)); + packet.push_back(static_cast(packet_id & 0xff)); + for (const std::string& device_id : device_ids) { + append_string(packet, report_topic(device_id)); + if (type == 0x82) // SUBSCRIBE, QoS 0 is sufficient for printer reports. + packet.push_back(0); + } + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} + +std::vector OrcaCloudMqttConnection::make_ping_packet() { return {0xc0, 0}; } + +void OrcaCloudMqttConnection::send(WebSocket& websocket, const std::vector& packet) { + if (packet.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: attempted to send empty MQTT packet"; + return; + } + // Writes come from the worker thread AND, for dynamic (un)subscribes, the + // caller thread. Serialise them; the worker's concurrent read is fine (beast + // allows one reader + one writer). + std::lock_guard lock(write_mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending MQTT packet type=0x" << std::hex + << static_cast(packet[0] >> 4) << std::dec + << " bytes=" << packet.size(); + websocket.binary(true); + websocket.write(boost::asio::buffer(packet)); +} + +void OrcaCloudMqttConnection::connect_and_read() { + auto connection = std::make_shared(); + { + std::lock_guard lock(connection_mutex); + active_connection = connection; + if (stopping.load()) + return; + } + + Endpoint endpoint; + if (!parse_endpoint(endpoint_url, endpoint)) { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: invalid MQTT endpoint=" << endpoint_url; + throw std::runtime_error("invalid Orca Cloud WebSocket endpoint"); + } + + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connecting host=" << endpoint.host + << " port=" << endpoint.port << " target=" << endpoint.target; + + auto& websocket = connection->websocket; + const auto results = connection->resolver.resolve(endpoint.host, endpoint.port); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT DNS resolution succeeded host=" << endpoint.host; + auto& stream = boost::beast::get_lowest_layer(websocket); + stream.expires_after(std::chrono::seconds(10)); + stream.connect(results); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TCP connection established host=" << endpoint.host + << " port=" << endpoint.port; + + // The aggregate viewer is a TLS WebSocket endpoint. Set SNI before the + // TLS handshake so the cloud edge selects the correct certificate. + auto& tls_stream = websocket.next_layer(); + if (!SSL_set_tlsext_host_name(tls_stream.native_handle(), endpoint.host.c_str())) + throw std::runtime_error("failed to set Orca Cloud TLS server name"); + connection->ssl_context.set_default_verify_paths(); + tls_stream.set_verify_mode(boost::asio::ssl::verify_peer); + tls_stream.set_verify_callback(boost::asio::ssl::host_name_verification(endpoint.host)); + tls_stream.handshake(boost::asio::ssl::stream_base::client); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TLS handshake completed host=" << endpoint.host; + + const std::string token = get_token ? get_token() : std::string(); + if (token.empty()) { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: MQTT token callback returned an empty token"; + throw std::runtime_error("no access token for Orca Cloud WebSocket"); + } + + websocket.set_option(boost::beast::websocket::stream_base::decorator( + [token](boost::beast::websocket::request_type& request) { + request.set(boost::beast::http::field::user_agent, "OrcaSlicer"); + request.set(boost::beast::http::field::authorization, "Bearer " + token); + request.set("Sec-WebSocket-Protocol", "mqtt"); + })); + boost::beast::http::response response; + boost::system::error_code handshake_error; + websocket.handshake(response, endpoint.host, endpoint.target, handshake_error); + if (handshake_error) { + // Surface the server's HTTP status so a persistent rejection (stale token, + // missing api key, wrong route) is diagnosable from the log rather than an + // opaque "handshake declined". + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudMqttConnection: handshake rejected, http=" + << response.result_int() << " (" << response.reason() << "), " + << handshake_error.message(); + throw boost::system::system_error(handshake_error, "Orca Cloud WebSocket handshake"); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: WebSocket handshake completed http=" << response.result_int() + << " negotiated_protocol=" << response["Sec-WebSocket-Protocol"]; + if (response["Sec-WebSocket-Protocol"] != "mqtt") { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: WebSocket handshake did not negotiate MQTT"; + throw std::runtime_error("Orca Cloud WebSocket did not negotiate MQTT"); + } + + stream.expires_never(); + send(websocket, make_connect_packet()); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNECT packet sent"; + + boost::beast::flat_buffer buffer; + stream.expires_after(std::chrono::seconds(10)); + websocket.read(buffer); + const std::string connack = boost::beast::buffers_to_string(buffer.data()); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNACK received bytes=" << connack.size() + << " header=" << (connack.empty() ? -1 : static_cast(static_cast(connack[0]))) + << " return_code=" << (connack.size() > 3 ? static_cast(static_cast(connack[3])) : -1); + if (connack.size() != 4 || static_cast(connack[0]) != 0x20 || + static_cast(connack[2]) != 0x00 || static_cast(connack[3]) != 0x00) { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: MQTT CONNECT was refused or malformed"; + throw std::runtime_error("Orca Cloud MQTT CONNECT was refused"); + } + + notify_state(true); + reconnect_delay_seconds.store(1); // a fresh CONNACK resets the backoff + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection is ready; sending current subscriptions"; + send_current_subscriptions(websocket); + std::chrono::steady_clock::time_point next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30); + + while (!stopping.load()) { + send_pending_subscriptions(websocket); + buffer.consume(buffer.size()); + stream.expires_after(std::chrono::seconds(1)); + boost::system::error_code error; + websocket.read(buffer, error); + if (error == boost::beast::error::timeout) { + if (std::chrono::steady_clock::now() >= next_ping) { + send(websocket, make_ping_packet()); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT PINGREQ sent"; + next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30); + } + continue; + } + if (error) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT WebSocket read failed code=" << error.value() + << " message=" << error.message(); + throw boost::system::system_error(error, "read Orca Cloud MQTT message"); + } + handle_packet(boost::beast::buffers_to_string(buffer.data())); + } + + boost::system::error_code close_error; + websocket.close(boost::beast::websocket::close_code::normal, close_error); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection closed code=" << close_error.value() + << " message=" << close_error.message(); + if (!stopping.load()) + notify_state(false); +} + +void OrcaCloudMqttConnection::send_current_subscriptions(WebSocket& websocket) { + std::vector devices; + { + std::lock_guard lock(mutex); + devices.assign(subscriptions.begin(), subscriptions.end()); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending current MQTT subscriptions count=" << devices.size(); + if (!devices.empty()) { + const uint16_t packet_id = next_packet_id++; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending SUBSCRIBE packet_id=" << packet_id; + send(websocket, make_topic_packet(0x82, packet_id, devices)); + } +} + +void OrcaCloudMqttConnection::send_pending_subscriptions(WebSocket& websocket) { + std::vector subscribe_ids; + std::vector unsubscribe_ids; + { + std::lock_guard lock(mutex); + subscribe_ids.assign(pending_subscriptions.begin(), pending_subscriptions.end()); + unsubscribe_ids.assign(pending_unsubscriptions.begin(), pending_unsubscriptions.end()); + pending_subscriptions.clear(); + pending_unsubscriptions.clear(); + } + if (!subscribe_ids.empty()) { + const uint16_t packet_id = next_packet_id++; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending SUBSCRIBE count=" << subscribe_ids.size() + << " packet_id=" << packet_id; + for (const std::string& device_id : subscribe_ids) + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: SUBSCRIBE topic=" << report_topic(device_id); + send(websocket, make_topic_packet(0x82, packet_id, subscribe_ids)); + } + if (!unsubscribe_ids.empty()) { + const uint16_t packet_id = next_packet_id++; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending UNSUBSCRIBE count=" << unsubscribe_ids.size() + << " packet_id=" << packet_id; + for (const std::string& device_id : unsubscribe_ids) + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: UNSUBSCRIBE topic=" << report_topic(device_id); + send(websocket, make_topic_packet(0xa2, packet_id, unsubscribe_ids)); + } +} + +void OrcaCloudMqttConnection::handle_packet(const std::string& packet) { + if (packet.size() < 2) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: received undersized MQTT packet bytes=" << packet.size(); + return; + } + const uint8_t header = static_cast(packet[0]); + const uint8_t packet_type = header >> 4; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received MQTT packet type=" << static_cast(packet_type) + << " header=0x" << std::hex << static_cast(header) << std::dec + << " bytes=" << packet.size(); + if (packet_type != 3) { // Only QoS 0 PUBLISH carries printer status. + if (packet_type == 9 && packet.size() >= 5) { + std::ostringstream result_codes; + for (size_t index = 4; index < packet.size(); ++index) { + if (index != 4) + result_codes << ','; + result_codes << "0x" << std::hex << static_cast(static_cast(packet[index])); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received SUBACK packet_id=" + << ((static_cast(static_cast(packet[2])) << 8) | + static_cast(static_cast(packet[3]))) + << " result_codes=" << result_codes.str(); + } + return; + } + size_t index = 1; + size_t multiplier = 1; + size_t remaining = 0; + uint8_t encoded = 0; + do { + if (index >= packet.size() || multiplier > 128 * 128 * 128) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH remaining length"; + return; + } + encoded = static_cast(packet[index++]); + remaining += (encoded & 0x7f) * multiplier; + multiplier *= 128; + } while ((encoded & 0x80) != 0); + const size_t remaining_end = index + remaining; + if (remaining_end > packet.size() || remaining < 2 || index + 2 > remaining_end) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH body remaining=" << remaining + << " packet_bytes=" << packet.size(); + return; + } + const uint16_t topic_length = (static_cast(packet[index]) << 8) | + static_cast(packet[index + 1]); + index += 2; + if (topic_length > packet.size() - index) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH topic length=" << topic_length; + return; + } + const std::string topic(packet.data() + index, topic_length); + index += topic_length; + if (((header >> 1) & 0x03) != 0) { + if (index + 2 > remaining_end) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH packet identifier"; + return; + } + index += 2; // QoS 1/2 packet identifier; the service currently sends QoS 0. + } + const size_t payload_size = remaining_end - index; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received PUBLISH topic=" << topic + << " payload_bytes=" << payload_size + << " message_callback=" << (on_message ? "set" : "null"); + if (on_message) + on_message(topic, packet.substr(index, remaining_end - index)); + else + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping PUBLISH because message callback is not set"; +} + +void OrcaCloudMqttConnection::notify_state(bool is_now_connected) { + StateHandler callback; + bool initial = false; + { + std::lock_guard lock(mutex); + connected = is_now_connected; + initial = !initial_completed; + if (initial) { + initial_result = is_now_connected; + initial_completed = true; + } + callback = on_state; + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT state changed connected=" << is_now_connected + << " initial=" << initial << " state_callback=" << (callback ? "set" : "null"); + if (initial) + initial_cv.notify_all(); + else if (callback) + callback(is_now_connected, false); +} + +void OrcaCloudMqttConnection::run() { + while (!stopping.load()) { + const int retry_seconds = reconnect_delay_seconds.load(); + try { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection attempt retry_delay=" << retry_seconds; + connect_and_read(); + } catch (const std::exception& error) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT connection attempt failed: " << error.what(); + if (!stopping.load()) + notify_state(false); + } + if (stopping.load()) + break; + // Grow the backoff only across attempts that never reached CONNACK; a + // successful connection resets reconnect_delay_seconds to 1 (connect_and_read). + reconnect_delay_seconds.store(std::min(retry_seconds * 2, 30)); + std::unique_lock lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT waiting before reconnect seconds=" << retry_seconds; + state_cv.wait_for(lock, std::chrono::seconds(retry_seconds), [this] { return stopping.load(); }); + } +} + namespace { constexpr const char* ORCA_DEFAULT_API_URL = "api.orcaslicer.com"; constexpr const char* ORCA_DEFAULT_AUTH_URL = "https://auth.orcaslicer.com"; @@ -82,6 +604,7 @@ constexpr const char* ORCA_UNSUBSCRIBE_PLUGINS = "/api/v1/plugins/subscriptions" constexpr const char* ORCA_PLUGINS_MINE = "/api/v1/plugins/mine"; constexpr const char* ORCA_PLUGINS_BASE = "/api/v1/plugins"; constexpr const char* ORCA_PLUGIN_DOWNLOAD_URL = "/api/v1/plugins/download"; +constexpr const char* ORCA_CLOUD_PRINTER = "/api/v1/printers"; constexpr const char* ORCA_CLOUD_LOGIN_PATH = "/orcaslicer-login"; @@ -490,6 +1013,7 @@ OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir) , api_base_url(ORCA_DEFAULT_API_URL) , auth_base_url(ORCA_DEFAULT_AUTH_URL) , cloud_base_url(ORCA_DEFAULT_CLOUD_URL) + , mqtt_connection(std::make_unique()) { auth_headers["apikey"] = ORCA_DEFAULT_PUB_KEY; pkce_bundle.loopback_port = choose_loopback_port(); @@ -500,6 +1024,8 @@ OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir) OrcaCloudServiceAgent::~OrcaCloudServiceAgent() { + if (mqtt_connection) + mqtt_connection->stop(); if (refresh_thread.joinable()) { refresh_thread.join(); } @@ -938,22 +1464,102 @@ bool OrcaCloudServiceAgent::ensure_token_fresh(const std::string& reason) { retu int OrcaCloudServiceAgent::connect_server() { + const bool logged_in = is_user_login(); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::connect_server logged_in=" << logged_in + << " api_base_url=" << api_base_url; + if (!logged_in) { + if (mqtt_connection) + mqtt_connection->stop(); + { + std::lock_guard lock(state_mutex); + is_connected = false; + } + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: connect_server requires a logged-in user"; + invoke_server_connected_callback(-1, 401); + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + std::string response; unsigned int http_code = 0; int result = http_get(ORCA_HEALTH_PATH, &response, &http_code); bool connected = (result == BAMBU_NETWORK_SUCCESS && http_code >= 200 && http_code < 300); - { - std::lock_guard lock(state_mutex); - is_connected = connected; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health result=" << result << " http_code=" << http_code + << " connected=" << connected << " response_bytes=" << response.size(); + + if (connected && mqtt_connection && !mqtt_connection->is_running()) { + // Only (re)start when the worker isn't already alive. connect_server() is + // also called every ~5s by DeviceManagerRefresher::on_timer via + // refresh_connection(); start() begins with stop(), so calling it + // unconditionally tears down and rebuilds a healthy socket every tick. + const std::string endpoint = "wss://" + api_base_url + "/api/v1/printers/mqtt"; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: starting aggregate MQTT endpoint=" << endpoint; + // Fire-and-forget: start() spawns a worker that reconnects with exponential + // backoff. A failed *initial* attempt (token/network not ready yet during a + // startup gap) must NOT gate the socket's lifetime here — folding it into + // `connected` trips the stop() below and kills the retry loop for the whole + // session. The socket is torn down only on logout / clear_session. + const bool mqtt_started = mqtt_connection->start( + endpoint, + [this] { return get_access_token(); }, + [this](const std::string& topic, const std::string& message) { + constexpr const char* prefix = "device/"; + constexpr const char* suffix = "/report"; + if (topic.compare(0, 7, prefix) != 0 || topic.size() <= 14 || + topic.compare(topic.size() - 7, 7, suffix) != 0) + return; + const std::string device_id = topic.substr(7, topic.size() - 14); + OnMessageFn callback; + { + std::lock_guard lock(callback_mutex); + callback = printer_status_callback; + } + if (callback) + callback(device_id, message); + }, + [this](bool socket_connected, bool initial) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: aggregate MQTT state callback connected=" + << socket_connected << " initial=" << initial; + if (initial) + return; + { + std::lock_guard lock(state_mutex); + is_connected = socket_connected; + } + invoke_server_connected_callback(socket_connected ? 0 : -1, 0); + }); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: aggregate MQTT start returned=" << mqtt_started; + } + if (!connected) { + // Transient health-check failure (DNS blip / brief 5xx). Do NOT stop the + // MQTT worker — it owns its own reconnect loop, and connect_server() runs + // on the 5s refresher tick. The socket is torn down only on logout (the + // !logged_in branch above) and clear_session(). + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health check failed; leaving aggregate MQTT running"; } - invoke_server_connected_callback(connected ? 0 : -1, http_code); - return connected ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + // While the aggregate MQTT worker is alive it owns is_connected via its + // StateHandler. Don't let the 5s health probe overwrite it (a DNS blip would + // otherwise flap the "server connected" state and the Device tab). + const bool mqtt_alive = mqtt_connection && mqtt_connection->is_running(); + if (!mqtt_alive) { + { + std::lock_guard lock(state_mutex); + is_connected = connected; + } + invoke_server_connected_callback(connected ? 0 : -1, http_code); + } + + return (connected || mqtt_alive) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; } bool OrcaCloudServiceAgent::is_server_connected() { + // The aggregate MQTT socket is the real signal. While its worker is alive, + // report its actual CONNACK state — immune to the 5s health probe's DNS blips. + // Fall back to the last health-check result only when there is no socket. + if (mqtt_connection && mqtt_connection->is_running()) + return mqtt_connection->is_connected(); std::lock_guard lock(state_mutex); return is_connected; } @@ -974,16 +1580,59 @@ int OrcaCloudServiceAgent::stop_subscribe(std::string module) int OrcaCloudServiceAgent::add_subscribe(std::vector dev_list) { - (void) dev_list; - return BAMBU_NETWORK_SUCCESS; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::add_subscribe count=" << dev_list.size() + << " logged_in=" << is_user_login() << " mqtt_connection=" << (mqtt_connection ? "set" : "null"); + if (!is_user_login() || !mqtt_connection) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: add_subscribe rejected because cloud is not ready"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + const bool queued = mqtt_connection->subscribe(dev_list); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: add_subscribe queued=" << queued; + return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; } int OrcaCloudServiceAgent::del_subscribe(std::vector dev_list) { - (void) dev_list; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::del_subscribe count=" << dev_list.size() + << " logged_in=" << is_user_login() << " mqtt_connection=" << (mqtt_connection ? "set" : "null"); + if (!is_user_login() || !mqtt_connection) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: del_subscribe rejected because cloud is not ready"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + const bool queued = mqtt_connection->unsubscribe(dev_list); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: del_subscribe queued=" << queued; + return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; +} + +int OrcaCloudServiceAgent::set_printer_status_callback(OnMessageFn fn) +{ + std::lock_guard lock(callback_mutex); + printer_status_callback = std::move(fn); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: printer status callback=" << (printer_status_callback ? "set" : "clear"); return BAMBU_NETWORK_SUCCESS; } +int OrcaCloudServiceAgent::send_printer_command(const std::string& dev_id, const std::string& body) +{ + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: send_printer_command dev_id=" << dev_id + << " body_bytes=" << body.size() << " logged_in=" << is_user_login(); + if (dev_id.empty() || !is_user_login()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: send_printer_command rejected"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + + const std::string path = std::string(ORCA_CLOUD_PRINTER) + "/" + dev_id + "/commands"; + std::string response; + unsigned int http_code = 0; + int result = http_post(path, body, &response, &http_code); + BOOST_LOG_TRIVIAL(info) << "OrcaCloudServiceAgent: command dev=" << dev_id + << " http=" << http_code << " result=" << result + << " response_bytes=" << response.size(); + return (result == BAMBU_NETWORK_SUCCESS && http_code >= 200 && http_code < 300) + ? BAMBU_NETWORK_SUCCESS + : BAMBU_NETWORK_ERR_CONNECT_FAILED; +} + void OrcaCloudServiceAgent::enable_multi_machine(bool enable) { std::lock_guard lock(state_mutex); @@ -2021,6 +2670,10 @@ bool OrcaCloudServiceAgent::set_user_session(const json& session_json, bool noti void OrcaCloudServiceAgent::clear_session() { + if (mqtt_connection) { + mqtt_connection->stop(); + mqtt_connection->clear_subscriptions(); + } { std::lock_guard lock(session_mutex); session = SessionInfo{}; @@ -2620,11 +3273,56 @@ int OrcaCloudServiceAgent::check_user_task_report(int* task_id, bool* printable) int OrcaCloudServiceAgent::get_user_print_info(unsigned int* http_code, std::string* http_body) { - BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_user_print_info (stub)"; + std::string response; + unsigned int code = 0; + int result = http_get(ORCA_CLOUD_PRINTER, &response, &code); + if (http_code) - *http_code = 200; - if (http_body) - *http_body = "{}"; + *http_code = code; + + if (result != 0 || code != 200) { + BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: get_user_print_info failed - http_code=" << code << ", response=" << response; + return result != 0 ? result : BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED; + } + + BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: get_user_print_info fetched - http_code=" << code << ", response=" << response; + + try { + auto resp_json = nlohmann::json::parse(response); + nlohmann::json devices = nlohmann::json::array(); + + for (const auto& printer : resp_json.value("data", nlohmann::json::array())) { + nlohmann::json device; + device["dev_id"] = printer.value("id", ""); + device["dev_name"] = printer.value("name", ""); + if (printer.contains("model") && printer["model"].is_string()) + device["dev_model_name"] = printer["model"].get(); + + bool online = false; + if (printer.contains("status_snapshot") && printer["status_snapshot"].is_object()) { + const auto& status = printer["status_snapshot"].value("status", nlohmann::json::object()); + online = status.value("connection", nlohmann::json::object()).value("state", "") == "online"; + if (status.contains("job") && status["job"].is_object()) + device["task_status"] = status["job"].value("state", ""); + } + device["dev_online"] = online; + + devices.push_back(device); + } + + if (http_body) { + nlohmann::json out; + out["devices"] = devices; + *http_body = out.dump(); + } + + BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_user_print_info parsed - device_count=" << devices.size() + << ", devices=" << devices.dump(); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: get_user_print_info parse exception - " << e.what(); + return BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED; + } + return BAMBU_NETWORK_SUCCESS; } diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp index 3ae86ec27c..95f95b7d92 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp @@ -2,6 +2,12 @@ #define __ORCA_CLOUD_SERVICE_AGENT_HPP__ #include "ICloudServiceAgent.hpp" + +#include +#include +#include +#include +#include #include #include #include @@ -9,6 +15,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -21,6 +30,78 @@ namespace Slic3r { // Forward declarations class AppConfig; +// MQTT 3.1.1 over the aggregate WebSocket is deliberately kept here instead +// of using the printer SDK. The endpoint is a read-only status stream; MQTT +// PUBLISH must never be sent on it because the cloud closes such sessions. +class OrcaCloudMqttConnection +{ +public: + using TokenProvider = std::function; + using MessageHandler = std::function; + using StateHandler = std::function; + + ~OrcaCloudMqttConnection(); + + bool start(const std::string& endpoint, TokenProvider token_provider, MessageHandler message_handler, StateHandler state_handler); + void stop(); + // True while the worker thread is alive (connected OR retrying). Lets callers + // avoid restarting a healthy connection. + bool is_running() const; + // True once CONNACK has been received and the socket has not since dropped. + bool is_connected() const { return connected.load(); } + bool subscribe(const std::vector& device_ids); + bool unsubscribe(const std::vector& device_ids); + void clear_subscriptions(); + +private: + struct Endpoint { std::string host; std::string port; std::string target; }; + using WebSocket = boost::beast::websocket::stream< + boost::asio::ssl::stream>; + struct Connection; + + static bool parse_endpoint(const std::string& url, Endpoint& endpoint); + static void append_string(std::vector& packet, const std::string& value); + static void prepend_remaining_length(std::vector& packet, size_t length); + static std::vector make_connect_packet(); + static std::string report_topic(const std::string& device_id); + static std::vector make_topic_packet(uint8_t type, uint16_t packet_id, const std::vector& device_ids); + static std::vector make_ping_packet(); + + void send(WebSocket& websocket, const std::vector& packet); + // Emit a queued SUBSCRIBE/UNSUBSCRIBE on the live socket right now (from the + // caller thread), so a selection change is applied without waiting for the + // blocking read loop to next return. No-op if no CONNACKed socket exists yet + // (the worker sends the set on connect). The aggregate viewer is dynamic — the + // WebSocket is never dropped for a subscription change. + void flush_subscription_change(); + void connect_and_read(); + void send_current_subscriptions(WebSocket& websocket); + void send_pending_subscriptions(WebSocket& websocket); + void handle_packet(const std::string& packet); + void notify_state(bool is_now_connected); + void run(); + + std::atomic_bool stopping{true}; + std::atomic_int reconnect_delay_seconds{1}; + std::thread worker; + std::mutex mutex; + std::mutex connection_mutex; + std::mutex write_mutex; // serialises every websocket write (worker + caller threads) + std::shared_ptr active_connection; + std::condition_variable initial_cv; + std::condition_variable state_cv; + std::string endpoint_url; + TokenProvider get_token; + MessageHandler on_message; + StateHandler on_state; + std::set subscriptions; + std::set pending_subscriptions; + std::set pending_unsubscriptions; + std::atomic next_packet_id{1}; + bool initial_result{false}; + bool initial_completed{false}; + std::atomic_bool connected{false}; +}; struct BundleMetadata; struct PluginDescriptor; struct PluginChangelog; @@ -207,6 +288,18 @@ public: int del_subscribe(std::vector dev_list) override; void enable_multi_machine(bool enable) override; + // The aggregate printer socket is status-only. OrcaPrinterAgent registers + // its normal message callback here and adds/removes device report topics + // through add_subscribe()/del_subscribe(). Printer commands continue to + // use the REST commands endpoint; they must never be published here. + int set_printer_status_callback(OnMessageFn fn); + + // Send a Bambu-dialect command to one printer via the cloud relay's REST + // endpoint (POST /api/v1/printers//commands). Synchronous - wraps http_post, + // so it carries the standard apikey + bearer headers and token refresh. Callers + // that need non-blocking behaviour run it on their own thread. + int send_printer_command(const std::string& dev_id, const std::string& body); + // ======================================================================== // ICloudServiceAgent Interface Implementation - Settings Synchronization // ======================================================================== @@ -423,6 +516,7 @@ private: std::chrono::system_clock::now().time_since_epoch()).count()}; // Member variables - connection state + std::unique_ptr mqtt_connection; bool is_connected{false}; bool enable_track{false}; bool multi_machine_enabled{false}; @@ -436,6 +530,7 @@ private: AppOnHttpErrorFn on_http_error_fn; GetCountryCodeFn get_country_code_fn; QueueOnMainFn queue_on_main_fn; + OnMessageFn printer_status_callback; mutable std::mutex callback_mutex; // Thread safety diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index cb70dafcca..93cadd9abd 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -1,5 +1,8 @@ #include "OrcaPrinterAgent.hpp" #include "NetworkAgentFactory.hpp" +#include "OrcaCloudServiceAgent.hpp" +#include +#include namespace Slic3r { @@ -13,8 +16,35 @@ OrcaPrinterAgent::~OrcaPrinterAgent() = default; void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud) { - std::lock_guard lock(state_mutex); - m_cloud_agent = cloud; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_cloud_agent: cloud=" << (cloud ? cloud->get_id() : ""); + { + std::lock_guard lock(state_mutex); + m_cloud_agent = cloud; + m_orca_cloud = dynamic_cast(cloud.get()); + } + if (!m_orca_cloud) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_cloud_agent: cloud is not OrcaCloudServiceAgent"; + return; // BBL provider active - nothing to bridge + } + + // OrcaCloudServiceAgent owns the aggregate MQTT socket; it already strips the + // device//report topic and hands us (dev_id, raw_json). Forward to the + // standard sink. message_arrive_fn self-marshals to the UI thread via CallAfter, + // so being called from the MQTT worker thread is fine. + const int callback_result = m_orca_cloud->set_printer_status_callback([this](std::string dev_id, std::string payload) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: received cloud status dev_id=" << dev_id + << " payload_bytes=" << payload.size(); + OnMessageFn fn; + { + std::lock_guard lock(state_mutex); + fn = on_message_fn; + } + if (fn) + fn(std::move(dev_id), std::move(payload)); + else + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: cloud status has no registered on_message callback"; + }); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_cloud_agent: status callback result=" << callback_result; } // ============================================================================ @@ -23,6 +53,34 @@ void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud int OrcaPrinterAgent::send_message(std::string dev_id, std::string json_str, int qos, int flag) { + (void) qos; + (void) flag; // MQTT concepts; N/A for the REST command endpoint + + std::shared_ptr cloud; + { + std::lock_guard lock(state_mutex); + cloud = m_cloud_agent; + } + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::send_message: dev_id=" << dev_id + << " payload_bytes=" << json_str.size() << " qos=" << qos << " flag=" << flag + << " cloud=" << (cloud ? cloud->get_id() : ""); + if (!cloud || dev_id.empty()) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::send_message: rejected due to missing cloud or device ID"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + + // Detached worker so the UI thread is never blocked on HTTP. Capture a shared_ptr + // copy (keeps the cloud agent alive) - never `this`. + std::thread([cloud, dev_id, body = std::move(json_str)]() { + if (auto* orca = dynamic_cast(cloud.get())) { + const int result = orca->send_printer_command(dev_id, body); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::send_message: cloud command result=" << result + << " dev_id=" << dev_id; + } else { + BOOST_LOG_TRIVIAL(error) << "OrcaPrinterAgent::send_message: cloud agent is not OrcaCloudServiceAgent"; + } + }).detach(); + return BAMBU_NETWORK_SUCCESS; } @@ -123,11 +181,68 @@ std::string OrcaPrinterAgent::get_user_selected_machine() int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) { - std::lock_guard lock(state_mutex); - selected_machine = dev_id; + std::shared_ptr cloud; + std::string previous; + { + std::lock_guard lock(state_mutex); + if (dev_id == selected_machine) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: unchanged dev_id=" << dev_id; + return BAMBU_NETWORK_SUCCESS; + } + previous = selected_machine; + selected_machine = dev_id; + cloud = m_cloud_agent; + } + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: previous=" << previous + << " new=" << dev_id << " cloud=" << (cloud ? cloud->get_id() : ""); + if (!cloud) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_user_selected_machine: no cloud agent"; + return BAMBU_NETWORK_SUCCESS; + } + + // One report topic at a time. add_subscribe/del_subscribe only mutate a set and + // wake the MQTT worker, so they are safe to call synchronously on the UI thread. + if (!previous.empty()) { + const int result = cloud->del_subscribe({previous}); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: unsubscribe dev_id=" << previous + << " result=" << result; + } + if (!dev_id.empty()) { + const int result = cloud->add_subscribe({dev_id}); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: subscribe dev_id=" << dev_id + << " result=" << result; + // Relay retains nothing: ask the printer for a full snapshot. Async inside + // send_message; returns immediately. + send_message(dev_id, + R"({"pushing":{"command":"pushall","sequence_id":"20001","version":1,"push_target":1}})", + 0, 0); + deliver_mock_get_version(dev_id); + } return BAMBU_NETWORK_SUCCESS; } +void OrcaPrinterAgent::deliver_mock_get_version(const std::string& dev_id) +{ + // The printer would answer an info.get_version request with its firmware/module + // list; OrcaCloud does not relay that yet, so MachineObject::module_vers stays + // empty and is_info_ready(check_version) never passes (StatusPanel bails, every + // field renders N/A). Synthesize the reply and push it through the same sink as + // real report messages so parse_json handles it identically. Remove once the + // backend answers info.get_version on device//report. + OnMessageFn fn; + { + std::lock_guard lock(state_mutex); + fn = on_message_fn; + } + if (!fn) + return; + static const std::string kMockGetVersion = + R"({"info":{"command":"get_version","sequence_id":"0","module":[)" + R"({"name":"ota","product_name":"OrcaCloud Printer","hw_ver":"","sw_ver":"01.00.00.00","sn":""}]}})"; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: delivering mock info.get_version for dev_id=" << dev_id; + fn(dev_id, kMockGetVersion); +} + // ============================================================================ // Agent Information // ============================================================================ @@ -197,6 +312,7 @@ int OrcaPrinterAgent::set_on_message_fn(OnMessageFn fn) { std::lock_guard lock(state_mutex); on_message_fn = fn; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_on_message_fn: callback=" << (fn ? "set" : "clear"); return BAMBU_NETWORK_SUCCESS; } diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index a1613420a5..9cf2b29638 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -9,6 +9,8 @@ namespace Slic3r { +class OrcaCloudServiceAgent; + /** * OrcaPrinterAgent - Stub implementation for printer operations. * @@ -81,6 +83,12 @@ private: std::string log_dir; std::string selected_machine; std::shared_ptr m_cloud_agent; + OrcaCloudServiceAgent* m_orca_cloud = nullptr; // == m_cloud_agent.get() when the Orca provider is active + + // MOCK: OrcaCloud does not yet relay the printer's info.get_version reply, so + // synthesize it and feed it through on_message_fn (same sink as real report + // messages). Delete once the backend answers info.get_version. + void deliver_mock_get_version(const std::string& dev_id); // Callbacks OnMsgArrivedFn on_ssdp_msg_fn; From 84e929ea2405d49a5ec3d86aa18f1dc178fcc611 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 1 Sep 2026 18:02:53 +0800 Subject: [PATCH 03/24] feat: generic camera stream support for http snapshot and rtsp --- CMakeLists.txt | 12 +- deps/FFMPEG/FFMPEG.cmake | 4 +- src/slic3r/CMakeLists.txt | 13 +- src/slic3r/GUI/AVVideoDecoder.cpp | 27 ++ src/slic3r/GUI/AVVideoDecoder.hpp | 2 + src/slic3r/GUI/CameraPopup.cpp | 60 ---- src/slic3r/GUI/CameraPopup.hpp | 9 - src/slic3r/GUI/DeviceManager.cpp | 1 - src/slic3r/GUI/DeviceManager.hpp | 2 +- src/slic3r/GUI/IMediaController.hpp | 35 +++ src/slic3r/GUI/MediaPlayCtrl.cpp | 110 ++++++- src/slic3r/GUI/MediaPlayCtrl.h | 11 + src/slic3r/GUI/StatusPanel.cpp | 130 +++------ src/slic3r/GUI/StatusPanel.hpp | 12 +- src/slic3r/GUI/WebMediaController.cpp | 83 ++++++ src/slic3r/GUI/WebMediaController.hpp | 36 +++ src/slic3r/GUI/wxMediaCtrl3.cpp | 274 ++++++++++++------ src/slic3r/GUI/wxMediaCtrl3.h | 3 + src/slic3r/Utils/IPrinterAgent.hpp | 22 ++ src/slic3r/Utils/MoonrakerPrinterAgent.cpp | 91 +++++- src/slic3r/Utils/MoonrakerPrinterAgent.hpp | 19 +- src/slic3r/Utils/NetworkAgent.cpp | 14 + src/slic3r/Utils/NetworkAgent.hpp | 2 + src/slic3r/Utils/SnapmakerPrinterAgent.cpp | 235 +++++++-------- src/slic3r/Utils/SnapmakerPrinterAgent.hpp | 3 + .../PrinterAgentPluginCapability.cpp | 10 + .../PrinterAgentPluginCapability.hpp | 2 + ...PrinterAgentPluginCapabilityTrampoline.hpp | 16 + 28 files changed, 839 insertions(+), 399 deletions(-) create mode 100644 src/slic3r/GUI/IMediaController.hpp create mode 100644 src/slic3r/GUI/WebMediaController.cpp create mode 100644 src/slic3r/GUI/WebMediaController.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 4f5ccbeb44..8b954ef753 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1067,6 +1067,7 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) ${CMAKE_PREFIX_PATH}/bin/occt/TKXDESTEP.dll ${CMAKE_PREFIX_PATH}/bin/occt/TKXSBase.dll ${CMAKE_PREFIX_PATH}/bin/freetype.dll + ${CMAKE_PREFIX_PATH}/bin/avformat-61.dll ${CMAKE_PREFIX_PATH}/bin/avcodec-61.dll ${CMAKE_PREFIX_PATH}/bin/swresample-5.dll ${CMAKE_PREFIX_PATH}/bin/swscale-8.dll @@ -1106,6 +1107,7 @@ function(orcaslicer_copy_dlls target config postfix output_dlls) ${_out_dir}/TKXSBase.dll ${_out_dir}/freetype.dll + ${_out_dir}/avformat-61.dll ${_out_dir}/avcodec-61.dll ${_out_dir}/swresample-5.dll ${_out_dir}/swscale-8.dll @@ -1128,7 +1130,10 @@ function(orcaslicer_copy_sos target config postfix output_sos) set(_out_dir "${CMAKE_CURRENT_BINARY_DIR}") endif () - file(COPY ${CMAKE_PREFIX_PATH}/lib/libavcodec.so + file(COPY ${CMAKE_PREFIX_PATH}/lib/libavformat.so + ${CMAKE_PREFIX_PATH}/lib/libavformat.so.61 + ${CMAKE_PREFIX_PATH}/lib/libavformat.so.61.1.100 + ${CMAKE_PREFIX_PATH}/lib/libavcodec.so ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61 ${CMAKE_PREFIX_PATH}/lib/libavcodec.so.61.3.100 ${CMAKE_PREFIX_PATH}/lib/libavutil.so @@ -1143,6 +1148,9 @@ function(orcaslicer_copy_sos target config postfix output_sos) DESTINATION ${_out_dir}) set(${output_sos} + ${_out_dir}/libavformat.so + ${_out_dir}/libavformat.so.61 + ${_out_dir}/libavformat.so.61.1.100 ${_out_dir}/libavcodec.so ${_out_dir}/libavcodec.so.61 ${_out_dir}/libavcodec.so.61.3.100 @@ -1271,6 +1279,8 @@ endif () if (CMAKE_SYSTEM_NAME STREQUAL "Linux") set(LIBRARY_FILES + ${LIBDIR_BIN}/libavformat.so.61 + ${LIBDIR_BIN}/libavformat.so.61.1.100 ${LIBDIR_BIN}/libavcodec.so.61 ${LIBDIR_BIN}/libavcodec.so.61.3.100 ${LIBDIR_BIN}/libavutil.so.59 diff --git a/deps/FFMPEG/FFMPEG.cmake b/deps/FFMPEG/FFMPEG.cmake index 38e952bbdd..3c582f0682 100644 --- a/deps/FFMPEG/FFMPEG.cmake +++ b/deps/FFMPEG/FFMPEG.cmake @@ -69,14 +69,14 @@ else () --disable-filters --enable-filter=*null*,afade,*fifo,*format,*resample,aeval,allrgb,allyuv,atempo,pan,*bars,color,*key,crop,draw*,eq*,framerate,*_qsv,*_vaapi,*v4l2*,hw*,scale,volume,test* --disable-protocols - --enable-protocol=file,fd,pipe,rtp,udp + --enable-protocol=file,fd,pipe,rtp,tcp,udp --disable-muxers --enable-muxer=rtp --disable-encoders --disable-decoders --enable-decoder=*aac*,h264*,mp3*,mjpeg,rv* --disable-demuxers - --enable-demuxer=h264,mp3,mov + --enable-demuxer=h264,mp3,mov,rtsp,sdp --disable-zlib --disable-avdevice BUILD_IN_SOURCE ON diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 140b1cec39..8308161a7b 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -222,6 +222,7 @@ set(SLIC3R_GUI_SOURCES GUI/GLToolbar.hpp GUI/ImageDPIFrame.cpp GUI/ImageDPIFrame.hpp + GUI/IMediaController.hpp GUI/GUI_App.cpp GUI/GUI_App.hpp GUI/GUI_AuxiliaryList.cpp @@ -530,6 +531,8 @@ set(SLIC3R_GUI_SOURCES GUI/WebUserLoginDialog.hpp GUI/WebViewDialog.cpp GUI/WebViewDialog.hpp + GUI/WebMediaController.hpp + GUI/WebMediaController.cpp GUI/Widgets/AMSControl.cpp GUI/Widgets/AMSControl.hpp GUI/Widgets/AMSItem.cpp @@ -906,14 +909,15 @@ endif () if (APPLE) # Static FFmpeg from the deps install: nothing to bundle into the .app, - # no rpath/install_name handling. Order matters: avcodec -> swscale -> avutil. + # no rpath/install_name handling. Order matters: avformat -> avcodec -> swscale -> avutil. + find_library(LIBAVFORMAT_LIBRARY NAMES libavformat.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) find_library(LIBAVCODEC_LIBRARY NAMES libavcodec.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) find_library(LIBSWSCALE_LIBRARY NAMES libswscale.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) find_library(LIBAVUTIL_LIBRARY NAMES libavutil.a PATHS ${CMAKE_PREFIX_PATH}/lib NO_DEFAULT_PATH) - if (NOT LIBAVCODEC_LIBRARY OR NOT LIBSWSCALE_LIBRARY OR NOT LIBAVUTIL_LIBRARY) - message(FATAL_ERROR "Static FFmpeg (libavcodec.a/libswscale.a/libavutil.a) not found under ${CMAKE_PREFIX_PATH}/lib. Rebuild the deps — FFMPEG builds static-only on macOS.") + if (NOT LIBAVFORMAT_LIBRARY OR NOT LIBAVCODEC_LIBRARY OR NOT LIBSWSCALE_LIBRARY OR NOT LIBAVUTIL_LIBRARY) + message(FATAL_ERROR "Static FFmpeg (libavformat.a/libavcodec.a/libswscale.a/libavutil.a) not found under ${CMAKE_PREFIX_PATH}/lib. Rebuild the deps — FFMPEG builds static-only on macOS.") endif () - target_link_libraries(libslic3r_gui ${LIBAVCODEC_LIBRARY} ${LIBSWSCALE_LIBRARY} ${LIBAVUTIL_LIBRARY}) + target_link_libraries(libslic3r_gui ${LIBAVFORMAT_LIBRARY} ${LIBAVCODEC_LIBRARY} ${LIBSWSCALE_LIBRARY} ${LIBAVUTIL_LIBRARY}) target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_PREFIX_PATH}/include) elseif (WIN32) # Prebuilt shared FFmpeg from the deps install. Windows has no pkg-config, @@ -929,6 +933,7 @@ elseif (WIN32) target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_PREFIX_PATH}/include) else () pkg_check_modules(LIBAV REQUIRED IMPORTED_TARGET + libavformat libavcodec libswscale libavutil diff --git a/src/slic3r/GUI/AVVideoDecoder.cpp b/src/slic3r/GUI/AVVideoDecoder.cpp index d7b8432bd3..76965fedef 100644 --- a/src/slic3r/GUI/AVVideoDecoder.cpp +++ b/src/slic3r/GUI/AVVideoDecoder.cpp @@ -45,6 +45,25 @@ int AVVideoDecoder::open(Bambu_StreamInfo const &info) return 0; } +int AVVideoDecoder::open(AVCodecParameters const ¶meters) +{ + if (avcodec_parameters_to_context(codec_ctx_, ¶meters) < 0) + return -1; + + auto codec = avcodec_find_decoder(codec_ctx_->codec_id); + if (codec == nullptr) { + fprintf(stderr, "AVVideoDecoder: unsupported codec!\n"); + return -1; + } + if (avcodec_open2(codec_ctx_, codec, nullptr) < 0) { + fprintf(stderr, "AVVideoDecoder: could not open codec\n"); + return -1; + } + + frame_ = av_frame_alloc(); + return frame_ == nullptr ? -1 : 0; +} + int AVVideoDecoder::decode(const Bambu_Sample &sample) { int ret = -1; @@ -71,6 +90,14 @@ int AVVideoDecoder::decode(const Bambu_Sample &sample) return ret; } +int AVVideoDecoder::decode(const AVPacket &packet) +{ + int ret = avcodec_send_packet(codec_ctx_, &packet); + if (ret == 0) + got_frame_ = avcodec_receive_frame(codec_ctx_, frame_) == 0; + return ret; +} + int AVVideoDecoder::flush() { int ret = avcodec_send_packet(codec_ctx_, nullptr); diff --git a/src/slic3r/GUI/AVVideoDecoder.hpp b/src/slic3r/GUI/AVVideoDecoder.hpp index 4111e860a2..4277734e08 100644 --- a/src/slic3r/GUI/AVVideoDecoder.hpp +++ b/src/slic3r/GUI/AVVideoDecoder.hpp @@ -23,8 +23,10 @@ public: public: int open(Bambu_StreamInfo const &info); + int open(AVCodecParameters const ¶meters); int decode(Bambu_Sample const &sample); + int decode(AVPacket const &packet); int flush(); diff --git a/src/slic3r/GUI/CameraPopup.cpp b/src/slic3r/GUI/CameraPopup.cpp index 0ee5694a34..f0b71cb7bf 100644 --- a/src/slic3r/GUI/CameraPopup.cpp +++ b/src/slic3r/GUI/CameraPopup.cpp @@ -28,7 +28,6 @@ wxEND_EVENT_TABLE() wxDEFINE_EVENT(EVT_VCAMERA_SWITCH, wxMouseEvent); wxDEFINE_EVENT(EVT_SDCARD_ABSENT_HINT, wxCommandEvent); -wxDEFINE_EVENT(EVT_CAM_SOURCE_CHANGE, wxCommandEvent); #define CAMERAPOPUP_CLICK_INTERVAL 20 @@ -102,34 +101,6 @@ CameraPopup::CameraPopup(wxWindow *parent) top_sizer->Add(0, 0, wxALL, 0); } - // Orca: custom IP camera source — lets the user point Live Video at any camera URL (Orca feature; not in the reference) - m_custom_camera_input_confirm = new Button(m_panel, _L("Enable")); - m_custom_camera_input_confirm->SetBackgroundColor(wxColour(38, 166, 154)); - m_custom_camera_input_confirm->SetBorderColor(wxColour(38, 166, 154)); - m_custom_camera_input_confirm->SetTextColor(wxColour(0xFFFFFE)); - m_custom_camera_input_confirm->SetFont(Label::Body_14); - m_custom_camera_input_confirm->SetMinSize(wxSize(FromDIP(90), FromDIP(30))); - m_custom_camera_input_confirm->SetPosition(wxDefaultPosition); - m_custom_camera_input_confirm->SetCornerRadius(FromDIP(12)); - m_custom_camera_input = new TextInput(m_panel, wxEmptyString, wxEmptyString, wxEmptyString, wxDefaultPosition, wxDefaultSize); - m_custom_camera_input->GetTextCtrl()->SetHint(_L("Hostname or IP")); - m_custom_camera_input->GetTextCtrl()->SetFont(Label::Body_14); - m_custom_camera_hint = new wxStaticText(m_panel, wxID_ANY, _L("Custom camera source")); - m_custom_camera_hint->Wrap(-1); - m_custom_camera_hint->SetFont(Label::Head_14); - m_custom_camera_hint->SetForegroundColour(TEXT_COL); - - m_custom_camera_input_confirm->Bind(wxEVT_BUTTON, &CameraPopup::on_camera_source_changed, this); - - if (!wxGetApp().app_config->get("camera", "custom_source").empty()) { - m_custom_camera_input->GetTextCtrl()->SetValue(wxGetApp().app_config->get("camera", "custom_source")); - set_custom_cam_button_state(wxGetApp().app_config->get("camera", "enable_custom_source") == "true"); - } - - top_sizer->Add(m_custom_camera_hint, 0, wxALIGN_CENTER_VERTICAL | wxALIGN_LEFT | wxALL, FromDIP(5)); - top_sizer->Add(0, 0, wxALL, 0); - top_sizer->Add(m_custom_camera_input, 2, wxALIGN_CENTER_VERTICAL | wxEXPAND | wxALL, FromDIP(5)); - top_sizer->Add(m_custom_camera_input_confirm, 1, wxALIGN_CENTER_VERTICAL | wxALIGN_RIGHT | wxALL, FromDIP(5)); main_sizer->Add(top_sizer, 0, wxALL, FromDIP(10)); auto url = wxString(L"https://www.orcaslicer.com/wiki/"); // Orca: neutral wiki link (vendor URL removed) @@ -184,37 +155,6 @@ void CameraPopup::sdcard_absent_hint() GetEventHandler()->ProcessEvent(evt); } -void CameraPopup::on_camera_source_changed(wxCommandEvent &event) -{ - if (m_obj && !m_custom_camera_input->GetTextCtrl()->IsEmpty()) { - handle_camera_source_change(); - } -} - -void CameraPopup::handle_camera_source_change() -{ - m_custom_camera_enabled = !m_custom_camera_enabled; - - set_custom_cam_button_state(m_custom_camera_enabled); - - wxGetApp().app_config->set("camera", "custom_source", m_custom_camera_input->GetTextCtrl()->GetValue().ToStdString()); - wxGetApp().app_config->set("camera", "enable_custom_source", m_custom_camera_enabled); - - wxCommandEvent evt(EVT_CAM_SOURCE_CHANGE); - evt.SetEventObject(this); - GetEventHandler()->ProcessEvent(evt); -} - -void CameraPopup::set_custom_cam_button_state(bool state) -{ - m_custom_camera_enabled = state; - auto stateColour = state ? wxColour(170, 0, 0) : wxColour(38, 166, 154); - auto stateText = state ? "Disable" : "Enable"; - m_custom_camera_input_confirm->SetBackgroundColor(stateColour); - m_custom_camera_input_confirm->SetBorderColor(stateColour); - m_custom_camera_input_confirm->SetLabel(_L(stateText)); -} - void CameraPopup::on_switch_recording(wxCommandEvent& event) { if (!m_obj) return; diff --git a/src/slic3r/GUI/CameraPopup.hpp b/src/slic3r/GUI/CameraPopup.hpp index dbdb81a9cb..5295407e8f 100644 --- a/src/slic3r/GUI/CameraPopup.hpp +++ b/src/slic3r/GUI/CameraPopup.hpp @@ -15,14 +15,12 @@ #include "Widgets/SwitchButton.hpp" #include "Widgets/RadioBox.hpp" #include "Widgets/PopupWindow.hpp" -#include "Widgets/TextInput.hpp" namespace Slic3r { namespace GUI { wxDECLARE_EVENT(EVT_VCAMERA_SWITCH, wxMouseEvent); wxDECLARE_EVENT(EVT_SDCARD_ABSENT_HINT, wxCommandEvent); -wxDECLARE_EVENT(EVT_CAM_SOURCE_CHANGE, wxCommandEvent); class CameraPopup : public PopupWindow { @@ -53,9 +51,6 @@ protected: void on_switch_recording(wxCommandEvent& event); void on_set_resolution(); void sdcard_absent_hint(); - void on_camera_source_changed(wxCommandEvent& event); - void handle_camera_source_change(); - void set_custom_cam_button_state(bool state); wxWindow * create_item_radiobox(wxString title, wxWindow *parent, wxString tooltip, int padding_left); void select_curr_radiobox(int btn_idx); @@ -74,10 +69,6 @@ private: SwitchButton* m_switch_vcamera; wxStaticText* m_text_liveview_retry; SwitchButton* m_switch_liveview_retry; - wxStaticText* m_custom_camera_hint; - TextInput* m_custom_camera_input; - Button* m_custom_camera_input_confirm; - bool m_custom_camera_enabled{ false }; wxStaticText* m_text_resolution; wxWindow* m_resolution_options[RESOLUTION_OPTIONS_NUM]; wxScrolledWindow *m_panel; diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index 06f574c8a2..741ed788fc 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -1338,7 +1338,6 @@ int MachineObject::command_get_access_code() { return this->publish_json(j); } - int MachineObject::command_request_push_all(bool request_now) { auto curr_time = std::chrono::system_clock::now(); diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index 6df11d10f5..8788c2288d 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -749,6 +749,7 @@ public: int command_set_printer_nozzle(std::string nozzle_type, float diameter); int command_set_printer_nozzle2(int id, std::string nozzle_type, float diameter); int command_get_access_code(); + int command_start_camera(); int command_ack_proceed(json& proceed); int command_purification_disable(); int command_dont_remind_next_time(json& mqtt_guard_json); @@ -797,7 +798,6 @@ public: int command_ams_select_tray(std::string tray_id); int command_ams_refresh_rfid(std::string tray_id); int command_ams_refresh_rfid2(int ams_id, int slot_id); - int command_start_camera(); int command_ams_control(std::string action); int command_ams_drying_stop(); int command_start_extrusion_cali(int tray_index, int nozzle_temp, int bed_temp, float max_volumetric_speed, std::string setting_id = ""); diff --git a/src/slic3r/GUI/IMediaController.hpp b/src/slic3r/GUI/IMediaController.hpp new file mode 100644 index 0000000000..982157e5bc --- /dev/null +++ b/src/slic3r/GUI/IMediaController.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +#include + +namespace Slic3r { namespace GUI { + +class IMediaController +{ +public: + virtual void Load(wxURI url) = 0; + + // The default keeps existing media controllers unaware of camera-specific modes. + virtual void Load(wxURI url, CameraStreamMode mode) + { + (void) mode; + Load(url); + } + + virtual void Play() = 0; + + virtual void Stop() = 0; + + virtual wxMediaState GetState() { return wxMediaState{}; } + + virtual int GetLastError() const { return {}; }; + + virtual wxSize GetVideoSize() const { return {}; }; + +private: +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/MediaPlayCtrl.cpp b/src/slic3r/GUI/MediaPlayCtrl.cpp index 707c7f853c..77a183f090 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.cpp +++ b/src/slic3r/GUI/MediaPlayCtrl.cpp @@ -146,8 +146,48 @@ MediaPlayCtrl::~MediaPlayCtrl() BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": " << this; } +void MediaPlayCtrl::SetWebMediaController(IMediaController *ctrl) +{ + m_web_ctrl = ctrl; +} + +CameraStreamMode MediaPlayCtrl::current_mode() const +{ + auto agent = wxGetApp().getAgent(); + return agent ? agent->get_camera_stream_mode() : CameraStreamMode::none; +} + void MediaPlayCtrl::SetMachineObject(MachineObject* obj) { + switch (current_mode()) { + case CameraStreamMode::http: + case CameraStreamMode::http_snapshot: + case CameraStreamMode::rtsp: { + std::string machine = obj ? obj->get_dev_id() : ""; + auto agent = wxGetApp().getAgent(); + std::string url = agent ? agent->get_local_camera_stream_url() : ""; + m_camera_exists = !url.empty(); + Enable(obj && m_camera_exists); + bool changed = machine != m_machine || url != m_agent_camera_url; + m_machine = machine; + m_agent_camera_url = url; + m_url = from_u8(url); + if (!changed) { + if (m_last_state == MEDIASTATE_IDLE && IsEnabled() && !m_web_user_stopped) + Play(); + return; + } + m_web_user_stopped = false; + if (m_last_state != MEDIASTATE_IDLE) + Stop(" "); + if (IsEnabled()) + Play(); + return; + } + default: + break; + } + std::string machine = obj ? obj->get_dev_id() : ""; if (obj) { m_camera_exists = obj->has_ipcam; @@ -249,6 +289,42 @@ void refresh_agora_url(char const* device, char const* dev_ver, char const* chan void MediaPlayCtrl::Play() { + switch (current_mode()) { + case CameraStreamMode::http: + case CameraStreamMode::http_snapshot: + if (!m_next_retry.IsValid() || wxDateTime::Now() < m_next_retry) + return; + if (!IsShownOnScreen()) return; + if (m_last_state != MEDIASTATE_IDLE) return; + if (m_machine.empty() || !IsEnabled() || !m_camera_exists || m_url.IsEmpty() || !m_web_ctrl) { + Stop(_L("Please confirm if the printer is connected.")); + return; + } + if (auto agent = wxGetApp().getAgent()) + agent->command_start_camera(m_machine); + m_button_play->SetIcon("media_stop"); + m_web_ctrl->Load(wxURI(m_url), current_mode()); + m_web_ctrl->Play(); + m_last_state = wxMEDIASTATE_PLAYING; + SetStatus(_L("Playing..."), false); + return; + case CameraStreamMode::rtsp: + if (m_next_retry.IsValid() && wxDateTime::Now() < m_next_retry) + return; + if (!IsShownOnScreen()) return; + if (m_last_state != MEDIASTATE_IDLE) return; + m_failed_code = 0; + if (m_machine.empty() || !IsEnabled() || !m_camera_exists || m_url.IsEmpty()) { + Stop(_L("Please confirm if the printer is connected.")); + return; + } + m_button_play->SetIcon("media_stop"); + load(); + return; + default: + break; + } + if (!m_next_retry.IsValid() || wxDateTime::Now() < m_next_retry) return; if (!IsShownOnScreen()) @@ -376,8 +452,38 @@ void MediaPlayCtrl::Play() void start_ping_test(); +void MediaPlayCtrl::StopWebStream() +{ + if (m_last_state == MEDIASTATE_IDLE) + return; + if (m_web_ctrl) + m_web_ctrl->Stop(); + m_button_play->SetIcon("media_play"); + m_last_state = MEDIASTATE_IDLE; + SetStatus(_L("Video Stopped."), false); +} + void MediaPlayCtrl::Stop(wxString const &msg, wxString const &msg2) { + switch (current_mode()) { + case CameraStreamMode::http: + case CameraStreamMode::http_snapshot: + if (m_last_state != MEDIASTATE_IDLE) { + if (m_web_ctrl) m_web_ctrl->Stop(); + m_button_play->SetIcon("media_play"); + m_last_state = MEDIASTATE_IDLE; + if (!msg.IsEmpty()) + SetStatus(msg); + else + SetStatus(_L("Video Stopped."), false); + } else if (!msg.IsEmpty()) { + SetStatus(msg, false); + } + return; + default: + break; + } + int last_state = m_last_state; if (m_last_state != MEDIASTATE_IDLE) { @@ -454,10 +560,12 @@ void MediaPlayCtrl::TogglePlay() BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl::TogglePlay"; if (m_last_state != MEDIASTATE_IDLE) { m_next_retry = wxDateTime(); + m_web_user_stopped = true; Stop(); } else { m_failed_retry = 0; m_user_triggered = true; + m_web_user_stopped = false; if (m_last_user_play + wxTimeSpan::Minutes(5) < wxDateTime::Now()) { m_last_failed_codes.clear(); m_last_user_play = wxDateTime::Now(); @@ -661,7 +769,7 @@ void MediaPlayCtrl::load() { m_last_state = MEDIASTATE_LOADING; SetStatus(_L("Loading...")); - if (wxGetApp().app_config->get("internal_developer_mode") == "true") { + if (current_mode() != CameraStreamMode::rtsp) { std::string file_h264 = data_dir() + "/video.h264"; std::string file_info = data_dir() + "/video.info"; BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl dump video to " << file_h264; diff --git a/src/slic3r/GUI/MediaPlayCtrl.h b/src/slic3r/GUI/MediaPlayCtrl.h index 0a01daefc9..e8ad0ee82c 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.h +++ b/src/slic3r/GUI/MediaPlayCtrl.h @@ -9,6 +9,8 @@ #define MediaPlayCtrl_h #include "wxMediaCtrl3.h" +#include "IMediaController.hpp" +#include "slic3r/Utils/IPrinterAgent.hpp" #include @@ -36,6 +38,10 @@ public: void SetMachineObject(MachineObject * obj); + void SetWebMediaController(IMediaController *ctrl); + + void StopWebStream(); + bool IsStreaming() const; void ToggleStream(); @@ -66,6 +72,8 @@ private: static bool get_stream_url(std::string *url = nullptr); + CameraStreamMode current_mode() const; + private: static inline const wxMediaState MEDIASTATE_IDLE = static_cast(3); static inline const wxMediaState MEDIASTATE_INITIALIZING = static_cast(4); @@ -76,6 +84,9 @@ private: std::shared_ptr m_token = std::make_shared(0); wxMediaCtrl3 * m_media_ctrl; + IMediaController * m_web_ctrl = nullptr; + std::string m_agent_camera_url; + bool m_web_user_stopped = false; wxMediaState m_last_state = MEDIASTATE_IDLE; std::string m_machine; int m_lan_proto = 0; diff --git a/src/slic3r/GUI/StatusPanel.cpp b/src/slic3r/GUI/StatusPanel.cpp index 97cd1c6e2e..b00dee3dca 100644 --- a/src/slic3r/GUI/StatusPanel.cpp +++ b/src/slic3r/GUI/StatusPanel.cpp @@ -1494,27 +1494,26 @@ wxBoxSizer *StatusBasePanel::create_monitoring_page() m_setting_button->SetMinSize(wxSize(FromDIP(38), FromDIP(24))); m_setting_button->SetBackgroundColour(STATUS_TITLE_BG); - m_camera_switch_button = new wxStaticBitmap(m_panel_monitoring_title, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize(FromDIP(38), FromDIP(24)), 0); - m_camera_switch_button->SetMinSize(wxSize(FromDIP(38), FromDIP(24))); - m_camera_switch_button->SetBackgroundColour(STATUS_TITLE_BG); - m_camera_switch_button->SetBitmap(m_bitmap_switch_camera.bmp()); - m_camera_switch_button->Bind(wxEVT_LEFT_DOWN, &StatusBasePanel::on_camera_switch_toggled, this); - m_camera_switch_button->Bind(wxEVT_RIGHT_DOWN, [this](auto& e) { - const std::string js_request_pip = R"( - document.querySelector('video').requestPictureInPicture(); - )"; - m_custom_camera_view->RunScript(js_request_pip); - }); - m_camera_switch_button->Hide(); + // m_camera_switch_button = new wxStaticBitmap(m_panel_monitoring_title, wxID_ANY, wxNullBitmap, wxDefaultPosition, wxSize(FromDIP(38), FromDIP(24)), 0); + // m_camera_switch_button->SetMinSize(wxSize(FromDIP(38), FromDIP(24))); + // m_camera_switch_button->SetBackgroundColour(STATUS_TITLE_BG); + // m_camera_switch_button->SetBitmap(m_bitmap_switch_camera.bmp()); + // m_camera_switch_button->Bind(wxEVT_RIGHT_DOWN, [this](auto& e) { + // const std::string js_request_pip = R"( + // document.querySelector('video').requestPictureInPicture(); + // )"; + // m_custom_camera_view->RunScript(js_request_pip); + // }); + // m_camera_switch_button->Hide(); m_bitmap_sdcard_img->SetToolTip(_L("Storage")); m_bitmap_timelapse_img->SetToolTip(_L("Timelapse")); m_bitmap_recording_img->SetToolTip(_L("Video")); m_bitmap_vcamera_img->SetToolTip(_L("Go Live")); m_setting_button->SetToolTip(_L("Camera Setting")); - m_camera_switch_button->SetToolTip(_L("Switch Camera View")); + // m_camera_switch_button->SetToolTip(_L("Switch Camera View")); - bSizer_monitoring_title->Add(m_camera_switch_button, 0, wxALIGN_CENTER_VERTICAL | wxALL, FromDIP(5)); + // bSizer_monitoring_title->Add(m_camera_switch_button, 0, wxALIGN_CENTER_VERTICAL | wxALL, FromDIP(5)); bSizer_monitoring_title->Add(m_bitmap_sdcard_img, 0, wxALIGN_CENTER_VERTICAL | wxALL, FromDIP(5)); bSizer_monitoring_title->Add(m_bitmap_timelapse_img, 0, wxALIGN_CENTER_VERTICAL | wxALL, FromDIP(5)); bSizer_monitoring_title->Add(m_bitmap_recording_img, 0, wxALIGN_CENTER_VERTICAL | wxALL, FromDIP(5)); @@ -1537,19 +1536,18 @@ wxBoxSizer *StatusBasePanel::create_monitoring_page() m_custom_camera_view = WebView::CreateWebView(this, wxEmptyString); m_custom_camera_view->EnableContextMenu(false); Bind(wxEVT_WEBVIEW_NAVIGATING, &StatusBasePanel::on_webview_navigating, this, m_custom_camera_view->GetId()); + m_web_media_controller = std::make_unique(m_custom_camera_view); m_media_play_ctrl = new MediaPlayCtrl(this, m_media_ctrl, wxDefaultPosition, wxSize(-1, FromDIP(40))); + m_media_play_ctrl->SetWebMediaController(m_web_media_controller.get()); m_custom_camera_view->Hide(); - m_custom_camera_view->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, [this](wxWebViewEvent& evt) { - if (evt.GetString() == "leavepictureinpicture") { - // When leaving PiP, video gets paused in some cases and toggling play - // programmatically does not work. - m_custom_camera_view->Reload(); - } - else if (evt.GetString() == "enterpictureinpicture") { - toggle_builtin_camera(); - } - }); + // m_custom_camera_view->Bind(wxEVT_WEBVIEW_SCRIPT_MESSAGE_RECEIVED, [this](wxWebViewEvent& evt) { + // if (evt.GetString() == "leavepictureinpicture") { + // // When leaving PiP, video gets paused in some cases and toggling play + // // programmatically does not work. + // m_custom_camera_view->Reload(); + // } + // }); sizer->Add(m_media_ctrl, 1, wxEXPAND | wxALL, 0); sizer->Add(m_custom_camera_view, 1, wxEXPAND | wxALL, 0); @@ -1559,10 +1557,6 @@ wxBoxSizer *StatusBasePanel::create_monitoring_page() // // sizer->Add(media_ctrl_panel, 1, wxEXPAND | wxALL, 1); - if (wxGetApp().app_config->get("camera", "enable_custom_source") == "true") { - handle_camera_source_change(); - } - return sizer; } @@ -2312,25 +2306,21 @@ void StatusPanel::update_camera_state(MachineObject* obj) { if (!obj) return; - const bool has_printer_webcam = !obj->webcam_stream_url.empty(); + auto agent = wxGetApp().getAgent(); + const auto camera_mode = agent ? agent->get_camera_stream_mode() : CameraStreamMode::none; + const bool has_printer_webcam = camera_mode == CameraStreamMode::http || camera_mode == CameraStreamMode::http_snapshot; 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(); + //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. + // why: do not reload the WebView URL per tick, or redirects can cause a reload loop. + // MediaPlayCtrl (via its WebMediaController) owns loading/playing the stream itself. 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(); + } else if (m_custom_camera_view->IsShown()) { + m_custom_camera_view->Hide(); + m_media_ctrl->Show(); + m_media_play_ctrl->StopWebStream(); } //sdcard @@ -5056,7 +5046,6 @@ void StatusPanel::on_camera_enter(wxMouseEvent& event) } sdcard_hint_dlg->on_show(); }); - m_camera_popup->Bind(EVT_CAM_SOURCE_CHANGE, &StatusPanel::on_camera_source_change, this); wxWindow* ctrl = (wxWindow*)event.GetEventObject(); wxPoint pos = ctrl->ClientToScreen(wxPoint(0, 0)); wxSize sz = ctrl->GetSize(); @@ -5068,54 +5057,6 @@ void StatusPanel::on_camera_enter(wxMouseEvent& event) } } -void StatusBasePanel::on_camera_source_change(wxCommandEvent& event) -{ - handle_camera_source_change(); -} - -void StatusBasePanel::handle_camera_source_change() -{ - const auto new_cam_url = wxGetApp().app_config->get("camera", "custom_source"); - const auto enabled = wxGetApp().app_config->get("camera", "enable_custom_source") == "true"; - - if (enabled && !new_cam_url.empty()) { - m_custom_camera_view->LoadURL(new_cam_url); - toggle_custom_camera(); - m_camera_switch_button->Show(); - } else { - toggle_builtin_camera(); - m_camera_switch_button->Hide(); - } -} - -void StatusBasePanel::toggle_builtin_camera() -{ - m_custom_camera_view->Hide(); - m_media_ctrl->Show(); - m_media_play_ctrl->Show(); -} - -void StatusBasePanel::toggle_custom_camera() -{ - const auto enabled = wxGetApp().app_config->get("camera", "enable_custom_source") == "true"; - - if (enabled) { - m_custom_camera_view->Show(); - m_media_ctrl->Hide(); - m_media_play_ctrl->Hide(); - } -} - -void StatusBasePanel::on_camera_switch_toggled(wxMouseEvent& event) -{ - const auto enabled = wxGetApp().app_config->get("camera", "enable_custom_source") == "true"; - if (enabled && m_media_ctrl->IsShown()) { - toggle_custom_camera(); - } else { - toggle_builtin_camera(); - } -} - void StatusBasePanel::remove_controls() { const std::string js_cleanup_video_element = R"( @@ -5268,9 +5209,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(); + if (m_custom_camera_view->IsShown()) { + m_custom_camera_view->Hide(); + m_media_ctrl->Show(); + m_media_play_ctrl->StopWebStream(); } obj = nullptr; last_subtask = nullptr; diff --git a/src/slic3r/GUI/StatusPanel.hpp b/src/slic3r/GUI/StatusPanel.hpp index ec863cb474..1478feef8b 100644 --- a/src/slic3r/GUI/StatusPanel.hpp +++ b/src/slic3r/GUI/StatusPanel.hpp @@ -15,7 +15,9 @@ #include #include #include +#include #include "MediaPlayCtrl.h" +#include "WebMediaController.hpp" #include "AMSSetting.hpp" #include "Calibration.hpp" #include "CalibrationWizardPage.hpp" @@ -440,7 +442,7 @@ protected: wxStaticBitmap *m_bitmap_sdcard_img; wxStaticBitmap *m_bitmap_static_use_time; wxStaticBitmap *m_bitmap_static_use_weight; - wxStaticBitmap* m_camera_switch_button; + // wxStaticBitmap* m_camera_switch_button; wxMediaCtrl3 * m_media_ctrl; @@ -462,6 +464,8 @@ protected: ScalableButton *m_button_abort; Button * m_button_clean; wxWebView * m_custom_camera_view{nullptr}; + std::unique_ptr m_web_media_controller; + wxSimplebook* m_extruder_book; std::vector m_extruderImage; @@ -577,13 +581,8 @@ protected: virtual void on_axis_ctrl_e_up_10(wxCommandEvent &event) { event.Skip(); } virtual void on_axis_ctrl_e_down_10(wxCommandEvent &event) { event.Skip(); } virtual void on_nozzle_selected(wxCommandEvent &event) { event.Skip(); } - void on_camera_source_change(wxCommandEvent& event); - void handle_camera_source_change(); void remove_controls(); void on_webview_navigating(wxWebViewEvent& evt); - void on_camera_switch_toggled(wxMouseEvent& event); - void toggle_custom_camera(); - void toggle_builtin_camera(); public: StatusBasePanel(wxWindow * parent, @@ -664,7 +663,6 @@ 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/GUI/WebMediaController.cpp b/src/slic3r/GUI/WebMediaController.cpp new file mode 100644 index 0000000000..13d820c98f --- /dev/null +++ b/src/slic3r/GUI/WebMediaController.cpp @@ -0,0 +1,83 @@ +#include "WebMediaController.hpp" + +#include + +namespace Slic3r { namespace GUI { + +WebMediaController::WebMediaController(wxWebView *webview) : m_webview(webview) +{ + if (!m_webview) + return; + + m_webview->SetBackgroundColour(*wxBLACK); + m_webview->SetPage( + "", + ""); +} + +void WebMediaController::Load(wxURI url) +{ + Load(url, CameraStreamMode::http); +} + +void WebMediaController::Load(wxURI url, CameraStreamMode mode) +{ + m_url = url.BuildURI().ToStdString(); + m_stream_mode = mode; +} + +void WebMediaController::Play() +{ + if (!m_webview) + return; + + wxString url = wxString::FromUTF8(m_url); + wxString html = ""; + } else { + html += " src=\"" + url + "\">"; + } + m_webview->SetPage(html, url); +} + +void WebMediaController::Stop() +{ + m_url.clear(); + if (m_webview) + m_webview->Stop(); +} + +// wxMediaState WebMediaController::GetState() +// { +// return wxMediaState{}; +// } + +// int WebMediaController::GetLastError() const +// { +// return 0; +// } + +// wxSize WebMediaController::GetVideoSize() const +// { +// return wxSize{}; +// } + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/WebMediaController.hpp b/src/slic3r/GUI/WebMediaController.hpp new file mode 100644 index 0000000000..cdbee33cc3 --- /dev/null +++ b/src/slic3r/GUI/WebMediaController.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include + +class wxWebView; + +namespace Slic3r { namespace GUI { + +class WebMediaController : public IMediaController +{ +public: + explicit WebMediaController(wxWebView *webview); + + void Load(wxURI url) override; + + void Load(wxURI url, CameraStreamMode mode) override; + + void Play() override; + + void Stop() override; + + // wxMediaState GetState() override; + + // int GetLastError() const override; + + // wxSize GetVideoSize() const override; + +private: + wxWebView * m_webview; + std::string m_url; + CameraStreamMode m_stream_mode = CameraStreamMode::http; +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index 098db68808..d0d53072d6 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -4,6 +4,9 @@ #include "libslic3r/Utils.hpp" #include #include +extern "C" { +#include +} #ifdef __WIN32__ #include #include @@ -185,6 +188,106 @@ void wxMediaCtrl3::bambu_log(void *ctx, int level, tchar const *msg2) BOOST_LOG_TRIVIAL(info) << msg.ToUTF8().data(); } +int wxMediaCtrl3::rtsp_interrupt_callback(void *opaque) +{ + auto *ctrl = static_cast(opaque); + std::lock_guard lock(ctrl->m_mutex); + return ctrl->m_url != ctrl->m_active_url; +} + +int wxMediaCtrl3::PlayRtsp(std::shared_ptr const &url, std::unique_lock &lock) +{ + if (avformat_network_init() < 0) + return 2; + + AVFormatContext *format_context = avformat_alloc_context(); + if (!format_context) { + avformat_network_deinit(); + return 2; + } + + format_context->interrupt_callback = {&wxMediaCtrl3::rtsp_interrupt_callback, this}; + m_active_url = url; + + auto finish = [&](int error) { + lock.unlock(); + avformat_close_input(&format_context); + avformat_network_deinit(); + lock.lock(); + m_active_url.reset(); + return error; + }; + + const std::string uri = url->BuildURI().ToUTF8().data(); + AVDictionary *options = nullptr; + av_dict_set(&options, "rtsp_transport", "tcp", 0); + lock.unlock(); + int error = avformat_open_input(&format_context, uri.c_str(), nullptr, &options); + av_dict_free(&options); + lock.lock(); + if (error < 0) + return finish(2); + + lock.unlock(); + error = avformat_find_stream_info(format_context, nullptr); + lock.lock(); + if (error < 0) + return finish(2); + + const int video_stream = av_find_best_stream(format_context, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); + if (video_stream < 0) + return finish(2); + + AVVideoDecoder decoder; + if (decoder.open(*format_context->streams[video_stream]->codecpar) < 0) + return finish(2); + + m_video_size = {format_context->streams[video_stream]->codecpar->width, + format_context->streams[video_stream]->codecpar->height}; + if (!m_video_size.IsFullySpecified() || m_video_size.x <= 0 || m_video_size.y <= 0) + return finish(2); + adjust_frame_size(m_frame_size, m_video_size, GetSize()); + NotifyStopped(); + + AVPacket *packet = av_packet_alloc(); + if (!packet) + return finish(2); + + while (m_url == url) { + lock.unlock(); + error = av_read_frame(format_context, packet); + lock.lock(); + if (m_url != url) + break; + if (error < 0) + break; + if (packet->stream_index == video_stream) { + const int decode_error = decoder.decode(*packet); + if (decode_error == 0) { + auto frame_size = m_frame_size; + lock.unlock(); +#ifdef _WIN32 + wxBitmap frame; + decoder.toWxBitmap(frame, frame_size); +#else + wxImage frame; + decoder.toWxImage(frame, frame_size); +#endif + lock.lock(); + if (m_url != url) + break; + if (frame.IsOk()) + m_frame = frame; + CallAfter([this] { Refresh(); }); + } + } + av_packet_unref(packet); + } + + av_packet_free(&packet); + return finish(m_url == url ? 2 : 1); +} + void wxMediaCtrl3::PlayThread() { using namespace std::chrono_literals; @@ -197,42 +300,21 @@ void wxMediaCtrl3::PlayThread() continue; if (!url->HasScheme()) break; - lk.unlock(); - Bambu_Tunnel tunnel = nullptr; - int error = Bambu_Create(&tunnel, m_url->BuildURI().ToUTF8()); - if (error == 0) { - Bambu_SetLogger(tunnel, &wxMediaCtrl3::bambu_log, this); - error = Bambu_Open(tunnel); - if (error == 0) - error = Bambu_would_block; - } - lk.lock(); - while (error == int(Bambu_would_block)) { - m_cond.wait_for(lk, 100ms); - if (m_url != url) { - error = 1; - break; + const wxString scheme = url->GetScheme(); + const bool generic_rtsp = scheme.CmpNoCase("rtsp") == 0 || scheme.CmpNoCase("rtsps") == 0; + int error = 0; + if (generic_rtsp) { + error = PlayRtsp(url, lk); + } else { + lk.unlock(); + Bambu_Tunnel tunnel = nullptr; + error = Bambu_Create(&tunnel, m_url->BuildURI().ToUTF8()); + if (error == 0) { + Bambu_SetLogger(tunnel, &wxMediaCtrl3::bambu_log, this); + error = Bambu_Open(tunnel); + if (error == 0) + error = Bambu_would_block; } - lk.unlock(); - error = Bambu_StartStream(tunnel, true); - lk.lock(); - } - Bambu_StreamInfo info; - if (error == 0) - error = Bambu_GetStreamInfo(tunnel, 0, &info); - AVVideoDecoder decoder; - int minFrameDuration = 0; - if (error == 0) { - decoder.open(info); - m_video_size = { info.format.video.width, info.format.video.height }; - adjust_frame_size(m_frame_size, m_video_size, GetSize()); - minFrameDuration = 800 / info.format.video.frame_rate; // 80% - NotifyStopped(); - } - Bambu_Sample sample; - while (error == 0) { - lk.unlock(); - error = Bambu_ReadSample(tunnel, &sample); lk.lock(); while (error == int(Bambu_would_block)) { m_cond.wait_for(lk, 100ms); @@ -240,59 +322,87 @@ void wxMediaCtrl3::PlayThread() error = 1; break; } + lk.unlock(); + error = Bambu_StartStream(tunnel, true); + lk.lock(); + } + Bambu_StreamInfo info; + if (error == 0) + error = Bambu_GetStreamInfo(tunnel, 0, &info); + AVVideoDecoder decoder; + int minFrameDuration = 0; + if (error == 0) { + decoder.open(info); + m_video_size = { info.format.video.width, info.format.video.height }; + adjust_frame_size(m_frame_size, m_video_size, GetSize()); + minFrameDuration = 800 / info.format.video.frame_rate; // 80% + NotifyStopped(); + } + Bambu_Sample sample; + while (error == 0) { lk.unlock(); error = Bambu_ReadSample(tunnel, &sample); lk.lock(); - } - if (error == 0) { - auto frame_size = m_frame_size; - lk.unlock(); - decoder.decode(sample); -#ifdef _WIN32 - wxBitmap bm; - decoder.toWxBitmap(bm, frame_size); -#else - wxImage bm; - decoder.toWxImage(bm, frame_size); -#endif - lk.lock(); - if (m_url != url) { - error = 1; - break; - } - if (bm.IsOk()) { - auto now = std::chrono::system_clock::now(); - if (m_last_PTS && (sample.decode_time - m_last_PTS) < 30000000ULL) { // 3s - auto next_PTS_expected = m_last_PTS_expected + std::chrono::milliseconds((sample.decode_time - m_last_PTS) / 10000ULL); - // The frame is late, catch up a little - auto next_PTS_practical = m_last_PTS_practical + std::chrono::milliseconds(minFrameDuration); - auto next_PTS = std::max(next_PTS_expected, next_PTS_practical); - if(now < next_PTS) - std::this_thread::sleep_until(next_PTS); - else - next_PTS = now; - //auto text = wxString::Format(L"wxMediaCtrl3 pts diff %ld\n", std::chrono::duration_cast(next_PTS - next_PTS_expected).count()); - //OutputDebugString(text); - m_last_PTS = sample.decode_time; - m_last_PTS_expected = next_PTS_expected; - m_last_PTS_practical = next_PTS; - } else { - // Resync - m_last_PTS = sample.decode_time; - m_last_PTS_expected = now; - m_last_PTS_practical = now; + while (error == int(Bambu_would_block)) { + m_cond.wait_for(lk, 100ms); + if (m_url != url) { + error = 1; + break; } - m_frame = bm; + lk.unlock(); + error = Bambu_ReadSample(tunnel, &sample); + lk.lock(); + } + if (error == 0) { + auto frame_size = m_frame_size; + lk.unlock(); + decoder.decode(sample); +#ifdef _WIN32 + wxBitmap bm; + decoder.toWxBitmap(bm, frame_size); +#else + wxImage bm; + decoder.toWxImage(bm, frame_size); +#endif + lk.lock(); + if (m_url != url) { + error = 1; + break; + } + if (bm.IsOk()) { + auto now = std::chrono::system_clock::now(); + if (m_last_PTS && (sample.decode_time - m_last_PTS) < 30000000ULL) { // 3s + auto next_PTS_expected = m_last_PTS_expected + std::chrono::milliseconds((sample.decode_time - m_last_PTS) / 10000ULL); + // The frame is late, catch up a little + auto next_PTS_practical = m_last_PTS_practical + std::chrono::milliseconds(minFrameDuration); + auto next_PTS = std::max(next_PTS_expected, next_PTS_practical); + if(now < next_PTS) + std::this_thread::sleep_until(next_PTS); + else + next_PTS = now; + //auto text = wxString::Format(L"wxMediaCtrl3 pts diff %ld\n", std::chrono::duration_cast(next_PTS - next_PTS_expected).count()); + //OutputDebugString(text); + m_last_PTS = sample.decode_time; + m_last_PTS_expected = next_PTS_expected; + m_last_PTS_practical = next_PTS; + } else { + // Resync + m_last_PTS = sample.decode_time; + m_last_PTS_expected = now; + m_last_PTS_practical = now; + } + m_frame = bm; + } + CallAfter([this] { Refresh(); }); } - CallAfter([this] { Refresh(); }); } - } - if (tunnel) { - lk.unlock(); - Bambu_Close(tunnel); - Bambu_Destroy(tunnel); - tunnel = nullptr; - lk.lock(); + if (tunnel) { + lk.unlock(); + Bambu_Close(tunnel); + Bambu_Destroy(tunnel); + tunnel = nullptr; + lk.lock(); + } } if (m_url == url) m_error = error; diff --git a/src/slic3r/GUI/wxMediaCtrl3.h b/src/slic3r/GUI/wxMediaCtrl3.h index 1d64955ffd..bcc94a17ef 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.h +++ b/src/slic3r/GUI/wxMediaCtrl3.h @@ -56,8 +56,10 @@ protected: void DoSetSize(int x, int y, int width, int height, int sizeFlags) override; static void bambu_log(void *ctx, int level, tchar const *msg); + static int rtsp_interrupt_callback(void *opaque); void PlayThread(); + int PlayRtsp(std::shared_ptr const &url, std::unique_lock &lock); void NotifyStopped(); @@ -74,6 +76,7 @@ private: #endif std::shared_ptr m_url; + std::shared_ptr m_active_url; std::uint64_t m_last_PTS{0}; std::chrono::system_clock::time_point m_last_PTS_expected; std::chrono::system_clock::time_point m_last_PTS_practical; diff --git a/src/slic3r/Utils/IPrinterAgent.hpp b/src/slic3r/Utils/IPrinterAgent.hpp index b77a27276f..c076b57be5 100644 --- a/src/slic3r/Utils/IPrinterAgent.hpp +++ b/src/slic3r/Utils/IPrinterAgent.hpp @@ -81,6 +81,14 @@ enum class FilamentSyncMode { pull ///< On-demand fetch via REST API (blocking call) }; +enum class CameraStreamMode { + none = 0, + http, // LAN or Cloud + rtsp, // LAN only + webrtc, // Cloud only + http_snapshot // HTTP endpoint returning one image per request +}; + /** * IPrinterAgent - Interface for printer operations. * @@ -354,12 +362,26 @@ public: */ virtual FilamentSyncMode get_filament_sync_mode() const { return FilamentSyncMode::none; } + /** + * Get the camera stream mode for this agent. This value can be deterministic and derived at + * runtime if the printer supports multiple camera stream modes. E.g. LAN => HTTP/RTSP, Cloud => WebRTC. + * + * @return CameraStreamMode indicating how the camera stream is obtained or used: + */ + virtual CameraStreamMode get_camera_stream_mode() const { return CameraStreamMode::none; } + /** * Refresh filament info from the printer synchronously. * Should only be called when get_filament_sync_mode() returns FilamentSyncMode::pull. * Populates the MachineObject's DevFilaSystem with fetched filament data. */ virtual bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) { return false; } + + /** + * Get the current camera stream URL for this agent's active machine. + * Only meaningful when get_camera_stream_mode() returns an HTTP or RTSP mode. + */ + virtual std::string get_camera_url() const { return {}; } }; } // namespace Slic3r diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp index d898c51e3a..d2c8781fbe 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp @@ -750,6 +750,57 @@ bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync return false; } +CameraStreamMode MoonrakerPrinterAgent::get_camera_stream_mode() const +{ + refresh_webcam_info(); + 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 +{ + std::string base_url; + std::string api_key; + uint64_t generation; + { + std::lock_guard lock(connect_mutex); + base_url = device_info.base_url; + api_key = device_info.api_key; + generation = connect_generation.load(); + } + + const uint64_t now_ms = static_cast( + std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count()); + + if (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; + 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(base_url, api_key, generation); +} + std::string MoonrakerPrinterAgent::trim_and_upper(const std::string& input) { std::string result = input; @@ -1605,10 +1656,11 @@ bool MoonrakerPrinterAgent::send_ws_rpc(const std::string& method, const nlohman return false; } -bool MoonrakerPrinterAgent::fetch_webcam_info(const std::string& base_url, const std::string& api_key, uint64_t generation) +bool MoonrakerPrinterAgent::fetch_webcam_info(const std::string& base_url, const std::string& api_key, uint64_t generation) const { - std::string stream_url; + std::string camera_url; std::string webcam_name; + CameraStreamMode stream_mode = CameraStreamMode::none; std::string error; try { std::string response_body; @@ -1649,16 +1701,24 @@ bool MoonrakerPrinterAgent::fetch_webcam_info(const std::string& base_url, const 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.is_object() && webcam.value("enabled", false)) { + 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(); } - break; + if (!camera_url.empty()) + break; } } - if (stream_url.empty()) { + if (camera_url.empty()) { error = "No enabled webcam"; } } @@ -1666,8 +1726,8 @@ bool MoonrakerPrinterAgent::fetch_webcam_info(const std::string& base_url, const } 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. + 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 = 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); @@ -1678,9 +1738,9 @@ bool MoonrakerPrinterAgent::fetch_webcam_info(const std::string& base_url, const 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"; + camera_url = scheme + authority + camera_url; + } else if (camera_url.rfind("http", 0) != 0) { + error = "Unsupported webcam URL"; } } } catch (const std::exception& e) { @@ -1692,14 +1752,15 @@ bool MoonrakerPrinterAgent::fetch_webcam_info(const std::string& base_url, const { std::lock_guard lock(payload_mutex); if (generation == connect_generation.load()) { - webcam_stream_url = error.empty() ? stream_url : ""; + webcam_stream_url = error.empty() ? camera_url : ""; + webcam_stream_mode = error.empty() ? stream_mode : CameraStreamMode::none; } } 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; + BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent: selected webcam '" << webcam_name << "' with camera URL " << camera_url; return true; } @@ -2880,8 +2941,6 @@ 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 19ef02ee22..8750085181 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp @@ -73,10 +73,9 @@ public: int set_on_local_connect_fn(OnLocalConnectedFn fn) override; int set_on_local_message_fn(OnMessageFn fn) override; int set_queue_on_main_fn(QueueOnMainFn fn) override; - - // Pull-mode agent (on-demand filament sync) - FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; } bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override; + CameraStreamMode get_camera_stream_mode() const override; + std::string get_camera_url() const override; protected: struct MoonrakerDeviceInfo @@ -153,7 +152,6 @@ 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); @@ -188,6 +186,12 @@ private: const std::string& api_key, uint64_t generation); + // why: a printer with no /server/webcams/list entry can still name its stream directly; + // subclasses (e.g. printers with a fixed webcam path) can override this instead. + virtual std::string webcam_stream_override(const std::string& base_url) const { return {}; } + void refresh_webcam_info() const; + bool fetch_webcam_info(const std::string& base_url, const std::string& api_key, uint64_t generation) const; + // System-specific filament fetch methods bool fetch_hh_filament_info(std::vector& trays, int& max_lane_index); bool fetch_moonraker_filament_data(std::vector& trays, int& max_lane_index); @@ -219,9 +223,14 @@ 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; + 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; unsigned thumbnail_lookup_attempts = 0; + static constexpr uint64_t WEBCAM_INFO_REFRESH_INTERVAL_MS = 1000; + std::atomic next_jsonrpc_id{1}; std::set available_objects; // Track for feature detection bool assumed_light_on = false; diff --git a/src/slic3r/Utils/NetworkAgent.cpp b/src/slic3r/Utils/NetworkAgent.cpp index eca59d30ce..01ed03f84f 100644 --- a/src/slic3r/Utils/NetworkAgent.cpp +++ b/src/slic3r/Utils/NetworkAgent.cpp @@ -1026,6 +1026,20 @@ bool NetworkAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync return false; } +CameraStreamMode NetworkAgent::get_camera_stream_mode() const +{ + if (m_printer_agent) + return m_printer_agent->get_camera_stream_mode(); + return CameraStreamMode::none; +} + +std::string NetworkAgent::get_local_camera_stream_url() const +{ + if (m_printer_agent) + return m_printer_agent->get_camera_url(); + return {}; +} + int NetworkAgent::request_bind_ticket(std::string* ticket) { if (m_printer_agent) diff --git a/src/slic3r/Utils/NetworkAgent.hpp b/src/slic3r/Utils/NetworkAgent.hpp index e9d05143a1..42ded7e885 100644 --- a/src/slic3r/Utils/NetworkAgent.hpp +++ b/src/slic3r/Utils/NetworkAgent.hpp @@ -181,6 +181,8 @@ public: int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn); FilamentSyncMode get_filament_sync_mode() const; bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull); + CameraStreamMode get_camera_stream_mode() const; + std::string get_local_camera_stream_url() const; int request_bind_ticket(std::string* ticket); int get_hms_snapshot(std::string dev_id, std::string file_name, std::function callback); diff --git a/src/slic3r/Utils/SnapmakerPrinterAgent.cpp b/src/slic3r/Utils/SnapmakerPrinterAgent.cpp index fe1a29af53..cd2241e0d6 100644 --- a/src/slic3r/Utils/SnapmakerPrinterAgent.cpp +++ b/src/slic3r/Utils/SnapmakerPrinterAgent.cpp @@ -96,15 +96,6 @@ void SnapmakerPrinterAgent::on_status_loop_tick(const std::string& dev_id) } } -int SnapmakerPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) -{ - const int rtn = MoonrakerPrinterAgent::connect_printer(dev_id, dev_ip, username, password, use_ssl); - if (rtn == BAMBU_NETWORK_SUCCESS) { - start_camera_monitor(); - } - return rtn; -} - int SnapmakerPrinterAgent::command_start_camera(std::string dev_id) { (void) dev_id; @@ -149,127 +140,139 @@ std::string SnapmakerPrinterAgent::combine_filament_type(const std::string& type bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode) { + (void) dev_id; if (sync_mode != get_filament_sync_mode()) return false; - std::string url = join_url(device_info.base_url, "/printer/objects/query?print_task_config&filament_detect"); + const std::string base_url = device_info.base_url; + const std::string api_key = device_info.api_key; - std::string response_body; - bool success = false; - std::string http_error; + filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed); - auto http = Http::get(url); - if (!device_info.api_key.empty()) { - http.header("X-Api-Key", device_info.api_key); - } - http.timeout_connect(5) - .timeout_max(10) - .on_complete([&](std::string body, unsigned status) { - if (status == 200) { - response_body = body; - success = true; - } else { - http_error = "HTTP error: " + std::to_string(status); - } - }) - .on_error([&](std::string body, std::string err, unsigned status) { - http_error = err; - if (status > 0) { - http_error += " (HTTP " + std::to_string(status) + ")"; - } - }) - .perform_sync(); + std::thread([this, base_url, api_key]() { + struct InFlightGuard + { + std::atomic& counter; + ~InFlightGuard() { counter.fetch_sub(1, std::memory_order_relaxed); } + } guard{filament_fetch_in_flight}; - if (!success) { - BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error; - return false; - } + const std::string url = join_url(base_url, "/printer/objects/query?print_task_config&filament_detect"); - auto json = nlohmann::json::parse(response_body, nullptr, false, true); - if (json.is_discarded()) { - BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response"; - return false; - } + std::string response_body; + bool success = false; + std::string http_error; - // Navigate to result.status.print_task_config - if (!json.contains("result") || !json["result"].contains("status") || - !json["result"]["status"].contains("print_task_config")) { - BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response"; - return false; - } - - auto& ptc = json["result"]["status"]["print_task_config"]; - - // Read parallel arrays from print_task_config - auto filament_exist = ptc.value("filament_exist", std::vector{}); - auto filament_type = ptc.value("filament_type", std::vector{}); - auto filament_sub_type = ptc.value("filament_sub_type", std::vector{}); - auto filament_color = ptc.value("filament_color_rgba", std::vector{}); - auto filament_vendor = ptc.value("filament_vendor", std::vector{}); - - const int slot_count = static_cast(filament_exist.size()); - if (slot_count == 0) { - BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported"; - return false; - } - - // Read NFC filament_detect data for temperature info (optional) - nlohmann::json nfc_info; - if (json["result"]["status"].contains("filament_detect") && - json["result"]["status"]["filament_detect"].contains("info")) { - nfc_info = json["result"]["status"]["filament_detect"]["info"]; - } - - static const std::string empty_str; - static const std::string default_color = "FFFFFFFF"; - - std::vector trays; - trays.reserve(slot_count); - - for (int i = 0; i < slot_count; ++i) { - AmsTrayData tray; - tray.slot_index = i; - tray.has_filament = filament_exist[i]; - - if (tray.has_filament) { - tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str), - safe_at(filament_sub_type, i, empty_str)); - tray.tray_color = safe_at(filament_color, i, default_color); - - auto* bundle = GUI::wxGetApp().preset_bundle; - // Try to find a matching preset for this filament based on vendor, type and color. - // If not found, default to traditional search by type only or generic type mapping. - if (bundle) { - std::string vendor = safe_at(filament_vendor, i, empty_str); - std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type, - tray.tray_color); - - if (!filament_id.empty()) { - tray.tray_info_idx = filament_id; - BOOST_LOG_TRIVIAL(warning) << "Filament sync: Found manufacturer-specific profile for slot " << i << ": " - << filament_id; + auto http = Http::get(url); + 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) { + if (status == 200) { + response_body = body; + success = true; } else { - tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type); + http_error = "HTTP error: " + std::to_string(status); } - } else { - tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type); - } + }) + .on_error([&](std::string body, std::string err, unsigned status) { + http_error = err; + if (status > 0) { + http_error += " (HTTP " + std::to_string(status) + ")"; + } + }) + .perform_sync(); - // Extract NFC temperature data if available - if (nfc_info.is_array() && i < static_cast(nfc_info.size()) && nfc_info[i].is_object()) { - auto& nfc_slot = nfc_info[i]; - std::string vendor = nfc_slot.value("VENDOR", "NONE"); - if (vendor != "NONE" && !vendor.empty()) { - tray.bed_temp = nfc_slot.value("BED_TEMP", 0); - tray.nozzle_temp = nfc_slot.value("FIRST_LAYER_TEMP", 0); - } - } + if (!success) { + BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error; + return; } - trays.emplace_back(std::move(tray)); - } + auto json = nlohmann::json::parse(response_body, nullptr, false, true); + if (json.is_discarded()) { + BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response"; + return; + } + + // Navigate to result.status.print_task_config + if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("print_task_config")) { + BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response"; + return; + } + + auto& ptc = json["result"]["status"]["print_task_config"]; + + // Read parallel arrays from print_task_config + auto filament_exist = ptc.value("filament_exist", std::vector{}); + auto filament_type = ptc.value("filament_type", std::vector{}); + auto filament_sub_type = ptc.value("filament_sub_type", std::vector{}); + auto filament_color = ptc.value("filament_color_rgba", std::vector{}); + auto filament_vendor = ptc.value("filament_vendor", std::vector{}); + + const int slot_count = static_cast(filament_exist.size()); + if (slot_count == 0) { + BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported"; + return; + } + + // Read NFC filament_detect data for temperature info (optional) + nlohmann::json nfc_info; + if (json["result"]["status"].contains("filament_detect") && json["result"]["status"]["filament_detect"].contains("info")) { + nfc_info = json["result"]["status"]["filament_detect"]["info"]; + } + + static const std::string empty_str; + static const std::string default_color = "FFFFFFFF"; + + std::vector trays; + trays.reserve(slot_count); + + for (int i = 0; i < slot_count; ++i) { + AmsTrayData tray; + tray.slot_index = i; + tray.has_filament = filament_exist[i]; + + if (tray.has_filament) { + tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str), safe_at(filament_sub_type, i, empty_str)); + tray.tray_color = safe_at(filament_color, i, default_color); + + auto* bundle = GUI::wxGetApp().preset_bundle; + // Try to find a matching preset for this filament based on vendor, type and color. + // If not found, default to traditional search by type only or generic type mapping. + if (bundle) { + std::string vendor = safe_at(filament_vendor, i, empty_str); + std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type, + tray.tray_color); + + if (!filament_id.empty()) { + tray.tray_info_idx = filament_id; + BOOST_LOG_TRIVIAL(warning) + << "Filament sync: Found manufacturer-specific profile for slot " << i << ": " << filament_id; + } else { + tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type); + } + } else { + tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type); + } + + // Extract NFC temperature data if available + if (nfc_info.is_array() && i < static_cast(nfc_info.size()) && nfc_info[i].is_object()) { + auto& nfc_slot = nfc_info[i]; + std::string vendor = nfc_slot.value("VENDOR", "NONE"); + if (vendor != "NONE" && !vendor.empty()) { + tray.bed_temp = nfc_slot.value("BED_TEMP", 0); + tray.nozzle_temp = nfc_slot.value("FIRST_LAYER_TEMP", 0); + } + } + } + + trays.emplace_back(std::move(tray)); + } + + build_ams_payload(1, slot_count - 1, trays); + }).detach(); - build_ams_payload(1, slot_count - 1, trays); return true; } diff --git a/src/slic3r/Utils/SnapmakerPrinterAgent.hpp b/src/slic3r/Utils/SnapmakerPrinterAgent.hpp index 5f7b6b1c30..c1e35701c3 100644 --- a/src/slic3r/Utils/SnapmakerPrinterAgent.hpp +++ b/src/slic3r/Utils/SnapmakerPrinterAgent.hpp @@ -1,5 +1,6 @@ #pragma once +#include "IPrinterAgent.hpp" #include "MoonrakerPrinterAgent.hpp" #include @@ -21,6 +22,8 @@ public: FilamentSyncMode get_filament_sync_mode() const override; int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override; int command_start_camera(std::string dev_id) override; + CameraStreamMode get_camera_stream_mode() const override { return CameraStreamMode::http_snapshot; } + std::string get_camera_url() const override { return device_info.base_url + "/server/files/camera/monitor.jpg"; } private: // Combine filament_type + filament_sub_type into a unified type string diff --git a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp index d428775c12..c9ea52e8bb 100644 --- a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp +++ b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp @@ -23,6 +23,14 @@ void PrinterAgentPluginCapability::RegisterBindings(pybind11::module_& module) .value("Pull", FilamentSyncMode::pull) .export_values(); + py::enum_(printer_agent_module, "CameraStreamMode") + .value("None_", CameraStreamMode::none) + .value("HTTP", CameraStreamMode::http) + .value("HTTP_SNAPSHOT", CameraStreamMode::http_snapshot) + .value("RTSP", CameraStreamMode::rtsp) + .value("WebRTC", CameraStreamMode::webrtc) + .export_values(); + py::class_(printer_agent_module, "AgentInfo") .def(py::init<>()) .def(py::init([](std::string id, std::string name, std::string version, std::string description) { @@ -105,7 +113,9 @@ void PrinterAgentPluginCapability::RegisterBindings(pybind11::module_& module) .def("start_send_gcode_to_sdcard", &PrinterAgentPluginCapability::start_send_gcode_to_sdcard) .def("start_local_print", &PrinterAgentPluginCapability::start_local_print) .def("get_filament_sync_mode", &PrinterAgentPluginCapability::get_filament_sync_mode) + .def("get_camera_stream_mode", &PrinterAgentPluginCapability::get_camera_stream_mode) .def("fetch_filament_info", &PrinterAgentPluginCapability::fetch_filament_info) + .def("get_camera_url", &PrinterAgentPluginCapability::get_camera_url) .def("check_cert", &PrinterAgentPluginCapability::check_cert) .def("install_device_cert", &PrinterAgentPluginCapability::install_device_cert) .def("ping_bind", &PrinterAgentPluginCapability::ping_bind) diff --git a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp index af869b68f3..ae602ca38c 100644 --- a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp +++ b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.hpp @@ -41,6 +41,8 @@ public: int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override = 0; FilamentSyncMode get_filament_sync_mode() const override = 0; bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override = 0; + CameraStreamMode get_camera_stream_mode() const override = 0; + std::string get_camera_url() const override = 0; int check_cert() override = 0; void install_device_cert(std::string dev_id, bool lan_only) override = 0; diff --git a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp index 7e017ca55e..88051ed649 100644 --- a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp @@ -5,7 +5,10 @@ #include "../../PyPluginTrampoline.hpp" #include "IPrinterAgent.hpp" +#include "pybind11/pybind11.h" +#include #include +#include namespace Slic3r { class PyPrinterAgentPluginCapabilityTrampoline : public PyPluginCommonTrampoline @@ -96,6 +99,19 @@ public: get_filament_sync_mode); } + CameraStreamMode get_camera_stream_mode() const override + { + ORCA_PY_OVERRIDE_AUDITED( + ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, CameraStreamMode, PrinterAgentPluginCapability, + get_camera_stream_mode); + } + + std::string get_camera_url() const override + { + ORCA_PY_OVERRIDE_AUDITED( + ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, std::string, PrinterAgentPluginCapability, get_camera_url); + } + bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override { ORCA_PY_OVERRIDE_AUDITED( From e8f089dfa401ce525cc69cc3de47d227cc4c121b Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 1 Sep 2026 18:04:37 +0800 Subject: [PATCH 04/24] fix: build & access code UI --- src/slic3r/GUI/ReleaseNote.cpp | 1 - src/slic3r/Utils/NetworkAgent.hpp | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/slic3r/GUI/ReleaseNote.cpp b/src/slic3r/GUI/ReleaseNote.cpp index 6c9adb015b..034f40283f 100644 --- a/src/slic3r/GUI/ReleaseNote.cpp +++ b/src/slic3r/GUI/ReleaseNote.cpp @@ -2063,7 +2063,6 @@ void InputIpAddressDialog::on_text(wxCommandEvent &evt) if (str_access_code.IsEmpty()) { str_access_code = "88888888"; - m_input_access_code->GetTextCtrl()->SetValue(str_access_code); } auto str_name = m_input_printer_name->GetTextCtrl()->GetValue().Strip(wxString::both); diff --git a/src/slic3r/Utils/NetworkAgent.hpp b/src/slic3r/Utils/NetworkAgent.hpp index 42ded7e885..7eb220b341 100644 --- a/src/slic3r/Utils/NetworkAgent.hpp +++ b/src/slic3r/Utils/NetworkAgent.hpp @@ -5,6 +5,7 @@ #include "libslic3r/ProjectTask.hpp" #include "ICloudServiceAgent.hpp" +#include "IPrinterAgent.hpp" #include #include @@ -14,7 +15,6 @@ namespace Slic3r { class IPrinterAgent; -enum class FilamentSyncMode; // Forward declaration class BBLNetworkPlugin; From 1e8805d43d3ae24eeaa5f83c292694842bad270f Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 28 Aug 2026 18:10:31 +0800 Subject: [PATCH 05/24] feat: connect to cloud printer and monitor From eb9cfe0ecb047f57fad5bef0b4e4a515ac7e5e6e Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 28 Aug 2026 18:10:31 +0800 Subject: [PATCH 06/24] feat: connect to cloud printer and monitor --- src/slic3r/GUI/DeviceCore/DevManager.cpp | 50 +- src/slic3r/GUI/GUI_App.cpp | 9 +- src/slic3r/GUI/GUI_App.hpp | 2 +- src/slic3r/GUI/Monitor.cpp | 12 +- src/slic3r/Utils/NetworkAgent.cpp | 30 +- src/slic3r/Utils/OrcaCloudServiceAgent.cpp | 722 ++++++++++++++++++++- src/slic3r/Utils/OrcaCloudServiceAgent.hpp | 95 +++ src/slic3r/Utils/OrcaPrinterAgent.cpp | 124 +++- src/slic3r/Utils/OrcaPrinterAgent.hpp | 8 + 9 files changed, 1020 insertions(+), 32 deletions(-) diff --git a/src/slic3r/GUI/DeviceCore/DevManager.cpp b/src/slic3r/GUI/DeviceCore/DevManager.cpp index 8f7953005a..1046377e9f 100644 --- a/src/slic3r/GUI/DeviceCore/DevManager.cpp +++ b/src/slic3r/GUI/DeviceCore/DevManager.cpp @@ -572,6 +572,19 @@ namespace Slic3r << " cur_selected=" << selected_machine; auto my_machine_list = get_my_machine_list(); auto it = my_machine_list.find(dev_id); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: set_selected_machine lookup dev_id=" << dev_id + << " found=" << (it != my_machine_list.end()) + << " my_machine_count=" << my_machine_list.size() + << " current_agent=" << get_current_printer_agent_id() + << " provider=" << GUI::wxGetApp().get_printer_cloud_provider(); + if (it != my_machine_list.end() && it->second) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: target machine dev_id=" << it->second->get_dev_id() + << " printer_agent_id=" << it->second->printer_agent_id + << " connection_type=" << it->second->connection_type() + << " dev_connection_type=" << it->second->dev_connection_type; + } else { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: target machine was not found in the current agent's machine list"; + } // disconnect last if dev_id difference from previous one auto last_selected = my_machine_list.find(selected_machine); @@ -582,7 +595,9 @@ namespace Slic3r m_agent->disconnect_printer(); } else if (last_selected->second->connection_type() == "cloud") { - m_agent->set_user_selected_machine(""); + const int result = m_agent->set_user_selected_machine(""); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cleared previous cloud selection dev_id=" + << selected_machine << " result=" << result; } } @@ -634,7 +649,9 @@ namespace Slic3r { // diff dev_id, cloud => set_user_selected_machine(new) BOOST_LOG_TRIVIAL(info) << "set_selected_machine: select new cloud machine, dev_id =" << dev_id; - m_agent->set_user_selected_machine(dev_id); + const int result = m_agent->set_user_selected_machine(dev_id); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: set new cloud selection dev_id=" + << dev_id << " result=" << result; it->second->reset(); } else @@ -662,6 +679,8 @@ namespace Slic3r selected_machine = dev_id; record_user_last_machine(selected_machine); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: DeviceManager selection complete selected_machine=" + << selected_machine; return true; } @@ -692,7 +711,9 @@ namespace Slic3r dev_list.push_back(it->first); BOOST_LOG_TRIVIAL(trace) << "add_user_subscribe: " << it->first; } - m_agent->add_subscribe(dev_list); + const int result = m_agent->add_subscribe(dev_list); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: add_user_subscribe count=" << dev_list.size() + << " result=" << result; } @@ -705,7 +726,9 @@ namespace Slic3r dev_list.push_back(it->first); BOOST_LOG_TRIVIAL(trace) << "del_user_subscribe: " << it->first; } - m_agent->del_subscribe(dev_list); + const int result = m_agent->del_subscribe(dev_list); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: del_user_subscribe count=" << dev_list.size() + << " result=" << result; } void DeviceManager::subscribe_device_list(std::vector dev_list) @@ -869,6 +892,12 @@ namespace Slic3r if (!obj) continue; + // Orca cloud printers are only ever delivered through this REST + // account list; tag them so DeviceManager's cloud/lan branches + // (subscribe + deselect in set_selected_machine) treat them right. + if (provider == "orca") + obj->dev_connection_type = "cloud"; + if (!elem["dev_id"].is_null()) obj->set_dev_id(elem["dev_id"].get()); if (!elem["dev_name"].is_null()) @@ -900,6 +929,12 @@ namespace Slic3r acc_code.erase(std::remove(acc_code.begin(), acc_code.end(), '\n'), acc_code.end()); obj->set_access_code(acc_code); } + + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: parsed cloud machine dev_id=" << dev_id + << " name=" << obj->get_dev_name() + << " agent_id=" << obj->printer_agent_id + << " connection_type=" << obj->connection_type() + << " online=" << obj->m_is_online; } //remove MachineObject from userMachineList @@ -915,6 +950,9 @@ namespace Slic3r iterat++; } } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: parse_user_print_info complete provider=" << provider + << " parsed_count=" << new_list.size() + << " stored_count=" << userMachineList.size(); } } catch (std::exception& e) @@ -931,10 +969,14 @@ namespace Slic3r unsigned int http_code; std::string body; int result = m_agent->get_user_print_info(&http_code, &body, provider); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: get_user_print_info provider=" << provider + << " result=" << result << " http_code=" << http_code + << " body_bytes=" << body.size(); if (result == 0) { // parse_user_print_info and on_machine_alive (SSDP for discovery) both mutate the same userMachineList map. // on_machine_alive mutates the map on the UI thread, do the same for parse_user_print_info. + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: queueing parse_user_print_info on UI thread"; Slic3r::GUI::wxGetApp().CallAfter([this, body]() { parse_user_print_info(body); }); } } diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index eb536d93df..4165b071cb 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -4971,11 +4971,12 @@ bool GUI_App::is_user_login(const std::string& provider/* = ORCA_CLOUD_PROVIDER* return false; } -const std::string& GUI_App::get_printer_cloud_provider() const +std::string GUI_App::get_printer_cloud_provider() const { - // Orca todo: this need to be revisted. currently it is mainly used for device manager and related clausses and only bambu machines use them. - // - return BBL_CLOUD_PROVIDER; + std::string provider = preset_bundle->printers.get_edited_preset().config.opt_string("printer_agent"); + if (provider.empty()) + provider = ORCA_CLOUD_PROVIDER; + return provider; } diff --git a/src/slic3r/GUI/GUI_App.hpp b/src/slic3r/GUI/GUI_App.hpp index 8bf32df64c..67c82129c8 100644 --- a/src/slic3r/GUI/GUI_App.hpp +++ b/src/slic3r/GUI/GUI_App.hpp @@ -494,7 +494,7 @@ public: bool check_login(const std::string& provider = ORCA_CLOUD_PROVIDER); void get_login_info(const std::string& provider = ORCA_CLOUD_PROVIDER); bool is_user_login(const std::string& provider = ORCA_CLOUD_PROVIDER); - const std::string& get_printer_cloud_provider() const; + std::string get_printer_cloud_provider() const; void request_user_login(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER); void request_user_handle(int online_login = 0, const std::string& provider = ORCA_CLOUD_PROVIDER); diff --git a/src/slic3r/GUI/Monitor.cpp b/src/slic3r/GUI/Monitor.cpp index 1a6d969988..dd0350ae0e 100644 --- a/src/slic3r/GUI/Monitor.cpp +++ b/src/slic3r/GUI/Monitor.cpp @@ -34,6 +34,8 @@ #include "DeviceCore/DevManager.h" +#include + namespace Slic3r { namespace GUI { @@ -259,6 +261,7 @@ void MonitorPanel::msw_rescale() void MonitorPanel::select_machine(std::string machine_sn) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MonitorPanel::select_machine queueing machine_sn=" << machine_sn; wxCommandEvent *event = new wxCommandEvent(wxEVT_COMMAND_CHOICE_SELECTED); event->SetString(machine_sn); wxQueueEvent(this, event); @@ -276,13 +279,20 @@ void MonitorPanel::on_timer(wxTimerEvent& event) void MonitorPanel::on_select_printer(wxCommandEvent& event) { Slic3r::DeviceManager* dev = Slic3r::GUI::wxGetApp().getDeviceManager(); + const std::string requested_dev_id = event.GetString().ToStdString(); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MonitorPanel::on_select_printer requested_dev_id=" + << requested_dev_id << " device_manager=" << (dev ? "set" : "null"); if (!dev) return; if ( dev->get_selected_machine() && (dev->get_selected_machine()->get_dev_id() != event.GetString().ToStdString()) && m_hms_panel) { m_hms_panel->clear_hms_tag(); } - if (!dev->set_selected_machine(event.GetString().ToStdString())) + const bool selected = dev->set_selected_machine(requested_dev_id); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MonitorPanel::on_select_printer set_selected_machine result=" + << selected << " selected_dev_id=" + << (dev->get_selected_machine() ? dev->get_selected_machine()->get_dev_id() : ""); + if (!selected) return; set_default(); diff --git a/src/slic3r/Utils/NetworkAgent.cpp b/src/slic3r/Utils/NetworkAgent.cpp index e71b57a1b7..01ed03f84f 100644 --- a/src/slic3r/Utils/NetworkAgent.cpp +++ b/src/slic3r/Utils/NetworkAgent.cpp @@ -925,8 +925,14 @@ std::string NetworkAgent::get_user_selected_machine() int NetworkAgent::set_user_selected_machine(std::string dev_id) { - if (m_printer_agent) - return m_printer_agent->set_user_selected_machine(dev_id); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::set_user_selected_machine: dev_id=" << dev_id + << " printer_agent=" << (m_printer_agent ? m_printer_agent->get_agent_info().id : ""); + if (m_printer_agent) { + const int result = m_printer_agent->set_user_selected_machine(dev_id); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::set_user_selected_machine: result=" << result; + return result; + } + BOOST_LOG_TRIVIAL(warning) << "NetworkAgent::set_user_selected_machine: no printer agent"; return -1; } @@ -946,15 +952,27 @@ int NetworkAgent::stop_subscribe(std::string module) int NetworkAgent::add_subscribe(std::vector dev_list) { - if (m_printer_agent) - return m_printer_agent->add_subscribe(std::move(dev_list)); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::add_subscribe: count=" << dev_list.size() + << " printer_agent=" << (m_printer_agent ? m_printer_agent->get_agent_info().id : ""); + if (m_printer_agent) { + const int result = m_printer_agent->add_subscribe(std::move(dev_list)); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::add_subscribe: result=" << result; + return result; + } + BOOST_LOG_TRIVIAL(warning) << "NetworkAgent::add_subscribe: no printer agent"; return -1; } int NetworkAgent::del_subscribe(std::vector dev_list) { - if (m_printer_agent) - return m_printer_agent->del_subscribe(std::move(dev_list)); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::del_subscribe: count=" << dev_list.size() + << " printer_agent=" << (m_printer_agent ? m_printer_agent->get_agent_info().id : ""); + if (m_printer_agent) { + const int result = m_printer_agent->del_subscribe(std::move(dev_list)); + BOOST_LOG_TRIVIAL(info) << "NetworkAgent::del_subscribe: result=" << result; + return result; + } + BOOST_LOG_TRIVIAL(warning) << "NetworkAgent::del_subscribe: no printer agent"; return -1; } diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index 4419395b4d..4a6c545a82 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -6,6 +6,8 @@ #include #include +#include +#include #include #include #include @@ -20,14 +22,18 @@ #include #include #include +#include #include #include +#include +#include #include #include #include #include #include +#include #include #include @@ -59,6 +65,522 @@ using json = nlohmann::json; namespace Slic3r { +struct OrcaCloudMqttConnection::Connection { + boost::asio::io_context io_context; + boost::asio::ssl::context ssl_context; + WebSocket websocket; + boost::asio::ip::tcp::resolver resolver; + + Connection() + : ssl_context(boost::asio::ssl::context::tls_client) + , websocket(io_context, ssl_context) + , resolver(io_context) + {} +}; + +OrcaCloudMqttConnection::~OrcaCloudMqttConnection() { stop(); } + +bool OrcaCloudMqttConnection::start(const std::string& endpoint, TokenProvider token_provider, MessageHandler message_handler, StateHandler state_handler) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start endpoint=" << endpoint + << " token_callback=" << (token_provider ? "set" : "null") + << " message_callback=" << (message_handler ? "set" : "null") + << " state_callback=" << (state_handler ? "set" : "null"); + stop(); + { + std::lock_guard lock(mutex); + endpoint_url = endpoint; + get_token = std::move(token_provider); + on_message = std::move(message_handler); + on_state = std::move(state_handler); + initial_result = false; + initial_completed = false; + connected = false; + } + stopping.store(false); + worker = std::thread(&OrcaCloudMqttConnection::run, this); + + std::unique_lock lock(mutex); + if (!initial_cv.wait_for(lock, std::chrono::seconds(10), [this] { return initial_completed; })) { + initial_completed = true; + initial_result = false; + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT initial connection timed out after 10 seconds; worker will retry"; + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start initial_result=" << initial_result + << " initial_completed=" << initial_completed; + return initial_result; +} + +void OrcaCloudMqttConnection::stop() { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT stop requested"; + stopping.store(true); + state_cv.notify_all(); + { + std::lock_guard lock(connection_mutex); + if (active_connection) { + auto& socket = boost::beast::get_lowest_layer(active_connection->websocket).socket(); + boost::system::error_code socket_error; + socket.cancel(socket_error); + socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, socket_error); + socket.close(socket_error); + active_connection->resolver.cancel(); + } + } + if (worker.joinable()) + worker.join(); + + { + std::lock_guard lock(mutex); + connected = false; + if (!initial_completed) { + initial_completed = true; + initial_result = false; + } + } + initial_cv.notify_all(); +} + +bool OrcaCloudMqttConnection::is_running() const { + return worker.joinable() && !stopping.load(); +} + +void OrcaCloudMqttConnection::flush_subscription_change() { + std::shared_ptr conn; + { + std::lock_guard lock(connection_mutex); + conn = active_connection; + } + bool connacked; + { + std::lock_guard lock(mutex); + connacked = connected; + } + if (!conn || !connacked) + return; // no live MQTT session yet — the worker sends the set on CONNACK + + // beast permits a concurrent writer while the worker is blocked in + // websocket.read(); every write is serialised by write_mutex inside send(). + try { + send_pending_subscriptions(conn->websocket); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: direct subscription write failed (" << e.what() + << "); worker will resend the full set on reconnect"; + } +} + +bool OrcaCloudMqttConnection::subscribe(const std::vector& device_ids) { + { + std::lock_guard lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe requested count=" << device_ids.size() + << " connected=" << connected.load(); + for (const std::string& device_id : device_ids) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe requested dev_id=" << device_id; + if (device_id.empty() || report_topic(device_id).size() > 96) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT subscribe rejected invalid dev_id=" << device_id; + return false; + } + } + for (const std::string& device_id : device_ids) { + subscriptions.insert(device_id); + pending_subscriptions.insert(device_id); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe queued total_subscriptions=" << subscriptions.size() + << " pending_subscriptions=" << pending_subscriptions.size(); + } + state_cv.notify_all(); + flush_subscription_change(); // emit SUBSCRIBE now on the live socket (no reconnect) + return true; +} + +bool OrcaCloudMqttConnection::unsubscribe(const std::vector& device_ids) { + { + std::lock_guard lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe requested count=" << device_ids.size() + << " connected=" << connected.load(); + for (const std::string& device_id : device_ids) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe requested dev_id=" << device_id; + subscriptions.erase(device_id); + pending_subscriptions.erase(device_id); + pending_unsubscriptions.insert(device_id); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe queued total_subscriptions=" << subscriptions.size() + << " pending_unsubscriptions=" << pending_unsubscriptions.size(); + } + state_cv.notify_all(); + flush_subscription_change(); // emit UNSUBSCRIBE now on the live socket (no reconnect) + return true; +} + +void OrcaCloudMqttConnection::clear_subscriptions() { + std::lock_guard lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT clear subscriptions count=" << subscriptions.size(); + subscriptions.clear(); + pending_subscriptions.clear(); + pending_unsubscriptions.clear(); +} + +bool OrcaCloudMqttConnection::parse_endpoint(const std::string& url, Endpoint& endpoint) { + constexpr const char* scheme = "wss://"; + constexpr size_t scheme_length = 6; + if (url.compare(0, scheme_length, scheme) != 0) + return false; + + const size_t authority_start = scheme_length; + const size_t path_start = url.find('/', authority_start); + const std::string authority = url.substr(authority_start, path_start - authority_start); + if (authority.empty()) + return false; + + const size_t port_start = authority.rfind(':'); + if (port_start != std::string::npos && authority.find(']') == std::string::npos) { + endpoint.host = authority.substr(0, port_start); + endpoint.port = authority.substr(port_start + 1); + } else { + endpoint.host = authority; + endpoint.port = "443"; + } + endpoint.target = path_start == std::string::npos ? "/" : url.substr(path_start); + return !endpoint.host.empty() && !endpoint.port.empty() && !endpoint.target.empty(); +} + +void OrcaCloudMqttConnection::append_string(std::vector& packet, const std::string& value) { + if (value.size() > 0xffff) + throw std::runtime_error("MQTT string is too long"); + packet.push_back(static_cast(value.size() >> 8)); + packet.push_back(static_cast(value.size() & 0xff)); + packet.insert(packet.end(), value.begin(), value.end()); +} + +void OrcaCloudMqttConnection::prepend_remaining_length(std::vector& packet, size_t length) { + std::vector encoded; + do { + uint8_t byte = static_cast(length % 128); + length /= 128; + if (length != 0) + byte |= 0x80; + encoded.push_back(byte); + } while (length != 0); + packet.insert(packet.begin() + 1, encoded.begin(), encoded.end()); +} + +std::vector OrcaCloudMqttConnection::make_connect_packet() { + std::vector packet{0x10}; + append_string(packet, "MQTT"); + packet.insert(packet.end(), {4, 2, 0, 60}); // level 4, clean session, 60 s keepalive + append_string(packet, "OrcaSlicer"); + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} + +std::string OrcaCloudMqttConnection::report_topic(const std::string& device_id) { return "device/" + device_id + "/report"; } + +std::vector OrcaCloudMqttConnection::make_topic_packet(uint8_t type, uint16_t packet_id, const std::vector& device_ids) { + std::vector packet{type}; + packet.push_back(static_cast(packet_id >> 8)); + packet.push_back(static_cast(packet_id & 0xff)); + for (const std::string& device_id : device_ids) { + append_string(packet, report_topic(device_id)); + if (type == 0x82) // SUBSCRIBE, QoS 0 is sufficient for printer reports. + packet.push_back(0); + } + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} + +std::vector OrcaCloudMqttConnection::make_ping_packet() { return {0xc0, 0}; } + +void OrcaCloudMqttConnection::send(WebSocket& websocket, const std::vector& packet) { + if (packet.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: attempted to send empty MQTT packet"; + return; + } + // Writes come from the worker thread AND, for dynamic (un)subscribes, the + // caller thread. Serialise them; the worker's concurrent read is fine (beast + // allows one reader + one writer). + std::lock_guard lock(write_mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending MQTT packet type=0x" << std::hex + << static_cast(packet[0] >> 4) << std::dec + << " bytes=" << packet.size(); + websocket.binary(true); + websocket.write(boost::asio::buffer(packet)); +} + +void OrcaCloudMqttConnection::connect_and_read() { + auto connection = std::make_shared(); + { + std::lock_guard lock(connection_mutex); + active_connection = connection; + if (stopping.load()) + return; + } + + Endpoint endpoint; + if (!parse_endpoint(endpoint_url, endpoint)) { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: invalid MQTT endpoint=" << endpoint_url; + throw std::runtime_error("invalid Orca Cloud WebSocket endpoint"); + } + + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connecting host=" << endpoint.host + << " port=" << endpoint.port << " target=" << endpoint.target; + + auto& websocket = connection->websocket; + const auto results = connection->resolver.resolve(endpoint.host, endpoint.port); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT DNS resolution succeeded host=" << endpoint.host; + auto& stream = boost::beast::get_lowest_layer(websocket); + stream.expires_after(std::chrono::seconds(10)); + stream.connect(results); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TCP connection established host=" << endpoint.host + << " port=" << endpoint.port; + + // The aggregate viewer is a TLS WebSocket endpoint. Set SNI before the + // TLS handshake so the cloud edge selects the correct certificate. + auto& tls_stream = websocket.next_layer(); + if (!SSL_set_tlsext_host_name(tls_stream.native_handle(), endpoint.host.c_str())) + throw std::runtime_error("failed to set Orca Cloud TLS server name"); + connection->ssl_context.set_default_verify_paths(); + tls_stream.set_verify_mode(boost::asio::ssl::verify_peer); + tls_stream.set_verify_callback(boost::asio::ssl::host_name_verification(endpoint.host)); + tls_stream.handshake(boost::asio::ssl::stream_base::client); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TLS handshake completed host=" << endpoint.host; + + const std::string token = get_token ? get_token() : std::string(); + if (token.empty()) { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: MQTT token callback returned an empty token"; + throw std::runtime_error("no access token for Orca Cloud WebSocket"); + } + + websocket.set_option(boost::beast::websocket::stream_base::decorator( + [token](boost::beast::websocket::request_type& request) { + request.set(boost::beast::http::field::user_agent, "OrcaSlicer"); + request.set(boost::beast::http::field::authorization, "Bearer " + token); + request.set("Sec-WebSocket-Protocol", "mqtt"); + })); + boost::beast::http::response response; + boost::system::error_code handshake_error; + websocket.handshake(response, endpoint.host, endpoint.target, handshake_error); + if (handshake_error) { + // Surface the server's HTTP status so a persistent rejection (stale token, + // missing api key, wrong route) is diagnosable from the log rather than an + // opaque "handshake declined". + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudMqttConnection: handshake rejected, http=" + << response.result_int() << " (" << response.reason() << "), " + << handshake_error.message(); + throw boost::system::system_error(handshake_error, "Orca Cloud WebSocket handshake"); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: WebSocket handshake completed http=" << response.result_int() + << " negotiated_protocol=" << response["Sec-WebSocket-Protocol"]; + if (response["Sec-WebSocket-Protocol"] != "mqtt") { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: WebSocket handshake did not negotiate MQTT"; + throw std::runtime_error("Orca Cloud WebSocket did not negotiate MQTT"); + } + + stream.expires_never(); + send(websocket, make_connect_packet()); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNECT packet sent"; + + boost::beast::flat_buffer buffer; + stream.expires_after(std::chrono::seconds(10)); + websocket.read(buffer); + const std::string connack = boost::beast::buffers_to_string(buffer.data()); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNACK received bytes=" << connack.size() + << " header=" << (connack.empty() ? -1 : static_cast(static_cast(connack[0]))) + << " return_code=" << (connack.size() > 3 ? static_cast(static_cast(connack[3])) : -1); + if (connack.size() != 4 || static_cast(connack[0]) != 0x20 || + static_cast(connack[2]) != 0x00 || static_cast(connack[3]) != 0x00) { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: MQTT CONNECT was refused or malformed"; + throw std::runtime_error("Orca Cloud MQTT CONNECT was refused"); + } + + notify_state(true); + reconnect_delay_seconds.store(1); // a fresh CONNACK resets the backoff + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection is ready; sending current subscriptions"; + send_current_subscriptions(websocket); + std::chrono::steady_clock::time_point next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30); + + while (!stopping.load()) { + send_pending_subscriptions(websocket); + buffer.consume(buffer.size()); + stream.expires_after(std::chrono::seconds(1)); + boost::system::error_code error; + websocket.read(buffer, error); + if (error == boost::beast::error::timeout) { + if (std::chrono::steady_clock::now() >= next_ping) { + send(websocket, make_ping_packet()); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT PINGREQ sent"; + next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30); + } + continue; + } + if (error) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT WebSocket read failed code=" << error.value() + << " message=" << error.message(); + throw boost::system::system_error(error, "read Orca Cloud MQTT message"); + } + handle_packet(boost::beast::buffers_to_string(buffer.data())); + } + + boost::system::error_code close_error; + websocket.close(boost::beast::websocket::close_code::normal, close_error); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection closed code=" << close_error.value() + << " message=" << close_error.message(); + if (!stopping.load()) + notify_state(false); +} + +void OrcaCloudMqttConnection::send_current_subscriptions(WebSocket& websocket) { + std::vector devices; + { + std::lock_guard lock(mutex); + devices.assign(subscriptions.begin(), subscriptions.end()); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending current MQTT subscriptions count=" << devices.size(); + if (!devices.empty()) { + const uint16_t packet_id = next_packet_id++; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending SUBSCRIBE packet_id=" << packet_id; + send(websocket, make_topic_packet(0x82, packet_id, devices)); + } +} + +void OrcaCloudMqttConnection::send_pending_subscriptions(WebSocket& websocket) { + std::vector subscribe_ids; + std::vector unsubscribe_ids; + { + std::lock_guard lock(mutex); + subscribe_ids.assign(pending_subscriptions.begin(), pending_subscriptions.end()); + unsubscribe_ids.assign(pending_unsubscriptions.begin(), pending_unsubscriptions.end()); + pending_subscriptions.clear(); + pending_unsubscriptions.clear(); + } + if (!subscribe_ids.empty()) { + const uint16_t packet_id = next_packet_id++; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending SUBSCRIBE count=" << subscribe_ids.size() + << " packet_id=" << packet_id; + for (const std::string& device_id : subscribe_ids) + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: SUBSCRIBE topic=" << report_topic(device_id); + send(websocket, make_topic_packet(0x82, packet_id, subscribe_ids)); + } + if (!unsubscribe_ids.empty()) { + const uint16_t packet_id = next_packet_id++; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending UNSUBSCRIBE count=" << unsubscribe_ids.size() + << " packet_id=" << packet_id; + for (const std::string& device_id : unsubscribe_ids) + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: UNSUBSCRIBE topic=" << report_topic(device_id); + send(websocket, make_topic_packet(0xa2, packet_id, unsubscribe_ids)); + } +} + +void OrcaCloudMqttConnection::handle_packet(const std::string& packet) { + if (packet.size() < 2) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: received undersized MQTT packet bytes=" << packet.size(); + return; + } + const uint8_t header = static_cast(packet[0]); + const uint8_t packet_type = header >> 4; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received MQTT packet type=" << static_cast(packet_type) + << " header=0x" << std::hex << static_cast(header) << std::dec + << " bytes=" << packet.size(); + if (packet_type != 3) { // Only QoS 0 PUBLISH carries printer status. + if (packet_type == 9 && packet.size() >= 5) { + std::ostringstream result_codes; + for (size_t index = 4; index < packet.size(); ++index) { + if (index != 4) + result_codes << ','; + result_codes << "0x" << std::hex << static_cast(static_cast(packet[index])); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received SUBACK packet_id=" + << ((static_cast(static_cast(packet[2])) << 8) | + static_cast(static_cast(packet[3]))) + << " result_codes=" << result_codes.str(); + } + return; + } + size_t index = 1; + size_t multiplier = 1; + size_t remaining = 0; + uint8_t encoded = 0; + do { + if (index >= packet.size() || multiplier > 128 * 128 * 128) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH remaining length"; + return; + } + encoded = static_cast(packet[index++]); + remaining += (encoded & 0x7f) * multiplier; + multiplier *= 128; + } while ((encoded & 0x80) != 0); + const size_t remaining_end = index + remaining; + if (remaining_end > packet.size() || remaining < 2 || index + 2 > remaining_end) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH body remaining=" << remaining + << " packet_bytes=" << packet.size(); + return; + } + const uint16_t topic_length = (static_cast(packet[index]) << 8) | + static_cast(packet[index + 1]); + index += 2; + if (topic_length > packet.size() - index) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH topic length=" << topic_length; + return; + } + const std::string topic(packet.data() + index, topic_length); + index += topic_length; + if (((header >> 1) & 0x03) != 0) { + if (index + 2 > remaining_end) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH packet identifier"; + return; + } + index += 2; // QoS 1/2 packet identifier; the service currently sends QoS 0. + } + const size_t payload_size = remaining_end - index; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received PUBLISH topic=" << topic + << " payload_bytes=" << payload_size + << " message_callback=" << (on_message ? "set" : "null"); + if (on_message) + on_message(topic, packet.substr(index, remaining_end - index)); + else + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping PUBLISH because message callback is not set"; +} + +void OrcaCloudMqttConnection::notify_state(bool is_now_connected) { + StateHandler callback; + bool initial = false; + { + std::lock_guard lock(mutex); + connected = is_now_connected; + initial = !initial_completed; + if (initial) { + initial_result = is_now_connected; + initial_completed = true; + } + callback = on_state; + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT state changed connected=" << is_now_connected + << " initial=" << initial << " state_callback=" << (callback ? "set" : "null"); + if (initial) + initial_cv.notify_all(); + else if (callback) + callback(is_now_connected, false); +} + +void OrcaCloudMqttConnection::run() { + while (!stopping.load()) { + const int retry_seconds = reconnect_delay_seconds.load(); + try { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection attempt retry_delay=" << retry_seconds; + connect_and_read(); + } catch (const std::exception& error) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT connection attempt failed: " << error.what(); + if (!stopping.load()) + notify_state(false); + } + if (stopping.load()) + break; + // Grow the backoff only across attempts that never reached CONNACK; a + // successful connection resets reconnect_delay_seconds to 1 (connect_and_read). + reconnect_delay_seconds.store(std::min(retry_seconds * 2, 30)); + std::unique_lock lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT waiting before reconnect seconds=" << retry_seconds; + state_cv.wait_for(lock, std::chrono::seconds(retry_seconds), [this] { return stopping.load(); }); + } +} + namespace { constexpr const char* ORCA_DEFAULT_API_URL = "api.orcaslicer.com"; constexpr const char* ORCA_DEFAULT_AUTH_URL = "https://auth.orcaslicer.com"; @@ -82,6 +604,7 @@ constexpr const char* ORCA_UNSUBSCRIBE_PLUGINS = "/api/v1/plugins/subscriptions" constexpr const char* ORCA_PLUGINS_MINE = "/api/v1/plugins/mine"; constexpr const char* ORCA_PLUGINS_BASE = "/api/v1/plugins"; constexpr const char* ORCA_PLUGIN_DOWNLOAD_URL = "/api/v1/plugins/download"; +constexpr const char* ORCA_CLOUD_PRINTER = "/api/v1/printers"; constexpr const char* ORCA_CLOUD_LOGIN_PATH = "/orcaslicer-login"; @@ -490,6 +1013,7 @@ OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir) , api_base_url(ORCA_DEFAULT_API_URL) , auth_base_url(ORCA_DEFAULT_AUTH_URL) , cloud_base_url(ORCA_DEFAULT_CLOUD_URL) + , mqtt_connection(std::make_unique()) { auth_headers["apikey"] = ORCA_DEFAULT_PUB_KEY; pkce_bundle.loopback_port = choose_loopback_port(); @@ -500,6 +1024,8 @@ OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir) OrcaCloudServiceAgent::~OrcaCloudServiceAgent() { + if (mqtt_connection) + mqtt_connection->stop(); if (refresh_thread.joinable()) { refresh_thread.join(); } @@ -938,22 +1464,102 @@ bool OrcaCloudServiceAgent::ensure_token_fresh(const std::string& reason) { retu int OrcaCloudServiceAgent::connect_server() { + const bool logged_in = is_user_login(); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::connect_server logged_in=" << logged_in + << " api_base_url=" << api_base_url; + if (!logged_in) { + if (mqtt_connection) + mqtt_connection->stop(); + { + std::lock_guard lock(state_mutex); + is_connected = false; + } + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: connect_server requires a logged-in user"; + invoke_server_connected_callback(-1, 401); + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + std::string response; unsigned int http_code = 0; int result = http_get(ORCA_HEALTH_PATH, &response, &http_code); bool connected = (result == BAMBU_NETWORK_SUCCESS && http_code >= 200 && http_code < 300); - { - std::lock_guard lock(state_mutex); - is_connected = connected; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health result=" << result << " http_code=" << http_code + << " connected=" << connected << " response_bytes=" << response.size(); + + if (connected && mqtt_connection && !mqtt_connection->is_running()) { + // Only (re)start when the worker isn't already alive. connect_server() is + // also called every ~5s by DeviceManagerRefresher::on_timer via + // refresh_connection(); start() begins with stop(), so calling it + // unconditionally tears down and rebuilds a healthy socket every tick. + const std::string endpoint = "wss://" + api_base_url + "/api/v1/printers/mqtt"; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: starting aggregate MQTT endpoint=" << endpoint; + // Fire-and-forget: start() spawns a worker that reconnects with exponential + // backoff. A failed *initial* attempt (token/network not ready yet during a + // startup gap) must NOT gate the socket's lifetime here — folding it into + // `connected` trips the stop() below and kills the retry loop for the whole + // session. The socket is torn down only on logout / clear_session. + const bool mqtt_started = mqtt_connection->start( + endpoint, + [this] { return get_access_token(); }, + [this](const std::string& topic, const std::string& message) { + constexpr const char* prefix = "device/"; + constexpr const char* suffix = "/report"; + if (topic.compare(0, 7, prefix) != 0 || topic.size() <= 14 || + topic.compare(topic.size() - 7, 7, suffix) != 0) + return; + const std::string device_id = topic.substr(7, topic.size() - 14); + OnMessageFn callback; + { + std::lock_guard lock(callback_mutex); + callback = printer_status_callback; + } + if (callback) + callback(device_id, message); + }, + [this](bool socket_connected, bool initial) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: aggregate MQTT state callback connected=" + << socket_connected << " initial=" << initial; + if (initial) + return; + { + std::lock_guard lock(state_mutex); + is_connected = socket_connected; + } + invoke_server_connected_callback(socket_connected ? 0 : -1, 0); + }); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: aggregate MQTT start returned=" << mqtt_started; + } + if (!connected) { + // Transient health-check failure (DNS blip / brief 5xx). Do NOT stop the + // MQTT worker — it owns its own reconnect loop, and connect_server() runs + // on the 5s refresher tick. The socket is torn down only on logout (the + // !logged_in branch above) and clear_session(). + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health check failed; leaving aggregate MQTT running"; } - invoke_server_connected_callback(connected ? 0 : -1, http_code); - return connected ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + // While the aggregate MQTT worker is alive it owns is_connected via its + // StateHandler. Don't let the 5s health probe overwrite it (a DNS blip would + // otherwise flap the "server connected" state and the Device tab). + const bool mqtt_alive = mqtt_connection && mqtt_connection->is_running(); + if (!mqtt_alive) { + { + std::lock_guard lock(state_mutex); + is_connected = connected; + } + invoke_server_connected_callback(connected ? 0 : -1, http_code); + } + + return (connected || mqtt_alive) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; } bool OrcaCloudServiceAgent::is_server_connected() { + // The aggregate MQTT socket is the real signal. While its worker is alive, + // report its actual CONNACK state — immune to the 5s health probe's DNS blips. + // Fall back to the last health-check result only when there is no socket. + if (mqtt_connection && mqtt_connection->is_running()) + return mqtt_connection->is_connected(); std::lock_guard lock(state_mutex); return is_connected; } @@ -974,16 +1580,59 @@ int OrcaCloudServiceAgent::stop_subscribe(std::string module) int OrcaCloudServiceAgent::add_subscribe(std::vector dev_list) { - (void) dev_list; - return BAMBU_NETWORK_SUCCESS; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::add_subscribe count=" << dev_list.size() + << " logged_in=" << is_user_login() << " mqtt_connection=" << (mqtt_connection ? "set" : "null"); + if (!is_user_login() || !mqtt_connection) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: add_subscribe rejected because cloud is not ready"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + const bool queued = mqtt_connection->subscribe(dev_list); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: add_subscribe queued=" << queued; + return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; } int OrcaCloudServiceAgent::del_subscribe(std::vector dev_list) { - (void) dev_list; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::del_subscribe count=" << dev_list.size() + << " logged_in=" << is_user_login() << " mqtt_connection=" << (mqtt_connection ? "set" : "null"); + if (!is_user_login() || !mqtt_connection) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: del_subscribe rejected because cloud is not ready"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + const bool queued = mqtt_connection->unsubscribe(dev_list); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: del_subscribe queued=" << queued; + return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; +} + +int OrcaCloudServiceAgent::set_printer_status_callback(OnMessageFn fn) +{ + std::lock_guard lock(callback_mutex); + printer_status_callback = std::move(fn); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: printer status callback=" << (printer_status_callback ? "set" : "clear"); return BAMBU_NETWORK_SUCCESS; } +int OrcaCloudServiceAgent::send_printer_command(const std::string& dev_id, const std::string& body) +{ + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: send_printer_command dev_id=" << dev_id + << " body_bytes=" << body.size() << " logged_in=" << is_user_login(); + if (dev_id.empty() || !is_user_login()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: send_printer_command rejected"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + + const std::string path = std::string(ORCA_CLOUD_PRINTER) + "/" + dev_id + "/commands"; + std::string response; + unsigned int http_code = 0; + int result = http_post(path, body, &response, &http_code); + BOOST_LOG_TRIVIAL(info) << "OrcaCloudServiceAgent: command dev=" << dev_id + << " http=" << http_code << " result=" << result + << " response_bytes=" << response.size(); + return (result == BAMBU_NETWORK_SUCCESS && http_code >= 200 && http_code < 300) + ? BAMBU_NETWORK_SUCCESS + : BAMBU_NETWORK_ERR_CONNECT_FAILED; +} + void OrcaCloudServiceAgent::enable_multi_machine(bool enable) { std::lock_guard lock(state_mutex); @@ -2021,6 +2670,10 @@ bool OrcaCloudServiceAgent::set_user_session(const json& session_json, bool noti void OrcaCloudServiceAgent::clear_session() { + if (mqtt_connection) { + mqtt_connection->stop(); + mqtt_connection->clear_subscriptions(); + } { std::lock_guard lock(session_mutex); session = SessionInfo{}; @@ -2620,11 +3273,56 @@ int OrcaCloudServiceAgent::check_user_task_report(int* task_id, bool* printable) int OrcaCloudServiceAgent::get_user_print_info(unsigned int* http_code, std::string* http_body) { - BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_user_print_info (stub)"; + std::string response; + unsigned int code = 0; + int result = http_get(ORCA_CLOUD_PRINTER, &response, &code); + if (http_code) - *http_code = 200; - if (http_body) - *http_body = "{}"; + *http_code = code; + + if (result != 0 || code != 200) { + BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: get_user_print_info failed - http_code=" << code << ", response=" << response; + return result != 0 ? result : BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED; + } + + BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: get_user_print_info fetched - http_code=" << code << ", response=" << response; + + try { + auto resp_json = nlohmann::json::parse(response); + nlohmann::json devices = nlohmann::json::array(); + + for (const auto& printer : resp_json.value("data", nlohmann::json::array())) { + nlohmann::json device; + device["dev_id"] = printer.value("id", ""); + device["dev_name"] = printer.value("name", ""); + if (printer.contains("model") && printer["model"].is_string()) + device["dev_model_name"] = printer["model"].get(); + + bool online = false; + if (printer.contains("status_snapshot") && printer["status_snapshot"].is_object()) { + const auto& status = printer["status_snapshot"].value("status", nlohmann::json::object()); + online = status.value("connection", nlohmann::json::object()).value("state", "") == "online"; + if (status.contains("job") && status["job"].is_object()) + device["task_status"] = status["job"].value("state", ""); + } + device["dev_online"] = online; + + devices.push_back(device); + } + + if (http_body) { + nlohmann::json out; + out["devices"] = devices; + *http_body = out.dump(); + } + + BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_user_print_info parsed - device_count=" << devices.size() + << ", devices=" << devices.dump(); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: get_user_print_info parse exception - " << e.what(); + return BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED; + } + return BAMBU_NETWORK_SUCCESS; } diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp index 3ae86ec27c..95f95b7d92 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp @@ -2,6 +2,12 @@ #define __ORCA_CLOUD_SERVICE_AGENT_HPP__ #include "ICloudServiceAgent.hpp" + +#include +#include +#include +#include +#include #include #include #include @@ -9,6 +15,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -21,6 +30,78 @@ namespace Slic3r { // Forward declarations class AppConfig; +// MQTT 3.1.1 over the aggregate WebSocket is deliberately kept here instead +// of using the printer SDK. The endpoint is a read-only status stream; MQTT +// PUBLISH must never be sent on it because the cloud closes such sessions. +class OrcaCloudMqttConnection +{ +public: + using TokenProvider = std::function; + using MessageHandler = std::function; + using StateHandler = std::function; + + ~OrcaCloudMqttConnection(); + + bool start(const std::string& endpoint, TokenProvider token_provider, MessageHandler message_handler, StateHandler state_handler); + void stop(); + // True while the worker thread is alive (connected OR retrying). Lets callers + // avoid restarting a healthy connection. + bool is_running() const; + // True once CONNACK has been received and the socket has not since dropped. + bool is_connected() const { return connected.load(); } + bool subscribe(const std::vector& device_ids); + bool unsubscribe(const std::vector& device_ids); + void clear_subscriptions(); + +private: + struct Endpoint { std::string host; std::string port; std::string target; }; + using WebSocket = boost::beast::websocket::stream< + boost::asio::ssl::stream>; + struct Connection; + + static bool parse_endpoint(const std::string& url, Endpoint& endpoint); + static void append_string(std::vector& packet, const std::string& value); + static void prepend_remaining_length(std::vector& packet, size_t length); + static std::vector make_connect_packet(); + static std::string report_topic(const std::string& device_id); + static std::vector make_topic_packet(uint8_t type, uint16_t packet_id, const std::vector& device_ids); + static std::vector make_ping_packet(); + + void send(WebSocket& websocket, const std::vector& packet); + // Emit a queued SUBSCRIBE/UNSUBSCRIBE on the live socket right now (from the + // caller thread), so a selection change is applied without waiting for the + // blocking read loop to next return. No-op if no CONNACKed socket exists yet + // (the worker sends the set on connect). The aggregate viewer is dynamic — the + // WebSocket is never dropped for a subscription change. + void flush_subscription_change(); + void connect_and_read(); + void send_current_subscriptions(WebSocket& websocket); + void send_pending_subscriptions(WebSocket& websocket); + void handle_packet(const std::string& packet); + void notify_state(bool is_now_connected); + void run(); + + std::atomic_bool stopping{true}; + std::atomic_int reconnect_delay_seconds{1}; + std::thread worker; + std::mutex mutex; + std::mutex connection_mutex; + std::mutex write_mutex; // serialises every websocket write (worker + caller threads) + std::shared_ptr active_connection; + std::condition_variable initial_cv; + std::condition_variable state_cv; + std::string endpoint_url; + TokenProvider get_token; + MessageHandler on_message; + StateHandler on_state; + std::set subscriptions; + std::set pending_subscriptions; + std::set pending_unsubscriptions; + std::atomic next_packet_id{1}; + bool initial_result{false}; + bool initial_completed{false}; + std::atomic_bool connected{false}; +}; struct BundleMetadata; struct PluginDescriptor; struct PluginChangelog; @@ -207,6 +288,18 @@ public: int del_subscribe(std::vector dev_list) override; void enable_multi_machine(bool enable) override; + // The aggregate printer socket is status-only. OrcaPrinterAgent registers + // its normal message callback here and adds/removes device report topics + // through add_subscribe()/del_subscribe(). Printer commands continue to + // use the REST commands endpoint; they must never be published here. + int set_printer_status_callback(OnMessageFn fn); + + // Send a Bambu-dialect command to one printer via the cloud relay's REST + // endpoint (POST /api/v1/printers//commands). Synchronous - wraps http_post, + // so it carries the standard apikey + bearer headers and token refresh. Callers + // that need non-blocking behaviour run it on their own thread. + int send_printer_command(const std::string& dev_id, const std::string& body); + // ======================================================================== // ICloudServiceAgent Interface Implementation - Settings Synchronization // ======================================================================== @@ -423,6 +516,7 @@ private: std::chrono::system_clock::now().time_since_epoch()).count()}; // Member variables - connection state + std::unique_ptr mqtt_connection; bool is_connected{false}; bool enable_track{false}; bool multi_machine_enabled{false}; @@ -436,6 +530,7 @@ private: AppOnHttpErrorFn on_http_error_fn; GetCountryCodeFn get_country_code_fn; QueueOnMainFn queue_on_main_fn; + OnMessageFn printer_status_callback; mutable std::mutex callback_mutex; // Thread safety diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index cb70dafcca..93cadd9abd 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -1,5 +1,8 @@ #include "OrcaPrinterAgent.hpp" #include "NetworkAgentFactory.hpp" +#include "OrcaCloudServiceAgent.hpp" +#include +#include namespace Slic3r { @@ -13,8 +16,35 @@ OrcaPrinterAgent::~OrcaPrinterAgent() = default; void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud) { - std::lock_guard lock(state_mutex); - m_cloud_agent = cloud; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_cloud_agent: cloud=" << (cloud ? cloud->get_id() : ""); + { + std::lock_guard lock(state_mutex); + m_cloud_agent = cloud; + m_orca_cloud = dynamic_cast(cloud.get()); + } + if (!m_orca_cloud) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_cloud_agent: cloud is not OrcaCloudServiceAgent"; + return; // BBL provider active - nothing to bridge + } + + // OrcaCloudServiceAgent owns the aggregate MQTT socket; it already strips the + // device//report topic and hands us (dev_id, raw_json). Forward to the + // standard sink. message_arrive_fn self-marshals to the UI thread via CallAfter, + // so being called from the MQTT worker thread is fine. + const int callback_result = m_orca_cloud->set_printer_status_callback([this](std::string dev_id, std::string payload) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: received cloud status dev_id=" << dev_id + << " payload_bytes=" << payload.size(); + OnMessageFn fn; + { + std::lock_guard lock(state_mutex); + fn = on_message_fn; + } + if (fn) + fn(std::move(dev_id), std::move(payload)); + else + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: cloud status has no registered on_message callback"; + }); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_cloud_agent: status callback result=" << callback_result; } // ============================================================================ @@ -23,6 +53,34 @@ void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud int OrcaPrinterAgent::send_message(std::string dev_id, std::string json_str, int qos, int flag) { + (void) qos; + (void) flag; // MQTT concepts; N/A for the REST command endpoint + + std::shared_ptr cloud; + { + std::lock_guard lock(state_mutex); + cloud = m_cloud_agent; + } + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::send_message: dev_id=" << dev_id + << " payload_bytes=" << json_str.size() << " qos=" << qos << " flag=" << flag + << " cloud=" << (cloud ? cloud->get_id() : ""); + if (!cloud || dev_id.empty()) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::send_message: rejected due to missing cloud or device ID"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + + // Detached worker so the UI thread is never blocked on HTTP. Capture a shared_ptr + // copy (keeps the cloud agent alive) - never `this`. + std::thread([cloud, dev_id, body = std::move(json_str)]() { + if (auto* orca = dynamic_cast(cloud.get())) { + const int result = orca->send_printer_command(dev_id, body); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::send_message: cloud command result=" << result + << " dev_id=" << dev_id; + } else { + BOOST_LOG_TRIVIAL(error) << "OrcaPrinterAgent::send_message: cloud agent is not OrcaCloudServiceAgent"; + } + }).detach(); + return BAMBU_NETWORK_SUCCESS; } @@ -123,11 +181,68 @@ std::string OrcaPrinterAgent::get_user_selected_machine() int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) { - std::lock_guard lock(state_mutex); - selected_machine = dev_id; + std::shared_ptr cloud; + std::string previous; + { + std::lock_guard lock(state_mutex); + if (dev_id == selected_machine) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: unchanged dev_id=" << dev_id; + return BAMBU_NETWORK_SUCCESS; + } + previous = selected_machine; + selected_machine = dev_id; + cloud = m_cloud_agent; + } + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: previous=" << previous + << " new=" << dev_id << " cloud=" << (cloud ? cloud->get_id() : ""); + if (!cloud) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_user_selected_machine: no cloud agent"; + return BAMBU_NETWORK_SUCCESS; + } + + // One report topic at a time. add_subscribe/del_subscribe only mutate a set and + // wake the MQTT worker, so they are safe to call synchronously on the UI thread. + if (!previous.empty()) { + const int result = cloud->del_subscribe({previous}); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: unsubscribe dev_id=" << previous + << " result=" << result; + } + if (!dev_id.empty()) { + const int result = cloud->add_subscribe({dev_id}); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: subscribe dev_id=" << dev_id + << " result=" << result; + // Relay retains nothing: ask the printer for a full snapshot. Async inside + // send_message; returns immediately. + send_message(dev_id, + R"({"pushing":{"command":"pushall","sequence_id":"20001","version":1,"push_target":1}})", + 0, 0); + deliver_mock_get_version(dev_id); + } return BAMBU_NETWORK_SUCCESS; } +void OrcaPrinterAgent::deliver_mock_get_version(const std::string& dev_id) +{ + // The printer would answer an info.get_version request with its firmware/module + // list; OrcaCloud does not relay that yet, so MachineObject::module_vers stays + // empty and is_info_ready(check_version) never passes (StatusPanel bails, every + // field renders N/A). Synthesize the reply and push it through the same sink as + // real report messages so parse_json handles it identically. Remove once the + // backend answers info.get_version on device//report. + OnMessageFn fn; + { + std::lock_guard lock(state_mutex); + fn = on_message_fn; + } + if (!fn) + return; + static const std::string kMockGetVersion = + R"({"info":{"command":"get_version","sequence_id":"0","module":[)" + R"({"name":"ota","product_name":"OrcaCloud Printer","hw_ver":"","sw_ver":"01.00.00.00","sn":""}]}})"; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: delivering mock info.get_version for dev_id=" << dev_id; + fn(dev_id, kMockGetVersion); +} + // ============================================================================ // Agent Information // ============================================================================ @@ -197,6 +312,7 @@ int OrcaPrinterAgent::set_on_message_fn(OnMessageFn fn) { std::lock_guard lock(state_mutex); on_message_fn = fn; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_on_message_fn: callback=" << (fn ? "set" : "clear"); return BAMBU_NETWORK_SUCCESS; } diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index a1613420a5..9cf2b29638 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -9,6 +9,8 @@ namespace Slic3r { +class OrcaCloudServiceAgent; + /** * OrcaPrinterAgent - Stub implementation for printer operations. * @@ -81,6 +83,12 @@ private: std::string log_dir; std::string selected_machine; std::shared_ptr m_cloud_agent; + OrcaCloudServiceAgent* m_orca_cloud = nullptr; // == m_cloud_agent.get() when the Orca provider is active + + // MOCK: OrcaCloud does not yet relay the printer's info.get_version reply, so + // synthesize it and feed it through on_message_fn (same sink as real report + // messages). Delete once the backend answers info.get_version. + void deliver_mock_get_version(const std::string& dev_id); // Callbacks OnMsgArrivedFn on_ssdp_msg_fn; From d55a0bfec689a841b9a1a034f34d54ef1b3efb90 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 1 Sep 2026 18:37:04 +0800 Subject: [PATCH 07/24] fix: build errors --- src/slic3r/Utils/SnapmakerPrinterAgent.hpp | 2 +- .../printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/slic3r/Utils/SnapmakerPrinterAgent.hpp b/src/slic3r/Utils/SnapmakerPrinterAgent.hpp index c1e35701c3..416796be56 100644 --- a/src/slic3r/Utils/SnapmakerPrinterAgent.hpp +++ b/src/slic3r/Utils/SnapmakerPrinterAgent.hpp @@ -20,7 +20,7 @@ public: bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override; FilamentSyncMode get_filament_sync_mode() const override; - int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override; + // int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override; int command_start_camera(std::string dev_id) override; CameraStreamMode get_camera_stream_mode() const override { return CameraStreamMode::http_snapshot; } std::string get_camera_url() const override { return device_info.base_url + "/server/files/camera/monitor.jpg"; } diff --git a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp index 88051ed649..9ffd2f4643 100644 --- a/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp +++ b/src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapabilityTrampoline.hpp @@ -102,14 +102,14 @@ public: CameraStreamMode get_camera_stream_mode() const override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, CameraStreamMode, PrinterAgentPluginCapability, + [] {}, PYBIND11_OVERRIDE_PURE, CameraStreamMode, PrinterAgentPluginCapability, get_camera_stream_mode); } std::string get_camera_url() const override { ORCA_PY_OVERRIDE_AUDITED( - ::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, std::string, PrinterAgentPluginCapability, get_camera_url); + [] {}, PYBIND11_OVERRIDE_PURE, std::string, PrinterAgentPluginCapability, get_camera_url); } bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override From 4320cc78d9117a1788b8d8fa10165ae00b120262 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 1 Sep 2026 18:59:20 +0800 Subject: [PATCH 08/24] feat: camera via webrtc --- CMakeLists.txt | 43 ++ deps/CMakeLists.txt | 3 + deps/DataChannel/DataChannel.cmake | 20 + src/slic3r/CMakeLists.txt | 9 +- src/slic3r/GUI/IMediaController.hpp | 11 + src/slic3r/GUI/MediaPlayCtrl.cpp | 110 ++++- src/slic3r/GUI/MediaPlayCtrl.h | 6 + src/slic3r/GUI/StatusPanel.cpp | 14 +- src/slic3r/GUI/WebRtcFrameAssembler.cpp | 86 ++++ src/slic3r/GUI/WebRtcFrameAssembler.hpp | 39 ++ src/slic3r/GUI/WebRtcMediaController.cpp | 449 ++++++++++++++++++ src/slic3r/GUI/WebRtcMediaController.hpp | 96 ++++ src/slic3r/GUI/wxMediaCtrl3.cpp | 62 +++ src/slic3r/GUI/wxMediaCtrl3.h | 13 +- src/slic3r/Utils/ICameraSignalingChannel.hpp | 40 ++ src/slic3r/Utils/IPrinterAgent.hpp | 10 + src/slic3r/Utils/NetworkAgent.cpp | 8 + src/slic3r/Utils/NetworkAgent.hpp | 1 + .../Utils/OrcaCloudSignalingChannel.cpp | 320 +++++++++++++ .../Utils/OrcaCloudSignalingChannel.hpp | 63 +++ src/slic3r/Utils/OrcaPrinterAgent.cpp | 35 ++ src/slic3r/Utils/OrcaPrinterAgent.hpp | 7 + tests/slic3rutils/CMakeLists.txt | 1 + .../test_webrtc_frame_assembler.cpp | 67 +++ 24 files changed, 1505 insertions(+), 8 deletions(-) create mode 100644 deps/DataChannel/DataChannel.cmake create mode 100644 src/slic3r/GUI/WebRtcFrameAssembler.cpp create mode 100644 src/slic3r/GUI/WebRtcFrameAssembler.hpp create mode 100644 src/slic3r/GUI/WebRtcMediaController.cpp create mode 100644 src/slic3r/GUI/WebRtcMediaController.hpp create mode 100644 src/slic3r/Utils/ICameraSignalingChannel.hpp create mode 100644 src/slic3r/Utils/OrcaCloudSignalingChannel.cpp create mode 100644 src/slic3r/Utils/OrcaCloudSignalingChannel.hpp create mode 100644 tests/slic3rutils/test_webrtc_frame_assembler.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8b954ef753..457a7b96c8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -785,6 +785,49 @@ find_package(OpenSSL REQUIRED) find_package(CURL REQUIRED) find_package(Freetype REQUIRED) +if (SLIC3R_GUI) + # LibDataChannel's installed export references its bundled dependencies, + # but does not install their CMake targets. Recreate those targets from + # the same dependency prefix before loading the LibDataChannel config. + if (NOT TARGET Usrsctp::usrsctp) + find_library(_ORCA_USRSCTP_LIBRARY NAMES usrsctp + PATHS "${CMAKE_PREFIX_PATH}/lib" NO_DEFAULT_PATH) + if (_ORCA_USRSCTP_LIBRARY) + add_library(Usrsctp::usrsctp UNKNOWN IMPORTED GLOBAL) + set_target_properties(Usrsctp::usrsctp PROPERTIES + IMPORTED_LOCATION "${_ORCA_USRSCTP_LIBRARY}" + IMPORTED_LINK_INTERFACE_LANGUAGES C + INTERFACE_LINK_LIBRARIES "Threads::Threads") + endif() + endif() + + if (NOT TARGET libSRTP::srtp2) + find_library(_ORCA_SRTP_LIBRARY NAMES srtp2 + PATHS "${CMAKE_PREFIX_PATH}/lib" NO_DEFAULT_PATH) + if (_ORCA_SRTP_LIBRARY) + add_library(libSRTP::srtp2 UNKNOWN IMPORTED GLOBAL) + set_target_properties(libSRTP::srtp2 PROPERTIES + IMPORTED_LOCATION "${_ORCA_SRTP_LIBRARY}" + IMPORTED_LINK_INTERFACE_LANGUAGES C + INTERFACE_LINK_LIBRARIES "OpenSSL::Crypto") + endif() + endif() + + if (NOT TARGET LibJuice::LibJuice) + find_library(_ORCA_LIBJUICE_LIBRARY NAMES juice + PATHS "${CMAKE_PREFIX_PATH}/lib" NO_DEFAULT_PATH) + if (_ORCA_LIBJUICE_LIBRARY) + add_library(LibJuice::LibJuice UNKNOWN IMPORTED GLOBAL) + set_target_properties(LibJuice::LibJuice PROPERTIES + IMPORTED_LOCATION "${_ORCA_LIBJUICE_LIBRARY}" + IMPORTED_LINK_INTERFACE_LANGUAGES C + INTERFACE_LINK_LIBRARIES "Threads::Threads") + endif() + endif() + + find_package(LibDataChannel CONFIG REQUIRED) +endif() + add_library(libcurl INTERFACE) target_link_libraries(libcurl INTERFACE CURL::libcurl) diff --git a/deps/CMakeLists.txt b/deps/CMakeLists.txt index ed3af70d03..a645e3a752 100644 --- a/deps/CMakeLists.txt +++ b/deps/CMakeLists.txt @@ -389,6 +389,8 @@ if(NOT OPENSSL_FOUND) set(OPENSSL_PKG dep_OpenSSL) endif() +include(DataChannel/DataChannel.cmake) + # we don't want to load a "wrong" openssl when loading curl # so, just don't even bother # ...i think this is how it works? change if wrong @@ -461,6 +463,7 @@ set(_dep_list dep_wxInspector dep_FFMPEG dep_Assimp + dep_DataChannel ) if (MSVC) diff --git a/deps/DataChannel/DataChannel.cmake b/deps/DataChannel/DataChannel.cmake new file mode 100644 index 0000000000..5e9b9a358b --- /dev/null +++ b/deps/DataChannel/DataChannel.cmake @@ -0,0 +1,20 @@ +# libdatachannel is the native ICE/DTLS/SCTP/SRTP implementation used by the +# GUI WebRTC camera controller. Keep the source revision fixed: the signaling +# protocol is evolving independently of this transport dependency. +orcaslicer_add_cmake_project(DataChannel + CMAKE_ARGS + -DNO_EXAMPLES=ON + -DNO_TESTS=ON + -DNO_WEBSOCKET=ON + -DNO_MEDIA=OFF + -DUSE_NICE=OFF + -DUSE_SYSTEM_SRTP=OFF + -DUSE_SYSTEM_JUICE=OFF + -DUSE_SYSTEM_USRSCTP=OFF + -DOPENSSL_ROOT_DIR:PATH=${DESTDIR} + -DOPENSSL_USE_STATIC_LIBS=ON + GIT_REPOSITORY https://github.com/paullouisageneau/libdatachannel.git + GIT_TAG v0.22.2 + GIT_SHALLOW ON + GIT_SUBMODULES_RECURSE ON +) diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 8308161a7b..322b3b3f0e 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -344,6 +344,10 @@ set(SLIC3R_GUI_SOURCES GUI/MediaFilePanel.h GUI/MediaPlayCtrl.cpp GUI/MediaPlayCtrl.h + GUI/WebRtcFrameAssembler.cpp + GUI/WebRtcFrameAssembler.hpp + GUI/WebRtcMediaController.cpp + GUI/WebRtcMediaController.hpp GUI/MeshUtils.cpp GUI/MeshUtils.hpp GUI/ModelMall.cpp @@ -723,10 +727,13 @@ set(SLIC3R_GUI_SOURCES Utils/NetworkAgentFactory.cpp Utils/ICloudServiceAgent.hpp Utils/IPrinterAgent.hpp + Utils/ICameraSignalingChannel.hpp Utils/OrcaCloudServiceAgent.cpp Utils/OrcaCloudServiceAgent.hpp Utils/OrcaPrinterAgent.cpp Utils/OrcaPrinterAgent.hpp + Utils/OrcaCloudSignalingChannel.cpp + Utils/OrcaCloudSignalingChannel.hpp Utils/QidiPrinterAgent.cpp Utils/QidiPrinterAgent.hpp Utils/SnapmakerPrinterAgent.cpp @@ -855,7 +862,7 @@ else() set(_opengl_link_lib OpenGL::GL) endif() -target_link_libraries(libslic3r_gui libslic3r cereal::cereal imgui imguizmo minilzo libvgcode md4c-html glad ${_opengl_link_lib} hidapi mdns ${wxWidgets_LIBRARIES} glfw libcurl OpenSSL::SSL OpenSSL::Crypto noise::noise pybind11::embed) +target_link_libraries(libslic3r_gui libslic3r cereal::cereal imgui imguizmo minilzo libvgcode md4c-html glad ${_opengl_link_lib} hidapi mdns ${wxWidgets_LIBRARIES} glfw libcurl OpenSSL::SSL OpenSSL::Crypto LibDataChannel::LibDataChannel noise::noise pybind11::embed) if (CMAKE_SYSTEM_NAME STREQUAL "Linux") # Linux finds wxWidgets in module mode, whose include dirs and definitions diff --git a/src/slic3r/GUI/IMediaController.hpp b/src/slic3r/GUI/IMediaController.hpp index 982157e5bc..411932ec7d 100644 --- a/src/slic3r/GUI/IMediaController.hpp +++ b/src/slic3r/GUI/IMediaController.hpp @@ -3,6 +3,8 @@ #include #include +#include + #include namespace Slic3r { namespace GUI { @@ -10,6 +12,8 @@ namespace Slic3r { namespace GUI { class IMediaController { public: + virtual ~IMediaController() = default; + virtual void Load(wxURI url) = 0; // The default keeps existing media controllers unaware of camera-specific modes. @@ -29,6 +33,13 @@ public: virtual wxSize GetVideoSize() const { return {}; }; + virtual void StartSession(std::unique_ptr channel) + { + (void) channel; + } + + virtual void StopSession() {} + private: }; diff --git a/src/slic3r/GUI/MediaPlayCtrl.cpp b/src/slic3r/GUI/MediaPlayCtrl.cpp index 77a183f090..9b7198005a 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.cpp +++ b/src/slic3r/GUI/MediaPlayCtrl.cpp @@ -134,6 +134,11 @@ MediaPlayCtrl::MediaPlayCtrl(wxWindow *parent, wxMediaCtrl3 *media_ctrl, const w MediaPlayCtrl::~MediaPlayCtrl() { + m_webrtc_stopping = true; + if (m_webrtc_ctrl) + m_webrtc_ctrl->StopSession(); + m_media_ctrl->EndExternalStream(); + m_webrtc_stopping = false; { boost::unique_lock lock(m_mutex); m_tasks.push_back(""); @@ -159,7 +164,14 @@ CameraStreamMode MediaPlayCtrl::current_mode() const void MediaPlayCtrl::SetMachineObject(MachineObject* obj) { - switch (current_mode()) { + const CameraStreamMode mode = current_mode(); + if (mode != m_last_mode) { + if (m_last_state != MEDIASTATE_IDLE) + Stop(" "); + m_last_mode = mode; + } + + switch (mode) { case CameraStreamMode::http: case CameraStreamMode::http_snapshot: case CameraStreamMode::rtsp: { @@ -184,6 +196,28 @@ void MediaPlayCtrl::SetMachineObject(MachineObject* obj) Play(); return; } + case CameraStreamMode::webrtc: { + std::string machine = obj ? obj->get_dev_id() : ""; + m_camera_exists = obj != nullptr; + Enable(obj != nullptr); + const bool changed = machine != m_machine; + BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl::SetMachineObject webrtc: changed=" << changed + << " last_state=" << m_last_state << " web_user_stopped=" << m_web_user_stopped; + m_machine = machine; + m_url.clear(); + m_agent_camera_url.clear(); + if (!changed) { + if (m_last_state == MEDIASTATE_IDLE && IsEnabled() && !m_web_user_stopped) + Play(); + return; + } + m_web_user_stopped = false; + if (m_last_state != MEDIASTATE_IDLE) + Stop(" "); + if (IsEnabled()) + Play(); + return; + } default: break; } @@ -321,6 +355,48 @@ void MediaPlayCtrl::Play() m_button_play->SetIcon("media_stop"); load(); return; + case CameraStreamMode::webrtc: { + BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl::Play webrtc: last_state=" << m_last_state + << " next_retry_valid=" << m_next_retry.IsValid() + << " next_retry_future=" << (m_next_retry.IsValid() && wxDateTime::Now() < m_next_retry) + << " failed_retry=" << m_failed_retry << " shown=" << IsShownOnScreen(); + if (m_webrtc_ctrl && m_webrtc_ctrl->is_active()) { + BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl::Play webrtc: session already active, ignoring"; + return; + } + if (m_next_retry.IsValid() && wxDateTime::Now() < m_next_retry) + return; + if (!IsShownOnScreen() || m_last_state != MEDIASTATE_IDLE) + return; + m_failed_code = 0; + if (m_machine.empty() || !IsEnabled() || !m_camera_exists) { + Stop(_L("Please confirm if the printer is connected.")); + return; + } + auto agent = wxGetApp().getAgent(); + auto channel = agent ? agent->create_camera_signaling_channel(m_machine) : nullptr; + if (!channel) { + Stop(_L("Sign in to OrcaCloud to view the camera.")); + return; + } + if (!m_webrtc_ctrl) { + m_webrtc_ctrl = std::make_unique( + [this](const wxImage& image, wxSize size) { m_media_ctrl->SetExternalFrame(image, size); }, + [this, token = std::weak_ptr(m_token)](WebRtcMediaController::Status status) { + if (token.expired()) + return; + CallAfter([this, status] { on_webrtc_status(status); }); + }); + } + m_button_play->SetIcon("media_stop"); + m_media_ctrl->BeginExternalStream(); + m_last_state = MEDIASTATE_INITIALIZING; + SetStatus(_L("Initializing..."), false); + m_webrtc_stopping = false; + m_webrtc_ctrl->StartSession(std::move(channel)); + m_webrtc_epoch = m_webrtc_ctrl->epoch(); + return; + } default: break; } @@ -465,6 +541,17 @@ void MediaPlayCtrl::StopWebStream() void MediaPlayCtrl::Stop(wxString const &msg, wxString const &msg2) { + const bool webrtc_active = m_webrtc_ctrl && (m_last_mode == CameraStreamMode::webrtc || + current_mode() == CameraStreamMode::webrtc); + BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl::Stop: last_state=" << m_last_state + << " webrtc_active=" << webrtc_active << " failed_code=" << m_failed_code + << " msg='" << msg.ToUTF8().data() << "'"; + if (webrtc_active) { + m_webrtc_stopping = true; + m_webrtc_ctrl->StopSession(); + m_media_ctrl->EndExternalStream(); + m_webrtc_stopping = false; + } switch (current_mode()) { case CameraStreamMode::http: case CameraStreamMode::http_snapshot: @@ -555,6 +642,27 @@ void MediaPlayCtrl::Stop(wxString const &msg, wxString const &msg2) m_next_retry = wxDateTime::Now() + wxTimeSpan::Seconds(5 * m_failed_retry); } +void MediaPlayCtrl::on_webrtc_status(WebRtcMediaController::Status status) +{ + // Drop CallAfter-queued events from a superseded StartSession attempt. + if (status.epoch != m_webrtc_epoch) + return; + if (status.kind == WebRtcMediaController::Status::Connecting) { + m_last_state = MEDIASTATE_INITIALIZING; + SetStatus(_L("Initializing..."), false); + } else if (status.kind == WebRtcMediaController::Status::Playing) { + m_last_state = wxMEDIASTATE_PLAYING; + m_failed_code = 0; + m_failed_retry = 0; + SetStatus(_L("Playing..."), false); + } else if (status.kind == WebRtcMediaController::Status::Failed) { + m_failed_code = static_cast(status.code) + 1; + Stop(); + } + // Status::Stopped needs no action: a genuine failure arrives as Failed, and + // a stop we initiated is already handled by Stop() itself. +} + void MediaPlayCtrl::TogglePlay() { BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl::TogglePlay"; diff --git a/src/slic3r/GUI/MediaPlayCtrl.h b/src/slic3r/GUI/MediaPlayCtrl.h index e8ad0ee82c..64fb75498e 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.h +++ b/src/slic3r/GUI/MediaPlayCtrl.h @@ -10,6 +10,7 @@ #include "wxMediaCtrl3.h" #include "IMediaController.hpp" +#include "WebRtcMediaController.hpp" #include "slic3r/Utils/IPrinterAgent.hpp" #include @@ -60,6 +61,7 @@ protected: void TogglePlay(); void SetStatus(wxString const &msg, bool hyperlink = true); + void on_webrtc_status(WebRtcMediaController::Status status); private: void load(); @@ -85,6 +87,10 @@ private: wxMediaCtrl3 * m_media_ctrl; IMediaController * m_web_ctrl = nullptr; + std::unique_ptr m_webrtc_ctrl; + CameraStreamMode m_last_mode = CameraStreamMode::none; + bool m_webrtc_stopping = false; + std::uint64_t m_webrtc_epoch = 0; std::string m_agent_camera_url; bool m_web_user_stopped = false; wxMediaState m_last_state = MEDIASTATE_IDLE; diff --git a/src/slic3r/GUI/StatusPanel.cpp b/src/slic3r/GUI/StatusPanel.cpp index b00dee3dca..2d267df082 100644 --- a/src/slic3r/GUI/StatusPanel.cpp +++ b/src/slic3r/GUI/StatusPanel.cpp @@ -2317,10 +2317,16 @@ void StatusPanel::update_camera_state(MachineObject* obj) m_custom_camera_view->Show(); m_media_ctrl->Hide(); } - } else if (m_custom_camera_view->IsShown()) { - m_custom_camera_view->Hide(); - m_media_ctrl->Show(); - m_media_play_ctrl->StopWebStream(); + } else if (camera_mode == CameraStreamMode::rtsp || camera_mode == CameraStreamMode::webrtc || + m_custom_camera_view->IsShown()) { + // Only act on the actual transition away from the webview. Running this + // every tick would call StopWebStream() (which forces m_last_state to + // IDLE) on a live rtsp/webrtc session and desync the state machine. + if (m_custom_camera_view->IsShown()) { + m_custom_camera_view->Hide(); + m_media_ctrl->Show(); + m_media_play_ctrl->StopWebStream(); + } } //sdcard diff --git a/src/slic3r/GUI/WebRtcFrameAssembler.cpp b/src/slic3r/GUI/WebRtcFrameAssembler.cpp new file mode 100644 index 0000000000..2771cc0887 --- /dev/null +++ b/src/slic3r/GUI/WebRtcFrameAssembler.cpp @@ -0,0 +1,86 @@ +#include "WebRtcFrameAssembler.hpp" + +#include +#include + +namespace Slic3r { namespace GUI { + +void WebRtcFrameAssembler::discard() +{ + m_active = false; + m_frame_id = 0; + m_chunk_count = 0; + m_received_chunks = 0; + m_total_size = 0; + m_chunks.clear(); + m_received.clear(); +} + +void WebRtcFrameAssembler::reset() +{ + discard(); +} + +void WebRtcFrameAssembler::feed(const std::byte* data, std::size_t len) +{ + if (data == nullptr || len < HeaderSize) + return; + + if (std::to_integer(data[0]) != 1) + return; + + const std::uint16_t chunk_index = static_cast((std::to_integer(data[2]) << 8) | + std::to_integer(data[3])); + const std::uint16_t chunk_count = static_cast((std::to_integer(data[4]) << 8) | + std::to_integer(data[5])); + const std::uint32_t frame_id = (static_cast(std::to_integer(data[6])) << 24) | + (static_cast(std::to_integer(data[7])) << 16) | + (static_cast(std::to_integer(data[8])) << 8) | + static_cast(std::to_integer(data[9])); + const std::size_t payload_size = len - HeaderSize; + + if (chunk_count == 0 || chunk_count > MaxChunkCount || chunk_index >= chunk_count || + payload_size > MaxChunkPayload) + return; + + if (!m_active || frame_id > m_frame_id) { + discard(); + m_active = true; + m_frame_id = frame_id; + m_chunk_count = chunk_count; + m_chunks.resize(chunk_count); + m_received.assign(chunk_count, false); + } else if (frame_id < m_frame_id) { + return; + } else if (chunk_count != m_chunk_count) { + discard(); + return; + } + + Frame& chunk = m_chunks[chunk_index]; + if (m_received[chunk_index]) + return; + + if (m_total_size > MaxFrameSize || payload_size > MaxFrameSize - m_total_size) { + discard(); + return; + } + + chunk.assign(data + HeaderSize, data + len); + m_received[chunk_index] = true; + m_total_size += payload_size; + ++m_received_chunks; + + if (m_received_chunks != m_chunk_count) + return; + + Frame frame; + frame.reserve(m_total_size); + for (const Frame& slice : m_chunks) + frame.insert(frame.end(), slice.begin(), slice.end()); + if (on_frame) + on_frame(std::move(frame)); + discard(); +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/WebRtcFrameAssembler.hpp b/src/slic3r/GUI/WebRtcFrameAssembler.hpp new file mode 100644 index 0000000000..c526cad588 --- /dev/null +++ b/src/slic3r/GUI/WebRtcFrameAssembler.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include + +namespace Slic3r { namespace GUI { + +class WebRtcFrameAssembler { +public: + using Frame = std::vector; + + // The wire protocol uses a 10-byte header followed by one JPEG slice: + // version, flags, chunk index, chunk count, and frame id, all in network + // byte order where applicable. + static constexpr std::size_t HeaderSize = 10; + static constexpr std::size_t MaxChunkPayload = 16000; + static constexpr std::size_t MaxChunkCount = 4096; + static constexpr std::size_t MaxFrameSize = 8 * 1024 * 1024; + + std::function on_frame; + + void feed(const std::byte* data, std::size_t len); + void reset(); + +private: + void discard(); + + bool m_active = false; + std::uint32_t m_frame_id = 0; + std::uint16_t m_chunk_count = 0; + std::size_t m_received_chunks = 0; + std::size_t m_total_size = 0; + std::vector m_chunks; + std::vector m_received; +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/WebRtcMediaController.cpp b/src/slic3r/GUI/WebRtcMediaController.cpp new file mode 100644 index 0000000000..7eafbd4a3a --- /dev/null +++ b/src/slic3r/GUI/WebRtcMediaController.cpp @@ -0,0 +1,449 @@ +#include "WebRtcMediaController.hpp" + +#include "AVVideoDecoder.hpp" + +#include +#include + +#include +#include +#include + +#include + +#include + +namespace { +void init_rtc_logger_once() +{ + static std::once_flag flag; + std::call_once(flag, [] { + rtc::InitLogger(rtc::LogLevel::Verbose, [](rtc::LogLevel level, std::string message) { + BOOST_LOG_TRIVIAL(info) << "[rtc:" << static_cast(level) << "] " << message; + }); + }); +} +} // namespace + +extern "C" { +#include +} + +namespace Slic3r { namespace GUI { + +WebRtcMediaController::WebRtcMediaController(std::function frame_sink, + std::function on_status) + : m_frame_sink(std::move(frame_sink)) + , m_on_status(std::move(on_status)) +{ +} + +WebRtcMediaController::~WebRtcMediaController() +{ + StopSession(); +} + +void WebRtcMediaController::report(Status status) +{ + status.epoch = m_epoch.load(); + BOOST_LOG_TRIVIAL(info) << "WebRTC: report kind=" << static_cast(status.kind) + << " code=" << static_cast(status.code) << " epoch=" << status.epoch; + { + std::lock_guard lock(m_mutex); + if (status.kind == Status::Connecting) + m_state = static_cast(4); + else if (status.kind == Status::Playing) + m_state = wxMEDIASTATE_PLAYING; + else + m_state = static_cast(3); + } + if (m_on_status) + m_on_status(status); +} + +void WebRtcMediaController::StartSession(std::unique_ptr channel) +{ + // Tear down any previous attempt WITHOUT notifying: the Stopped that would + // otherwise be delivered (async, via CallAfter) races the new attempt's + // Connecting and makes the consumer cancel a session that is mid-connect. + teardown(false); + if (!channel) + return; + + m_epoch.fetch_add(1); + m_alive.store(true); + { + std::lock_guard lock(m_mutex); + m_signaling = std::move(channel); + m_chunk_queue.clear(); + m_nal_queue.clear(); + m_pending_candidates.clear(); + m_remote_description_set = false; + m_video_size = wxDefaultSize; + m_has_frame = false; + m_last_frame_time = {}; + } + + ICameraSignalingChannel* signaling = nullptr; + { + std::lock_guard lock(m_mutex); + signaling = m_signaling.get(); + } + signaling->on_ready = [this](std::vector servers) { + if (m_alive.load()) + on_ready(std::move(servers)); + }; + signaling->on_answer = [this](std::string sdp) { + if (m_alive.load()) + on_answer(std::move(sdp)); + }; + signaling->on_ice = [this](std::string candidate, std::string mid) { + if (m_alive.load()) + on_ice(std::move(candidate), std::move(mid)); + }; + signaling->on_unavailable = [this](CameraUnavailableReason reason, std::string detail) { + if (m_alive.load()) + on_unavailable(reason, std::move(detail)); + }; + + m_decode_thread = std::thread([this] { decode_loop(); }); + report({Status::Connecting}); + signaling->open(); +} + +void WebRtcMediaController::StopSession() +{ + teardown(true); +} + +void WebRtcMediaController::teardown(bool notify) +{ + const bool was_alive = m_alive.exchange(false); + if (!was_alive && !m_decode_thread.joinable()) + return; + + m_cond.notify_all(); + std::unique_ptr signaling; + std::shared_ptr peer_connection; + { + std::lock_guard lock(m_mutex); + signaling = std::move(m_signaling); + peer_connection = std::move(m_peer_connection); + m_data_channel.reset(); + m_video_track.reset(); + } + if (signaling) + signaling->close(); + if (peer_connection) + peer_connection->close(); + if (m_decode_thread.joinable()) + m_decode_thread.join(); + { + std::lock_guard lock(m_mutex); + m_chunk_queue.clear(); + m_nal_queue.clear(); + } + if (was_alive && notify) + report({Status::Stopped}); +} + +wxMediaState WebRtcMediaController::GetState() +{ + std::lock_guard lock(m_mutex); + return m_state; +} + +wxSize WebRtcMediaController::GetVideoSize() const +{ + std::lock_guard lock(m_mutex); + return m_video_size; +} + +void WebRtcMediaController::bind_data_channel(const std::shared_ptr& dc) +{ + const std::string label = dc->label(); + dc->onOpen([this, label] { BOOST_LOG_TRIVIAL(info) << "WebRTC: data channel '" << label << "' open"; }); + dc->onClosed([this, label] { BOOST_LOG_TRIVIAL(info) << "WebRTC: data channel '" << label << "' closed"; }); + dc->onError([label](std::string e) { + BOOST_LOG_TRIVIAL(warning) << "WebRTC: data channel '" << label << "' error: " << e; + }); + dc->onMessage( + [this](rtc::binary data) { + if (m_alive.load()) + enqueue_chunk(std::vector(data.begin(), data.end())); + }, + [](rtc::string) {}); +} + +void WebRtcMediaController::on_ready(std::vector servers) +{ + init_rtc_logger_once(); + rtc::Configuration configuration; + for (const CameraIceServer& server : servers) { + try { + rtc::IceServer ice_server(server.urls); + ice_server.username = server.username; + ice_server.password = server.credential; + configuration.iceServers.emplace_back(std::move(ice_server)); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "WebRTC: invalid ICE server: " << e.what(); + } + } + configuration.iceServers.emplace_back("stun:stun.cloudflare.com:3478"); + configuration.iceServers.emplace_back("stun:stun.l.google.com:19302"); + + auto peer_connection = std::make_shared(std::move(configuration)); + BOOST_LOG_TRIVIAL(info) << "WebRTC: creating peer connection with " << configuration.iceServers.size() + << " ice servers"; + peer_connection->onLocalDescription([this](rtc::Description description) { + if (!m_alive.load()) + return; + const std::string sdp(description); + BOOST_LOG_TRIVIAL(info) << "WebRTC: local description ready (" << description.typeString() + << "), OFFER SDP:\n" << sdp; + std::lock_guard lock(m_mutex); + if (m_signaling) + m_signaling->send_offer(sdp); + }); + peer_connection->onLocalCandidate([this](rtc::Candidate candidate) { + if (!m_alive.load()) + return; + std::lock_guard lock(m_mutex); + if (m_signaling) + m_signaling->send_ice(std::string(candidate), candidate.mid()); + }); + peer_connection->onStateChange([this](rtc::PeerConnection::State state) { + BOOST_LOG_TRIVIAL(info) << "WebRTC: peer state -> " << static_cast(state); + if (!m_alive.load()) + return; + if (state == rtc::PeerConnection::State::Failed || state == rtc::PeerConnection::State::Disconnected) + report({Status::Failed, Status::ICE_FAILED}); + }); + peer_connection->onGatheringStateChange([](rtc::PeerConnection::GatheringState state) { + BOOST_LOG_TRIVIAL(info) << "WebRTC: gathering state -> " << static_cast(state); + }); + + // Accept a DataChannel opened by the remote peer (OrcaSonar may create the + // "camera" channel from its side rather than answering the one we offer). + peer_connection->onDataChannel([this](std::shared_ptr dc) { + BOOST_LOG_TRIVIAL(info) << "WebRTC: remote opened data channel '" << dc->label() << "'"; + bind_data_channel(dc); + std::lock_guard lock(m_mutex); + m_data_channel = std::move(dc); + }); + + rtc::DataChannelInit init; + init.reliability.unordered = true; + init.reliability.maxPacketLifeTime = std::chrono::milliseconds(350); + auto data_channel = peer_connection->createDataChannel("camera", init); + if (data_channel) + bind_data_channel(data_channel); + + // NOTE: the H.264 RTP RecvOnly track is intentionally NOT added to the offer + // yet. OrcaSonar answers application-only (no m=video), which makes + // libdatachannel renegotiate and send a second offer that OrcaSonar rejects + // with "webrtc.unavailable: error". Re-add the video m-line (enqueue_nal / + // the decode_loop NAL branch are already in place) once OrcaSonar answers it. + + std::shared_ptr peer_for_description; + { + std::lock_guard lock(m_mutex); + if (!m_alive.load()) + return; + m_peer_connection = std::move(peer_connection); + peer_for_description = m_peer_connection; + m_data_channel = std::move(data_channel); + } + if (peer_for_description) + peer_for_description->setLocalDescription(); +} + +void WebRtcMediaController::on_answer(std::string sdp) +{ + std::shared_ptr peer_connection; + { + std::lock_guard lock(m_mutex); + peer_connection = m_peer_connection; + } + BOOST_LOG_TRIVIAL(info) << "WebRTC: applying remote answer (" << sdp.size() << " bytes), ANSWER SDP:\n" << sdp; + if (!peer_connection) + return; + try { + peer_connection->setRemoteDescription(rtc::Description(sdp, "answer")); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "WebRTC: setRemoteDescription failed: " << e.what(); + report({Status::Failed, Status::ICE_FAILED}); + return; + } + + // Flush any remote candidates that arrived before the answer. + std::vector> pending; + { + std::lock_guard lock(m_mutex); + m_remote_description_set = true; + pending.swap(m_pending_candidates); + } + BOOST_LOG_TRIVIAL(info) << "WebRTC: remote description set, flushing " << pending.size() + << " buffered candidate(s)"; + for (const auto& c : pending) { + try { + peer_connection->addRemoteCandidate(rtc::Candidate(c.first, c.second)); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "WebRTC: addRemoteCandidate (buffered) failed: " << e.what(); + } + } +} + +void WebRtcMediaController::on_ice(std::string candidate, std::string mid) +{ + std::shared_ptr peer_connection; + { + std::lock_guard lock(m_mutex); + if (!m_remote_description_set) { + m_pending_candidates.emplace_back(std::move(candidate), std::move(mid)); + return; + } + peer_connection = m_peer_connection; + } + if (!peer_connection) + return; + try { + peer_connection->addRemoteCandidate(rtc::Candidate(candidate, mid)); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "WebRTC: addRemoteCandidate failed: " << e.what(); + } +} + +void WebRtcMediaController::on_unavailable(CameraUnavailableReason reason, std::string detail) +{ + BOOST_LOG_TRIVIAL(warning) << "WebRTC camera unavailable: " << detail; + Status::Code code = Status::UNAVAILABLE_ERROR; + if (reason == CameraUnavailableReason::Busy) + code = Status::UNAVAILABLE_BUSY; + else if (reason == CameraUnavailableReason::Disabled) + code = Status::UNAVAILABLE_DISABLED; + else if (reason == CameraUnavailableReason::Closed) + code = Status::SIGNALING_CLOSED; + report({Status::Failed, code}); +} + +void WebRtcMediaController::enqueue_chunk(std::vector chunk) +{ + std::lock_guard lock(m_mutex); + if (m_chunk_queue.size() >= 8) + m_chunk_queue.pop_front(); + m_chunk_queue.emplace_back(std::move(chunk)); + m_cond.notify_one(); +} + +void WebRtcMediaController::enqueue_nal(std::vector nal) +{ + std::lock_guard lock(m_mutex); + if (m_nal_queue.size() >= 4) + m_nal_queue.pop_front(); + m_nal_queue.emplace_back(std::move(nal)); + m_cond.notify_one(); +} + +void WebRtcMediaController::deliver_jpeg(std::vector jpeg) +{ + const auto now = std::chrono::steady_clock::now(); + { + std::lock_guard lock(m_mutex); + if (m_last_frame_time != std::chrono::steady_clock::time_point{} && + now - m_last_frame_time < std::chrono::milliseconds(33)) + return; + m_last_frame_time = now; + } + wxMemoryInputStream stream(jpeg.data(), jpeg.size()); + wxImage image; + if (!image.LoadFile(stream, wxBITMAP_TYPE_JPEG)) { + report({Status::Failed, Status::DECODE_ERROR}); + return; + } + bool first_frame = false; + { + std::lock_guard lock(m_mutex); + m_video_size = image.GetSize(); + first_frame = !m_has_frame; + m_has_frame = true; + } + if (m_frame_sink) + m_frame_sink(image, image.GetSize()); + if (first_frame) + report({Status::Playing}); +} + +void WebRtcMediaController::decode_loop() +{ + WebRtcFrameAssembler assembler; + assembler.on_frame = [this](std::vector jpeg) { deliver_jpeg(std::move(jpeg)); }; + AVCodecParameters parameters{}; + parameters.codec_type = AVMEDIA_TYPE_VIDEO; + parameters.codec_id = AV_CODEC_ID_H264; + AVVideoDecoder decoder; + bool decoder_open = false; + + int stall_polls = 0; + std::unique_lock lock(m_mutex); + while (m_alive.load()) { + const bool woke = m_cond.wait_for(lock, std::chrono::seconds(2), [this] { + return !m_alive.load() || !m_chunk_queue.empty() || !m_nal_queue.empty(); + }); + if (!m_alive.load()) + break; + if (!woke && !m_has_frame) { + const int pc_state = m_peer_connection ? static_cast(m_peer_connection->state()) : -1; + std::string dc = "none"; + if (m_data_channel) + dc = "label='" + m_data_channel->label() + "' open=" + + (m_data_channel->isOpen() ? "1" : "0"); + lock.unlock(); + BOOST_LOG_TRIVIAL(info) << "WebRTC: waiting for frames; peer_state=" << pc_state + << " data_channel=" << dc; + if (++stall_polls >= 8) { // ~16s connected with no frame -> give up so the UI can retry + report({Status::Failed, Status::TIMEOUT}); + lock.lock(); + break; + } + lock.lock(); + continue; + } + stall_polls = 0; + if (!m_chunk_queue.empty()) { + auto chunk = std::move(m_chunk_queue.front()); + m_chunk_queue.pop_front(); + lock.unlock(); + assembler.feed(chunk.data(), chunk.size()); + lock.lock(); + } else if (!m_nal_queue.empty()) { + auto nal = std::move(m_nal_queue.front()); + m_nal_queue.pop_front(); + lock.unlock(); + if (!decoder_open) + decoder_open = decoder.open(parameters) == 0; + if (decoder_open) { + AVPacket* packet = av_packet_alloc(); + if (packet && av_new_packet(packet, static_cast(nal.size())) == 0) { + std::memcpy(packet->data, nal.data(), nal.size()); + if (decoder.decode(*packet) == 0) { + wxImage image; + if (decoder.toWxImage(image, wxDefaultSize)) { + { + std::lock_guard frame_lock(m_mutex); + m_video_size = image.GetSize(); + } + if (m_frame_sink) + m_frame_sink(image, image.GetSize()); + report({Status::Playing}); + } + } + } + av_packet_free(&packet); + } + lock.lock(); + } + } +} + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/WebRtcMediaController.hpp b/src/slic3r/GUI/WebRtcMediaController.hpp new file mode 100644 index 0000000000..1cbd05fcd7 --- /dev/null +++ b/src/slic3r/GUI/WebRtcMediaController.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include "IMediaController.hpp" +#include "WebRtcFrameAssembler.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rtc { +class DataChannel; +class PeerConnection; +class Track; +} + +namespace Slic3r { namespace GUI { + +class WebRtcMediaController : public IMediaController { +public: + struct Status { + enum Kind { Connecting, Playing, Stopped, Failed } kind = Stopped; + enum Code { + ICE_FAILED, + SIGNALING_CLOSED, + UNAVAILABLE_BUSY, + UNAVAILABLE_ERROR, + UNAVAILABLE_DISABLED, + DECODE_ERROR, + TIMEOUT, + } code = ICE_FAILED; + // Identifies the StartSession attempt this status belongs to, so the + // consumer can drop CallAfter-queued events from a superseded attempt. + std::uint64_t epoch = 0; + }; + + WebRtcMediaController(std::function frame_sink, + std::function on_status); + ~WebRtcMediaController() override; + + void StartSession(std::unique_ptr channel) override; + void StopSession() override; + std::uint64_t epoch() const { return m_epoch.load(); } + bool is_active() const { return m_alive.load(); } + + void Load(wxURI) override {} + void Play() override {} + void Stop() override { StopSession(); } + wxMediaState GetState() override; + wxSize GetVideoSize() const override; + +private: + void teardown(bool notify); + void report(Status status); + void bind_data_channel(const std::shared_ptr& dc); + void on_ready(std::vector servers); + void on_answer(std::string sdp); + void on_ice(std::string candidate, std::string mid); + void on_unavailable(CameraUnavailableReason reason, std::string detail); + void decode_loop(); + void enqueue_chunk(std::vector chunk); + void enqueue_nal(std::vector nal); + void deliver_jpeg(std::vector jpeg); + + mutable std::mutex m_mutex; + std::condition_variable m_cond; + std::deque> m_chunk_queue; + std::deque> m_nal_queue; + // Remote candidates can arrive before the answer; libdatachannel rejects + // addRemoteCandidate until a remote description is set, so buffer them. + std::vector> m_pending_candidates; + bool m_remote_description_set = false; + std::unique_ptr m_signaling; + std::shared_ptr m_peer_connection; + std::shared_ptr m_data_channel; + std::shared_ptr m_video_track; + std::thread m_decode_thread; + std::atomic m_alive{false}; + std::atomic m_epoch{0}; + wxMediaState m_state = static_cast(3); + wxSize m_video_size = wxDefaultSize; + std::function m_frame_sink; + std::function m_on_status; + bool m_has_frame = false; + std::chrono::steady_clock::time_point m_last_frame_time{}; +}; + +}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index d0d53072d6..2d6412afe4 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -46,9 +46,13 @@ wxMediaCtrl3::~wxMediaCtrl3() m_thread.join(); } +static void adjust_frame_size(wxSize& frame, wxSize const& video, wxSize const& window); + void wxMediaCtrl3::Load(wxURI url) { std::unique_lock lk(m_mutex); + if (m_external) + return; m_video_size = wxDefaultSize; m_error = 0; m_url.reset(new wxURI(url)); @@ -58,6 +62,8 @@ void wxMediaCtrl3::Load(wxURI url) void wxMediaCtrl3::Play() { std::unique_lock lk(m_mutex); + if (m_external) + return; if (m_state != wxMEDIASTATE_PLAYING) { m_state = wxMEDIASTATE_PLAYING; wxMediaEvent event(wxEVT_MEDIA_STATECHANGED); @@ -77,6 +83,62 @@ void wxMediaCtrl3::Stop() Refresh(); } +void wxMediaCtrl3::SetExternalFrame(const wxImage& frame, wxSize videoSize) +{ + if (!frame.IsOk()) + return; + { + std::unique_lock lk(m_mutex); + if (!m_external) + return; + m_frame = frame; + m_video_size = videoSize.IsFullySpecified() ? videoSize : frame.GetSize(); + adjust_frame_size(m_frame_size, m_video_size, GetSize()); + } + CallAfter([this] { Refresh(); }); +} + +#ifdef _WIN32 +void wxMediaCtrl3::SetExternalFrame(const wxBitmap& frame, wxSize videoSize) +{ + if (!frame.IsOk()) + return; + { + std::unique_lock lk(m_mutex); + if (!m_external) + return; + m_frame = frame; + m_video_size = videoSize.IsFullySpecified() ? videoSize : frame.GetSize(); + adjust_frame_size(m_frame_size, m_video_size, GetSize()); + } + CallAfter([this] { Refresh(); }); +} +#endif + +void wxMediaCtrl3::BeginExternalStream() +{ + std::unique_lock lk(m_mutex); + m_external = true; + m_url.reset(); + m_active_url.reset(); + m_video_size = wxDefaultSize; + m_frame = wxImage(m_idle_image); + m_cond.notify_all(); + Refresh(); +} + +void wxMediaCtrl3::EndExternalStream() +{ + std::unique_lock lk(m_mutex); + m_external = false; + m_url.reset(); + m_active_url.reset(); + m_video_size = wxDefaultSize; + m_frame = wxImage(m_idle_image); + m_cond.notify_all(); + Refresh(); +} + void wxMediaCtrl3::SetIdleImage(wxString const &image) { if (m_idle_image == image) diff --git a/src/slic3r/GUI/wxMediaCtrl3.h b/src/slic3r/GUI/wxMediaCtrl3.h index bcc94a17ef..dee49ddbe8 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.h +++ b/src/slic3r/GUI/wxMediaCtrl3.h @@ -18,9 +18,7 @@ void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, in #define BAMBU_DYNAMIC #include #include -#ifndef _WIN32 #include -#endif #include "Printer/BambuTunnel.h" class AVVideoDecoder; @@ -38,6 +36,16 @@ public: void Stop(); + // Render frames supplied by a controller which owns its own transport. + // The frame is copied while m_mutex is held; callers may release it after + // this method returns. + void SetExternalFrame(const wxImage& frame, wxSize videoSize); +#ifdef _WIN32 + void SetExternalFrame(const wxBitmap& frame, wxSize videoSize); +#endif + void BeginExternalStream(); + void EndExternalStream(); + void SetIdleImage(wxString const & image); wxMediaState GetState(); @@ -77,6 +85,7 @@ private: std::shared_ptr m_url; std::shared_ptr m_active_url; + bool m_external = false; std::uint64_t m_last_PTS{0}; std::chrono::system_clock::time_point m_last_PTS_expected; std::chrono::system_clock::time_point m_last_PTS_practical; diff --git a/src/slic3r/Utils/ICameraSignalingChannel.hpp b/src/slic3r/Utils/ICameraSignalingChannel.hpp new file mode 100644 index 0000000000..70df298843 --- /dev/null +++ b/src/slic3r/Utils/ICameraSignalingChannel.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +namespace Slic3r { + +struct CameraIceServer { + std::string urls; + std::string username; + std::string credential; +}; + +enum class CameraUnavailableReason { + Busy, + Error, + Disabled, + Closed, +}; + +class ICameraSignalingChannel { +public: + virtual ~ICameraSignalingChannel() = default; + + virtual void open() = 0; + virtual void close() = 0; + virtual void send_offer(std::string sdp) = 0; + virtual void send_ice(std::string candidate, std::string mid) = 0; + + // These callbacks are invoked by the channel's worker thread. Consumers + // must marshal UI work to the GUI thread themselves. + std::function)> on_ready; + std::function on_answer; + std::function on_ice; + std::function on_unavailable; +}; + +} // namespace Slic3r diff --git a/src/slic3r/Utils/IPrinterAgent.hpp b/src/slic3r/Utils/IPrinterAgent.hpp index c076b57be5..240b00395b 100644 --- a/src/slic3r/Utils/IPrinterAgent.hpp +++ b/src/slic3r/Utils/IPrinterAgent.hpp @@ -14,6 +14,7 @@ #include #include #include +#include "ICameraSignalingChannel.hpp" #if 1 @@ -382,6 +383,15 @@ public: * Only meaningful when get_camera_stream_mode() returns an HTTP or RTSP mode. */ virtual std::string get_camera_url() const { return {}; } + + // Optional native camera signaling. Plugin agents retain the default + // nullptr until a plugin-facing WebRTC contract is defined. + virtual std::unique_ptr + create_camera_signaling_channel(const std::string& dev_id) + { + (void) dev_id; + return nullptr; + } }; } // namespace Slic3r diff --git a/src/slic3r/Utils/NetworkAgent.cpp b/src/slic3r/Utils/NetworkAgent.cpp index 01ed03f84f..4857151c58 100644 --- a/src/slic3r/Utils/NetworkAgent.cpp +++ b/src/slic3r/Utils/NetworkAgent.cpp @@ -1040,6 +1040,14 @@ std::string NetworkAgent::get_local_camera_stream_url() const return {}; } +std::unique_ptr +NetworkAgent::create_camera_signaling_channel(const std::string& dev_id) +{ + if (m_printer_agent) + return m_printer_agent->create_camera_signaling_channel(dev_id); + return nullptr; +} + int NetworkAgent::request_bind_ticket(std::string* ticket) { if (m_printer_agent) diff --git a/src/slic3r/Utils/NetworkAgent.hpp b/src/slic3r/Utils/NetworkAgent.hpp index 7eb220b341..a051bdd231 100644 --- a/src/slic3r/Utils/NetworkAgent.hpp +++ b/src/slic3r/Utils/NetworkAgent.hpp @@ -183,6 +183,7 @@ public: bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull); CameraStreamMode get_camera_stream_mode() const; std::string get_local_camera_stream_url() const; + std::unique_ptr create_camera_signaling_channel(const std::string& dev_id); int request_bind_ticket(std::string* ticket); int get_hms_snapshot(std::string dev_id, std::string file_name, std::function callback); diff --git a/src/slic3r/Utils/OrcaCloudSignalingChannel.cpp b/src/slic3r/Utils/OrcaCloudSignalingChannel.cpp new file mode 100644 index 0000000000..9c3a029db3 --- /dev/null +++ b/src/slic3r/Utils/OrcaCloudSignalingChannel.cpp @@ -0,0 +1,320 @@ +#include "OrcaCloudSignalingChannel.hpp" + +#include "Http.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace Slic3r { + +OrcaCloudSignalingChannel::OrcaCloudSignalingChannel(std::shared_ptr cloud, std::string dev_id) + : m_cloud(std::move(cloud)) + , m_dev_id(std::move(dev_id)) +{ +} + +OrcaCloudSignalingChannel::~OrcaCloudSignalingChannel() +{ + close(); +} + +void OrcaCloudSignalingChannel::open() +{ + bool expected = false; + if (!m_open.compare_exchange_strong(expected, true)) + return; + m_stop.store(false); + m_thread = std::thread([this] { run(); }); +} + +void OrcaCloudSignalingChannel::close() +{ + m_stop.store(true); + std::shared_ptr conn; + { + std::lock_guard lock(m_mutex); + conn = m_conn; + } + if (conn) { + // Established session: close the socket on the io_context's own thread so + // the pending async_read completes and io_context.run() unwinds. + boost::asio::post(conn->io_context, [conn] { + boost::system::error_code ec; + boost::beast::get_lowest_layer(conn->websocket).cancel(ec); + boost::beast::get_lowest_layer(conn->websocket).close(ec); + }); + // Pre-run() phase (still in the synchronous connect/handshake): best-effort + // direct interruption. + boost::system::error_code ec; + boost::beast::get_lowest_layer(conn->websocket).cancel(ec); + boost::beast::get_lowest_layer(conn->websocket).close(ec); + } + if (m_thread.joinable()) + m_thread.join(); + m_open.store(false); +} + +void OrcaCloudSignalingChannel::send_offer(std::string sdp) +{ + send_json(nlohmann::json{{"type", "webrtc.offer"}, {"sdp", std::move(sdp)}}.dump()); +} + +void OrcaCloudSignalingChannel::send_ice(std::string candidate, std::string mid) +{ + send_json(nlohmann::json{{"type", "webrtc.ice"}, + {"candidate", std::move(candidate)}, + {"sdpMid", std::move(mid)}} + .dump()); +} + +std::string OrcaCloudSignalingChannel::encode_path_component(const std::string& value) +{ + std::ostringstream encoded; + encoded << std::uppercase << std::hex; + for (unsigned char c : value) { + if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') + encoded << c; + else + encoded << '%' << std::setw(2) << std::setfill('0') << static_cast(c); + } + return encoded.str(); +} + +std::string OrcaCloudSignalingChannel::host_without_scheme(std::string value) +{ + const auto scheme = value.find("://"); + if (scheme != std::string::npos) + value.erase(0, scheme + 3); + const auto slash = value.find('/'); + if (slash != std::string::npos) + value.erase(slash); + return value; +} + +void OrcaCloudSignalingChannel::unavailable(CameraUnavailableReason reason, std::string detail) +{ + if (on_unavailable) + on_unavailable(reason, std::move(detail)); +} + +void OrcaCloudSignalingChannel::run() +{ + try { + if (!m_cloud || !m_cloud->ensure_token_fresh("camera")) { + unavailable(CameraUnavailableReason::Error, "Unable to refresh OrcaCloud credentials"); + m_open.store(false); + return; + } + const std::string token = m_cloud->get_access_token(); + const std::string host = host_without_scheme(m_cloud->get_cloud_service_host()); + if (token.empty() || host.empty()) { + unavailable(CameraUnavailableReason::Error, "OrcaCloud session is unavailable"); + m_open.store(false); + return; + } + + const std::string live_token_url = + "https://" + host + "/api/v1/printers/" + encode_path_component(m_dev_id) + "/live-token"; + BOOST_LOG_TRIVIAL(info) << "signaling: POST " << live_token_url << " (dev_id=" << m_dev_id << ")"; + + nlohmann::json token_response; + std::string token_body; + std::string token_error; + unsigned int http_code = 0; + auto request = Http::post(live_token_url); + request.set_post_body(std::string("{}")) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/json") + .tls_verify(true) + .timeout_max(30) + .on_complete([&token_body, &http_code](std::string body, unsigned status) { + http_code = status; + token_body = std::move(body); + }) + .on_error([&token_body, &token_error, &http_code](std::string body, std::string error, unsigned status) { + http_code = status; + token_body = std::move(body); + token_error = std::move(error); + }) + .perform_sync(); + BOOST_LOG_TRIVIAL(info) << "signaling: live-token HTTP " << http_code + << (token_error.empty() ? "" : " error=" + token_error) + << " body=" << token_body.substr(0, 512); + try { + token_response = nlohmann::json::parse(token_body); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "signaling: live-token body is not JSON: " << e.what(); + } + if (http_code < 200 || http_code >= 300 || !token_response.contains("token")) { + unavailable(CameraUnavailableReason::Error, "Unable to mint camera live token"); + m_open.store(false); + return; + } + + std::vector ice_servers; + if (token_response.contains("ice_servers") && token_response["ice_servers"].is_array()) { + for (const auto& entry : token_response["ice_servers"]) { + if (entry.is_string()) { + ice_servers.push_back({entry.get(), {}, {}}); + } else if (entry.is_object()) { + // RTCIceServer.urls is "string | string[]" (Cloudflare + // Realtime returns an array). Emit one CameraIceServer per + // URL, sharing the credentials. + const std::string username = entry.value("username", std::string{}); + const std::string credential = entry.value("credential", std::string{}); + const auto add_url = [&](const nlohmann::json& url) { + if (url.is_string() && !url.get().empty()) + ice_servers.push_back({url.get(), username, credential}); + }; + const auto urls = entry.find("urls"); + if (urls != entry.end()) { + if (urls->is_array()) { + for (const auto& url : *urls) + add_url(url); + } else { + add_url(*urls); + } + } + } + } + } + + auto conn = std::make_shared(); + conn->ssl_context.set_default_verify_paths(); + { + std::lock_guard lock(m_mutex); + m_conn = conn; + } + auto& websocket = conn->websocket; + boost::asio::ip::tcp::resolver resolver(conn->io_context); + const auto endpoints = resolver.resolve(host, "443"); + boost::asio::connect(boost::beast::get_lowest_layer(websocket), endpoints); + if (!SSL_set_tlsext_host_name(websocket.next_layer().native_handle(), host.c_str())) + throw std::runtime_error("Unable to configure TLS server name"); + websocket.next_layer().set_verify_mode(boost::asio::ssl::verify_peer); + websocket.next_layer().handshake(boost::asio::ssl::stream_base::client); + const std::string ws_target = "/api/v1/printers/" + encode_path_component(m_dev_id) + + "/camera/live?token=" + + encode_path_component(token_response["token"].get()); + websocket.handshake(host, ws_target); + BOOST_LOG_TRIVIAL(info) << "signaling: websocket handshake ok (" << ice_servers.size() + << " ice servers)"; + + if (on_ready) + on_ready(std::move(ice_servers)); + send_json(nlohmann::json{{"type", "camera.mode"}, {"mode", "webrtc"}}.dump()); + // Async read loop, driven by the connection's own io_context. run() + // returns once close() has shut the socket down, giving a bounded, + // deadlock-free teardown from any thread. + do_read(conn); + conn->io_context.run(); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "signaling: run() exception: " << e.what(); + if (!m_stop.load()) + unavailable(CameraUnavailableReason::Closed, e.what()); + } + { + std::lock_guard lock(m_mutex); + m_conn.reset(); + } + m_open.store(false); +} + +void OrcaCloudSignalingChannel::do_read(std::shared_ptr conn) +{ + auto buffer = std::make_shared(); + conn->websocket.async_read( + *buffer, [this, conn, buffer](boost::system::error_code ec, std::size_t) { + if (ec) { + if (!m_stop.load()) + unavailable(CameraUnavailableReason::Closed, ec.message()); + return; // do not re-arm; io_context.run() unwinds + } + const std::string raw = boost::beast::buffers_to_string(buffer->data()); + // A malformed or unexpectedly-shaped message must not tear down the + // session: parse/dispatch is guarded. + try { + dispatch_message(nlohmann::json::parse(raw), raw); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "signaling: ignoring malformed message: " << e.what() + << " raw=" << raw.substr(0, 256); + } + if (!m_stop.load()) + do_read(conn); + }); +} + +// Returns the string at key, or "" if absent or not a string (JSON null included). +static std::string json_string(const nlohmann::json& object, const char* key) +{ + const auto it = object.find(key); + return (it != object.end() && it->is_string()) ? it->get() : std::string{}; +} + +void OrcaCloudSignalingChannel::dispatch_message(const nlohmann::json& message, const std::string& raw) +{ + const std::string type = json_string(message, "type"); + BOOST_LOG_TRIVIAL(info) << "signaling: recv type=" << type << " raw=" << raw.substr(0, 256); + if (type == "webrtc.answer") { + const std::string sdp = json_string(message, "sdp"); + if (on_answer && !sdp.empty()) + on_answer(sdp); + } else if (type == "webrtc.ice" && message.contains("candidate")) { + // The peer may send "candidate" as a flat string or as a nested + // RTCIceCandidateInit object { candidate, sdpMid, sdpMLineIndex }. + const nlohmann::json& candidate = message["candidate"]; + std::string sdp_candidate; + std::string mid = json_string(message, "sdpMid"); + if (candidate.is_string()) { + sdp_candidate = candidate.get(); + } else if (candidate.is_object()) { + sdp_candidate = json_string(candidate, "candidate"); + std::string nested_mid = json_string(candidate, "sdpMid"); + if (!nested_mid.empty()) + mid = std::move(nested_mid); + } + if (on_ice && !sdp_candidate.empty()) + on_ice(sdp_candidate, mid); + } else if (type == "webrtc.unavailable") { + const std::string reason = json_string(message, "reason"); + unavailable(reason == "busy" ? CameraUnavailableReason::Busy + : reason == "disabled" ? CameraUnavailableReason::Disabled + : CameraUnavailableReason::Error, + reason.empty() ? "error" : reason); + } +} + +void OrcaCloudSignalingChannel::send_json(const std::string& message) +{ + std::shared_ptr conn; + { + std::lock_guard lock(m_mutex); + conn = m_conn; + } + if (!conn || m_stop.load()) + return; + // Serialize the write onto the io_context thread (same thread that runs + // async_read), so reads and writes never touch the stream concurrently. + auto payload = std::make_shared(message); + boost::asio::post(conn->io_context, [this, conn, payload] { + if (m_stop.load()) + return; + boost::system::error_code ec; + conn->websocket.write(boost::asio::buffer(*payload), ec); + if (ec && !m_stop.load()) + unavailable(CameraUnavailableReason::Closed, ec.message()); + }); +} + +} // namespace Slic3r diff --git a/src/slic3r/Utils/OrcaCloudSignalingChannel.hpp b/src/slic3r/Utils/OrcaCloudSignalingChannel.hpp new file mode 100644 index 0000000000..b87b7400e2 --- /dev/null +++ b/src/slic3r/Utils/OrcaCloudSignalingChannel.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include "ICameraSignalingChannel.hpp" +#include "ICloudServiceAgent.hpp" + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace Slic3r { + +class OrcaCloudSignalingChannel : public ICameraSignalingChannel { +public: + OrcaCloudSignalingChannel(std::shared_ptr cloud, std::string dev_id); + ~OrcaCloudSignalingChannel() override; + + void open() override; + void close() override; + void send_offer(std::string sdp) override; + void send_ice(std::string candidate, std::string mid) override; + +private: + using WebSocket = boost::beast::websocket::stream< + boost::beast::ssl_stream>; + + // The io_context and ssl_context must outlive the websocket stream that + // references them. Bundling them here with the stream declared last makes + // the destruction order correct (stream first, then contexts), and lets a + // single shared_ptr own the whole set. + struct Connection { + boost::asio::io_context io_context; + boost::asio::ssl::context ssl_context{boost::asio::ssl::context::tls_client}; + WebSocket websocket{io_context, ssl_context}; + }; + + void run(); + void do_read(std::shared_ptr conn); + void dispatch_message(const nlohmann::json& message, const std::string& raw); + void send_json(const std::string& message); + void unavailable(CameraUnavailableReason reason, std::string detail); + static std::string encode_path_component(const std::string& value); + static std::string host_without_scheme(std::string value); + + std::shared_ptr m_cloud; + std::string m_dev_id; + std::atomic m_stop{false}; + std::atomic m_open{false}; + std::thread m_thread; + mutable std::mutex m_mutex; + std::shared_ptr m_conn; +}; + +} // namespace Slic3r diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 93cadd9abd..520686db3a 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -1,4 +1,5 @@ #include "OrcaPrinterAgent.hpp" +#include "OrcaCloudSignalingChannel.hpp" #include "NetworkAgentFactory.hpp" #include "OrcaCloudServiceAgent.hpp" #include @@ -47,6 +48,31 @@ void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_cloud_agent: status callback result=" << callback_result; } +CameraStreamMode OrcaPrinterAgent::get_camera_stream_mode() const +{ + std::lock_guard lock(state_mutex); + if (m_lan_connected && !m_lan_rtsp_url.empty()) + return CameraStreamMode::rtsp; + if (m_cloud_agent && m_cloud_agent->is_user_login()) + return CameraStreamMode::webrtc; + return CameraStreamMode::none; +} + +std::string OrcaPrinterAgent::get_camera_url() const +{ + std::lock_guard lock(state_mutex); + return m_lan_connected ? m_lan_rtsp_url : std::string{}; +} + +std::unique_ptr +OrcaPrinterAgent::create_camera_signaling_channel(const std::string& dev_id) +{ + std::lock_guard lock(state_mutex); + if (!m_cloud_agent) + return nullptr; + return std::make_unique(m_cloud_agent, dev_id); +} + // ============================================================================ // Communication - All Stubs // ============================================================================ @@ -86,11 +112,20 @@ int OrcaPrinterAgent::send_message(std::string dev_id, std::string json_str, int int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) { + std::lock_guard lock(state_mutex); + m_lan_connected = !dev_ip.empty(); + // OrcaPrinterAgent currently has no LAN status-push decoder. Preserve the + // standard OrcaSonar endpoint convention until the reported RTSP field is + // available, while keeping the URL behind the connection-aware mode API. + m_lan_rtsp_url = m_lan_connected ? "rtsp://" + dev_ip + ":8554/stream" : std::string{}; return BAMBU_NETWORK_SUCCESS; } int OrcaPrinterAgent::disconnect_printer() { + std::lock_guard lock(state_mutex); + m_lan_connected = false; + m_lan_rtsp_url.clear(); return BAMBU_NETWORK_SUCCESS; } diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index 9cf2b29638..00d7e372f6 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -27,6 +27,10 @@ public: // ======================================================================== void set_cloud_agent(std::shared_ptr cloud) override; + CameraStreamMode get_camera_stream_mode() const override; + std::string get_camera_url() const override; + std::unique_ptr + create_camera_signaling_channel(const std::string& dev_id) override; // Communication int send_message(std::string dev_id, std::string json_str, int qos, int flag) override; @@ -85,6 +89,9 @@ private: std::shared_ptr m_cloud_agent; OrcaCloudServiceAgent* m_orca_cloud = nullptr; // == m_cloud_agent.get() when the Orca provider is active + bool m_lan_connected = false; + std::string m_lan_rtsp_url; + // MOCK: OrcaCloud does not yet relay the printer's info.get_version reply, so // synthesize it and feed it through on_message_fn (same sink as real report // messages). Delete once the backend answers info.get_version. diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index edff97ff42..fb5b0bda1e 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -12,6 +12,7 @@ add_executable(${_TEST_NAME}_tests test_plugin_capabilities_in_use.cpp test_plugin_status.cpp test_printer_agent.cpp + test_webrtc_frame_assembler.cpp test_qidi_printer_agent.cpp test_plugin_install.cpp test_plugin_lifecycle.cpp diff --git a/tests/slic3rutils/test_webrtc_frame_assembler.cpp b/tests/slic3rutils/test_webrtc_frame_assembler.cpp new file mode 100644 index 0000000000..377d2931b7 --- /dev/null +++ b/tests/slic3rutils/test_webrtc_frame_assembler.cpp @@ -0,0 +1,67 @@ +#include + +#include + +#include +#include +#include +#include + +using Slic3r::GUI::WebRtcFrameAssembler; + +static std::vector make_chunk(std::uint32_t frame_id, + std::uint16_t index, + std::uint16_t count, + std::initializer_list payload) +{ + std::vector result(WebRtcFrameAssembler::HeaderSize + payload.size()); + result[0] = std::byte{1}; + result[1] = std::byte{0}; + result[2] = std::byte{static_cast(index >> 8)}; + result[3] = std::byte{static_cast(index)}; + result[4] = std::byte{static_cast(count >> 8)}; + result[5] = std::byte{static_cast(count)}; + result[6] = std::byte{static_cast(frame_id >> 24)}; + result[7] = std::byte{static_cast(frame_id >> 16)}; + result[8] = std::byte{static_cast(frame_id >> 8)}; + result[9] = std::byte{static_cast(frame_id)}; + for (std::size_t i = 0; i < payload.size(); ++i) + result[WebRtcFrameAssembler::HeaderSize + i] = std::byte{static_cast(payload.begin()[i])}; + return result; +} + +TEST_CASE("WebRTC frame assembler joins chunks in order", "[webrtc][unit]") +{ + WebRtcFrameAssembler assembler; + std::vector frame; + assembler.on_frame = [&frame](std::vector value) { frame = std::move(value); }; + + const auto second = make_chunk(7, 1, 2, {'C', 'D'}); + const auto first = make_chunk(7, 0, 2, {'A', 'B'}); + assembler.feed(second.data(), second.size()); + assembler.feed(first.data(), first.size()); + + REQUIRE(frame.size() == 4); + CHECK(std::to_integer(frame[0]) == 'A'); + CHECK(std::to_integer(frame[3]) == 'D'); +} + +TEST_CASE("WebRTC frame assembler discards stale and malformed chunks", "[webrtc][unit]") +{ + WebRtcFrameAssembler assembler; + int frames = 0; + assembler.on_frame = [&frames](std::vector) { ++frames; }; + + const auto stale = make_chunk(1, 0, 2, {'A'}); + const auto current = make_chunk(2, 0, 1, {'B'}); + const auto stale_tail = make_chunk(1, 1, 2, {'C'}); + assembler.feed(stale.data(), stale.size()); + assembler.feed(current.data(), current.size()); + assembler.feed(stale_tail.data(), stale_tail.size()); + + CHECK(frames == 1); + + auto malformed = make_chunk(3, 0, 0, {'X'}); + assembler.feed(malformed.data(), malformed.size()); + CHECK(frames == 1); +} From 2a4792e7626b03ef25b353a3bd02614b20f22bfa Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Wed, 2 Sep 2026 15:59:13 +0800 Subject: [PATCH 09/24] feat: remove frame assembler and change config to set protocol --- src/slic3r/CMakeLists.txt | 2 - src/slic3r/GUI/WebRtcFrameAssembler.cpp | 86 ----------------- src/slic3r/GUI/WebRtcFrameAssembler.hpp | 39 -------- src/slic3r/GUI/WebRtcMediaController.cpp | 95 +++++-------------- src/slic3r/GUI/WebRtcMediaController.hpp | 10 +- tests/slic3rutils/CMakeLists.txt | 1 - .../test_webrtc_frame_assembler.cpp | 67 ------------- 7 files changed, 25 insertions(+), 275 deletions(-) delete mode 100644 src/slic3r/GUI/WebRtcFrameAssembler.cpp delete mode 100644 src/slic3r/GUI/WebRtcFrameAssembler.hpp delete mode 100644 tests/slic3rutils/test_webrtc_frame_assembler.cpp diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 322b3b3f0e..c1ce3b22a6 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -344,8 +344,6 @@ set(SLIC3R_GUI_SOURCES GUI/MediaFilePanel.h GUI/MediaPlayCtrl.cpp GUI/MediaPlayCtrl.h - GUI/WebRtcFrameAssembler.cpp - GUI/WebRtcFrameAssembler.hpp GUI/WebRtcMediaController.cpp GUI/WebRtcMediaController.hpp GUI/MeshUtils.cpp diff --git a/src/slic3r/GUI/WebRtcFrameAssembler.cpp b/src/slic3r/GUI/WebRtcFrameAssembler.cpp deleted file mode 100644 index 2771cc0887..0000000000 --- a/src/slic3r/GUI/WebRtcFrameAssembler.cpp +++ /dev/null @@ -1,86 +0,0 @@ -#include "WebRtcFrameAssembler.hpp" - -#include -#include - -namespace Slic3r { namespace GUI { - -void WebRtcFrameAssembler::discard() -{ - m_active = false; - m_frame_id = 0; - m_chunk_count = 0; - m_received_chunks = 0; - m_total_size = 0; - m_chunks.clear(); - m_received.clear(); -} - -void WebRtcFrameAssembler::reset() -{ - discard(); -} - -void WebRtcFrameAssembler::feed(const std::byte* data, std::size_t len) -{ - if (data == nullptr || len < HeaderSize) - return; - - if (std::to_integer(data[0]) != 1) - return; - - const std::uint16_t chunk_index = static_cast((std::to_integer(data[2]) << 8) | - std::to_integer(data[3])); - const std::uint16_t chunk_count = static_cast((std::to_integer(data[4]) << 8) | - std::to_integer(data[5])); - const std::uint32_t frame_id = (static_cast(std::to_integer(data[6])) << 24) | - (static_cast(std::to_integer(data[7])) << 16) | - (static_cast(std::to_integer(data[8])) << 8) | - static_cast(std::to_integer(data[9])); - const std::size_t payload_size = len - HeaderSize; - - if (chunk_count == 0 || chunk_count > MaxChunkCount || chunk_index >= chunk_count || - payload_size > MaxChunkPayload) - return; - - if (!m_active || frame_id > m_frame_id) { - discard(); - m_active = true; - m_frame_id = frame_id; - m_chunk_count = chunk_count; - m_chunks.resize(chunk_count); - m_received.assign(chunk_count, false); - } else if (frame_id < m_frame_id) { - return; - } else if (chunk_count != m_chunk_count) { - discard(); - return; - } - - Frame& chunk = m_chunks[chunk_index]; - if (m_received[chunk_index]) - return; - - if (m_total_size > MaxFrameSize || payload_size > MaxFrameSize - m_total_size) { - discard(); - return; - } - - chunk.assign(data + HeaderSize, data + len); - m_received[chunk_index] = true; - m_total_size += payload_size; - ++m_received_chunks; - - if (m_received_chunks != m_chunk_count) - return; - - Frame frame; - frame.reserve(m_total_size); - for (const Frame& slice : m_chunks) - frame.insert(frame.end(), slice.begin(), slice.end()); - if (on_frame) - on_frame(std::move(frame)); - discard(); -} - -}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/WebRtcFrameAssembler.hpp b/src/slic3r/GUI/WebRtcFrameAssembler.hpp deleted file mode 100644 index c526cad588..0000000000 --- a/src/slic3r/GUI/WebRtcFrameAssembler.hpp +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace Slic3r { namespace GUI { - -class WebRtcFrameAssembler { -public: - using Frame = std::vector; - - // The wire protocol uses a 10-byte header followed by one JPEG slice: - // version, flags, chunk index, chunk count, and frame id, all in network - // byte order where applicable. - static constexpr std::size_t HeaderSize = 10; - static constexpr std::size_t MaxChunkPayload = 16000; - static constexpr std::size_t MaxChunkCount = 4096; - static constexpr std::size_t MaxFrameSize = 8 * 1024 * 1024; - - std::function on_frame; - - void feed(const std::byte* data, std::size_t len); - void reset(); - -private: - void discard(); - - bool m_active = false; - std::uint32_t m_frame_id = 0; - std::uint16_t m_chunk_count = 0; - std::size_t m_received_chunks = 0; - std::size_t m_total_size = 0; - std::vector m_chunks; - std::vector m_received; -}; - -}} // namespace Slic3r::GUI diff --git a/src/slic3r/GUI/WebRtcMediaController.cpp b/src/slic3r/GUI/WebRtcMediaController.cpp index 7eafbd4a3a..36a21c5c5d 100644 --- a/src/slic3r/GUI/WebRtcMediaController.cpp +++ b/src/slic3r/GUI/WebRtcMediaController.cpp @@ -1,18 +1,13 @@ #include "WebRtcMediaController.hpp" -#include "AVVideoDecoder.hpp" - #include #include #include -#include #include #include -#include - namespace { void init_rtc_logger_once() { @@ -25,10 +20,6 @@ void init_rtc_logger_once() } } // namespace -extern "C" { -#include -} - namespace Slic3r { namespace GUI { WebRtcMediaController::WebRtcMediaController(std::function frame_sink, @@ -75,8 +66,7 @@ void WebRtcMediaController::StartSession(std::unique_ptr lock(m_mutex); m_signaling = std::move(channel); - m_chunk_queue.clear(); - m_nal_queue.clear(); + m_jpeg_queue.clear(); m_pending_candidates.clear(); m_remote_description_set = false; m_video_size = wxDefaultSize; @@ -130,7 +120,6 @@ void WebRtcMediaController::teardown(bool notify) signaling = std::move(m_signaling); peer_connection = std::move(m_peer_connection); m_data_channel.reset(); - m_video_track.reset(); } if (signaling) signaling->close(); @@ -140,8 +129,7 @@ void WebRtcMediaController::teardown(bool notify) m_decode_thread.join(); { std::lock_guard lock(m_mutex); - m_chunk_queue.clear(); - m_nal_queue.clear(); + m_jpeg_queue.clear(); } if (was_alive && notify) report({Status::Stopped}); @@ -170,7 +158,7 @@ void WebRtcMediaController::bind_data_channel(const std::shared_ptronMessage( [this](rtc::binary data) { if (m_alive.load()) - enqueue_chunk(std::vector(data.begin(), data.end())); + enqueue_jpeg(std::vector(data.begin(), data.end())); }, [](rtc::string) {}); } @@ -179,6 +167,10 @@ void WebRtcMediaController::on_ready(std::vector servers) { init_rtc_logger_once(); rtc::Configuration configuration; + // Allow complete-JPEG DataChannel messages up to 1 MiB. This value is + // advertised in SDP and becomes the upper bound for frames OrcaSonar can + // send to OrcaSlicer. + configuration.maxMessageSize = 1024 * 1024; for (const CameraIceServer& server : servers) { try { rtc::IceServer ice_server(server.urls); @@ -234,16 +226,16 @@ void WebRtcMediaController::on_ready(std::vector servers) rtc::DataChannelInit init; init.reliability.unordered = true; - init.reliability.maxPacketLifeTime = std::chrono::milliseconds(350); + // init.reliability.maxPacketLifeTime = std::chrono::milliseconds(350); + // Request one complete JPEG frame per DataChannel message. OrcaSonar + // keeps the legacy chunked protocol for clients that omit this property. + init.protocol = "orca-jpeg"; auto data_channel = peer_connection->createDataChannel("camera", init); if (data_channel) bind_data_channel(data_channel); - // NOTE: the H.264 RTP RecvOnly track is intentionally NOT added to the offer - // yet. OrcaSonar answers application-only (no m=video), which makes - // libdatachannel renegotiate and send a second offer that OrcaSonar rejects - // with "webrtc.unavailable: error". Re-add the video m-line (enqueue_nal / - // the decode_loop NAL branch are already in place) once OrcaSonar answers it. + // Camera media is carried as one complete JPEG per DataChannel message; + // no RTP video track or application-level framing is required. std::shared_ptr peer_for_description; { @@ -327,21 +319,12 @@ void WebRtcMediaController::on_unavailable(CameraUnavailableReason reason, std:: report({Status::Failed, code}); } -void WebRtcMediaController::enqueue_chunk(std::vector chunk) +void WebRtcMediaController::enqueue_jpeg(std::vector jpeg) { std::lock_guard lock(m_mutex); - if (m_chunk_queue.size() >= 8) - m_chunk_queue.pop_front(); - m_chunk_queue.emplace_back(std::move(chunk)); - m_cond.notify_one(); -} - -void WebRtcMediaController::enqueue_nal(std::vector nal) -{ - std::lock_guard lock(m_mutex); - if (m_nal_queue.size() >= 4) - m_nal_queue.pop_front(); - m_nal_queue.emplace_back(std::move(nal)); + if (m_jpeg_queue.size() >= 4) + m_jpeg_queue.pop_front(); + m_jpeg_queue.emplace_back(std::move(jpeg)); m_cond.notify_one(); } @@ -376,19 +359,11 @@ void WebRtcMediaController::deliver_jpeg(std::vector jpeg) void WebRtcMediaController::decode_loop() { - WebRtcFrameAssembler assembler; - assembler.on_frame = [this](std::vector jpeg) { deliver_jpeg(std::move(jpeg)); }; - AVCodecParameters parameters{}; - parameters.codec_type = AVMEDIA_TYPE_VIDEO; - parameters.codec_id = AV_CODEC_ID_H264; - AVVideoDecoder decoder; - bool decoder_open = false; - int stall_polls = 0; std::unique_lock lock(m_mutex); while (m_alive.load()) { const bool woke = m_cond.wait_for(lock, std::chrono::seconds(2), [this] { - return !m_alive.load() || !m_chunk_queue.empty() || !m_nal_queue.empty(); + return !m_alive.load() || !m_jpeg_queue.empty(); }); if (!m_alive.load()) break; @@ -410,37 +385,11 @@ void WebRtcMediaController::decode_loop() continue; } stall_polls = 0; - if (!m_chunk_queue.empty()) { - auto chunk = std::move(m_chunk_queue.front()); - m_chunk_queue.pop_front(); + if (!m_jpeg_queue.empty()) { + auto jpeg = std::move(m_jpeg_queue.front()); + m_jpeg_queue.pop_front(); lock.unlock(); - assembler.feed(chunk.data(), chunk.size()); - lock.lock(); - } else if (!m_nal_queue.empty()) { - auto nal = std::move(m_nal_queue.front()); - m_nal_queue.pop_front(); - lock.unlock(); - if (!decoder_open) - decoder_open = decoder.open(parameters) == 0; - if (decoder_open) { - AVPacket* packet = av_packet_alloc(); - if (packet && av_new_packet(packet, static_cast(nal.size())) == 0) { - std::memcpy(packet->data, nal.data(), nal.size()); - if (decoder.decode(*packet) == 0) { - wxImage image; - if (decoder.toWxImage(image, wxDefaultSize)) { - { - std::lock_guard frame_lock(m_mutex); - m_video_size = image.GetSize(); - } - if (m_frame_sink) - m_frame_sink(image, image.GetSize()); - report({Status::Playing}); - } - } - } - av_packet_free(&packet); - } + deliver_jpeg(std::move(jpeg)); lock.lock(); } } diff --git a/src/slic3r/GUI/WebRtcMediaController.hpp b/src/slic3r/GUI/WebRtcMediaController.hpp index 1cbd05fcd7..5b06b8e695 100644 --- a/src/slic3r/GUI/WebRtcMediaController.hpp +++ b/src/slic3r/GUI/WebRtcMediaController.hpp @@ -1,12 +1,12 @@ #pragma once #include "IMediaController.hpp" -#include "WebRtcFrameAssembler.hpp" #include #include #include +#include #include #include #include @@ -19,7 +19,6 @@ namespace rtc { class DataChannel; class PeerConnection; -class Track; } namespace Slic3r { namespace GUI { @@ -66,14 +65,12 @@ private: void on_ice(std::string candidate, std::string mid); void on_unavailable(CameraUnavailableReason reason, std::string detail); void decode_loop(); - void enqueue_chunk(std::vector chunk); - void enqueue_nal(std::vector nal); + void enqueue_jpeg(std::vector jpeg); void deliver_jpeg(std::vector jpeg); mutable std::mutex m_mutex; std::condition_variable m_cond; - std::deque> m_chunk_queue; - std::deque> m_nal_queue; + std::deque> m_jpeg_queue; // Remote candidates can arrive before the answer; libdatachannel rejects // addRemoteCandidate until a remote description is set, so buffer them. std::vector> m_pending_candidates; @@ -81,7 +78,6 @@ private: std::unique_ptr m_signaling; std::shared_ptr m_peer_connection; std::shared_ptr m_data_channel; - std::shared_ptr m_video_track; std::thread m_decode_thread; std::atomic m_alive{false}; std::atomic m_epoch{0}; diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index fb5b0bda1e..edff97ff42 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -12,7 +12,6 @@ add_executable(${_TEST_NAME}_tests test_plugin_capabilities_in_use.cpp test_plugin_status.cpp test_printer_agent.cpp - test_webrtc_frame_assembler.cpp test_qidi_printer_agent.cpp test_plugin_install.cpp test_plugin_lifecycle.cpp diff --git a/tests/slic3rutils/test_webrtc_frame_assembler.cpp b/tests/slic3rutils/test_webrtc_frame_assembler.cpp deleted file mode 100644 index 377d2931b7..0000000000 --- a/tests/slic3rutils/test_webrtc_frame_assembler.cpp +++ /dev/null @@ -1,67 +0,0 @@ -#include - -#include - -#include -#include -#include -#include - -using Slic3r::GUI::WebRtcFrameAssembler; - -static std::vector make_chunk(std::uint32_t frame_id, - std::uint16_t index, - std::uint16_t count, - std::initializer_list payload) -{ - std::vector result(WebRtcFrameAssembler::HeaderSize + payload.size()); - result[0] = std::byte{1}; - result[1] = std::byte{0}; - result[2] = std::byte{static_cast(index >> 8)}; - result[3] = std::byte{static_cast(index)}; - result[4] = std::byte{static_cast(count >> 8)}; - result[5] = std::byte{static_cast(count)}; - result[6] = std::byte{static_cast(frame_id >> 24)}; - result[7] = std::byte{static_cast(frame_id >> 16)}; - result[8] = std::byte{static_cast(frame_id >> 8)}; - result[9] = std::byte{static_cast(frame_id)}; - for (std::size_t i = 0; i < payload.size(); ++i) - result[WebRtcFrameAssembler::HeaderSize + i] = std::byte{static_cast(payload.begin()[i])}; - return result; -} - -TEST_CASE("WebRTC frame assembler joins chunks in order", "[webrtc][unit]") -{ - WebRtcFrameAssembler assembler; - std::vector frame; - assembler.on_frame = [&frame](std::vector value) { frame = std::move(value); }; - - const auto second = make_chunk(7, 1, 2, {'C', 'D'}); - const auto first = make_chunk(7, 0, 2, {'A', 'B'}); - assembler.feed(second.data(), second.size()); - assembler.feed(first.data(), first.size()); - - REQUIRE(frame.size() == 4); - CHECK(std::to_integer(frame[0]) == 'A'); - CHECK(std::to_integer(frame[3]) == 'D'); -} - -TEST_CASE("WebRTC frame assembler discards stale and malformed chunks", "[webrtc][unit]") -{ - WebRtcFrameAssembler assembler; - int frames = 0; - assembler.on_frame = [&frames](std::vector) { ++frames; }; - - const auto stale = make_chunk(1, 0, 2, {'A'}); - const auto current = make_chunk(2, 0, 1, {'B'}); - const auto stale_tail = make_chunk(1, 1, 2, {'C'}); - assembler.feed(stale.data(), stale.size()); - assembler.feed(current.data(), current.size()); - assembler.feed(stale_tail.data(), stale_tail.size()); - - CHECK(frames == 1); - - auto malformed = make_chunk(3, 0, 0, {'X'}); - assembler.feed(malformed.data(), malformed.size()); - CHECK(frames == 1); -} From 972031cf065d6235dacfecbb8e20ad95262fc379 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Thu, 3 Sep 2026 19:42:25 +0800 Subject: [PATCH 10/24] fix: orcaprinteragent refactor --- ...-09-03-orca-mqtt-contract-consolidation.md | 1194 +++++++++++++++++ ...orca-mqtt-contract-consolidation-design.md | 388 ++++++ src/slic3r/CMakeLists.txt | 2 + src/slic3r/GUI/ConnectPrinter.cpp | 4 +- src/slic3r/GUI/GUI_App.cpp | 1 + src/slic3r/Utils/IPrinterAgent.hpp | 2 +- src/slic3r/Utils/OrcaCloudServiceAgent.cpp | 669 ++------- src/slic3r/Utils/OrcaCloudServiceAgent.hpp | 105 +- src/slic3r/Utils/OrcaMqttConnection.cpp | 740 ++++++++++ src/slic3r/Utils/OrcaMqttConnection.hpp | 144 ++ src/slic3r/Utils/OrcaPrinterAgent.cpp | 773 +++++++++-- src/slic3r/Utils/OrcaPrinterAgent.hpp | 97 +- tests/slic3rutils/CMakeLists.txt | 2 + tests/slic3rutils/orca_mqtt_mock_broker.hpp | 317 +++++ .../slic3rutils/test_orca_mqtt_connection.cpp | 229 ++++ tests/slic3rutils/test_orca_printer_agent.cpp | 170 +++ 16 files changed, 4029 insertions(+), 808 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-03-orca-mqtt-contract-consolidation.md create mode 100644 docs/superpowers/specs/2026-09-03-orca-mqtt-contract-consolidation-design.md create mode 100644 src/slic3r/Utils/OrcaMqttConnection.cpp create mode 100644 src/slic3r/Utils/OrcaMqttConnection.hpp create mode 100644 tests/slic3rutils/orca_mqtt_mock_broker.hpp create mode 100644 tests/slic3rutils/test_orca_mqtt_connection.cpp create mode 100644 tests/slic3rutils/test_orca_printer_agent.cpp diff --git a/docs/superpowers/plans/2026-09-03-orca-mqtt-contract-consolidation.md b/docs/superpowers/plans/2026-09-03-orca-mqtt-contract-consolidation.md new file mode 100644 index 0000000000..fbcf8f9bc3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-orca-mqtt-contract-consolidation.md @@ -0,0 +1,1194 @@ +# OrcaSlicer MQTT Contract Consolidation — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `OrcaPrinterAgent` talk to OrcaCloud and OrcaSonar through one `OrcaMqttConnection` class where LAN and cloud differ only by a `Config` value — commands published to `device//request`, status subscribed on `device//report`, on both transports. + +**Architecture:** One `OrcaMqttConnection` (MQTT 3.1.1 codec + WebSocket transport, `ws://` and `wss://`) in its own translation unit. Two instances: a LAN one owned by `OrcaPrinterAgent`, a per-printer cloud one owned by `OrcaCloudServiceAgent` and driven by `OrcaPrinterAgent` via `get_mqtt_connection()`. `OrcaPrinterAgent` is routing + lifecycle only; `get_appropriate_mqtt_connection(is_lan)` picks the instance and `send_request()` is the uniform outbound seam. Inbound is a per-connection `MessageHandler` callback funnelled to `on_message_fn` (no reader loop). Exactly one socket is connected at a time, for the selected printer. + +**Tech Stack:** C++17, Boost.Beast (WebSocket + TLS), Boost.Asio, nlohmann/json, Catch2 (`tests/slic3rutils`, target `slic3rutils_tests`, discovered via `orcaslicer_discover_tests`). + +**Spec:** `docs/superpowers/specs/2026-09-03-orca-mqtt-contract-consolidation-design.md` — read it alongside this plan. + +## Global Constraints + +- **No git commits.** Per this repo's workflow, all changes stay unstaged; there is no per-task commit. Each task ends at "tests green" (see Workflow note). +- **Build once at the end.** Per-task verification builds and runs only the `slic3rutils_tests` target (`cmake --build build --target slic3rutils_tests`). The full `cmake --build build` runs only in the final task. +- **No back-compat.** OPCP v1.2.0 replaces v1.1.0. No REST `/commands` fallback in the client, no `/tunnel`, no dual-schema handling. +- **`sequence_id` band:** every `sequence_id` the client emits MUST be a decimal string in `20000`–`29999`. +- **Cross-platform:** Windows, macOS, Linux. No POSIX-only calls; use Boost/std. +- **Threading:** every `websocket::stream` write goes through `write_mutex`. Handlers run on the connection's worker thread and must marshal to the UI thread via `queue_on_main_fn` before touching wx. +- **Topic id:** the id in `device//...` is the id the client already holds (LAN: the mDNS-advertised `device_id` passed as `dev_id`; cloud: the cloud printer UUID). No wildcard subscribe, no learning the id from traffic. + +## Workflow note (per-task loop, adapted for no-commits) + +Each task's last step is **"Verify green"**, not "Commit": + +```bash +cmake --build build --target slic3rutils_tests -j +ctest --test-dir build -R 'OrcaMqtt|OrcaPrinterAgent' --output-on-failure +``` + +Leave changes unstaged. Move to the next task. + +--- + +## File Structure + +**New:** + +| File | Responsibility | +|---|---| +| `src/slic3r/Utils/OrcaMqttConnection.hpp` | `OrcaMqttConnection` class: `Config`, lifecycle (`start`/`stop`), `send_request`, `subscribe`/`unsubscribe`, state accessors, and the pure static codec helpers (`parse_endpoint`, `make_*_packet`) — public so tests reach them. | +| `src/slic3r/Utils/OrcaMqttConnection.cpp` | Its implementation (moved verbatim from `OrcaCloudServiceAgent.cpp`, then extended). | +| `tests/slic3rutils/test_orca_mqtt_connection.cpp` | Unit tests: codec byte-shapes, `parse_endpoint`, `Config`→handshake selection, `send_request`/`subscribe` topic strings. | +| `tests/slic3rutils/orca_mqtt_mock_broker.hpp` | Header-only in-process MQTT-over-WS mock (Beast server on `127.0.0.1:0`, plaintext): answers CONNECT→CONNACK, SUBSCRIBE→SUBACK, PUBLISH→PUBACK, and can push a canned message on a subscribed topic. Test-only. | +| `tests/slic3rutils/test_orca_printer_agent.cpp` | `OrcaPrinterAgent` routing + lifecycle tests + the parametrized LAN/cloud integration test. Seeded from `salvage/orcasonar-lan-agent-2026-09-03:tests/slic3rutils/test_orca_printer_agent.cpp` then rewritten for this design. | + +**Modified:** + +| File | Change | +|---|---| +| `src/slic3r/Utils/OrcaCloudServiceAgent.hpp` | Replace the inline `OrcaMqttConnection` class with `#include "OrcaMqttConnection.hpp"`. Keep `get_mqtt_connection()`. Add `configure_selected_printer_mqtt(std::string dev_id)` / `teardown_selected_printer_mqtt()` decls. | +| `src/slic3r/Utils/OrcaCloudServiceAgent.cpp` | Move the `OrcaMqttConnection` impl out. `connect_server()` stops starting the aggregate socket. Implement `configure_selected_printer_mqtt` / `teardown_selected_printer_mqtt`. `is_server_connected()` from the REST health result only. | +| `src/slic3r/Utils/OrcaPrinterAgent.hpp` | Remove `read_loop` / `m_read_loop_thread` / `m_should_end`. Add `std::atomic m_lan_generation`, `std::string m_lan_dev_id`, `void on_connected(const std::string& dev_id, OrcaMqttConnection* conn, uint64_t generation)`, and the `sequence_id`-stamped payload builders. | +| `src/slic3r/Utils/OrcaPrinterAgent.cpp` | Implement `connect_printer`, `disconnect_printer`, `set_user_selected_machine`, the `send_message*` collapse, per-connection `MessageHandler` wiring, generation guard, destructor teardown. Remove `read_loop`. | +| `src/slic3r/CMakeLists.txt` | Add `Utils/OrcaMqttConnection.cpp` + `.hpp` to `SLIC3R_GUI_SOURCES`. | +| `tests/slic3rutils/CMakeLists.txt` | Add `test_orca_mqtt_connection.cpp` and `test_orca_printer_agent.cpp` to `slic3rutils_tests`. | + +--- + +## Task 1: Extract `OrcaMqttConnection` to its own translation unit + +Pure move, no behaviour change. Makes every later diff legible. + +**Files:** +- Create: `src/slic3r/Utils/OrcaMqttConnection.hpp`, `src/slic3r/Utils/OrcaMqttConnection.cpp` +- Modify: `src/slic3r/Utils/OrcaCloudServiceAgent.hpp`, `src/slic3r/Utils/OrcaCloudServiceAgent.cpp`, `src/slic3r/CMakeLists.txt` + +**Interfaces:** +- Produces: `class Slic3r::OrcaMqttConnection` at `src/slic3r/Utils/OrcaMqttConnection.hpp` with today's exact API (`start(const std::string& endpoint, TokenProvider, MessageHandler, StateHandler)`, `stop()`, `is_running()`, `is_connected()`, `subscribe(const std::vector&)`, `unsubscribe(...)`, `clear_subscriptions()`, `send_request(const std::string&, const std::string&)`). + +- [ ] **Step 1: Create `OrcaMqttConnection.hpp`** + +Move the `class OrcaMqttConnection { ... };` block out of `OrcaCloudServiceAgent.hpp` (currently lines ~34–108) into a new header with include guard `#ifndef slic3r_OrcaMqttConnection_hpp_`. Carry the includes it needs: ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``. Keep it in `namespace Slic3r`. + +- [ ] **Step 2: Create `OrcaMqttConnection.cpp`** + +Move every `OrcaMqttConnection::` and `OrcaMqttConnection::Connection` definition out of `OrcaCloudServiceAgent.cpp` (the block from `struct OrcaMqttConnection::Connection {` to the end of `OrcaMqttConnection::run()`), plus the file-local `using json = nlohmann::json;` / anonymous-namespace helpers those definitions use. `#include "OrcaMqttConnection.hpp"` at the top; carry the boost/log/json includes it references. + +- [ ] **Step 3: Point `OrcaCloudServiceAgent` at the new header** + +In `OrcaCloudServiceAgent.hpp` replace the removed class with `#include "OrcaMqttConnection.hpp"`. `OrcaCloudServiceAgent.cpp` keeps compiling (it already `#include`s its own header). + +- [ ] **Step 4: Add to the build** + +In `src/slic3r/CMakeLists.txt`, in `set(SLIC3R_GUI_SOURCES` next to `Utils/OrcaCloudServiceAgent.cpp`: + +```cmake + Utils/OrcaMqttConnection.cpp + Utils/OrcaMqttConnection.hpp +``` + +- [ ] **Step 5: Verify green** + +```bash +cmake --build build --target slic3rutils_tests -j +ctest --test-dir build -R 'printer_agent|Orca' --output-on-failure +``` +Expected: builds; existing `test_printer_agent` / `test_qidi_printer_agent` unchanged and passing. No new tests yet. + +--- + +## Task 2: `Config` struct + `parse_endpoint` for `ws://` + +**Files:** +- Modify: `src/slic3r/Utils/OrcaMqttConnection.hpp`, `src/slic3r/Utils/OrcaMqttConnection.cpp` +- Create: `tests/slic3rutils/test_orca_mqtt_connection.cpp` +- Modify: `tests/slic3rutils/CMakeLists.txt` + +**Interfaces:** +- Produces: + ```cpp + struct OrcaMqttConnection::Config { + std::string url; + bool use_tls = false; + TokenProvider bearer_provider; // set => bearer on WS upgrade, CONNECT creds omitted + std::string username; + std::string password; + std::string client_id = "OrcaSlicer"; + int keepalive_seconds = 60; + }; + static bool OrcaMqttConnection::parse_endpoint(const std::string& url, Endpoint& out); // now public, ws:// + wss:// + struct OrcaMqttConnection::Endpoint { std::string host; std::string port; std::string target; }; + ``` + `Endpoint` and `parse_endpoint` move to the `public:` section. + +- [ ] **Step 1: Write the failing test** + +Create `tests/slic3rutils/test_orca_mqtt_connection.cpp`: + +```cpp +#include +#include "slic3r/Utils/OrcaMqttConnection.hpp" + +using Slic3r::OrcaMqttConnection; + +TEST_CASE("OrcaMqtt parse_endpoint handles ws and wss", "[OrcaMqtt]") { + OrcaMqttConnection::Endpoint ep; + + REQUIRE(OrcaMqttConnection::parse_endpoint("ws://printer.local:8280/mqtt", ep)); + CHECK(ep.host == "printer.local"); + CHECK(ep.port == "8280"); + CHECK(ep.target == "/mqtt"); + + REQUIRE(OrcaMqttConnection::parse_endpoint("ws://10.0.0.5/mqtt", ep)); + CHECK(ep.port == "80"); + + REQUIRE(OrcaMqttConnection::parse_endpoint("wss://api.example.com/api/v1/printers/abc/mqtt", ep)); + CHECK(ep.host == "api.example.com"); + CHECK(ep.port == "443"); + CHECK(ep.target == "/api/v1/printers/abc/mqtt"); + + CHECK_FALSE(OrcaMqttConnection::parse_endpoint("http://x/y", ep)); +} +``` + +- [ ] **Step 2: Register the test file and run it (expect FAIL)** + +Add `test_orca_mqtt_connection.cpp` to the `add_executable(${_TEST_NAME}_tests` list in `tests/slic3rutils/CMakeLists.txt`. + +```bash +cmake --build build --target slic3rutils_tests -j +``` +Expected: compile error — `parse_endpoint` is private / `Endpoint` is private / no `ws://` support. + +- [ ] **Step 3: Make it pass** + +In `OrcaMqttConnection.hpp` move `struct Endpoint` and `static bool parse_endpoint(...)` into `public:`. Add the `Config` struct (above) in `public:`. + +In `OrcaMqttConnection.cpp` replace the `wss://`-only `parse_endpoint` body: + +```cpp +bool OrcaMqttConnection::parse_endpoint(const std::string& url, Endpoint& endpoint) { + std::string rest; + std::string default_port; + if (url.rfind("wss://", 0) == 0) { rest = url.substr(6); default_port = "443"; } + else if (url.rfind("ws://", 0) == 0) { rest = url.substr(5); default_port = "80"; } + else return false; + + const auto slash = rest.find('/'); + const std::string authority = rest.substr(0, slash); + endpoint.target = (slash == std::string::npos) ? "/" : rest.substr(slash); + + // host[:port] — leave an unbracketed IPv6 literal alone + const auto colon = authority.rfind(':'); + if (colon != std::string::npos && authority.find(']') == std::string::npos) { + endpoint.host = authority.substr(0, colon); + endpoint.port = authority.substr(colon + 1); + } else { + endpoint.host = authority; + endpoint.port = default_port; + } + return !endpoint.host.empty() && !endpoint.port.empty() && !endpoint.target.empty(); +} +``` + +- [ ] **Step 4: Verify green** + +```bash +cmake --build build --target slic3rutils_tests -j +ctest --test-dir build -R OrcaMqtt --output-on-failure +``` +Expected: PASS. + +--- + +## Task 3: `make_connect_packet` with client-id / auth / keepalive + +**Files:** +- Modify: `src/slic3r/Utils/OrcaMqttConnection.hpp`, `.cpp`, `tests/slic3rutils/test_orca_mqtt_connection.cpp` + +**Interfaces:** +- Consumes: `Config` (Task 2). +- Produces: `static std::vector OrcaMqttConnection::make_connect_packet(const std::string& client_id, const std::string& username, const std::string& password, int keepalive_seconds);` — public. Clean-session always set; username/password flags + fields only when `username` non-empty. + +- [ ] **Step 1: Write the failing test** + +```cpp +TEST_CASE("OrcaMqtt CONNECT packet — no auth (cloud form)", "[OrcaMqtt]") { + auto p = OrcaMqttConnection::make_connect_packet("OrcaSlicer", "", "", 300); + REQUIRE(p.size() >= 12); + CHECK(p[0] == 0x10); // CONNECT + // variable header: protocol name "MQTT", level 4 + CHECK(p[2] == 'M'); CHECK(p[3] == 'Q'); CHECK(p[4] == 'T'); CHECK(p[5] == 'T'); + CHECK(p[6] == 0x04); + CHECK(p[7] == 0x02); // connect flags: clean session only + CHECK(((p[8] << 8) | p[9]) == 300); // keepalive +} + +TEST_CASE("OrcaMqtt CONNECT packet — username/password (LAN form)", "[OrcaMqtt]") { + auto p = OrcaMqttConnection::make_connect_packet("orcaslicer-lan-x", "orcasonar", "code123", 60); + CHECK(p[0] == 0x10); + CHECK(p[7] == (0x02 | 0x80 | 0x40)); // clean session + username + password flags + // payload contains the client id, then username, then password strings + const std::string blob(p.begin(), p.end()); + CHECK(blob.find("orcaslicer-lan-x") != std::string::npos); + CHECK(blob.find("orcasonar") != std::string::npos); + CHECK(blob.find("code123") != std::string::npos); +} +``` + +- [ ] **Step 2: Run it (expect FAIL)** + +```bash +cmake --build build --target slic3rutils_tests -j +``` +Expected: compile error — `make_connect_packet` takes no args / is private. + +- [ ] **Step 3: Implement** + +Move `make_connect_packet` to `public:` and replace its body: + +```cpp +std::vector OrcaMqttConnection::make_connect_packet( + const std::string& client_id, const std::string& username, + const std::string& password, int keepalive_seconds) { + std::vector packet{0x10}; + append_string(packet, "MQTT"); + packet.push_back(4); // protocol level 3.1.1 + + uint8_t flags = 0x02; // clean session + if (!username.empty()) { flags |= 0x80; if (!password.empty()) flags |= 0x40; } + packet.push_back(flags); + + packet.push_back(static_cast(keepalive_seconds >> 8)); + packet.push_back(static_cast(keepalive_seconds & 0xff)); + + append_string(packet, client_id.empty() ? "OrcaSlicer" : client_id); + if (!username.empty()) { + append_string(packet, username); + if (!password.empty()) append_string(packet, password); + } + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} +``` + +Update the one caller in `connect_and_read()` to pass +`current_config.client_id, current_config.username, current_config.password, current_config.keepalive_seconds` +(the `current_config` member arrives in Task 6; until then pass `"OrcaSlicer","","",60` and leave a `// TODO(Task 6): from Config` marker — **remove the marker in Task 6**). + +- [ ] **Step 4: Verify green** + +```bash +ctest --test-dir build -R OrcaMqtt --output-on-failure +``` +Expected: PASS. + +--- + +## Task 4: request / report topic packet builders + +**Files:** +- Modify: `src/slic3r/Utils/OrcaMqttConnection.hpp`, `.cpp`, `tests/slic3rutils/test_orca_mqtt_connection.cpp` + +**Interfaces:** +- Produces (all public, all static): + ```cpp + static std::string request_topic(const std::string& dev_id); // "device//request" + static std::string report_topic(const std::string& dev_id); // "device//report" (already exists; make public) + static std::vector make_publish_packet(const std::string& topic, const std::string& payload); // QoS 0 + static std::vector make_subscribe_packet(uint16_t packet_id, const std::string& topic, uint8_t qos); + static std::vector make_unsubscribe_packet(uint16_t packet_id, const std::string& topic); + ``` + +- [ ] **Step 1: Write the failing test** + +```cpp +TEST_CASE("OrcaMqtt topic helpers", "[OrcaMqtt]") { + CHECK(OrcaMqttConnection::request_topic("abc") == "device/abc/request"); + CHECK(OrcaMqttConnection::report_topic("abc") == "device/abc/report"); +} + +TEST_CASE("OrcaMqtt PUBLISH packet QoS0", "[OrcaMqtt]") { + auto p = OrcaMqttConnection::make_publish_packet("device/abc/request", "{\"ok\":1}"); + CHECK((p[0] & 0xf0) == 0x30); // PUBLISH + CHECK((p[0] & 0x06) == 0x00); // QoS 0 + const std::string blob(p.begin(), p.end()); + CHECK(blob.find("device/abc/request") != std::string::npos); + CHECK(blob.find("{\"ok\":1}") != std::string::npos); +} + +TEST_CASE("OrcaMqtt SUBSCRIBE packet", "[OrcaMqtt]") { + auto p = OrcaMqttConnection::make_subscribe_packet(7, "device/abc/report", 1); + CHECK(p[0] == 0x82); // SUBSCRIBE + reserved bit + CHECK(((p[2] << 8) | p[3]) == 7); // packet id + CHECK(p.back() == 1); // requested QoS +} +``` + +- [ ] **Step 2: Run it (expect FAIL)** — `cmake --build build --target slic3rutils_tests -j`; missing symbols. + +- [ ] **Step 3: Implement** + +Make `report_topic` public. Add: + +```cpp +std::string OrcaMqttConnection::request_topic(const std::string& id) { return "device/" + id + "/request"; } + +std::vector OrcaMqttConnection::make_publish_packet(const std::string& topic, const std::string& payload) { + std::vector packet{0x30}; // PUBLISH, QoS 0, no retain + append_string(packet, topic); // no packet id at QoS 0 + packet.insert(packet.end(), payload.begin(), payload.end()); + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} + +std::vector OrcaMqttConnection::make_subscribe_packet(uint16_t id, const std::string& topic, uint8_t qos) { + std::vector packet{0x82}; + packet.push_back(id >> 8); packet.push_back(id & 0xff); + append_string(packet, topic); + packet.push_back(qos); + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} + +std::vector OrcaMqttConnection::make_unsubscribe_packet(uint16_t id, const std::string& topic) { + std::vector packet{0xA2}; + packet.push_back(id >> 8); packet.push_back(id & 0xff); + append_string(packet, topic); + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} +``` + +Keep the existing `make_topic_packet(uint8_t, uint16_t, const std::vector&)` for now — Task 5 removes its callers. + +- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaMqtt --output-on-failure` + +--- + +## Task 5: single-id `subscribe` / `unsubscribe` + persistent set; `Config`-carrying `start` + +**Files:** +- Modify: `src/slic3r/Utils/OrcaMqttConnection.hpp`, `.cpp`, `tests/slic3rutils/test_orca_mqtt_connection.cpp` + +**Interfaces:** +- Produces: + ```cpp + bool start(const Config& config, MessageHandler on_message, StateHandler on_state); + bool subscribe(const std::string& dev_id); // adds report_topic(dev_id) to the set, SUBSCRIBEs if connected + bool unsubscribe(const std::string& dev_id); + int last_connack_rc() const; // 0 ok, 1..5 refusal, -1 none this attempt + ``` + The old `start(const std::string&, TokenProvider, ...)` and vector `subscribe`/`unsubscribe` are **removed** (no back-compat). `report_topic` set members replace the device-id set. + +- [ ] **Step 1: Write the failing test** + +```cpp +TEST_CASE("OrcaMqtt start takes a Config", "[OrcaMqtt]") { + OrcaMqttConnection conn; + OrcaMqttConnection::Config cfg; + cfg.url = "ws://127.0.0.1:1/mqtt"; // nothing listening + cfg.keepalive_seconds = 42; + // start() returns false (no server) but must compile with the Config overload + const bool ok = conn.start(cfg, [](auto, auto){}, [](bool, bool){}); + CHECK_FALSE(ok); + CHECK(conn.last_connack_rc() == -1); + conn.stop(); +} +``` + +- [ ] **Step 2: Run it (expect FAIL)** — compile error: no `start(Config, ...)`, no `last_connack_rc`. + +- [ ] **Step 3: Implement** + +- Replace the `endpoint_url` / `get_token` members with `Config current_config;` and `std::atomic m_last_connack_rc{-1};`. +- Replace `std::set subscriptions` semantics: it now holds full report-topic strings. Add `pending_subscriptions` / `pending_unsubscriptions` as topic strings too (rename in place). +- New `start`: + ```cpp + bool OrcaMqttConnection::start(const Config& config, MessageHandler on_message, StateHandler on_state) { + stop(); + { std::lock_guard l(mutex); + current_config = config; this->on_message = std::move(on_message); this->on_state = std::move(on_state); + initial_completed = false; initial_result = false; connected = false; m_last_connack_rc = -1; } + stopping.store(false); + worker = std::thread(&OrcaMqttConnection::run, this); + std::unique_lock l(mutex); + initial_cv.wait_for(l, std::chrono::seconds(10), [this]{ return initial_completed; }); + return initial_result; + } + ``` +- `subscribe(dev_id)` / `unsubscribe(dev_id)`: + ```cpp + bool OrcaMqttConnection::subscribe(const std::string& dev_id) { + const std::string topic = report_topic(dev_id); + { std::lock_guard l(mutex); + subscriptions.insert(topic); pending_unsubscriptions.erase(topic); + pending_subscriptions.insert(topic); } + flush_subscription_change(); + return true; + } + ``` + (mirror for `unsubscribe`). +- `connect_and_read()` / `send_current_subscriptions` / `send_pending_subscriptions`: iterate topic strings, build with `make_subscribe_packet(next_packet_id++, topic, 1)` / `make_unsubscribe_packet`. Delete `make_topic_packet` and its declaration. +- In `connect_and_read()` use `current_config`: `parse_endpoint(current_config.url, ep)`; branch TLS on `current_config.use_tls` (Task 7 completes the plaintext branch); build CONNECT via `make_connect_packet(current_config.client_id, current_config.username, current_config.password, current_config.keepalive_seconds)` — **delete the Task 3 TODO marker**; add the `Authorization: Bearer` upgrade header only when `current_config.bearer_provider` is set. +- On CONNACK: `m_last_connack_rc = ;`. If rc ∈ {4,5}: stop the worker, do not retry (terminal). +- Update `OrcaCloudServiceAgent.cpp::connect_server()` (its only caller) to the new signature — see Task 15; for now make it compile with a `Config` built from today's `wss://.../api/v1/printers/mqtt` URL and `bearer_provider = [this]{ return get_access_token(); }`. + +- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaMqtt --output-on-failure` (the new test plus Tasks 2–4). Also `ctest -R 'printer_agent'` still green. + +--- + +## Task 6: `send_request` publishes to `device//request` + +**Files:** +- Modify: `src/slic3r/Utils/OrcaMqttConnection.cpp`, `tests/slic3rutils/test_orca_mqtt_connection.cpp` + +**Interfaces:** +- Consumes: `make_publish_packet`, `request_topic`, `ws_write`/`send` (existing write path), `write_mutex`. +- Produces: `bool send_request(const std::string& dev_id, const std::string& payload)` — returns `false` without sending if `!is_connected()`. + +- [ ] **Step 1: Write the failing test** (uses the mock broker from Task 8 — so this test is added but `[.]`-hidden until Task 9 wires it; for now assert the guard): + +```cpp +TEST_CASE("OrcaMqtt send_request refuses when not connected", "[OrcaMqtt]") { + OrcaMqttConnection conn; + CHECK_FALSE(conn.send_request("abc", "{\"pushing\":{\"command\":\"pushall\",\"sequence_id\":\"20001\"}}")); +} +``` + +- [ ] **Step 2: Run it (expect FAIL)** — `send_request` currently returns something else / is unimplemented. + +- [ ] **Step 3: Implement** + +```cpp +bool OrcaMqttConnection::send_request(const std::string& dev_id, const std::string& payload) { + if (!connected.load()) return false; + std::shared_ptr conn; + { std::lock_guard l(connection_mutex); conn = active_connection; } + if (!conn) return false; + const auto packet = make_publish_packet(request_topic(dev_id), payload); + std::lock_guard w(write_mutex); + try { send(conn->websocket, packet); } // existing write helper + catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "OrcaMqtt send_request failed: " << e.what(); + return false; + } + return true; +} +``` + +(If Task 7 renamed `send`→`ws_write`, use that.) + +- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaMqtt --output-on-failure` + +--- + +## Task 7: plaintext (`ws://`) transport branch + +**Files:** +- Modify: `src/slic3r/Utils/OrcaMqttConnection.hpp`, `.cpp` + +**Interfaces:** +- Consumes: `Config.use_tls`, `Config.bearer_provider`. +- Produces: `struct Connection` holds `std::optional ws;` and `std::optional wss;` with `PlainWebSocket = websocket::stream` and `TlsWebSocket = websocket::stream>`. Internal helpers `ws_write(Connection&, const std::vector&)`, `ws_read(Connection&, beast::flat_buffer&, error_code&)`, `ws_handshake(Connection&, const Config&, const Endpoint&)`, `ws_close(Connection&)` dispatch on which optional is engaged. + +- [ ] **Step 1: Write the failing test** (needs the mock broker — mark `[.integration]`, un-hide in Task 9): + +```cpp +TEST_CASE("OrcaMqtt connects over plaintext ws", "[OrcaMqtt][.integration]") { + orca_mqtt_test::MockBroker broker; // Task 8 + OrcaMqttConnection conn; + OrcaMqttConnection::Config cfg; cfg.url = broker.ws_url(); cfg.use_tls = false; + std::atomic up{false}; + REQUIRE(conn.start(cfg, [](auto,auto){}, [&](bool c, bool){ if (c) up = true; })); + CHECK(conn.is_connected()); + conn.stop(); +} +``` + +- [ ] **Step 2: Run it (expect FAIL to build / link)** — no `ws_handshake` etc. + +- [ ] **Step 3: Implement** + +- `Connection` gets both optionals + the shared `beast::flat_buffer read_buffer;` and a `net::io_context io;` / `ssl::context tls{ssl::context::tlsv12_client};` as today. +- `ws_handshake`: resolve host/port; `beast::get_lowest_layer(stream).connect(results)`; if `use_tls`: set SNI (`SSL_set_tlsext_host_name`), `stream.next_layer().handshake(ssl::stream_base::client)`. Then set the upgrade decorator that adds `Authorization: Bearer ` when `config.bearer_provider` is set, and `Sec-WebSocket-Protocol: mqtt`. Then `stream.handshake(host, target)`. +- `ws_write` / `ws_read` / `ws_close`: `if (conn.wss) conn.wss->...; else conn.ws->...;`. +- `connect_and_read()`: replace direct `WebSocket` use with a `Connection` and the `ws_*` helpers; construct `conn->ws.emplace(conn->io)` or `conn->wss.emplace(conn->io, conn->tls)` based on `current_config.use_tls`. +- Delete the old `using WebSocket = websocket::stream>;` typedef and the `send(WebSocket&, ...)` signature (fold into `ws_write`). + +> API note: exact Beast calls (`beast::get_lowest_layer`, `tcp_stream::connect`, decorator signature) vary by Boost version. Match the version already vendored in `deps/`; the existing TLS code in this file is the reference for the `wss` side. + +- [ ] **Step 4: Verify green** + +```bash +cmake --build build --target slic3rutils_tests -j +ctest --test-dir build -R 'OrcaMqtt' --output-on-failure # non-integration subset still green +``` + +--- + +## Task 8: in-process MQTT-over-WS mock broker + +**Files:** +- Create: `tests/slic3rutils/orca_mqtt_mock_broker.hpp` + +**Interfaces:** +- Produces: + ```cpp + namespace orca_mqtt_test { + class MockBroker { // starts on ctor, stops on dtor + public: + MockBroker(); + ~MockBroker(); + std::string ws_url() const; // "ws://127.0.0.1:/mqtt" + void push_report(const std::string& dev_id, const std::string& payload); // server->client PUBLISH on device//report + std::vector received_requests() const; // payloads PUBLISHed by the client to any device//request + int connect_count() const; + }; + } + ``` + +- [ ] **Step 1: Smoke test** + +```cpp +#include "orca_mqtt_mock_broker.hpp" +TEST_CASE("MockBroker starts and reports a url", "[OrcaMqtt][.integration]") { + orca_mqtt_test::MockBroker b; + CHECK(b.ws_url().rfind("ws://127.0.0.1:", 0) == 0); + CHECK(b.connect_count() == 0); +} +``` + +- [ ] **Step 2: Run it (expect FAIL)** — header missing. + +- [ ] **Step 3: Implement** + +Header-only. One `std::thread` running a `net::io_context`; `tcp::acceptor` on `{net::ip::make_address("127.0.0.1"), 0}` (port 0 → OS-assigned, read back via `acceptor.local_endpoint().port()`). On accept: `websocket::stream`, accept the upgrade echoing `Sec-WebSocket-Protocol: mqtt`. Then a minimal MQTT read loop: +- `0x10` CONNECT → reply `0x20 0x02 0x00 0x00` (CONNACK accepted); `connect_count_++`. +- `0x82` SUBSCRIBE → reply `0x90` SUBACK with the echoed packet id and one granted-QoS byte `0x00`; remember the subscribed topic. +- `0x30` PUBLISH (QoS 0) → parse topic + payload; if topic ends `/request`, append payload to `received_requests_`. +- `0xC0` PINGREQ → reply `0xD0 0x00`. +- `0xE0` DISCONNECT / read error → close. +`push_report()` posts a `make`-style PUBLISH (`0x30`, topic `device//report`, payload) onto the connected client socket via `net::post(strand, ...)`. + +Use only Boost already vendored. Guard all shared state (`received_requests_`, `connect_count_`) with a `std::mutex`. + +- [ ] **Step 4: Verify green** — `ctest --test-dir build -R 'MockBroker' --output-on-failure` with `--allow-running-no-tests` off; run the hidden tag explicitly: `ctest --test-dir build -R OrcaMqtt -C RelWithDebInfo` then the test binary directly: `./build/tests/slic3rutils/slic3rutils_tests "[.integration]"`. + +--- + +## Task 9: parametrized LAN/cloud integration test + +**Files:** +- Modify: `tests/slic3rutils/test_orca_mqtt_connection.cpp` + +**Interfaces:** +- Consumes: `MockBroker` (Task 8), `OrcaMqttConnection::start/subscribe/send_request` (Tasks 5–7). + +- [ ] **Step 1: Write the test** + +```cpp +static void run_round_trip(bool use_tls_flag_only) { + orca_mqtt_test::MockBroker broker; + OrcaMqttConnection conn; + OrcaMqttConnection::Config cfg; + cfg.url = broker.ws_url(); // plaintext regardless + cfg.use_tls = false; // the mock is plaintext; the flag path is unit-tested elsewhere + if (use_tls_flag_only) cfg.bearer_provider = []{ return std::string("tok"); }; + else { cfg.username = "orcasonar"; cfg.password = "code"; } + + std::promise> got; + REQUIRE(conn.start(cfg, + [&](const std::string& id, const std::string& payload){ got.set_value({id, payload}); }, + [](bool,bool){})); + REQUIRE(conn.subscribe("dev-1")); + REQUIRE(conn.send_request("dev-1", R"({"pushing":{"command":"pushall","sequence_id":"20001"}})")); + + broker.push_report("dev-1", R"({"print":{"command":"push_status","sequence_id":"20001","result":"success"}})"); + auto fut = got.get_future(); + REQUIRE(fut.wait_for(std::chrono::seconds(3)) == std::future_status::ready); + auto [id, payload] = fut.get(); + CHECK(id == "dev-1"); + CHECK(payload.find("push_status") != std::string::npos); + + // the client's command reached the broker on the request topic + CHECK(broker.received_requests().size() == 1); + conn.stop(); +} + +TEST_CASE("OrcaMqtt round-trip — LAN-style config", "[OrcaMqtt][.integration]") { run_round_trip(false); } +TEST_CASE("OrcaMqtt round-trip — cloud-style config", "[OrcaMqtt][.integration]") { run_round_trip(true); } +``` + +- [ ] **Step 2: Run it (expect FAIL then iterate)** — `./build/tests/slic3rutils/slic3rutils_tests "[.integration]"`. + +- [ ] **Step 3: Fix defects** in `OrcaMqttConnection` until both cases pass with identical assertions. Un-hide the Task 6/7 integration tests (`[.integration]` → keep the tag; they run via explicit tag selection). + +- [ ] **Step 4: Verify green** — `./build/tests/slic3rutils/slic3rutils_tests "[OrcaMqtt]"` (all, including `[.integration]`). + +--- + +## Task 10: `OrcaPrinterAgent` — remove `read_loop`, wire per-connection inbound + +**Files:** +- Modify: `src/slic3r/Utils/OrcaPrinterAgent.hpp`, `.cpp` +- Create: `tests/slic3rutils/test_orca_printer_agent.cpp` (seed from `salvage/orcasonar-lan-agent-2026-09-03`) +- Modify: `tests/slic3rutils/CMakeLists.txt` + +**Interfaces:** +- Produces: `void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload)` (private) — snapshots `on_message_fn` under `state_mutex`, calls it (marshalling via `queue_on_main_fn` when set). Used as the body of every connection's `MessageHandler`. + +- [ ] **Step 1: Seed + write the failing test** + +```bash +git show salvage/orcasonar-lan-agent-2026-09-03:tests/slic3rutils/test_orca_printer_agent.cpp \ + > tests/slic3rutils/test_orca_printer_agent.cpp +``` +Then replace its body with this design's tests. First test: + +```cpp +#include +#include "slic3r/Utils/OrcaPrinterAgent.hpp" +using Slic3r::OrcaPrinterAgent; + +TEST_CASE("OrcaPrinterAgent forwards a status payload to on_message_fn", "[OrcaPrinterAgent]") { + OrcaPrinterAgent agent("/tmp"); + std::string got_id, got_payload; + agent.set_on_message_fn([&](std::string id, std::string p){ got_id = id; got_payload = p; }); + agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status"}})"); // test-only hook + CHECK(got_id == "dev-1"); + CHECK(got_payload.find("push_status") != std::string::npos); +} +``` + +Add `test_orca_printer_agent.cpp` to `tests/slic3rutils/CMakeLists.txt`. + +- [ ] **Step 2: Run it (expect FAIL)** — `deliver_to_sink` missing; `read_loop` still present. + +- [ ] **Step 3: Implement** + +- `OrcaPrinterAgent.hpp`: delete `read_loop`, `m_read_loop_thread`, `m_should_end`. Add `void deliver_to_sink(const std::string& dev_id, const std::string& payload);`. +- `OrcaPrinterAgent.cpp`: delete the `read_loop` definition and its ctor `std::thread(...)`/`detach()`. Delete the file-scope `std::mutex m_conn_type_mtx;`. Ctor body becomes empty (or just the log line). Add: + ```cpp + void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload) { + OnMessageFn fn; QueueOnMainFn q; + { std::lock_guard l(state_mutex); fn = on_message_fn; q = queue_on_main_fn; } + if (!fn) return; + if (q) q([fn, dev_id, payload]{ fn(dev_id, payload); }); + else fn(dev_id, payload); + } + ``` +- In `set_cloud_agent`, the existing `set_printer_status_callback` lambda body becomes `deliver_to_sink(std::move(dev_id), std::move(payload));`. + +- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaPrinterAgent --output-on-failure` + +--- + +## Task 11: `connect_printer` builds + starts the LAN connection + +**Files:** +- Modify: `src/slic3r/Utils/OrcaPrinterAgent.hpp`, `.cpp`, `tests/slic3rutils/test_orca_printer_agent.cpp` + +**Interfaces:** +- Consumes: `OrcaMqttConnection::Config`, `start`. +- Produces: + ```cpp + static bool OrcaPrinterAgent::parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port); // "8280" default, "/mqtt" implied + static std::string OrcaPrinterAgent::make_lan_client_id(const std::string& dev_id); + ``` + New members: `std::atomic m_lan_generation{0}`, `std::string m_lan_dev_id`. + +- [ ] **Step 1: Write the failing test** + +```cpp +TEST_CASE("OrcaPrinterAgent::parse_lan_endpoint", "[OrcaPrinterAgent]") { + std::string h, p; + REQUIRE(OrcaPrinterAgent::parse_lan_endpoint("192.168.1.9", h, p)); + CHECK(h == "192.168.1.9"); CHECK(p == "8280"); + REQUIRE(OrcaPrinterAgent::parse_lan_endpoint("http://host.local:9000/x", h, p)); + CHECK(h == "host.local"); CHECK(p == "9000"); +} + +TEST_CASE("connect_printer stands up a LAN connection", "[OrcaPrinterAgent]") { + OrcaPrinterAgent agent("/tmp"); + // 10.255.255.1 is unroutable → start() returns fast-ish; we only assert wiring + const int rc = agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false); + CHECK(rc == BAMBU_NETWORK_SUCCESS); + CHECK(agent.get_user_selected_machine().empty()); // LAN path does not set the cloud selection + agent.disconnect_printer(); +} +``` + +- [ ] **Step 2: Run it (expect FAIL)** — helpers missing; `connect_printer` is a stub returning success without doing anything (make the wiring assertion fail by checking an observable — see Step 3 for the observable: a protected `lan_connection_url()` test hook). + +- [ ] **Step 3: Implement** + +- Add `parse_lan_endpoint` (scheme strip, `host[:port]`, default `8280`) and `make_lan_client_id` (`"orcaslicer-lan-" + dev_id + "-" + <8 hex, drawn once per process>`). +- `connect_printer`: + ```cpp + int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, + std::string username, std::string password, bool use_ssl) { + if (dev_id.empty() || dev_ip.empty()) return BAMBU_NETWORK_ERR_INVALID_HANDLE; + std::string host, port; + if (!parse_lan_endpoint(dev_ip, host, port)) return BAMBU_NETWORK_ERR_INVALID_HANDLE; + disconnect_printer(); + const uint64_t gen = ++m_lan_generation; + OrcaMqttConnection::Config cfg; + cfg.url = "ws://" + host + ":" + port + "/mqtt"; + cfg.use_tls = false; // OrcaSonar LAN is plaintext; use_ssl ignored + cfg.username = username.empty() ? "orcasonar" : username; + cfg.password = password; + cfg.client_id = make_lan_client_id(dev_id); + cfg.keepalive_seconds = 60; + { std::lock_guard l(state_mutex); m_lan_dev_id = dev_id; m_current_connection = LAN; } + lan_mqtt_connection = std::make_unique(); + auto* conn = lan_mqtt_connection.get(); + std::thread([this, conn, cfg, dev_id, gen] { + const bool ok = conn->start(cfg, + [this, gen](const std::string& id, const std::string& p){ if (gen == m_lan_generation.load()) deliver_to_sink(id, p); }, + [this, gen, dev_id, conn](bool c, bool initial){ if (c && !initial && gen == m_lan_generation.load()) on_connected(dev_id, conn, gen); }); + if (ok && gen == m_lan_generation.load()) on_connected(dev_id, conn, gen); + }).detach(); + return BAMBU_NETWORK_SUCCESS; + } + ``` +- Add a protected test hook: `std::string lan_connection_target() const { return lan_mqtt_connection ? /* Config.url snapshot */ : ""; }` — store `cfg.url` in a `std::string m_lan_url;` member when building it, and return that. Assert it in the test. + +- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaPrinterAgent --output-on-failure` + +--- + +## Task 12: `on_connected` — the shared post-connect sequence + +**Files:** +- Modify: `src/slic3r/Utils/OrcaPrinterAgent.hpp`, `.cpp`, `tests/slic3rutils/test_orca_printer_agent.cpp` + +**Interfaces:** +- Consumes: `OrcaMqttConnection::subscribe`, `send_request`. +- Produces: + ```cpp + void OrcaPrinterAgent::on_connected(const std::string& dev_id, OrcaMqttConnection* conn, uint64_t generation); + static std::string OrcaPrinterAgent::seq(int n); // "2000" + zero-padded band offset; returns a string in 20000..29999 + static std::string OrcaPrinterAgent::build_pushing_start(const std::string& sequence_id); + static std::string OrcaPrinterAgent::build_pushall(const std::string& sequence_id); + static std::string OrcaPrinterAgent::build_get_version(const std::string& sequence_id); + static std::string OrcaPrinterAgent::build_get_capabilities(const std::string& sequence_id); + ``` + +- [ ] **Step 1: Write the failing test** — a fake `OrcaMqttConnection` subclass is not available (non-virtual). Instead test the payload builders + call order via a seam: `on_connected` takes an `OrcaMqttConnection*`; make the four `send_request` payloads and the `subscribe` observable by having `on_connected` delegate to a `protected virtual void emit_connect_sequence(const std::string&, std::function subscribe, std::function request)` that the test overrides. + +```cpp +TEST_CASE("post-connect sequence is subscribe then 4 requests in order", "[OrcaPrinterAgent]") { + struct Probe : OrcaPrinterAgent { + using OrcaPrinterAgent::OrcaPrinterAgent; + std::vector calls; + void emit_connect_sequence(const std::string& dev_id, + std::function sub, + std::function req) override { + OrcaPrinterAgent::emit_connect_sequence(dev_id, + [&](const std::string& id){ calls.push_back("sub:" + id); }, + [&](const std::string& body){ calls.push_back(body); }); + } + } probe("/tmp"); + probe.run_connect_sequence_for_test("dev-1"); // thin public shim calling emit_connect_sequence + REQUIRE(probe.calls.size() == 5); + CHECK(probe.calls[0] == "sub:dev-1"); + CHECK(probe.calls[1].find("\"pushing\"") != std::string::npos); + CHECK(probe.calls[1].find("\"start\"") != std::string::npos); + CHECK(probe.calls[2].find("pushall") != std::string::npos); + CHECK(probe.calls[3].find("get_version") != std::string::npos); + CHECK(probe.calls[4].find("get_capabilities") != std::string::npos); + for (auto& c : probe.calls) // sequence_id band + if (auto pos = c.find("sequence_id"); pos != std::string::npos) + CHECK(c.substr(pos).find("\"2") != std::string::npos); +} +``` + +- [ ] **Step 2: Run it (expect FAIL)** — members missing. + +- [ ] **Step 3: Implement** + +- Payload builders return exact JSON, e.g.: + ```cpp + std::string OrcaPrinterAgent::build_pushall(const std::string& sid) { + return R"({"pushing":{"command":"pushall","sequence_id":")" + sid + R"(","version":1,"push_target":1}})"; + } + std::string OrcaPrinterAgent::build_pushing_start(const std::string& sid) { + return R"({"pushing":{"command":"start","sequence_id":")" + sid + R"("}})"; + } + std::string OrcaPrinterAgent::build_get_version(const std::string& sid) { + return R"({"info":{"command":"get_version","sequence_id":")" + sid + R"("}})"; + } + std::string OrcaPrinterAgent::build_get_capabilities(const std::string& sid) { + return R"({"info":{"command":"get_capabilities","sequence_id":")" + sid + R"("}})"; + } + ``` +- `seq(n)`: `return std::to_string(20000 + (n % 10000));` +- `emit_connect_sequence(dev_id, sub, req)`: `sub(dev_id); req(build_pushing_start(seq(1))); req(build_pushall(seq(2))); req(build_get_version(seq(3))); req(build_get_capabilities(seq(4)));` +- `on_connected(dev_id, conn, generation)`: if `generation != m_lan_generation.load()` **and** the cloud generation guard (Task 14) both mismatch, return. Else `emit_connect_sequence(dev_id, [conn](auto& id){ conn->subscribe(id); }, [conn, &dev_id](auto& body){ conn->send_request(dev_id, body); });` +- Add the tiny public shim `run_connect_sequence_for_test`. + +- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaPrinterAgent --output-on-failure` + +--- + +## Task 13: `disconnect_printer` + generation guard + +**Files:** +- Modify: `src/slic3r/Utils/OrcaPrinterAgent.cpp`, `tests/slic3rutils/test_orca_printer_agent.cpp` + +**Interfaces:** +- Consumes: `m_lan_generation`, `lan_mqtt_connection`, `OrcaMqttConnection::stop`. + +- [ ] **Step 1: Write the failing test** + +```cpp +TEST_CASE("a stale-generation inbound message is dropped", "[OrcaPrinterAgent]") { + struct Probe : OrcaPrinterAgent { using OrcaPrinterAgent::OrcaPrinterAgent; + using OrcaPrinterAgent::make_lan_message_handler; }; // expose for the test + Probe agent("/tmp"); + int hits = 0; + agent.set_on_message_fn([&](std::string, std::string){ ++hits; }); + auto handler_gen1 = agent.make_lan_message_handler(/*generation=*/1); + agent.bump_lan_generation_for_test(); // now current == 2 + handler_gen1("dev-1", "{}"); // late callback from gen 1 + CHECK(hits == 0); +} +``` + +- [ ] **Step 2: Run it (expect FAIL)** — helpers missing. + +- [ ] **Step 3: Implement** + +- Factor the message-handler lambda into `std::function make_lan_message_handler(uint64_t generation)` returning `[this, generation](auto& id, auto& p){ if (generation == m_lan_generation.load()) deliver_to_sink(id, p); }`. +- `disconnect_printer`: + ```cpp + int OrcaPrinterAgent::disconnect_printer() { + ++m_lan_generation; // fence stale callbacks + std::unique_ptr doomed; + { std::lock_guard l(state_mutex); + doomed = std::move(lan_mqtt_connection); + m_lan_dev_id.clear(); + if (m_current_connection == LAN) m_current_connection = NONE; } + if (doomed) doomed->stop(); // joins the worker, outside the lock + return BAMBU_NETWORK_SUCCESS; + } + ``` +- Add `bump_lan_generation_for_test()`. + +- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaPrinterAgent --output-on-failure` + +--- + +## Task 14: `set_user_selected_machine` drives the cloud per-printer connection + +**Files:** +- Modify: `src/slic3r/Utils/OrcaPrinterAgent.cpp`, `src/slic3r/Utils/OrcaCloudServiceAgent.hpp`, `.cpp`, `tests/slic3rutils/test_orca_printer_agent.cpp` + +**Interfaces:** +- Consumes: `OrcaCloudServiceAgent::get_mqtt_connection()`. +- Produces on `OrcaCloudServiceAgent`: + ```cpp + int configure_selected_printer_mqtt(const std::string& dev_id); // (re)build Config{wss:///api/v1/printers/{id}/mqtt, bearer}, start() + void teardown_selected_printer_mqtt(); // stop() + std::string selected_printer_mqtt_url() const; // test hook — "" when not configured + ``` + +- [ ] **Step 1: Write the failing test** + +```cpp +TEST_CASE("selecting a cloud printer configures the per-printer socket", "[OrcaPrinterAgent]") { + auto cloud = std::make_shared("/tmp"); + cloud->set_api_base_url("api.example.com"); + OrcaPrinterAgent agent("/tmp"); + agent.set_cloud_agent(cloud); + + agent.set_user_selected_machine("printer-uuid-1"); + CHECK(cloud->selected_printer_mqtt_url() == "wss://api.example.com/api/v1/printers/printer-uuid-1/mqtt"); + + agent.set_user_selected_machine(""); + CHECK(cloud->selected_printer_mqtt_url().empty()); +} +``` + +- [ ] **Step 2: Run it (expect FAIL)** — methods missing; `set_user_selected_machine` still does the aggregate `add_subscribe` dance. + +- [ ] **Step 3: Implement** + +- `OrcaCloudServiceAgent::configure_selected_printer_mqtt(dev_id)`: + ```cpp + OrcaMqttConnection::Config cfg; + cfg.url = "wss://" + api_base_url + "/api/v1/printers/" + dev_id + "/mqtt"; + cfg.use_tls = true; + cfg.bearer_provider = [this]{ return get_access_token(); }; + cfg.client_id = "OrcaSlicer"; + cfg.keepalive_seconds = 300; + m_selected_printer_mqtt_url = cfg.url; + return mqtt_connection->start(cfg, + [this](const std::string& id, const std::string& p){ /* Task 15 routes to printer_status_callback */ deliver_cloud_message(id, p); }, + [this](bool, bool){ }) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + ``` + `teardown_selected_printer_mqtt()`: `mqtt_connection->stop(); m_selected_printer_mqtt_url.clear();` +- `OrcaPrinterAgent::set_user_selected_machine(dev_id)`: + ```cpp + int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) { + auto* cloud = get_orca_cloud_agent(); + std::string previous; + { std::lock_guard l(state_mutex); + if (dev_id == selected_machine) return BAMBU_NETWORK_SUCCESS; + previous = selected_machine; selected_machine = dev_id; } + if (!cloud) return BAMBU_NETWORK_SUCCESS; + auto* conn = cloud->get_mqtt_connection(); + if (!previous.empty() && conn && conn->is_connected()) + conn->send_request(previous, build_pushing_stop(seq(5))); + cloud->teardown_selected_printer_mqtt(); + if (!dev_id.empty()) { + const uint64_t gen = ++m_lan_generation; // reuse the same fence for cloud + std::thread([this, cloud, dev_id, gen]{ + if (cloud->configure_selected_printer_mqtt(dev_id) == BAMBU_NETWORK_SUCCESS + && gen == m_lan_generation.load()) + on_connected(dev_id, cloud->get_mqtt_connection(), gen); + }).detach(); + { std::lock_guard l(state_mutex); m_current_connection = CLOUD; } + } else { + std::lock_guard l(state_mutex); m_current_connection = NONE; + } + return BAMBU_NETWORK_SUCCESS; + } + ``` + Add `build_pushing_stop` next to `build_pushing_start` (`"command":"stop"`). +- Delete the old aggregate `add_subscribe` / `del_subscribe` / `send_message(pushall)` / `deliver_mock_get_version` body from `set_user_selected_machine` (the mock get_version is now redundant — `on_connected` sends a real `info.get_version`). + +- [ ] **Step 4: Verify green** — `ctest --test-dir build -R OrcaPrinterAgent --output-on-failure` + +--- + +## Task 15: `OrcaCloudServiceAgent::connect_server()` stops starting the aggregate socket + +**Files:** +- Modify: `src/slic3r/Utils/OrcaCloudServiceAgent.cpp`, `.hpp`, `tests/slic3rutils/test_orca_printer_agent.cpp` (or a new `test_orca_cloud_service_agent.cpp`) + +**Interfaces:** +- Consumes: existing REST `http_get(ORCA_HEALTH_PATH, ...)`. +- Produces: `connect_server()` returns success/failure from the health probe alone; `is_server_connected()` returns the last health result; `mqtt_connection` is untouched by `connect_server()`. + +- [ ] **Step 1: Write the failing test** + +```cpp +TEST_CASE("connect_server does not start an MQTT socket", "[OrcaCloud]") { + auto cloud = std::make_shared("/tmp"); + cloud->set_api_base_url("127.0.0.1:1"); // health probe will fail fast + cloud->connect_server(); + REQUIRE(cloud->get_mqtt_connection() != nullptr); + CHECK_FALSE(cloud->get_mqtt_connection()->is_running()); + CHECK(cloud->selected_printer_mqtt_url().empty()); +} +``` + +- [ ] **Step 2: Run it (expect FAIL)** — `connect_server()` currently builds `cfg.url = "wss://" + api_base_url + "/api/v1/printers/mqtt"` and calls `mqtt_connection->start(...)`. + +- [ ] **Step 3: Implement** + +- Delete the whole `if (connected && mqtt_connection && !mqtt_connection->is_running()) { ... mqtt_connection->start(cfg, ...); }` block from `connect_server()` and the trailing "aggregate MQTT" logging. +- Keep the health check; set `is_connected = connected;` from it and call `invoke_server_connected_callback(connected ? 0 : -1, http_code);`. +- `refresh_connection()` still just calls `connect_server()`. +- The `printer_status_callback` / `set_printer_status_callback` machinery stays (Task 14's `configure_selected_printer_mqtt` message lambda calls `deliver_cloud_message` → the registered `printer_status_callback`). Add: + ```cpp + void OrcaCloudServiceAgent::deliver_cloud_message(const std::string& id, const std::string& p) { + OnMessageFn cb; + { std::lock_guard l(callback_mutex); cb = printer_status_callback; } + if (cb) cb(id, p); + } + ``` + +- [ ] **Step 4: Verify green** — `ctest --test-dir build -R 'OrcaCloud|OrcaPrinterAgent' --output-on-failure` + +--- + +## Task 16: collapse `send_message` / `send_message_to_printer` + +**Files:** +- Modify: `src/slic3r/Utils/OrcaPrinterAgent.cpp`, `tests/slic3rutils/test_orca_printer_agent.cpp` + +**Interfaces:** +- Consumes: `get_appropriate_mqtt_connection(bool)`, `OrcaMqttConnection::send_request`. + +- [ ] **Step 1: Write the failing test** + +```cpp +TEST_CASE("send_message* reject when no connection", "[OrcaPrinterAgent]") { + OrcaPrinterAgent agent("/tmp"); // no cloud agent, no LAN connection + CHECK(agent.send_message("d", "{}", 0, 0) == BAMBU_NETWORK_ERR_INVALID_HANDLE); + CHECK(agent.send_message_to_printer("d", "{}", 0, 0) == BAMBU_NETWORK_ERR_INVALID_HANDLE); +} + +TEST_CASE("send_message_to_printer publishes on the LAN connection", "[OrcaPrinterAgent][.integration]") { + orca_mqtt_test::MockBroker broker; + OrcaPrinterAgent agent("/tmp"); + // point the LAN connection at the mock by connecting to its host:port + auto ep = broker.host_port(); // {"127.0.0.1", } + agent.connect_printer("dev-1", ep.first + ":" + ep.second, "orcasonar", "code", false); + // wait for connect + for (int i = 0; i < 100 && broker.connect_count() == 0; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(20)); + CHECK(agent.send_message_to_printer("dev-1", R"({"print":{"command":"pause","sequence_id":"20007"}})", 0, 0) + == BAMBU_NETWORK_SUCCESS); + for (int i = 0; i < 100 && broker.received_requests().empty(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(20)); + REQUIRE(broker.received_requests().size() >= 1); + CHECK(broker.received_requests().back().find("pause") != std::string::npos); + agent.disconnect_printer(); +} +``` + +> `MockBroker::host_port()` — add a small accessor returning `{"127.0.0.1", std::to_string(port_)}` in Task 8's header (fold this one-line addition here). + +- [ ] **Step 2: Run it (expect FAIL)** — `send_message` still spawns the old REST thread / `send_message_to_printer` is a bare `return SUCCESS`. + +- [ ] **Step 3: Implement** + +```cpp +int OrcaPrinterAgent::send_message(std::string dev_id, std::string json_str, int, int) { + return route_send(false, dev_id, json_str); +} +int OrcaPrinterAgent::send_message_to_printer(std::string dev_id, std::string json_str, int, int) { + return route_send(true, dev_id, json_str); +} +int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const std::string& json_str) { + if (dev_id.empty()) return BAMBU_NETWORK_ERR_INVALID_HANDLE; + OrcaMqttConnection* conn = get_appropriate_mqtt_connection(is_lan); + if (!conn) return BAMBU_NETWORK_ERR_INVALID_HANDLE; + return conn->send_request(dev_id, json_str) ? BAMBU_NETWORK_SUCCESS + : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; +} +``` + +Guard `get_appropriate_mqtt_connection(false)` against a null cloud agent: `return get_orca_cloud_agent() ? get_orca_cloud_agent()->get_mqtt_connection() : nullptr;` + +- [ ] **Step 4: Verify green** — `./build/tests/slic3rutils/slic3rutils_tests "[OrcaPrinterAgent]"` + +--- + +## Task 17: destructor teardown — no detached threads touching `*this` + +**Files:** +- Modify: `src/slic3r/Utils/OrcaPrinterAgent.cpp`, `tests/slic3rutils/test_orca_printer_agent.cpp` + +- [ ] **Step 1: Write the failing test** + +```cpp +TEST_CASE("destroying an agent mid-connect does not hang or crash", "[OrcaPrinterAgent]") { + for (int i = 0; i < 20; ++i) { + auto agent = std::make_unique("/tmp"); + agent->connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false); // unroutable, still connecting + agent.reset(); // dtor must join/stop cleanly + } + SUCCEED(); +} +``` + +- [ ] **Step 2: Run it (expect FAIL / hang / TSAN error)** — the `connect_printer` detached thread may outlive `*this` and call `on_connected`/`deliver_to_sink`. + +- [ ] **Step 3: Implement** + +- The detached connect thread already guards every callback with the generation check. Add: `~OrcaPrinterAgent()` bumps `m_lan_generation` first, then: + ```cpp + OrcaPrinterAgent::~OrcaPrinterAgent() { + ++m_lan_generation; + std::unique_ptr lan; + { std::lock_guard l(state_mutex); lan = std::move(lan_mqtt_connection); } + if (lan) lan->stop(); // joins the OrcaMqttConnection worker + if (auto* cloud = get_orca_cloud_agent()) cloud->teardown_selected_printer_mqtt(); + } + ``` +- The remaining race is the *connect* `std::thread(...).detach()` still running `conn->start()` on a `conn` that `lan->stop()` then destroys. Fix: hold the connect thread as a member `std::thread m_lan_connect_thread;` (not detached) and `join()` it in the dtor **before** moving `lan_mqtt_connection`. Update Task 11 & Task 14 to assign `m_lan_connect_thread = std::thread(...)` (join any prior one first). + +- [ ] **Step 4: Verify green** — run under sanitizers if the build has them: `ctest --test-dir build -R OrcaPrinterAgent --output-on-failure` + +--- + +## Task 18: full build, full test run, manual smoke + +**Files:** +- Modify: none (verification only) + +- [ ] **Step 1: Full build** + +```bash +cmake --build build --config RelWithDebInfo --target all -j +``` +Expected: success on Linux. (CI covers Windows/macOS.) + +- [ ] **Step 2: Full unit + integration suite** + +```bash +ctest --test-dir build --output-on-failure +./build/tests/slic3rutils/slic3rutils_tests "[.integration]" +``` +Expected: no regressions in `test_printer_agent`, `test_qidi_printer_agent`, `test_plugin_*`; all `[OrcaMqtt]` / `[OrcaPrinterAgent]` green. + +- [ ] **Step 3: Manual smoke — LAN** + +Run OrcaSlicer against a real OrcaSonar hub: it appears via discovery; select it; Device tab populates (version, temps); set nozzle temperature; confirm the result echo in the log (`OrcaMqtt` / `on_message` lines). Record a log excerpt or screenshot. + +- [ ] **Step 4: Manual smoke — cloud** + +Against OrcaCloud staging with a paired printer (requires CH-1 deployed there): select the printer; same checks. If CH-1 is not yet deployed, record that this step is blocked on the §5.2 hand-off and verify only that the socket opens and status is received (commands will 4403 until CH-1 ships). + +- [ ] **Step 5: Record results** + +Append a short "Verification" section to the spec doc: build result, `ctest` summary line, and the two smoke outcomes (or the cloud-blocked note). + +--- + +## Self-Review + +**Spec coverage** + +| Spec section | Task(s) | +|---|---| +| §3.1 canonical core (topics, envelope) | T4 (topics), T3 (CONNECT), payload builders T12 | +| §3.2 command send = PUBLISH both transports | T6, T16 | +| §3.2 `sequence_id` band 20000–29999 | T12 (`seq`), asserted in T12 test | +| §3.2 capability via `info.get_capabilities` | T12 (`build_get_capabilities`, in the sequence) | +| §3.2 status gating (`pushing.start`/`stop`) | T12 (start), T14 (stop on deselect) | +| §3.2 exact-topic subscribe, no wildcard | T4/T5 (`report_topic(dev_id)`, single-id `subscribe`) | +| §3.2 auth: bearer or CONNECT creds, precedence | T3 (flags), T5/T7 (bearer header vs creds), T7 | +| §3.2 QoS ≤1, tolerate SUBACK QoS 0 | T4 (`make_subscribe_packet` qos arg), T8 mock grants 0, T9 round-trip | +| §3.2 keepalive from Config | T3, T5 | +| §4.1 `OrcaMqttConnection` own TU, Config, API | T1, T2, T5 | +| §4.1 reuse salvage pieces | T2/T3/T5 (parse_endpoint, connect packet, last_connack_rc) | +| §4.2 outbound collapse | T16 | +| §4.2 remove `read_loop`, per-connection MessageHandler | T10 | +| §4.2 `get_appropriate_mqtt_connection` kept | T16 (null-guarded) | +| §4.2 lifecycle table (connect/select/deselect) | T11, T13, T14 | +| §4.2 identical post-connect sequence | T12 | +| §4.2 generation guard | T11 (capture), T13 (test), T17 (dtor) | +| §4.2 CONNACK rc 4/5 terminal | T5 | +| §4.2 dtor stops both, no detached threads on `*this` | T17 | +| §4.3 one socket, per-printer cloud endpoint | T14 (`wss://.../printers/{id}/mqtt`) | +| §4.3 remove aggregate socket, health-only `is_server_connected` | T15 | +| §6.1 unit tests | T2–T6, T12 | +| §6.2 parametrized LAN/cloud integration | T9 | +| §6.2 reconnect re-sends subs + `pushing.start` | *gap → see below* | +| §6.2 auth-reject no retry storm | T5 (rc 4/5 terminal); add assertion in T5 | +| §6.5 gates: full build, no regressions, smoke | T18 | + +**Gaps found & fixed inline:** +- §6.2 "reconnect re-sends subscriptions and re-issues `pushing.start`" had no task. **Added to Task 9 Step 3**: extend the round-trip test with a `broker.drop_client()` call, assert the client reconnects (`connect_count() == 2`), that the report subscription is re-established (a second `push_report` is delivered), and that the agent re-runs `on_connected` (observable via a second `pushall` in `received_requests()`). Requires `MockBroker::drop_client()` — fold into Task 8. +- §6.2 "auth reject → terminal, no retry storm" — **added assertion to Task 5 Step 1 test**: point `Config.username/password` at a mock that CONNACKs `0x05`; assert `last_connack_rc() == 5` and `is_running() == false` within 1 s (needs `MockBroker` ctor flag `refuse_auth` — fold into Task 8). + +**Placeholder scan:** the Task 3 `// TODO(Task 6)` marker is intentional and explicitly removed in Task 5 Step 3 (corrected: the marker is added in T3, removed in T5 — not T6). No other TODO/TBD. All code steps carry real code. + +**Type consistency:** +- `OrcaMqttConnection::start(const Config&, MessageHandler, StateHandler)` — defined T5, used T7/T9/T11/T14. ✓ +- `subscribe(const std::string&)` / `send_request(const std::string&, const std::string&)` — defined T5/T6, used T9/T12/T16. ✓ +- `on_connected(const std::string&, OrcaMqttConnection*, uint64_t)` — decl T11 interfaces, defined T12, called T11/T14. ✓ +- `emit_connect_sequence` / `run_connect_sequence_for_test` — introduced T12, only used by T12's test. ✓ +- `deliver_to_sink` — T10, used T11/T13. ✓ +- `route_send` — T16 only. ✓ +- `configure_selected_printer_mqtt` / `teardown_selected_printer_mqtt` / `selected_printer_mqtt_url` / `deliver_cloud_message` — T14/T15, used T14/T15/T17. ✓ +- `MockBroker` API grows across T8 (`ws_url`, `push_report`, `received_requests`, `connect_count`), T16 (`host_port`), self-review (`drop_client`, `refuse_auth` ctor flag). All folded into Task 8's file; later tasks only call them. ✓ +- `make_connect_packet` 4-arg — T3, caller updated T5. Old 0-arg removed T3. ✓ +- `make_topic_packet` removed in T5; no later reference. ✓ diff --git a/docs/superpowers/specs/2026-09-03-orca-mqtt-contract-consolidation-design.md b/docs/superpowers/specs/2026-09-03-orca-mqtt-contract-consolidation-design.md new file mode 100644 index 0000000000..b4e45a30c5 --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-orca-mqtt-contract-consolidation-design.md @@ -0,0 +1,388 @@ +# OrcaCloud / OrcaSonar MQTT contract consolidation — design + +**Date:** 2026-09-03 +**Status:** Approved design, pre-implementation +**Repos touched by this task:** OrcaSlicer (implementation), plus a written conformance +checklist handed off to OrcaCloud and OrcaSonar (not edited here). + +--- + +## 1. Context & problem + +`OrcaPrinterAgent` talks to two backends that are meant to expose the *same* +printer API: + +- **OrcaCloud** — cloud relay. Cloudflare Durable Object "broker-lite" with a + hand-rolled MQTT 3.1.1 codec (`apps/gateway/src/lib/mqtt-codec.ts`, + `apps/gateway/src/durable-objects/printer-shard.ts`). +- **OrcaSonar** — LAN hub. Real mochi MQTT broker fronted by a `/mqtt` + WebSocket→TCP proxy (`internal/broker/broker.go`, `internal/httpws/server.go`). + +A survey of all three codebases found the *payload and topic layer already ~90 % +identical* (Bambu-dialect JSON, `device//request` + `device//report`, +shared command set, numeric-string `sequence_id`), but the *transport mechanics +diverge*: + +| Dimension | OrcaSonar LAN | OrcaCloud | +|---|---|---| +| Command send | client **PUBLISHes** `device//request` | viewers **receive-only**; commands via `POST /api/v1/printers/:id/commands` | +| Auth | MQTT CONNECT username/password | Bearer token on the HTTP upgrade | +| Endpoint | `ws://:8280/mqtt` | `wss:///api/v1/printers/{id}/mqtt` (and an aggregate `/printers/mqtt`) | +| Socket cardinality | 1 printer : 1 socket | aggregate socket, N printers, dynamic grant | +| Status stream | always on | demand-gated (`pushing.start` / `pushing.stop`) | +| Capability | retained `device//capability` | `info.get_capabilities` command, no retained | +| `sequence_id` bands | unenforced | enforced (OrcaSlicer must use 20000–29999) | +| Spec of record | `spec/protocol/orca_printer_comm_spec.md` (OPCP v1.1.0 + JSON schemas) | `doc/gateway/printer_mqtt_facade_2026-08-07.md` + dialect code | + +Both sides already track the drift: OrcaSonar `API.md:244-301` diffs itself against +OrcaCloud's dialect code; OrcaCloud's facade doc has an explicit "OrcaSonar +adoption" section. + +The client today mirrors the divergence — `send_message` (cloud) has historically +gone via REST while `send_message_to_printer` (LAN) publishes over MQTT — so the +`IPrinterAgent` split is a transport split, not just a routing switch. + +**Goal:** one contract (OPCP v1.2.0) that both backends conform to, and an +OrcaSlicer client where LAN and cloud run *identical code* differing only by a +`Config` value. + +--- + +## 2. Decisions + +| # | Decision | +|---|---| +| D1 | **Contract merge + thin client shim.** Merge to a canonical spec; fix payload-level divergences server-side; the client keeps only a `Config`-sized shim for endpoint/auth/keepalive. | +| D2 | **This task ships:** the OPCP v1.2.0 spec text (authored here), the OrcaSlicer client implementation, and an enumerated conformance checklist for OrcaCloud & OrcaSonar. The other two repos are **not** edited in this task. | +| D3 | **Command transport is MQTT PUBLISH `device//request` on both cloud and LAN.** OrcaCloud gains client PUBLISH via change CH-1. | +| D4 | **Client structure: one `OrcaMqttConnection` class, two `Config`-only instances, routed by `OrcaPrinterAgent`.** The cloud instance uses the **1:1 per-printer** endpoint `/api/v1/printers/{id}/mqtt`, exactly like LAN. | +| D5 | **One socket per transport, selected printer only.** The pre-existing aggregate cloud MQTT socket is removed; unselected printers show announce/REST status on both transports (see §4.3). | +| D6 | **No back-compat.** OPCP v1.2.0 replaces v1.1.0 outright. REST `/commands` is not a required alias. No `/tunnel` bridge concerns. | +| D7 | **OrcaSonar's OPCP is the source of truth.** The contract is OrcaSonar's spec (with the additions in §5.1, most of which absorb behaviours OrcaCloud already ships); OrcaCloud conforms to it. | + +--- + +## 3. OPCP v1.2.0 — the unified contract + +### 3.1 Canonical core (already aligned; ratified here) + +- **Transport:** MQTT 3.1.1 over WebSocket, binary frames, subprotocol `mqtt`, + `cleanSession = 1`. +- **Topics:** `device//request` (client → device), + `device//report` (device → client). `` is the identifier the + client is already provisioned with — the cloud printer UUID on the cloud + binding; the mDNS-advertised `device_id` (TXT `device_id=`, SSDP UDN + `uuid:`) on the LAN binding. The client never learns the id from + message traffic. +- **Envelope:** exactly one top-level namespace key ∈ + `{pushing, info, print, system, camera, xcam, upgrade, files, event}`, plus + `command` and `sequence_id` (decimal string, `^[0-9]+$`). +- **Result echo (single-phase):** same namespace + command + `sequence_id`, plus + `result ∈ {success, fail}`, `reason` on `fail`, optional `errno`. No separate + dialect-layer transport ack. +- **Status:** `.push_status` on the report topic; `msg` 0 = full, 1 = diff. +- **`info.get_version` reply:** `module[]` entries with `name / sw_ver / hw_ver / sn`. +- **Optional extended header** (adopt OrcaSonar spec §3.1 verbatim): + `protocol_version`, `schema_version`, `sent_at_utc_ms`, + `source{role, agent_id, transport}`. Receivers ignore unknown top-level keys. + +### 3.2 Divergence resolutions + +| Divergence | Resolution | Owner | +|---|---|---| +| Command send: REST vs PUBLISH | Client PUBLISHes `device//request` on both transports. | OrcaCloud CH-1 | +| `sequence_id` bands | Normative registry: OrcaSlicer **20000–29999**, dashboard 50000–59999, gateway-minted 70000–79999, status-mirror 90000+. Correlation is producer-scoped (match echoes against your own outstanding ids). | OPCP SPEC-2; client | +| Capability discovery | `info.get_capabilities` command is the REQUIRED path (returns the capability manifest). Retained `device//capability` is an OPTIONAL LAN optimization; clients MUST NOT depend on it. | client; OrcaSonar SN-2 | +| Status gating | Client issues `pushing.start` immediately after SUBSCRIBE and `pushing.stop` on deselect, on both transports. Always-streaming implementations accept both as `result:"success"` no-ops. | client; OrcaSonar SN-1; OrcaCloud CH-4 | +| Topic `` | LAN topic id == advertised `device_id`, so the client subscribes the exact topic — **no `device/+/report` wildcard**. | OrcaSonar SN-3 | +| `system.set_settings` | Schema defined in OPCP (SPEC-5): curated toggles — camera, discovery, moonraker_compat. Unknown setting → `result:"fail"`, `errno = UNSUPPORTED_SETTING`. Not client-driven in this task. | OPCP SPEC-5; OrcaSonar SN-4 | +| Auth | Two mechanisms, both normative: (a) bearer token in the `Authorization` header of the WS upgrade (cloud); (b) MQTT CONNECT username/password (LAN local broker). The client `Config` carries whichever applies. | documented only | +| QoS | Client requests SUBSCRIBE QoS 1 and PUBLISH QoS 0–1; MUST tolerate a SUBACK that grants QoS 0. | client | +| Keepalive | `Config.keepalive_seconds` (default 60 LAN / 300 cloud). Binary PINGREQ. | client | +| Endpoint | LAN `ws://:8280/mqtt`; cloud `wss:///api/v1/printers/{id}/mqtt`. | `Config` | + +Net effect: everything payload- and topic-level is identical on both transports; +the only per-transport variation is `Config` (URL + auth + keepalive) plus one +connect-time `pushing.start`. + +--- + +## 4. OrcaSlicer client architecture + +### 4.1 `OrcaMqttConnection` — the single transport class + +Lives in its own translation unit (extracted from `OrcaCloudServiceAgent.cpp`, +where an earlier `OrcaCloudMqttConnection` / renamed `OrcaMqttConnection` still +sits). No LAN/cloud conditionals in the body. + +```cpp +struct Config { + std::string url; // ws://host:8280/mqtt | wss://api/.../printers/{id}/mqtt + bool use_tls = false; // derived from the URL scheme + TokenProvider bearer_provider; // cloud: Authorization: Bearer on the WS upgrade + std::string username, password; // LAN: MQTT CONNECT credentials + // Precedence: if bearer_provider is set it is used for the WS upgrade and the + // CONNECT username/password are omitted; otherwise CONNECT carries the creds. + std::string client_id; // stable for the process run, unique per instance + int keepalive_seconds = 60; +}; +``` + +API (used identically by both instances): + +| Method | Behaviour | +|---|---| +| `bool start(Config, MessageHandler on_message, StateHandler on_state, CancellationHandler = {})` | spawns the worker thread; blocks (bounded, ~10 s) for the first CONNACK; returns the initial result | +| `void stop()` | idempotent; joins the worker | +| `bool send_request(const std::string& dev_id, const std::string& payload)` | PUBLISH `device//request` (QoS 0/1); thread-safe via the write mutex; false when there is no CONNACKed session. **The uniform outbound seam.** | +| `bool subscribe(const std::string& dev_id)` / `unsubscribe(...)` | SUBSCRIBE / UNSUBSCRIBE `device//report`; persistent set re-sent after every CONNACK | +| `bool is_connected() const` / `int last_connack_rc() const` | CONNACK state; rc 0 = accepted, 1..5 = MQTT refusal, -1 = no CONNACK this attempt | +| `MessageHandler(dev_id, payload)` | inbound: strips the `device//report` topic, hands up raw JSON, on the worker thread | + +All protocol logic (topic construction, MQTT framing, reconnect/backoff, write +serialization, QoS-downgrade tolerance, PINGREQ) is internal. The only internal +branches are `use_tls` (TLS handshake + SNI) and bearer-vs-CONNECT-creds during +the handshake. + +Reusable pieces from `salvage/orcasonar-lan-agent-2026-09-03` (the generalized +`Config`, `ws://` support in `parse_endpoint`, `last_connack_rc`, auth-reject +handling, static frame builders + their tests) are lifted in rather than +re-derived. + +### 4.2 `OrcaPrinterAgent` — routing + lifecycle + +- `std::unique_ptr lan_mqtt_connection` — owned here; + lifecycle = LAN printer selection. +- Cloud per-printer `OrcaMqttConnection` — owned by `OrcaCloudServiceAgent`, + reached via `get_orca_cloud_agent()->get_mqtt_connection()`. +- `OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan)` — the one + place that encodes the ownership split. Kept even though callers know their + `is_lan` bit; it is the seam and it is tiny. Where a caller has only a + `dev_id`, resolve via `DeviceManager::get_my_machine(dev_id)->is_lan_mode_printer()`. +- `enum CurrentConn { NONE, CLOUD, LAN } m_current_connection` — a label/gate, + **not** a socket selector. + +**Outbound collapse.** Both methods reduce to the same body: + +```cpp +int OrcaPrinterAgent::send_message(dev_id, json, qos, flag) // is_lan = false +int OrcaPrinterAgent::send_message_to_printer(dev_id, json, qos, flag) // is_lan = true +// -> +auto* conn = get_appropriate_mqtt_connection(is_lan); +if (!conn || dev_id.empty()) return BAMBU_NETWORK_ERR_INVALID_HANDLE; +return conn->send_request(dev_id, json) ? BAMBU_NETWORK_SUCCESS + : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; +``` + +The `command_*` helpers keep branching only to build the payload, then call one +or the other. + +**Inbound: remove `read_loop()`.** Each `OrcaMqttConnection` already delivers on +its worker thread via `MessageHandler`. The agent registers one handler per +connection that funnels to `on_message_fn`, marshalled onto the UI thread via +`queue_on_main_fn`. This is already the pattern `set_cloud_agent()` wires for the +cloud side (`set_printer_status_callback`); the LAN side gets the symmetric +wiring when `lan_mqtt_connection` is created. A single reader that "picks the +current connection" reintroduces a one-transport-at-a-time asymmetry and a +busy-spin; the per-connection callback is already uniform. + +**Lifecycle — the post-connect sequence is byte-identical on both transports:** + +| Trigger | LAN | Cloud | +|---|---|---| +| select | `connect_printer(dev_id, dev_ip, user, code, ssl)` | `set_user_selected_machine(dev_id)` | +| agent builds `Config` | `ws://:8280/mqtt` + username/password | `wss:///api/v1/printers/{dev_id}/mqtt` + `bearer_provider` | +| then — shared `on_connected(dev_id, conn)` | `subscribe(dev_id)` → `send_request(dev_id, pushing.start)` → `send_request(dev_id, pushall)` → `send_request(dev_id, info.get_version)` → `send_request(dev_id, info.get_capabilities)` | *the same five calls* | +| deselect / change | `disconnect_printer()` → `stop()` + reset | `send_request(prev, pushing.stop)` → `unsubscribe(prev)` → `stop()` / retarget | + +**Threading & lifetime:** + +- The blocking `start()` runs on a short-lived thread so the UI is never blocked + (mirrors the salvaged WIP). +- A generation counter (atomic, captured by value into every handler lambda) + makes a superseded connection's late `MessageHandler` / `StateHandler` calls + no-ops. +- `~OrcaPrinterAgent` calls `stop()` on both connections (joining their workers) + before any member is destroyed. No detached threads may touch `*this`. +- CONNACK rc 4/5 (bad credentials / not authorised) is terminal: report + `ConnectStatusFailed`, do not retry (a retry storm would flap + `ConnectStatusLost` → `set_selected_machine("")`). + +### 4.3 One socket per transport — the selected printer only + +The client opens exactly one MQTT socket at a time, for the **selected** printer, +identically on both transports: + +- LAN: `ws://:8280/mqtt` — opened on `connect_printer`, closed on + `disconnect_printer`. +- Cloud: `wss:///api/v1/printers/{dev_id}/mqtt` (the 1:1 per-printer + binding, D4) — opened on `set_user_selected_machine(dev_id)`, closed on + deselect. `OrcaCloudServiceAgent` owns the instance; `OrcaPrinterAgent` + drives it via `get_mqtt_connection()`. + +Unselected printers are never connected on either transport. They populate the +Device list from announce data alone — mDNS/SSDP `on_machine_alive` for LAN +hubs, the account REST list for cloud printers — exactly as unselected LAN hubs +already behave. + +**This removes the pre-existing aggregate cloud MQTT socket** +(`wss:///api/v1/printers/mqtt`, started today by +`OrcaCloudServiceAgent::connect_server()`). `is_server_connected()` falls back to +the REST health probe `connect_server()` already performs on the same 5 s +`refresh_connection()` tick. + +**Behaviour change (needs product sign-off, tracked as O2):** unselected cloud +printers in the Device list lose their live status feed and show last-known / +REST status. This is the price of LAN/cloud symmetry and matches how unselected +LAN hubs already appear. If live multi-printer status is later required it is an +`OrcaCloudServiceAgent` concern (its own aggregate consumer), and it must not add +a second `device//report` stream for the already-connected selected printer. + +--- + +## 5. Conformance checklist (hand-off) + +**Framing (D7):** the target state is "cloud and LAN expose an identical API", +and **OrcaSonar's OPCP spec is the source of truth**. Read this section as: + +- §5.1 — additions the OPCP spec (and therefore OrcaSonar's implementation) + needs. Small, and mostly formalising behaviours OrcaCloud already ships + (`pushing.start/stop`, `system.set_settings`, the `sequence_id` registry). +- §5.2 — OrcaCloud is the participant furthest from the contract (commands over + REST, viewer PUBLISH forbidden, aggregate-only socket). These changes make it + conform. +- §5.3 — OrcaSonar's own work: implement the §5.1 additions, plus one or two + guarantees to make explicit. + +### 5.1 OPCP spec additions → v1.2.0 (`OrcaSonar spec/protocol/orca_printer_comm_spec.md`) + +| ID | Change | +|---|---| +| SPEC-1 | Add a normative **Transports** section: the two bindings from §3.1 (LAN local broker; cloud gateway). Both MUST accept client PUBLISH to `device//request`. | +| SPEC-2 | Normative `sequence_id` band registry (§3.2); correlation is producer-scoped. | +| SPEC-3 | `pushing.start` / `pushing.stop` are normative commands — "begin / stop streaming `push_status` to this subscriber". Always-streaming implementations MUST still return `result:"success"` (no-op). | +| SPEC-4 | `info.get_capabilities` is the REQUIRED capability path; retained `device//capability` is OPTIONAL and non-load-bearing. | +| SPEC-5 | Define the `system.set_settings` schema (curated toggles: camera, discovery, moonraker_compat); unknown setting → `result:"fail"`, `errno = UNSUPPORTED_SETTING`. | +| SPEC-6 | Errno registry: enumerate values already in use plus `UNSUPPORTED_COMMAND`, `UNSUPPORTED_SETTING`, `NOT_AUTHORIZED`. Align semantics (not numeric values) with the client's `ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED` / `ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE`. | +| SPEC-7 | Promote the extended envelope header (§3.1 of the OrcaSonar spec) to OPTIONAL on every message; receivers ignore unknown top-level keys. | +| SPEC-8 | Topic `` MUST equal the id the client is provisioned with; no id discovery from traffic. | + +### 5.2 OrcaCloud — conform to OPCP (`~/repos/OrcaCloud/apps/gateway`) + +Assuming the OPCP contract (client PUBLISHes commands over a 1:1 MQTT-over-WS +socket, exactly as against OrcaSonar LAN), OrcaCloud must add: + +| ID | Change | Acceptance | +|---|---|---| +| **CH-1** | `mqtt-viewer` sessions MAY `PUBLISH` to `device/{id}/request` (today → close 4403 in `printer-shard.ts::handleMqttPublish`). Relay viewer → connector via the existing `awaitConnectorReply` / `deliverToConnector` path. `/report` stays connector-only (anti-forgery preserved). | e2e: a viewer publishes `print.pause` `sequence_id` 20001 → connector receives it on `device/{id}/request` → the result echo fans back to that viewer. | +| CH-2 | Move operator-role authorization from the REST `/commands` route (`routes/printer.ts`) into the shard publish handler. READ set — `pushing.pushall`, `info.get_version`, `info.get_capabilities`, `files.list`, `files.metadata` — allowed for viewer / live-token; every other command requires an operator+ session. | live-token viewer publishing `print.stop` → 4403; JWT operator → success. | +| CH-3 | Provide a **1:1 per-printer** WS binding `GET /api/v1/printers/{id}/mqtt` that behaves like OrcaSonar's `/mqtt` (one printer per socket; subscribe the exact `device/{id}/report`; no `X-Orca-Printer-Ids`). If #948 ("shard-only routing") removed this facade path, re-adding it is the change — the client does not use the aggregate `/printers/mqtt`. | client connects, subscribes, publishes a command, receives the echo, with no aggregate-grant header. | +| CH-4 | `pushing.start` / `pushing.stop` over the viewer publish path arm / disarm the demand-mirror for that printer (today armed only by dashboard page polls, #984). Behaviour matches OPCP SPEC-3: on always-streaming backends it is a success no-op; OrcaCloud's mirror is genuinely demand-gated so it acts. | after a viewer `pushing.start`, `push_status` frames arrive on that viewer's `report` subscription; after `pushing.stop` (last viewer gone) they cease. | +| CH-5 | `info.get_capabilities` returns an OPCP capability manifest matching OrcaSonar's `orca_capability_manifest` schema (`protocol_version` ≥ 1.2.0), whether the connector is Bambu- or Klipper-backed. | manifest validates against the OPCP schema. | + +### 5.3 OrcaSonar — implement the spec additions (`~/repos/OrcaSonar/internal`) + +OrcaSonar owns the contract, so its work is small: land the §5.1 spec text, make +the LAN dispatcher match it, and turn two already-true facts into guarantees. + +| ID | Change | Acceptance | +|---|---|---| +| **SN-1** | The LAN dispatcher accepts `pushing.start` / `pushing.stop` as `result:"success"` no-ops — the LAN broker always streams `push_status`, so these commands only need to be *recognised*, not fall through to "unsupported command". (Today they are handled only on the OrcaSonar→cloud connector path, not the LAN dispatcher.) `internal/protocol/dispatcher.go` specials + `internal/protocol/types.go` `SupportedCommands`. See O4: this is the "always-on, nothing to gate" reading, not a per-subscriber mirror. | golden: `{"pushing":{"command":"start","sequence_id":"20005"}}` → `{"pushing":{"command":"start","sequence_id":"20005","result":"success","errno":0}}`. | +| SN-2 | `info.get_capabilities` returns the manifest identically whether requested via command or read from the retained topic, and works before any `pushall`. | fresh connect → command → manifest with `protocol_version ≥ 1.2.0`. | +| SN-3 | Documented guarantee, plus a test, that topic `` == advertised `device_id` (mDNS TXT `device_id=`, SSDP UDN). Lets the client drop the `device/+/report` wildcard. Likely already true (`internal/discovery/discovery.go`, `internal/config/config.go`). | client subscribing `device//report` receives reports. | +| SN-4 | `system.set_settings` → implement per SPEC-5, or return `result:"fail"`, `errno = UNSUPPORTED_SETTING` (not a bare "unsupported command", not a parse error / close). | unknown setting key → structured errno, session stays open. | +| SN-5 | Capability manifest `protocol_version` / `schema_version` → `1.2.0`. | manifest validates against the v1.2.0 schema. | +| SN-6 | Conformance test only (no code change expected): broker accepts client-id `orcaslicer-lan--` and a QoS 1 SUBSCRIBE. | test passes. | + +### 5.4 OrcaSlicer client (this repo — implemented, not enumerated) + +Everything in §4, plus: uses `info.get_capabilities` (never the retained topic), +always issues `pushing.start` / `pushing.stop`, stays in `sequence_id` band +20000–29999, subscribes the exact report topic, tolerates a SUBACK that grants +QoS 0. + +--- + +## 6. Testing & verification + +### 6.1 Client unit tests (`tests/slic3rutils/`, Catch2) + +- `OrcaMqttConnection` static frame builders — CONNECT (bearer and + CONNECT-creds forms), SUBSCRIBE / UNSUBSCRIBE, PUBLISH, PINGREQ, + remaining-length codec, `parse_endpoint` for `ws://` and `wss://`. Byte-level + assertions. +- `send_request` produces topic `device//request` with a verbatim payload; + inbound strips `device//report` → `(id, payload)`. +- `Config` selects the handshake path (auth mode; `use_tls` from scheme). +- `command_*` payload builders match OPCP shapes (string `sequence_id`, band + 20000–29999, single namespace key). +- The post-connect sequence emits exactly + `subscribe → pushing.start → pushall → info.get_version → info.get_capabilities`, + in that order. +- Generation guard: a superseded connection's late handler calls are no-ops. +- SUBACK granting QoS 0 when 1 was requested → still connected, messages still + delivered. + +### 6.2 Client integration (in-process MQTT-over-WS mock) + +- **One parametrized test, two fixtures (LAN `Config` / cloud `Config`):** + connect → CONNACK → subscribe → publish command → mock emits the result echo → + assert `on_message_fn` fires with it. Identical assertions for both fixtures — + this is the "exactly the same" proof. +- Reconnect: mock drops the socket → worker backs off → reconnects → + subscriptions re-sent → `pushing.start` re-issued. +- Auth reject: CONNACK rc 4/5 → terminal `ConnectStatusFailed`, no retry storm. + +### 6.3 Cross-repo conformance (run in those repos' CI, from §5 acceptance rows) + +- OrcaCloud: extend `tests/e2e/lane-b-printers/printer_mqtt.e2e.test.ts` for + CH-1 / CH-2 / CH-3 / CH-4 / CH-5 (each row's acceptance criterion in §5.2). +- OrcaSonar: golden JSONL fixtures for SN-1 / SN-2 / SN-4. + +### 6.4 Manual smoke (record a log / screenshot for each) + +RelWithDebInfo build → + +- (a) real OrcaSonar hub on the LAN: discover, connect, Device tab populates, + set nozzle temperature, observe the result echo; +- (b) OrcaCloud staging with a paired printer: select, same checks. + +Both exercised through the *same* `OrcaMqttConnection` code path. + +### 6.5 Gates before "done" + +- New unit + integration tests green (ctest output as evidence). +- No regression in the existing `tests/slic3rutils` suites (printer-agent, + plugin, qidi). +- LAN and cloud smoke each confirmed with a log/screenshot. +- One `cmake --build build` at the end. + +--- + +## 7. Out of scope + +- Editing OrcaCloud or OrcaSonar in this task (the checklist in §5 is the + hand-off; those land as separate PRs in their own repos). +- A live multi-printer status feed for the Device list on cloud (would be its + own `OrcaCloudServiceAgent` aggregate consumer — see §4.3 / O2). The printer + agent connects the selected printer only. +- Reusing OrcaCloud's aggregate `/printers/mqtt` for the client (option B from + brainstorming — rejected; it forces aggregate-grant semantics into + `OrcaMqttConnection` that the LAN 1:1 case never needs). +- An `IPrinterTransport` abstraction above MQTT (option C — YAGNI while MQTT is + the only transport). +- Camera streaming, filesystem / file transfer, filament sync, AMS mapping. +- Back-compat: the legacy `/tunnel` envelope plane, retaining REST `/commands` + as a required alias, dual-schema acceptance on OrcaSonar. + +--- + +## 8. Open items, risks & resolved questions + +| # | Item | +|---|---| +| O1 | **Does OrcaCloud's 1:1 `/api/v1/printers/{id}/mqtt` still exist post-#948?** ("shard-only routing" made routing shard-backed.) Not a client fork any more — per CH-3 the client uses only the 1:1 endpoint, so if the facade path was removed, re-adding it *is* the OrcaCloud change. Just needs confirmation with the OrcaCloud team of whether CH-3 is "keep" or "re-add". | +| O2 | **Behaviour change: unselected cloud printers lose live status** (§4.3 — consequence of dropping the aggregate socket for LAN/cloud symmetry). They show last-known / REST status, matching unselected LAN hubs. Needs product sign-off before implementation. | +| O3 | **CONNECT-creds vs bearer through a fronting proxy.** If a deployment puts `wss://` in front of OrcaSonar, both auth inputs could be present. Precedence is fixed in §4.1 (`bearer_provider` set ⇒ bearer, CONNECT creds omitted); flagged only so the plan makes it a tested branch. | +| O4 | **Resolved.** `pushing.start` / `pushing.stop` mean "begin / stop streaming `push_status` to me". On OrcaCloud the mirror is genuinely demand-gated so the commands act; on OrcaSonar LAN the broker always streams, so they are recognised-and-succeed no-ops (SN-1). Not the "MQTT subscription granularity" reading — the client still SUBSCRIBEs `device//report` explicitly on both. | +| O5 | **`sequence_id` band collisions.** The client must never emit outside 20000–29999, including for any gateway-minted flow it triggers (print jobs). Audit every `sequence_id` source in `OrcaPrinterAgent`. | diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index 8308161a7b..4bb02ee659 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -725,6 +725,8 @@ set(SLIC3R_GUI_SOURCES Utils/IPrinterAgent.hpp Utils/OrcaCloudServiceAgent.cpp Utils/OrcaCloudServiceAgent.hpp + Utils/OrcaMqttConnection.cpp + Utils/OrcaMqttConnection.hpp Utils/OrcaPrinterAgent.cpp Utils/OrcaPrinterAgent.hpp Utils/QidiPrinterAgent.cpp diff --git a/src/slic3r/GUI/ConnectPrinter.cpp b/src/slic3r/GUI/ConnectPrinter.cpp index 3e78e7fe5c..8e3d58b413 100644 --- a/src/slic3r/GUI/ConnectPrinter.cpp +++ b/src/slic3r/GUI/ConnectPrinter.cpp @@ -35,7 +35,9 @@ ConnectPrinterDialog::ConnectPrinterDialog(wxWindow *parent, wxWindowID id, cons sizer_connect = new wxBoxSizer(wxHORIZONTAL); m_textCtrl_code = new TextInput(this, wxEmptyString); - m_textCtrl_code->GetTextCtrl()->SetMaxLength(10); + // OrcaSonar uses a 12-character base32 access code. Keep this field long + // enough for it while retaining the existing validation for LAN codes. + m_textCtrl_code->GetTextCtrl()->SetMaxLength(12); m_textCtrl_code->SetFont(Label::Body_14); m_textCtrl_code->SetCornerRadius(FromDIP(5)); m_textCtrl_code->SetSize(wxSize(FromDIP(330), FromDIP(40))); diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 4165b071cb..3319d177f5 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3996,6 +3996,7 @@ void GUI_App::switch_printer_agent() std::string log_dir = data_dir(); std::string cloud_agent_id = agent_info.id == BBL_PRINTER_AGENT_ID ? BBL_CLOUD_PROVIDER : ORCA_CLOUD_PROVIDER; + BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " " << agent_info.id; std::shared_ptr cloud_agent = m_agent->get_cloud_agent(cloud_agent_id); // Create new printer agent via registry diff --git a/src/slic3r/Utils/IPrinterAgent.hpp b/src/slic3r/Utils/IPrinterAgent.hpp index c076b57be5..219073cebc 100644 --- a/src/slic3r/Utils/IPrinterAgent.hpp +++ b/src/slic3r/Utils/IPrinterAgent.hpp @@ -246,7 +246,7 @@ public: virtual std::string get_user_selected_machine() = 0; /** - * Update the selected machine preference. + * Update the selected cloud machine preference. */ virtual int set_user_selected_machine(std::string dev_id) = 0; diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index 4a6c545a82..74bb2209d7 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -65,522 +65,6 @@ using json = nlohmann::json; namespace Slic3r { -struct OrcaCloudMqttConnection::Connection { - boost::asio::io_context io_context; - boost::asio::ssl::context ssl_context; - WebSocket websocket; - boost::asio::ip::tcp::resolver resolver; - - Connection() - : ssl_context(boost::asio::ssl::context::tls_client) - , websocket(io_context, ssl_context) - , resolver(io_context) - {} -}; - -OrcaCloudMqttConnection::~OrcaCloudMqttConnection() { stop(); } - -bool OrcaCloudMqttConnection::start(const std::string& endpoint, TokenProvider token_provider, MessageHandler message_handler, StateHandler state_handler) { - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start endpoint=" << endpoint - << " token_callback=" << (token_provider ? "set" : "null") - << " message_callback=" << (message_handler ? "set" : "null") - << " state_callback=" << (state_handler ? "set" : "null"); - stop(); - { - std::lock_guard lock(mutex); - endpoint_url = endpoint; - get_token = std::move(token_provider); - on_message = std::move(message_handler); - on_state = std::move(state_handler); - initial_result = false; - initial_completed = false; - connected = false; - } - stopping.store(false); - worker = std::thread(&OrcaCloudMqttConnection::run, this); - - std::unique_lock lock(mutex); - if (!initial_cv.wait_for(lock, std::chrono::seconds(10), [this] { return initial_completed; })) { - initial_completed = true; - initial_result = false; - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT initial connection timed out after 10 seconds; worker will retry"; - } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start initial_result=" << initial_result - << " initial_completed=" << initial_completed; - return initial_result; -} - -void OrcaCloudMqttConnection::stop() { - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT stop requested"; - stopping.store(true); - state_cv.notify_all(); - { - std::lock_guard lock(connection_mutex); - if (active_connection) { - auto& socket = boost::beast::get_lowest_layer(active_connection->websocket).socket(); - boost::system::error_code socket_error; - socket.cancel(socket_error); - socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, socket_error); - socket.close(socket_error); - active_connection->resolver.cancel(); - } - } - if (worker.joinable()) - worker.join(); - - { - std::lock_guard lock(mutex); - connected = false; - if (!initial_completed) { - initial_completed = true; - initial_result = false; - } - } - initial_cv.notify_all(); -} - -bool OrcaCloudMqttConnection::is_running() const { - return worker.joinable() && !stopping.load(); -} - -void OrcaCloudMqttConnection::flush_subscription_change() { - std::shared_ptr conn; - { - std::lock_guard lock(connection_mutex); - conn = active_connection; - } - bool connacked; - { - std::lock_guard lock(mutex); - connacked = connected; - } - if (!conn || !connacked) - return; // no live MQTT session yet — the worker sends the set on CONNACK - - // beast permits a concurrent writer while the worker is blocked in - // websocket.read(); every write is serialised by write_mutex inside send(). - try { - send_pending_subscriptions(conn->websocket); - } catch (const std::exception& e) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: direct subscription write failed (" << e.what() - << "); worker will resend the full set on reconnect"; - } -} - -bool OrcaCloudMqttConnection::subscribe(const std::vector& device_ids) { - { - std::lock_guard lock(mutex); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe requested count=" << device_ids.size() - << " connected=" << connected.load(); - for (const std::string& device_id : device_ids) { - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe requested dev_id=" << device_id; - if (device_id.empty() || report_topic(device_id).size() > 96) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT subscribe rejected invalid dev_id=" << device_id; - return false; - } - } - for (const std::string& device_id : device_ids) { - subscriptions.insert(device_id); - pending_subscriptions.insert(device_id); - } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe queued total_subscriptions=" << subscriptions.size() - << " pending_subscriptions=" << pending_subscriptions.size(); - } - state_cv.notify_all(); - flush_subscription_change(); // emit SUBSCRIBE now on the live socket (no reconnect) - return true; -} - -bool OrcaCloudMqttConnection::unsubscribe(const std::vector& device_ids) { - { - std::lock_guard lock(mutex); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe requested count=" << device_ids.size() - << " connected=" << connected.load(); - for (const std::string& device_id : device_ids) { - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe requested dev_id=" << device_id; - subscriptions.erase(device_id); - pending_subscriptions.erase(device_id); - pending_unsubscriptions.insert(device_id); - } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe queued total_subscriptions=" << subscriptions.size() - << " pending_unsubscriptions=" << pending_unsubscriptions.size(); - } - state_cv.notify_all(); - flush_subscription_change(); // emit UNSUBSCRIBE now on the live socket (no reconnect) - return true; -} - -void OrcaCloudMqttConnection::clear_subscriptions() { - std::lock_guard lock(mutex); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT clear subscriptions count=" << subscriptions.size(); - subscriptions.clear(); - pending_subscriptions.clear(); - pending_unsubscriptions.clear(); -} - -bool OrcaCloudMqttConnection::parse_endpoint(const std::string& url, Endpoint& endpoint) { - constexpr const char* scheme = "wss://"; - constexpr size_t scheme_length = 6; - if (url.compare(0, scheme_length, scheme) != 0) - return false; - - const size_t authority_start = scheme_length; - const size_t path_start = url.find('/', authority_start); - const std::string authority = url.substr(authority_start, path_start - authority_start); - if (authority.empty()) - return false; - - const size_t port_start = authority.rfind(':'); - if (port_start != std::string::npos && authority.find(']') == std::string::npos) { - endpoint.host = authority.substr(0, port_start); - endpoint.port = authority.substr(port_start + 1); - } else { - endpoint.host = authority; - endpoint.port = "443"; - } - endpoint.target = path_start == std::string::npos ? "/" : url.substr(path_start); - return !endpoint.host.empty() && !endpoint.port.empty() && !endpoint.target.empty(); -} - -void OrcaCloudMqttConnection::append_string(std::vector& packet, const std::string& value) { - if (value.size() > 0xffff) - throw std::runtime_error("MQTT string is too long"); - packet.push_back(static_cast(value.size() >> 8)); - packet.push_back(static_cast(value.size() & 0xff)); - packet.insert(packet.end(), value.begin(), value.end()); -} - -void OrcaCloudMqttConnection::prepend_remaining_length(std::vector& packet, size_t length) { - std::vector encoded; - do { - uint8_t byte = static_cast(length % 128); - length /= 128; - if (length != 0) - byte |= 0x80; - encoded.push_back(byte); - } while (length != 0); - packet.insert(packet.begin() + 1, encoded.begin(), encoded.end()); -} - -std::vector OrcaCloudMqttConnection::make_connect_packet() { - std::vector packet{0x10}; - append_string(packet, "MQTT"); - packet.insert(packet.end(), {4, 2, 0, 60}); // level 4, clean session, 60 s keepalive - append_string(packet, "OrcaSlicer"); - prepend_remaining_length(packet, packet.size() - 1); - return packet; -} - -std::string OrcaCloudMqttConnection::report_topic(const std::string& device_id) { return "device/" + device_id + "/report"; } - -std::vector OrcaCloudMqttConnection::make_topic_packet(uint8_t type, uint16_t packet_id, const std::vector& device_ids) { - std::vector packet{type}; - packet.push_back(static_cast(packet_id >> 8)); - packet.push_back(static_cast(packet_id & 0xff)); - for (const std::string& device_id : device_ids) { - append_string(packet, report_topic(device_id)); - if (type == 0x82) // SUBSCRIBE, QoS 0 is sufficient for printer reports. - packet.push_back(0); - } - prepend_remaining_length(packet, packet.size() - 1); - return packet; -} - -std::vector OrcaCloudMqttConnection::make_ping_packet() { return {0xc0, 0}; } - -void OrcaCloudMqttConnection::send(WebSocket& websocket, const std::vector& packet) { - if (packet.empty()) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: attempted to send empty MQTT packet"; - return; - } - // Writes come from the worker thread AND, for dynamic (un)subscribes, the - // caller thread. Serialise them; the worker's concurrent read is fine (beast - // allows one reader + one writer). - std::lock_guard lock(write_mutex); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending MQTT packet type=0x" << std::hex - << static_cast(packet[0] >> 4) << std::dec - << " bytes=" << packet.size(); - websocket.binary(true); - websocket.write(boost::asio::buffer(packet)); -} - -void OrcaCloudMqttConnection::connect_and_read() { - auto connection = std::make_shared(); - { - std::lock_guard lock(connection_mutex); - active_connection = connection; - if (stopping.load()) - return; - } - - Endpoint endpoint; - if (!parse_endpoint(endpoint_url, endpoint)) { - BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: invalid MQTT endpoint=" << endpoint_url; - throw std::runtime_error("invalid Orca Cloud WebSocket endpoint"); - } - - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connecting host=" << endpoint.host - << " port=" << endpoint.port << " target=" << endpoint.target; - - auto& websocket = connection->websocket; - const auto results = connection->resolver.resolve(endpoint.host, endpoint.port); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT DNS resolution succeeded host=" << endpoint.host; - auto& stream = boost::beast::get_lowest_layer(websocket); - stream.expires_after(std::chrono::seconds(10)); - stream.connect(results); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TCP connection established host=" << endpoint.host - << " port=" << endpoint.port; - - // The aggregate viewer is a TLS WebSocket endpoint. Set SNI before the - // TLS handshake so the cloud edge selects the correct certificate. - auto& tls_stream = websocket.next_layer(); - if (!SSL_set_tlsext_host_name(tls_stream.native_handle(), endpoint.host.c_str())) - throw std::runtime_error("failed to set Orca Cloud TLS server name"); - connection->ssl_context.set_default_verify_paths(); - tls_stream.set_verify_mode(boost::asio::ssl::verify_peer); - tls_stream.set_verify_callback(boost::asio::ssl::host_name_verification(endpoint.host)); - tls_stream.handshake(boost::asio::ssl::stream_base::client); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TLS handshake completed host=" << endpoint.host; - - const std::string token = get_token ? get_token() : std::string(); - if (token.empty()) { - BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: MQTT token callback returned an empty token"; - throw std::runtime_error("no access token for Orca Cloud WebSocket"); - } - - websocket.set_option(boost::beast::websocket::stream_base::decorator( - [token](boost::beast::websocket::request_type& request) { - request.set(boost::beast::http::field::user_agent, "OrcaSlicer"); - request.set(boost::beast::http::field::authorization, "Bearer " + token); - request.set("Sec-WebSocket-Protocol", "mqtt"); - })); - boost::beast::http::response response; - boost::system::error_code handshake_error; - websocket.handshake(response, endpoint.host, endpoint.target, handshake_error); - if (handshake_error) { - // Surface the server's HTTP status so a persistent rejection (stale token, - // missing api key, wrong route) is diagnosable from the log rather than an - // opaque "handshake declined". - BOOST_LOG_TRIVIAL(warning) << "OrcaCloudMqttConnection: handshake rejected, http=" - << response.result_int() << " (" << response.reason() << "), " - << handshake_error.message(); - throw boost::system::system_error(handshake_error, "Orca Cloud WebSocket handshake"); - } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: WebSocket handshake completed http=" << response.result_int() - << " negotiated_protocol=" << response["Sec-WebSocket-Protocol"]; - if (response["Sec-WebSocket-Protocol"] != "mqtt") { - BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: WebSocket handshake did not negotiate MQTT"; - throw std::runtime_error("Orca Cloud WebSocket did not negotiate MQTT"); - } - - stream.expires_never(); - send(websocket, make_connect_packet()); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNECT packet sent"; - - boost::beast::flat_buffer buffer; - stream.expires_after(std::chrono::seconds(10)); - websocket.read(buffer); - const std::string connack = boost::beast::buffers_to_string(buffer.data()); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNACK received bytes=" << connack.size() - << " header=" << (connack.empty() ? -1 : static_cast(static_cast(connack[0]))) - << " return_code=" << (connack.size() > 3 ? static_cast(static_cast(connack[3])) : -1); - if (connack.size() != 4 || static_cast(connack[0]) != 0x20 || - static_cast(connack[2]) != 0x00 || static_cast(connack[3]) != 0x00) { - BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: MQTT CONNECT was refused or malformed"; - throw std::runtime_error("Orca Cloud MQTT CONNECT was refused"); - } - - notify_state(true); - reconnect_delay_seconds.store(1); // a fresh CONNACK resets the backoff - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection is ready; sending current subscriptions"; - send_current_subscriptions(websocket); - std::chrono::steady_clock::time_point next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30); - - while (!stopping.load()) { - send_pending_subscriptions(websocket); - buffer.consume(buffer.size()); - stream.expires_after(std::chrono::seconds(1)); - boost::system::error_code error; - websocket.read(buffer, error); - if (error == boost::beast::error::timeout) { - if (std::chrono::steady_clock::now() >= next_ping) { - send(websocket, make_ping_packet()); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT PINGREQ sent"; - next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30); - } - continue; - } - if (error) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT WebSocket read failed code=" << error.value() - << " message=" << error.message(); - throw boost::system::system_error(error, "read Orca Cloud MQTT message"); - } - handle_packet(boost::beast::buffers_to_string(buffer.data())); - } - - boost::system::error_code close_error; - websocket.close(boost::beast::websocket::close_code::normal, close_error); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection closed code=" << close_error.value() - << " message=" << close_error.message(); - if (!stopping.load()) - notify_state(false); -} - -void OrcaCloudMqttConnection::send_current_subscriptions(WebSocket& websocket) { - std::vector devices; - { - std::lock_guard lock(mutex); - devices.assign(subscriptions.begin(), subscriptions.end()); - } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending current MQTT subscriptions count=" << devices.size(); - if (!devices.empty()) { - const uint16_t packet_id = next_packet_id++; - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending SUBSCRIBE packet_id=" << packet_id; - send(websocket, make_topic_packet(0x82, packet_id, devices)); - } -} - -void OrcaCloudMqttConnection::send_pending_subscriptions(WebSocket& websocket) { - std::vector subscribe_ids; - std::vector unsubscribe_ids; - { - std::lock_guard lock(mutex); - subscribe_ids.assign(pending_subscriptions.begin(), pending_subscriptions.end()); - unsubscribe_ids.assign(pending_unsubscriptions.begin(), pending_unsubscriptions.end()); - pending_subscriptions.clear(); - pending_unsubscriptions.clear(); - } - if (!subscribe_ids.empty()) { - const uint16_t packet_id = next_packet_id++; - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending SUBSCRIBE count=" << subscribe_ids.size() - << " packet_id=" << packet_id; - for (const std::string& device_id : subscribe_ids) - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: SUBSCRIBE topic=" << report_topic(device_id); - send(websocket, make_topic_packet(0x82, packet_id, subscribe_ids)); - } - if (!unsubscribe_ids.empty()) { - const uint16_t packet_id = next_packet_id++; - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending UNSUBSCRIBE count=" << unsubscribe_ids.size() - << " packet_id=" << packet_id; - for (const std::string& device_id : unsubscribe_ids) - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: UNSUBSCRIBE topic=" << report_topic(device_id); - send(websocket, make_topic_packet(0xa2, packet_id, unsubscribe_ids)); - } -} - -void OrcaCloudMqttConnection::handle_packet(const std::string& packet) { - if (packet.size() < 2) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: received undersized MQTT packet bytes=" << packet.size(); - return; - } - const uint8_t header = static_cast(packet[0]); - const uint8_t packet_type = header >> 4; - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received MQTT packet type=" << static_cast(packet_type) - << " header=0x" << std::hex << static_cast(header) << std::dec - << " bytes=" << packet.size(); - if (packet_type != 3) { // Only QoS 0 PUBLISH carries printer status. - if (packet_type == 9 && packet.size() >= 5) { - std::ostringstream result_codes; - for (size_t index = 4; index < packet.size(); ++index) { - if (index != 4) - result_codes << ','; - result_codes << "0x" << std::hex << static_cast(static_cast(packet[index])); - } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received SUBACK packet_id=" - << ((static_cast(static_cast(packet[2])) << 8) | - static_cast(static_cast(packet[3]))) - << " result_codes=" << result_codes.str(); - } - return; - } - size_t index = 1; - size_t multiplier = 1; - size_t remaining = 0; - uint8_t encoded = 0; - do { - if (index >= packet.size() || multiplier > 128 * 128 * 128) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH remaining length"; - return; - } - encoded = static_cast(packet[index++]); - remaining += (encoded & 0x7f) * multiplier; - multiplier *= 128; - } while ((encoded & 0x80) != 0); - const size_t remaining_end = index + remaining; - if (remaining_end > packet.size() || remaining < 2 || index + 2 > remaining_end) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH body remaining=" << remaining - << " packet_bytes=" << packet.size(); - return; - } - const uint16_t topic_length = (static_cast(packet[index]) << 8) | - static_cast(packet[index + 1]); - index += 2; - if (topic_length > packet.size() - index) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH topic length=" << topic_length; - return; - } - const std::string topic(packet.data() + index, topic_length); - index += topic_length; - if (((header >> 1) & 0x03) != 0) { - if (index + 2 > remaining_end) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH packet identifier"; - return; - } - index += 2; // QoS 1/2 packet identifier; the service currently sends QoS 0. - } - const size_t payload_size = remaining_end - index; - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received PUBLISH topic=" << topic - << " payload_bytes=" << payload_size - << " message_callback=" << (on_message ? "set" : "null"); - if (on_message) - on_message(topic, packet.substr(index, remaining_end - index)); - else - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping PUBLISH because message callback is not set"; -} - -void OrcaCloudMqttConnection::notify_state(bool is_now_connected) { - StateHandler callback; - bool initial = false; - { - std::lock_guard lock(mutex); - connected = is_now_connected; - initial = !initial_completed; - if (initial) { - initial_result = is_now_connected; - initial_completed = true; - } - callback = on_state; - } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT state changed connected=" << is_now_connected - << " initial=" << initial << " state_callback=" << (callback ? "set" : "null"); - if (initial) - initial_cv.notify_all(); - else if (callback) - callback(is_now_connected, false); -} - -void OrcaCloudMqttConnection::run() { - while (!stopping.load()) { - const int retry_seconds = reconnect_delay_seconds.load(); - try { - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection attempt retry_delay=" << retry_seconds; - connect_and_read(); - } catch (const std::exception& error) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT connection attempt failed: " << error.what(); - if (!stopping.load()) - notify_state(false); - } - if (stopping.load()) - break; - // Grow the backoff only across attempts that never reached CONNACK; a - // successful connection resets reconnect_delay_seconds to 1 (connect_and_read). - reconnect_delay_seconds.store(std::min(retry_seconds * 2, 30)); - std::unique_lock lock(mutex); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT waiting before reconnect seconds=" << retry_seconds; - state_cv.wait_for(lock, std::chrono::seconds(retry_seconds), [this] { return stopping.load(); }); - } -} - namespace { constexpr const char* ORCA_DEFAULT_API_URL = "api.orcaslicer.com"; constexpr const char* ORCA_DEFAULT_AUTH_URL = "https://auth.orcaslicer.com"; @@ -1013,7 +497,7 @@ OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir) , api_base_url(ORCA_DEFAULT_API_URL) , auth_base_url(ORCA_DEFAULT_AUTH_URL) , cloud_base_url(ORCA_DEFAULT_CLOUD_URL) - , mqtt_connection(std::make_unique()) + , mqtt_connection(std::make_unique()) { auth_headers["apikey"] = ORCA_DEFAULT_PUB_KEY; pkce_bundle.loopback_port = choose_loopback_port(); @@ -1487,79 +971,22 @@ int OrcaCloudServiceAgent::connect_server() BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health result=" << result << " http_code=" << http_code << " connected=" << connected << " response_bytes=" << response.size(); - if (connected && mqtt_connection && !mqtt_connection->is_running()) { - // Only (re)start when the worker isn't already alive. connect_server() is - // also called every ~5s by DeviceManagerRefresher::on_timer via - // refresh_connection(); start() begins with stop(), so calling it - // unconditionally tears down and rebuilds a healthy socket every tick. - const std::string endpoint = "wss://" + api_base_url + "/api/v1/printers/mqtt"; - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: starting aggregate MQTT endpoint=" << endpoint; - // Fire-and-forget: start() spawns a worker that reconnects with exponential - // backoff. A failed *initial* attempt (token/network not ready yet during a - // startup gap) must NOT gate the socket's lifetime here — folding it into - // `connected` trips the stop() below and kills the retry loop for the whole - // session. The socket is torn down only on logout / clear_session. - const bool mqtt_started = mqtt_connection->start( - endpoint, - [this] { return get_access_token(); }, - [this](const std::string& topic, const std::string& message) { - constexpr const char* prefix = "device/"; - constexpr const char* suffix = "/report"; - if (topic.compare(0, 7, prefix) != 0 || topic.size() <= 14 || - topic.compare(topic.size() - 7, 7, suffix) != 0) - return; - const std::string device_id = topic.substr(7, topic.size() - 14); - OnMessageFn callback; - { - std::lock_guard lock(callback_mutex); - callback = printer_status_callback; - } - if (callback) - callback(device_id, message); - }, - [this](bool socket_connected, bool initial) { - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: aggregate MQTT state callback connected=" - << socket_connected << " initial=" << initial; - if (initial) - return; - { - std::lock_guard lock(state_mutex); - is_connected = socket_connected; - } - invoke_server_connected_callback(socket_connected ? 0 : -1, 0); - }); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: aggregate MQTT start returned=" << mqtt_started; + // connect_server() is a REST health probe only. The long-lived MQTT socket is + // per-printer now, driven by set_user_selected_machine -> + // configure_selected_printer_mqtt; this method must not touch mqtt_connection. + { + std::lock_guard lock(state_mutex); + is_connected = connected; } - if (!connected) { - // Transient health-check failure (DNS blip / brief 5xx). Do NOT stop the - // MQTT worker — it owns its own reconnect loop, and connect_server() runs - // on the 5s refresher tick. The socket is torn down only on logout (the - // !logged_in branch above) and clear_session(). - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health check failed; leaving aggregate MQTT running"; - } - - // While the aggregate MQTT worker is alive it owns is_connected via its - // StateHandler. Don't let the 5s health probe overwrite it (a DNS blip would - // otherwise flap the "server connected" state and the Device tab). - const bool mqtt_alive = mqtt_connection && mqtt_connection->is_running(); - if (!mqtt_alive) { - { - std::lock_guard lock(state_mutex); - is_connected = connected; - } - invoke_server_connected_callback(connected ? 0 : -1, http_code); - } - - return (connected || mqtt_alive) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + invoke_server_connected_callback(connected ? 0 : -1, http_code); + return connected ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; } bool OrcaCloudServiceAgent::is_server_connected() { - // The aggregate MQTT socket is the real signal. While its worker is alive, - // report its actual CONNACK state — immune to the 5s health probe's DNS blips. - // Fall back to the last health-check result only when there is no socket. - if (mqtt_connection && mqtt_connection->is_running()) - return mqtt_connection->is_connected(); + // The REST health probe is the signal; the per-printer MQTT socket does not gate + // whole-cloud connectivity (one printer reconnecting must not report the whole + // cloud as lost). std::lock_guard lock(state_mutex); return is_connected; } @@ -1586,7 +1013,9 @@ int OrcaCloudServiceAgent::add_subscribe(std::vector dev_list) BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: add_subscribe rejected because cloud is not ready"; return BAMBU_NETWORK_ERR_INVALID_HANDLE; } - const bool queued = mqtt_connection->subscribe(dev_list); + bool queued = true; + for (const std::string& dev_id : dev_list) + queued = mqtt_connection->subscribe(dev_id) && queued; BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: add_subscribe queued=" << queued; return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; } @@ -1599,11 +1028,68 @@ int OrcaCloudServiceAgent::del_subscribe(std::vector dev_list) BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: del_subscribe rejected because cloud is not ready"; return BAMBU_NETWORK_ERR_INVALID_HANDLE; } - const bool queued = mqtt_connection->unsubscribe(dev_list); + bool queued = true; + for (const std::string& dev_id : dev_list) + queued = mqtt_connection->unsubscribe(dev_id) && queued; BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: del_subscribe queued=" << queued; return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; } +int OrcaCloudServiceAgent::configure_selected_printer_mqtt(const std::string& dev_id) +{ + OrcaMqttConnection::Config cfg; + cfg.url = "wss://" + api_base_url + "/api/v1/printers/" + dev_id + "/mqtt"; + cfg.use_tls = true; + cfg.bearer_provider = [this] { return get_access_token(); }; + cfg.client_id = "OrcaSlicer"; + cfg.keepalive_seconds = 300; + + { + std::lock_guard lock(m_selected_url_mutex); + m_selected_printer_mqtt_url = cfg.url; + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: configuring per-printer MQTT endpoint=" << cfg.url; + + // NOTE: no lock is held across start() — it blocks for the whole initial connect + // attempt (up to ~10s), and the message handler below re-enters callback_mutex on + // the MQTT worker thread. + const bool ok = mqtt_connection->start( + cfg, + [this](const std::string& id, const std::string& payload) { deliver_cloud_message(id, payload); }, + [this](bool, bool) {}); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: per-printer MQTT start returned=" << ok; + return ok ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; +} + +void OrcaCloudServiceAgent::teardown_selected_printer_mqtt() +{ + if (mqtt_connection) { + mqtt_connection->stop(); + // The connection object is reused for the next printer; drop this printer's + // report topic so its 1:1 socket does not re-subscribe the previous device. + mqtt_connection->clear_subscriptions(); + } + std::lock_guard lock(m_selected_url_mutex); + m_selected_printer_mqtt_url.clear(); +} + +std::string OrcaCloudServiceAgent::selected_printer_mqtt_url() const +{ + std::lock_guard lock(m_selected_url_mutex); + return m_selected_printer_mqtt_url; +} + +void OrcaCloudServiceAgent::deliver_cloud_message(const std::string& dev_id, const std::string& payload) +{ + OnMessageFn callback; + { + std::lock_guard lock(callback_mutex); + callback = printer_status_callback; + } + if (callback) + callback(dev_id, payload); +} + int OrcaCloudServiceAgent::set_printer_status_callback(OnMessageFn fn) { std::lock_guard lock(callback_mutex); @@ -3293,6 +2779,15 @@ int OrcaCloudServiceAgent::get_user_print_info(unsigned int* http_code, std::str for (const auto& printer : resp_json.value("data", nlohmann::json::array())) { nlohmann::json device; + std::string role = printer.value("access_role", ""); + + // A printer with the role "view" only has monitoring access for orca cloud. + // The printer is owned by a different person and was shared to the current user without + // any permission to control the printer so we discard this printer. Comment this out if + // OrcaSlicer wants to support view only printers. + if (role.empty() || role == "viewer") + continue; + device["dev_id"] = printer.value("id", ""); device["dev_name"] = printer.value("name", ""); if (printer.contains("model") && printer["model"].is_string()) diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp index 95f95b7d92..a435c9668f 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp @@ -24,84 +24,14 @@ #include #include +#include "OrcaMqttConnection.hpp" + class wxSecretStore; namespace Slic3r { // Forward declarations class AppConfig; -// MQTT 3.1.1 over the aggregate WebSocket is deliberately kept here instead -// of using the printer SDK. The endpoint is a read-only status stream; MQTT -// PUBLISH must never be sent on it because the cloud closes such sessions. -class OrcaCloudMqttConnection -{ -public: - using TokenProvider = std::function; - using MessageHandler = std::function; - using StateHandler = std::function; - - ~OrcaCloudMqttConnection(); - - bool start(const std::string& endpoint, TokenProvider token_provider, MessageHandler message_handler, StateHandler state_handler); - void stop(); - // True while the worker thread is alive (connected OR retrying). Lets callers - // avoid restarting a healthy connection. - bool is_running() const; - // True once CONNACK has been received and the socket has not since dropped. - bool is_connected() const { return connected.load(); } - bool subscribe(const std::vector& device_ids); - bool unsubscribe(const std::vector& device_ids); - void clear_subscriptions(); - -private: - struct Endpoint { std::string host; std::string port; std::string target; }; - using WebSocket = boost::beast::websocket::stream< - boost::asio::ssl::stream>; - struct Connection; - - static bool parse_endpoint(const std::string& url, Endpoint& endpoint); - static void append_string(std::vector& packet, const std::string& value); - static void prepend_remaining_length(std::vector& packet, size_t length); - static std::vector make_connect_packet(); - static std::string report_topic(const std::string& device_id); - static std::vector make_topic_packet(uint8_t type, uint16_t packet_id, const std::vector& device_ids); - static std::vector make_ping_packet(); - - void send(WebSocket& websocket, const std::vector& packet); - // Emit a queued SUBSCRIBE/UNSUBSCRIBE on the live socket right now (from the - // caller thread), so a selection change is applied without waiting for the - // blocking read loop to next return. No-op if no CONNACKed socket exists yet - // (the worker sends the set on connect). The aggregate viewer is dynamic — the - // WebSocket is never dropped for a subscription change. - void flush_subscription_change(); - void connect_and_read(); - void send_current_subscriptions(WebSocket& websocket); - void send_pending_subscriptions(WebSocket& websocket); - void handle_packet(const std::string& packet); - void notify_state(bool is_now_connected); - void run(); - - std::atomic_bool stopping{true}; - std::atomic_int reconnect_delay_seconds{1}; - std::thread worker; - std::mutex mutex; - std::mutex connection_mutex; - std::mutex write_mutex; // serialises every websocket write (worker + caller threads) - std::shared_ptr active_connection; - std::condition_variable initial_cv; - std::condition_variable state_cv; - std::string endpoint_url; - TokenProvider get_token; - MessageHandler on_message; - StateHandler on_state; - std::set subscriptions; - std::set pending_subscriptions; - std::set pending_unsubscriptions; - std::atomic next_packet_id{1}; - bool initial_result{false}; - bool initial_completed{false}; - std::atomic_bool connected{false}; -}; struct BundleMetadata; struct PluginDescriptor; struct PluginChangelog; @@ -288,10 +218,10 @@ public: int del_subscribe(std::vector dev_list) override; void enable_multi_machine(bool enable) override; - // The aggregate printer socket is status-only. OrcaPrinterAgent registers - // its normal message callback here and adds/removes device report topics - // through add_subscribe()/del_subscribe(). Printer commands continue to - // use the REST commands endpoint; they must never be published here. + // The per-printer MQTT socket carries both directions: inbound reports from + // device//report and commands PUBLISHed to device//request on this + // socket. OrcaPrinterAgent registers its message callback here to receive the + // inbound half; pass an empty fn to clear it before the agent is destroyed. int set_printer_status_callback(OnMessageFn fn); // Send a Bambu-dialect command to one printer via the cloud relay's REST @@ -439,7 +369,26 @@ public: static std::string generate_uuid_for_setting_id(const std::string& name, const std::string& user_id = ""); + OrcaMqttConnection* get_mqtt_connection() noexcept { + return mqtt_connection.get(); + } + + const OrcaMqttConnection* get_mqtt_connection() const noexcept { + return mqtt_connection.get(); + } + + // Per-printer cloud socket: wss:///api/v1/printers//mqtt. + // configure_ blocks for the duration of the initial connect attempt, so callers + // drive it off the UI thread; teardown_ is synchronous. + int configure_selected_printer_mqtt(const std::string& dev_id); + void teardown_selected_printer_mqtt(); + // Test hook: the wss:// URL of the current per-printer socket ("" when none). + std::string selected_printer_mqtt_url() const; + private: + // Fans one inbound per-printer MQTT message out to printer_status_callback. + void deliver_cloud_message(const std::string& dev_id, const std::string& payload); + // Sync protocol helpers int sync_pull( std::function on_success, @@ -516,7 +465,9 @@ private: std::chrono::system_clock::now().time_since_epoch()).count()}; // Member variables - connection state - std::unique_ptr mqtt_connection; + std::unique_ptr mqtt_connection; + std::string m_selected_printer_mqtt_url; // guarded by m_selected_url_mutex + mutable std::mutex m_selected_url_mutex; bool is_connected{false}; bool enable_track{false}; bool multi_machine_enabled{false}; diff --git a/src/slic3r/Utils/OrcaMqttConnection.cpp b/src/slic3r/Utils/OrcaMqttConnection.cpp new file mode 100644 index 0000000000..5ba0279fc2 --- /dev/null +++ b/src/slic3r/Utils/OrcaMqttConnection.cpp @@ -0,0 +1,740 @@ +#include "OrcaMqttConnection.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +struct OrcaMqttConnection::Connection { + boost::asio::io_context io_context; + boost::asio::ssl::context ssl_context; + boost::asio::ip::tcp::resolver resolver; + // Exactly one of these is engaged once ws_handshake() has run: wss for + // wss:// endpoints, ws for plaintext ws://. + std::optional wss; + std::optional ws; + + Connection() + : ssl_context(boost::asio::ssl::context::tls_client) + , resolver(io_context) + {} +}; + +namespace { +// Apply / clear a tcp_stream timeout on whichever websocket is engaged. +// Templated on the connection type only because Connection is a private nested +// type: a deduced parameter needs no (inaccessible) name for it. +template void expires_after(Conn& conn, std::chrono::seconds timeout) { + if (conn.wss) boost::beast::get_lowest_layer(*conn.wss).expires_after(timeout); + else if (conn.ws) boost::beast::get_lowest_layer(*conn.ws).expires_after(timeout); +} +template void expires_never(Conn& conn) { + if (conn.wss) boost::beast::get_lowest_layer(*conn.wss).expires_never(); + else if (conn.ws) boost::beast::get_lowest_layer(*conn.ws).expires_never(); +} +} // namespace + +OrcaMqttConnection::~OrcaMqttConnection() { stop(); } + +bool OrcaMqttConnection::start(const Config& config, MessageHandler on_message, StateHandler on_state) { + std::lock_guard lifecycle_lock(lifecycle_mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start url=" << config.url + << " use_tls=" << config.use_tls + << " bearer_provider=" << (config.bearer_provider ? "set" : "null") + << " username_present=" << (!config.username.empty()) + << " password_present=" << (!config.password.empty()) + << " client_id=" << config.client_id + << " keepalive_seconds=" << config.keepalive_seconds + << " message_callback=" << (on_message ? "set" : "null") + << " state_callback=" << (on_state ? "set" : "null"); + stop(); + { + std::lock_guard lock(mutex); + current_config = config; + this->on_message = std::move(on_message); + this->on_state = std::move(on_state); + initial_result = false; + initial_completed = false; + connected = false; + m_last_connack_rc.store(-1); + } + stopping.store(false); + worker = std::thread(&OrcaMqttConnection::run, this); + + std::unique_lock lock(mutex); + if (!initial_cv.wait_for(lock, std::chrono::seconds(10), [this] { return initial_completed; })) { + initial_completed = true; + initial_result = false; + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT initial connection timed out after 10 seconds" + << " url=" << current_config.url + << " last_connack_rc=" << m_last_connack_rc.load() + << " connected=" << connected.load() + << "; worker will retry"; + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT start initial_result=" << initial_result + << " initial_completed=" << initial_completed + << " last_connack_rc=" << m_last_connack_rc.load() + << " worker_running=" << (worker.joinable() && !stopping.load()); + return initial_result; +} + +void OrcaMqttConnection::stop() { + std::lock_guard lifecycle_lock(lifecycle_mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT stop requested" + << " url=" << current_config.url + << " connected=" << connected.load() + << " worker_joinable=" << worker.joinable() + << " last_connack_rc=" << m_last_connack_rc.load(); + stopping.store(true); + state_cv.notify_all(); + { + std::lock_guard lock(connection_mutex); + if (active_connection) { + // Generic so it accepts either the TLS or the plaintext websocket. + auto shutdown_socket = [](auto& websocket) { + auto& socket = boost::beast::get_lowest_layer(websocket).socket(); + boost::system::error_code socket_error; + socket.cancel(socket_error); + socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, socket_error); + socket.close(socket_error); + }; + if (active_connection->wss) + shutdown_socket(*active_connection->wss); + else if (active_connection->ws) + shutdown_socket(*active_connection->ws); + active_connection->resolver.cancel(); + } + } + if (worker.joinable()) + worker.join(); + + { + std::lock_guard lock(mutex); + connected = false; + if (!initial_completed) { + initial_completed = true; + initial_result = false; + } + } + initial_cv.notify_all(); +} + +bool OrcaMqttConnection::is_running() const { + return worker.joinable() && !stopping.load(); +} + +void OrcaMqttConnection::flush_subscription_change() { + std::shared_ptr conn; + { + std::lock_guard lock(connection_mutex); + conn = active_connection; + } + bool connacked; + { + std::lock_guard lock(mutex); + connacked = connected; + } + if (!conn || !connacked) + return; // no live MQTT session yet — the worker sends the set on CONNACK + + // beast permits a concurrent writer while the worker is blocked in + // websocket.read(); every write is serialised by write_mutex inside ws_write(). + try { + send_pending_subscriptions(*conn); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: direct subscription write failed (" << e.what() + << "); worker will resend the full set on reconnect"; + } +} + +bool OrcaMqttConnection::subscribe(const std::string& dev_id) { + if (dev_id.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT subscribe rejected empty dev_id"; + return false; + } + const std::string topic = report_topic(dev_id); + if (topic.size() > 96) { // MQTT topic filter cap enforced by the service + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT subscribe rejected oversized topic=" << topic; + return false; + } + { + std::lock_guard lock(mutex); + subscriptions.insert(topic); + pending_unsubscriptions.erase(topic); + pending_subscriptions.insert(topic); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe queued topic=" << topic + << " total_subscriptions=" << subscriptions.size() + << " connected=" << connected.load(); + } + state_cv.notify_all(); + flush_subscription_change(); // emit SUBSCRIBE now on the live socket (no reconnect) + return true; +} + +bool OrcaMqttConnection::unsubscribe(const std::string& dev_id) { + const std::string topic = report_topic(dev_id); + { + std::lock_guard lock(mutex); + subscriptions.erase(topic); + pending_subscriptions.erase(topic); + pending_unsubscriptions.insert(topic); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe queued topic=" << topic + << " total_subscriptions=" << subscriptions.size() + << " connected=" << connected.load(); + } + state_cv.notify_all(); + flush_subscription_change(); // emit UNSUBSCRIBE now on the live socket (no reconnect) + return true; +} + +void OrcaMqttConnection::clear_subscriptions() { + std::lock_guard lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT clear subscriptions count=" << subscriptions.size(); + subscriptions.clear(); + pending_subscriptions.clear(); + pending_unsubscriptions.clear(); +} + +bool OrcaMqttConnection::parse_endpoint(const std::string& url, Endpoint& endpoint) { + std::string rest; + std::string default_port; + if (url.rfind("wss://", 0) == 0) { rest = url.substr(6); default_port = "443"; } + else if (url.rfind("ws://", 0) == 0) { rest = url.substr(5); default_port = "80"; } + else return false; + + const auto slash = rest.find('/'); + const std::string authority = rest.substr(0, slash); + endpoint.target = (slash == std::string::npos) ? "/" : rest.substr(slash); + + // host[:port] — leave an unbracketed IPv6 literal alone + const auto colon = authority.rfind(':'); + if (colon != std::string::npos && authority.find(']') == std::string::npos) { + endpoint.host = authority.substr(0, colon); + endpoint.port = authority.substr(colon + 1); + } else { + endpoint.host = authority; + endpoint.port = default_port; + } + return !endpoint.host.empty() && !endpoint.port.empty() && !endpoint.target.empty(); +} + +void OrcaMqttConnection::append_string(std::vector& packet, const std::string& value) { + if (value.size() > 0xffff) + throw std::runtime_error("MQTT string is too long"); + packet.push_back(static_cast(value.size() >> 8)); + packet.push_back(static_cast(value.size() & 0xff)); + packet.insert(packet.end(), value.begin(), value.end()); +} + +void OrcaMqttConnection::prepend_remaining_length(std::vector& packet, size_t length) { + std::vector encoded; + do { + uint8_t byte = static_cast(length % 128); + length /= 128; + if (length != 0) + byte |= 0x80; + encoded.push_back(byte); + } while (length != 0); + packet.insert(packet.begin() + 1, encoded.begin(), encoded.end()); +} + +std::vector OrcaMqttConnection::make_connect_packet( + const std::string& client_id, const std::string& username, + const std::string& password, int keepalive_seconds) { + std::vector packet{0x10}; + append_string(packet, "MQTT"); + packet.push_back(4); // protocol level 3.1.1 + + uint8_t flags = 0x02; // clean session + if (!username.empty()) { flags |= 0x80; if (!password.empty()) flags |= 0x40; } + packet.push_back(flags); + + packet.push_back(static_cast(keepalive_seconds >> 8)); + packet.push_back(static_cast(keepalive_seconds & 0xff)); + + append_string(packet, client_id.empty() ? "OrcaSlicer" : client_id); + if (!username.empty()) { + append_string(packet, username); + if (!password.empty()) append_string(packet, password); + } + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} + +std::string OrcaMqttConnection::report_topic(const std::string& device_id) { return "device/" + device_id + "/report"; } + +std::string OrcaMqttConnection::request_topic(const std::string& id) { return "device/" + id + "/request"; } + +std::vector OrcaMqttConnection::make_publish_packet(const std::string& topic, const std::string& payload) { + std::vector packet{0x30}; // PUBLISH, QoS 0, no retain + append_string(packet, topic); // no packet id at QoS 0 + packet.insert(packet.end(), payload.begin(), payload.end()); + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} + +std::vector OrcaMqttConnection::make_subscribe_packet(uint16_t id, const std::string& topic, uint8_t qos) { + std::vector packet{0x82}; + packet.push_back(id >> 8); packet.push_back(id & 0xff); + append_string(packet, topic); + packet.push_back(qos); + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} + +std::vector OrcaMqttConnection::make_unsubscribe_packet(uint16_t id, const std::string& topic) { + std::vector packet{0xA2}; + packet.push_back(id >> 8); packet.push_back(id & 0xff); + append_string(packet, topic); + prepend_remaining_length(packet, packet.size() - 1); + return packet; +} + +std::vector OrcaMqttConnection::make_ping_packet() { return {0xc0, 0}; } + +void OrcaMqttConnection::ws_write(Connection& conn, const std::vector& packet) { + if (packet.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: attempted to send empty MQTT packet"; + return; + } + // Writes come from the worker thread AND, for dynamic (un)subscribes, the + // caller thread. Serialise them; the worker's concurrent read is fine (beast + // allows one reader + one writer). + std::lock_guard lock(write_mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending MQTT packet type=0x" << std::hex + << static_cast(packet[0] >> 4) << std::dec + << " bytes=" << packet.size(); + if (conn.wss) { + conn.wss->binary(true); + conn.wss->write(boost::asio::buffer(packet)); + } else if (conn.ws) { + conn.ws->binary(true); + conn.ws->write(boost::asio::buffer(packet)); + } +} + +std::size_t OrcaMqttConnection::ws_read(Connection& conn, boost::beast::flat_buffer& buffer, + boost::system::error_code& ec) { + if (conn.wss) + return conn.wss->read(buffer, ec); + if (conn.ws) + return conn.ws->read(buffer, ec); + ec = boost::asio::error::not_connected; + return 0; +} + +void OrcaMqttConnection::ws_close(Connection& conn) { + boost::system::error_code close_error; + if (conn.wss) + conn.wss->close(boost::beast::websocket::close_code::normal, close_error); + else if (conn.ws) + conn.ws->close(boost::beast::websocket::close_code::normal, close_error); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection closed code=" << close_error.value() + << " message=" << close_error.message(); +} + +void OrcaMqttConnection::ws_handshake(Connection& conn, const Config& config, const Endpoint& endpoint) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: WebSocket resolve starting host=" << endpoint.host + << " port=" << endpoint.port << " target=" << endpoint.target + << " tls=" << config.use_tls; + const auto results = conn.resolver.resolve(endpoint.host, endpoint.port); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT DNS resolution succeeded host=" << endpoint.host; + + std::string token; + if (config.bearer_provider) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: requesting bearer token for WebSocket upgrade"; + token = config.bearer_provider(); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: bearer token callback completed token_present=" << !token.empty(); + } + auto decorator = [token](boost::beast::websocket::request_type& request) { + request.set(boost::beast::http::field::user_agent, "OrcaSlicer"); + if (!token.empty()) + request.set(boost::beast::http::field::authorization, "Bearer " + token); + request.set("Sec-WebSocket-Protocol", "mqtt"); + }; + boost::beast::http::response response; + boost::system::error_code handshake_error; + + if (config.use_tls) { + // stop() inspects the engaged optional under connection_mutex; publish it + // under the same lock, then release before the blocking connect. + { + std::lock_guard lock(connection_mutex); + conn.wss.emplace(conn.io_context, conn.ssl_context); + } + auto& websocket = *conn.wss; + auto& stream = boost::beast::get_lowest_layer(websocket); + stream.expires_after(std::chrono::seconds(10)); + stream.connect(results); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TCP connection established host=" << endpoint.host + << " port=" << endpoint.port; + // Set SNI before the TLS handshake so the cloud edge selects the correct + // certificate. + auto& tls_stream = websocket.next_layer(); + if (!SSL_set_tlsext_host_name(tls_stream.native_handle(), endpoint.host.c_str())) + throw std::runtime_error("failed to set Orca Cloud TLS server name"); + conn.ssl_context.set_default_verify_paths(); + tls_stream.set_verify_mode(boost::asio::ssl::verify_peer); + tls_stream.set_verify_callback(boost::asio::ssl::host_name_verification(endpoint.host)); + tls_stream.handshake(boost::asio::ssl::stream_base::client); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TLS handshake completed host=" << endpoint.host; + websocket.set_option(boost::beast::websocket::stream_base::decorator(decorator)); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending TLS WebSocket upgrade target=" << endpoint.target + << " bearer_header=" << (!token.empty()); + websocket.handshake(response, endpoint.host, endpoint.target, handshake_error); + } else { + { + std::lock_guard lock(connection_mutex); + conn.ws.emplace(conn.io_context); + } + auto& websocket = *conn.ws; + auto& stream = boost::beast::get_lowest_layer(websocket); + stream.expires_after(std::chrono::seconds(10)); + stream.connect(results); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT TCP connection established host=" << endpoint.host + << " port=" << endpoint.port << " (plaintext)"; + websocket.set_option(boost::beast::websocket::stream_base::decorator(decorator)); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending plaintext WebSocket upgrade target=" << endpoint.target + << " bearer_header=" << (!token.empty()); + websocket.handshake(response, endpoint.host, endpoint.target, handshake_error); + } + + if (handshake_error) { + // Surface the server's HTTP status so a persistent rejection (stale token, + // missing api key, wrong route) is diagnosable from the log rather than an + // opaque "handshake declined". + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: WS handshake rejected http=" + << response.result_int() << " (" << response.reason() << "), " + << handshake_error.message(); + throw boost::system::system_error(handshake_error, "Orca WebSocket handshake"); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: WebSocket handshake completed http=" << response.result_int() + << " negotiated_protocol=" << response["Sec-WebSocket-Protocol"]; + if (response["Sec-WebSocket-Protocol"] != "mqtt") { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: WebSocket handshake did not negotiate MQTT"; + throw std::runtime_error("Orca WebSocket did not negotiate MQTT"); + } +} + +bool OrcaMqttConnection::send_request(const std::string& dev_id, const std::string& payload) { + if (dev_id.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT send_request rejected empty dev_id"; + return false; + } + if (!connected.load()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT send_request rejected because connection is not ready" + << " dev_id=" << dev_id << " last_connack_rc=" << m_last_connack_rc.load(); + return false; + } + std::shared_ptr conn; + { + std::lock_guard lock(connection_mutex); + conn = active_connection; + } + if (!conn) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT send_request rejected because active connection is null" + << " dev_id=" << dev_id; + return false; + } + try { + // ws_write() serialises the write via write_mutex; do not lock it here. + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT PUBLISH request dev_id=" << dev_id + << " topic=" << request_topic(dev_id) + << " payload_bytes=" << payload.size(); + ws_write(*conn, make_publish_packet(request_topic(dev_id), payload)); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: send_request failed dev_id=" << dev_id + << " (" << e.what() << ")"; + return false; + } + return true; +} + +void OrcaMqttConnection::connect_and_read() { + m_connection_stage = "creating connection"; + auto connection = std::make_shared(); + { + std::lock_guard lock(connection_mutex); + active_connection = connection; + if (stopping.load()) + return; + } + + m_connection_stage = "parsing endpoint"; + Endpoint endpoint; + if (!parse_endpoint(current_config.url, endpoint)) { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: invalid MQTT endpoint=" << current_config.url; + throw std::runtime_error("invalid Orca Cloud WebSocket endpoint"); + } + + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connecting host=" << endpoint.host + << " port=" << endpoint.port << " target=" << endpoint.target; + + m_connection_stage = "WebSocket handshake"; + ws_handshake(*connection, current_config, endpoint); + + m_connection_stage = "sending MQTT CONNECT"; + expires_never(*connection); + // Auth precedence: a bearer_provider authenticates the WebSocket upgrade, so the + // CONNECT username/password fields are omitted entirely (the cloud form). + const bool use_bearer = static_cast(current_config.bearer_provider); + ws_write(*connection, make_connect_packet(current_config.client_id, + use_bearer ? std::string() : current_config.username, + use_bearer ? std::string() : current_config.password, + current_config.keepalive_seconds)); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNECT packet sent"; + + m_connection_stage = "waiting for MQTT CONNACK"; + boost::beast::flat_buffer buffer; + expires_after(*connection, std::chrono::seconds(10)); + boost::system::error_code connack_error; + ws_read(*connection, buffer, connack_error); + if (connack_error) + throw boost::system::system_error(connack_error, "read Orca MQTT CONNACK"); + const std::string connack = boost::beast::buffers_to_string(buffer.data()); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT CONNACK received bytes=" << connack.size() + << " header=" << (connack.empty() ? -1 : static_cast(static_cast(connack[0]))) + << " return_code=" << (connack.size() > 3 ? static_cast(static_cast(connack[3])) : -1); + // rc: 0 accepted, 1..5 refusal, -1 malformed/not a CONNACK. + const int rc = (connack.size() == 4 && static_cast(connack[0]) == 0x20) + ? static_cast(static_cast(connack[3])) + : -1; + m_last_connack_rc.store(rc); + if (rc != 0) { + BOOST_LOG_TRIVIAL(error) << "Orca diagnostic: MQTT CONNECT refused rc=" << rc; + if (rc == 4 || rc == 5) { + // Bad credentials / not authorized — retrying cannot help. Make run()'s + // loop exit and unblock any waiting start(). + stopping.store(true); + { + std::lock_guard lock(mutex); + initial_completed = true; + initial_result = false; + } + initial_cv.notify_all(); + } + throw std::runtime_error("Orca MQTT CONNECT refused rc=" + std::to_string(rc)); + } + + m_connection_stage = "reading MQTT messages"; + notify_state(true); + reconnect_delay_seconds.store(1); // a fresh CONNACK resets the backoff + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection is ready; sending current subscriptions"; + send_current_subscriptions(*connection); + std::chrono::steady_clock::time_point next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30); + + while (!stopping.load()) { + send_pending_subscriptions(*connection); + // Keepalive is driven every iteration, not only from the read-timeout branch: + // a printer pushing faster than the 1s read deadline would otherwise keep the + // read hot and the broker would drop us at 1.5 x keepalive. + if (std::chrono::steady_clock::now() >= next_ping) { + ws_write(*connection, make_ping_packet()); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT PINGREQ sent"; + next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30); + } + buffer.consume(buffer.size()); + m_connection_stage = "reading MQTT frame"; + expires_after(*connection, std::chrono::seconds(1)); + boost::system::error_code error; + ws_read(*connection, buffer, error); + if (error == boost::beast::error::timeout) + continue; + if (error) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT WebSocket read failed code=" << error.value() + << " message=" << error.message(); + throw boost::system::system_error(error, "read Orca MQTT message"); + } + handle_packet(boost::beast::buffers_to_string(buffer.data())); + } + + ws_close(*connection); + if (!stopping.load()) + notify_state(false); +} + +void OrcaMqttConnection::send_current_subscriptions(Connection& conn) { + std::vector topics; + { + std::lock_guard lock(mutex); + topics.assign(subscriptions.begin(), subscriptions.end()); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending current MQTT subscriptions count=" << topics.size(); + for (const std::string& topic : topics) { + const uint16_t packet_id = next_packet_id++; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending SUBSCRIBE topic=" << topic << " packet_id=" << packet_id; + ws_write(conn, make_subscribe_packet(packet_id, topic, 1)); + } +} + +void OrcaMqttConnection::send_pending_subscriptions(Connection& conn) { + std::vector subscribe_topics; + std::vector unsubscribe_topics; + { + std::lock_guard lock(mutex); + subscribe_topics.assign(pending_subscriptions.begin(), pending_subscriptions.end()); + unsubscribe_topics.assign(pending_unsubscriptions.begin(), pending_unsubscriptions.end()); + pending_subscriptions.clear(); + pending_unsubscriptions.clear(); + } + for (const std::string& topic : subscribe_topics) { + const uint16_t packet_id = next_packet_id++; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending SUBSCRIBE topic=" << topic + << " packet_id=" << packet_id; + ws_write(conn, make_subscribe_packet(packet_id, topic, 1)); + } + for (const std::string& topic : unsubscribe_topics) { + const uint16_t packet_id = next_packet_id++; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending UNSUBSCRIBE topic=" << topic + << " packet_id=" << packet_id; + ws_write(conn, make_unsubscribe_packet(packet_id, topic)); + } +} + +void OrcaMqttConnection::handle_packet(const std::string& packet) { + if (packet.size() < 2) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: received undersized MQTT packet bytes=" << packet.size(); + return; + } + const uint8_t header = static_cast(packet[0]); + const uint8_t packet_type = header >> 4; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received MQTT packet type=" << static_cast(packet_type) + << " header=0x" << std::hex << static_cast(header) << std::dec + << " bytes=" << packet.size(); + if (packet_type != 3) { // Only QoS 0 PUBLISH carries printer status. + if (packet_type == 9 && packet.size() >= 5) { + std::ostringstream result_codes; + for (size_t index = 4; index < packet.size(); ++index) { + if (index != 4) + result_codes << ','; + result_codes << "0x" << std::hex << static_cast(static_cast(packet[index])); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received SUBACK packet_id=" + << ((static_cast(static_cast(packet[2])) << 8) | + static_cast(static_cast(packet[3]))) + << " result_codes=" << result_codes.str(); + } + return; + } + size_t index = 1; + size_t multiplier = 1; + size_t remaining = 0; + uint8_t encoded = 0; + do { + if (index >= packet.size() || multiplier > 128 * 128 * 128) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH remaining length"; + return; + } + encoded = static_cast(packet[index++]); + remaining += (encoded & 0x7f) * multiplier; + multiplier *= 128; + } while ((encoded & 0x80) != 0); + const size_t remaining_end = index + remaining; + if (remaining_end > packet.size() || remaining < 2 || index + 2 > remaining_end) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH body remaining=" << remaining + << " packet_bytes=" << packet.size(); + return; + } + const uint16_t topic_length = (static_cast(packet[index]) << 8) | + static_cast(packet[index + 1]); + index += 2; + if (topic_length > packet.size() - index) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH topic length=" << topic_length; + return; + } + const std::string topic(packet.data() + index, topic_length); + index += topic_length; + if (((header >> 1) & 0x03) != 0) { + if (index + 2 > remaining_end) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: malformed MQTT PUBLISH packet identifier"; + return; + } + index += 2; // QoS 1/2 packet identifier; the service currently sends QoS 0. + } + const size_t payload_size = remaining_end - index; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received PUBLISH topic=" << topic + << " payload_bytes=" << payload_size + << " message_callback=" << (on_message ? "set" : "null"); + // topic is "device//report" (or "/request"); hand the id up, drop anything else. + std::string dev_id; + if (topic.rfind("device/", 0) == 0) { + const size_t id_start = 7; + const size_t id_end = topic.rfind('/'); + if (id_end != std::string::npos && id_end > id_start) + dev_id = topic.substr(id_start, id_end - id_start); + } + if (dev_id.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping PUBLISH on unrecognized topic=" << topic; + } else if (on_message) { + on_message(dev_id, packet.substr(index, remaining_end - index)); + } else { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping PUBLISH because message callback is not set"; + } +} + +void OrcaMqttConnection::notify_state(bool is_now_connected) { + StateHandler callback; + bool initial = false; + { + std::lock_guard lock(mutex); + connected = is_now_connected; + initial = !initial_completed; + if (initial) { + initial_result = is_now_connected; + initial_completed = true; + } + callback = on_state; + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT state changed connected=" << is_now_connected + << " initial=" << initial << " state_callback=" << (callback ? "set" : "null"); + if (initial) + initial_cv.notify_all(); + else if (callback) + callback(is_now_connected, false); +} + +void OrcaMqttConnection::run() { + while (!stopping.load()) { + const int retry_seconds = reconnect_delay_seconds.load(); + const uint64_t attempt = ++m_attempt_number; + m_connection_stage = "starting attempt"; + try { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection attempt=" << attempt + << " retry_delay=" << retry_seconds + << " url=" << current_config.url; + connect_and_read(); + } catch (const std::exception& error) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: MQTT connection attempt=" << attempt + << " failed stage=" << m_connection_stage + << " error=" << error.what() + << " last_connack_rc=" << m_last_connack_rc.load() + << " stopping=" << stopping.load(); + if (!stopping.load()) + notify_state(false); + } + if (stopping.load()) + break; + // Grow the backoff only across attempts that never reached CONNACK; a + // successful connection resets reconnect_delay_seconds to 1 (connect_and_read). + reconnect_delay_seconds.store(std::min(retry_seconds * 2, 30)); + std::unique_lock lock(mutex); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT waiting before reconnect seconds=" << retry_seconds; + state_cv.wait_for(lock, std::chrono::seconds(retry_seconds), [this] { return stopping.load(); }); + } +} + +} // namespace Slic3r diff --git a/src/slic3r/Utils/OrcaMqttConnection.hpp b/src/slic3r/Utils/OrcaMqttConnection.hpp new file mode 100644 index 0000000000..cac1892a68 --- /dev/null +++ b/src/slic3r/Utils/OrcaMqttConnection.hpp @@ -0,0 +1,144 @@ +#ifndef slic3r_OrcaMqttConnection_hpp_ +#define slic3r_OrcaMqttConnection_hpp_ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Slic3r { + +// Minimal MQTT 3.1.1 codec + WebSocket transport (ws:// and wss://), shared by the +// LAN (OrcaSonar) and cloud (per-printer) printer connections. Both PUBLISH +// commands to device//request and SUBSCRIBE device//report; Config is the +// only per-transport difference. +class OrcaMqttConnection +{ +public: + using TokenProvider = std::function; + using MessageHandler = std::function; + using StateHandler = std::function; + + struct Endpoint { std::string host; std::string port; std::string target; }; + + struct Config { + std::string url; + bool use_tls = false; + TokenProvider bearer_provider; // set => bearer on WS upgrade, CONNECT creds omitted + std::string username; + std::string password; + std::string client_id = "OrcaSlicer"; + int keepalive_seconds = 60; + }; + + static bool parse_endpoint(const std::string& url, Endpoint& endpoint); + + // Build an MQTT 3.1.1 CONNECT packet. Clean-session is always set; the + // username/password connect flags and payload fields are added only when + // username is non-empty (the cloud form authenticates via a bearer on the + // WebSocket upgrade and omits CONNECT credentials). Public for unit tests. + static std::vector make_connect_packet(const std::string& client_id, + const std::string& username, + const std::string& password, + int keepalive_seconds); + + // Topic-string helpers for the per-device request/report channels and the + // MQTT 3.1.1 PUBLISH / SUBSCRIBE / UNSUBSCRIBE packet builders. All public + // for unit tests. make_publish_packet emits QoS 0 (no packet identifier). + static std::string request_topic(const std::string& dev_id); // "device//request" + static std::string report_topic(const std::string& dev_id); // "device//report" + static std::vector make_publish_packet(const std::string& topic, const std::string& payload); + static std::vector make_subscribe_packet(uint16_t packet_id, const std::string& topic, uint8_t qos); + static std::vector make_unsubscribe_packet(uint16_t packet_id, const std::string& topic); + + ~OrcaMqttConnection(); + + bool start(const Config& config, MessageHandler on_message, StateHandler on_state); + void stop(); + // True while the worker thread is alive (connected OR retrying). Lets callers + // avoid restarting a healthy connection. + bool is_running() const; + // True once CONNACK has been received and the socket has not since dropped. + bool is_connected() const { return connected.load(); } + bool subscribe(const std::string& dev_id); + bool unsubscribe(const std::string& dev_id); + // Last MQTT CONNACK return code: 0 ok, 1..5 refusal, -1 none seen this attempt. + int last_connack_rc() const { return m_last_connack_rc.load(); } + void clear_subscriptions(); + bool send_request(const std::string& dev_id, const std::string& payload); + +private: + // The endpoint may be either a TLS (wss://) or a plaintext (ws://) WebSocket; + // Connection holds whichever one is engaged and the ws_* helpers below + // dispatch on it. + using TlsWebSocket = boost::beast::websocket::stream< + boost::asio::ssl::stream>; + using PlainWebSocket = boost::beast::websocket::stream; + struct Connection; + + static void append_string(std::vector& packet, const std::string& value); + static void prepend_remaining_length(std::vector& packet, size_t length); + static std::vector make_ping_packet(); + + // Transport dispatch: each forwards to conn.wss (TLS) or conn.ws (plaintext). + void ws_write(Connection& conn, const std::vector& packet); // locks write_mutex + std::size_t ws_read(Connection& conn, boost::beast::flat_buffer& buffer, boost::system::error_code& ec); + void ws_handshake(Connection& conn, const Config& config, const Endpoint& endpoint); + void ws_close(Connection& conn); + // Emit a queued SUBSCRIBE/UNSUBSCRIBE on the live socket right now (from the + // caller thread), so a selection change is applied without waiting for the + // blocking read loop to next return. No-op if no CONNACKed socket exists yet + // (the worker sends the set on connect). The WebSocket is never dropped for a + // subscription change. + void flush_subscription_change(); + void connect_and_read(); + void send_current_subscriptions(Connection& conn); + void send_pending_subscriptions(Connection& conn); + void handle_packet(const std::string& packet); + void notify_state(bool is_now_connected); + void run(); + + std::atomic_bool stopping{true}; + std::atomic_int reconnect_delay_seconds{1}; + // Serialises the whole of start() and stop() against each other, so the UI + // thread's stop() (disconnect / dtor) cannot race the connect thread's start() + // into a concurrent worker.join(). Recursive because start() calls stop(). + std::recursive_mutex lifecycle_mutex; + std::thread worker; + std::mutex mutex; + std::mutex connection_mutex; + std::mutex write_mutex; // serialises every websocket write (worker + caller threads) + std::shared_ptr active_connection; + std::condition_variable initial_cv; + std::condition_variable state_cv; + Config current_config; + MessageHandler on_message; + StateHandler on_state; + // Full report-topic strings ("device//report"), not bare device ids. + std::set subscriptions; + std::set pending_subscriptions; + std::set pending_unsubscriptions; + std::atomic next_packet_id{1}; + std::atomic m_last_connack_rc{-1}; + uint64_t m_attempt_number{0}; // worker-thread diagnostic sequence + std::string m_connection_stage; // worker-thread diagnostic stage + bool initial_result{false}; + bool initial_completed{false}; + std::atomic_bool connected{false}; +}; + +} // namespace Slic3r + +#endif // slic3r_OrcaMqttConnection_hpp_ diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 93cadd9abd..0b2c38ae2a 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -1,18 +1,362 @@ #include "OrcaPrinterAgent.hpp" #include "NetworkAgentFactory.hpp" #include "OrcaCloudServiceAgent.hpp" +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include namespace Slic3r { const std::string OrcaPrinterAgent_VERSION = "0.0.1"; -OrcaPrinterAgent::OrcaPrinterAgent(std::string log_dir) : log_dir(std::move(log_dir)) +class OrcaPrinterAgent::OrcaSonarDiscovery { +public: + using EmitFn = std::function; + + explicit OrcaSonarDiscovery(EmitFn emit) : m_emit(std::move(emit)) {} + ~OrcaSonarDiscovery() { stop(); } + + OrcaSonarDiscovery(const OrcaSonarDiscovery&) = delete; + OrcaSonarDiscovery& operator=(const OrcaSonarDiscovery&) = delete; + + void start() + { + std::lock_guard lock(m_lifecycle_mutex); + if (m_running.exchange(true)) + return; + m_thread = std::thread(&OrcaSonarDiscovery::browse_loop, this); + } + + void stop() + { + std::lock_guard lock(m_lifecycle_mutex); + if (!m_running.exchange(false)) + return; + m_wait_cv.notify_all(); + if (m_thread.joinable()) + m_thread.join(); + } + +private: + static std::string lower_ascii(std::string value) + { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; + } + + static std::string trim_ascii(const std::string& value) + { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) + return {}; + const auto last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1); + } + + static bool get_header(const std::string& datagram, const std::string& header_name, std::string& value) + { + std::size_t line_start = 0; + while (line_start < datagram.size()) { + const std::size_t line_end = datagram.find('\n', line_start); + const std::string line = datagram.substr(line_start, line_end == std::string::npos ? std::string::npos : line_end - line_start); + line_start = line_end == std::string::npos ? datagram.size() : line_end + 1; + + const std::size_t colon = line.find(':'); + if (colon == std::string::npos) + continue; + if (lower_ascii(trim_ascii(line.substr(0, colon))) != lower_ascii(header_name)) + continue; + value = trim_ascii(line.substr(colon + 1)); + return !value.empty(); + } + return false; + } + + static bool make_machine_alive_json(const std::string& usn, const std::string& host, const std::string& location, std::string& json) + { + const std::string lower_usn = lower_ascii(usn); + static const std::string uuid_prefix = "uuid:"; + static const std::string device_type = "::urn:schemas-upnp-org:device:basic:1"; + if (lower_usn.rfind(uuid_prefix, 0) != 0) + return false; + + const std::size_t type_start = lower_usn.find(device_type, uuid_prefix.size()); + if (type_start == std::string::npos) + return false; + const std::string device_id = trim_ascii(usn.substr(uuid_prefix.size(), type_start - uuid_prefix.size())); + if (device_id.empty() || host.empty()) + return false; + + const std::string lower_location = lower_ascii(location); + const std::size_t scheme_end = lower_location.find("://"); + if (scheme_end == std::string::npos) + return false; + const std::size_t authority_start = scheme_end + 3; + const std::size_t authority_end = location.find_first_of("/ ?#", authority_start); + const std::string authority = location.substr(authority_start, authority_end == std::string::npos ? + std::string::npos : + authority_end - authority_start); + std::string port = "8280"; + if (!authority.empty()) { + if (authority.front() == '[') { + const std::size_t bracket = authority.find(']'); + if (bracket != std::string::npos && bracket + 1 < authority.size() && authority[bracket + 1] == ':') + port = authority.substr(bracket + 2); + } else { + const std::size_t colon = authority.rfind(':'); + if (colon != std::string::npos && colon + 1 < authority.size()) + port = authority.substr(colon + 1); + } + } + if (port.empty() || port.find_first_not_of("0123456789") != std::string::npos) + return false; + + nlohmann::json machine; + machine["dev_name"] = device_id; + machine["dev_id"] = device_id; + machine["dev_ip"] = host + ":" + port; + machine["dev_type"] = "orcasonar"; + machine["dev_signal"] = "0"; + machine["connect_type"] = "lan"; + machine["bind_state"] = "free"; + machine["sec_link"] = "secure"; + machine["ssdp_version"] = "v1"; + machine["connection_name"] = device_id; + json = machine.dump(); + return true; + } + + void ssdp_round() + { + namespace asio = boost::asio; + using asio::ip::udp; + try { + asio::io_context io_context; + udp::socket socket(io_context); + socket.open(udp::v4()); + socket.set_option(udp::socket::reuse_address(true)); + socket.bind(udp::endpoint(udp::v4(), 0)); + socket.non_blocking(true); + + static constexpr char search_request[] = "M-SEARCH * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1900\r\n" + "MAN: \"ssdp:discover\"\r\n" + "MX: 2\r\n" + "ST: urn:schemas-upnp-org:device:Basic:1\r\n" + "\r\n"; + const auto multicast = asio::ip::make_address_v4("239.255.255.250"); + socket.send_to(asio::buffer(search_request, sizeof(search_request) - 1), udp::endpoint(multicast, 1900)); + + std::array buffer{}; + udp::endpoint sender; + std::set seen_ids; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (m_running.load() && std::chrono::steady_clock::now() < deadline) { + boost::system::error_code error; + const std::size_t received = socket.receive_from(asio::buffer(buffer), sender, 0, error); + if (!error) { + const std::string datagram(buffer.data(), received); + std::string usn; + std::string location; + std::string server; + if (get_header(datagram, "USN", usn) && get_header(datagram, "LOCATION", location) && + (!get_header(datagram, "SERVER", server) || lower_ascii(server).find("orcasonar") != std::string::npos)) { + std::string machine_alive; + if (make_machine_alive_json(usn, sender.address().to_string(), location, machine_alive)) { + nlohmann::json machine = nlohmann::json::parse(machine_alive); + const std::string device_id = machine["dev_id"].get(); + if (seen_ids.insert(device_id).second && m_emit) + m_emit(machine_alive); + } + } + } else if (error != asio::error::would_block && error != asio::error::try_again) { + BOOST_LOG_TRIVIAL(warning) << "OrcaSonarDiscovery: SSDP receive failed: " << error.message(); + break; + } else { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } + } catch (const std::exception& error) { + BOOST_LOG_TRIVIAL(warning) << "OrcaSonarDiscovery: SSDP round failed: " << error.what(); + } + } + + void browse_loop() + { + while (m_running.load()) { + ssdp_round(); + std::unique_lock lock(m_wait_mutex); + m_wait_cv.wait_for(lock, std::chrono::seconds(5), [this] { return !m_running.load(); }); + } + } + + EmitFn m_emit; + std::atomic m_running{false}; + std::thread m_thread; + std::mutex m_lifecycle_mutex; + std::mutex m_wait_mutex; + std::condition_variable m_wait_cv; +}; + +OrcaPrinterAgent::OrcaPrinterAgent(std::string log_dir) : log_dir(std::move(log_dir)) {} + +OrcaPrinterAgent::~OrcaPrinterAgent() +{ + start_discovery(false, false); + ++m_lan_generation; // fence any late worker callback + ++m_cloud_generation; + + // Drop the cloud status callback before anything else: it holds `this`, and the + // cloud agent outlives the printer agent (NetworkAgent::set_printer_agent swaps + // the printer agent while m_cloud_agents persist). + if (auto* cloud = get_orca_cloud_agent()) + cloud->set_printer_status_callback(nullptr); + + // Stop the LAN connection so the connect thread's start() returns, but keep the + // object alive until that thread is joined (the thread holds a raw conn pointer). + OrcaMqttConnection* live_lan = nullptr; + { + std::lock_guard l(state_mutex); + live_lan = lan_mqtt_connection.get(); + } + if (live_lan) + live_lan->stop(); + + // Same for the cloud per-printer connection the cloud connect thread may hold. + if (auto* cloud = get_orca_cloud_agent()) + cloud->teardown_selected_printer_mqtt(); + + if (m_lan_connect_thread.joinable()) + m_lan_connect_thread.join(); + if (m_cloud_connect_thread.joinable()) + m_cloud_connect_thread.join(); + + // stop() is not sticky: a connect thread that had not yet reached start() when the + // teardown above ran could have raised a fresh socket in between. Tear down once + // more now that both threads are joined, so no live socket survives *this. + if (auto* cloud = get_orca_cloud_agent()) + cloud->teardown_selected_printer_mqtt(); + + { + std::lock_guard l(state_mutex); + lan_mqtt_connection.reset(); + } } -OrcaPrinterAgent::~OrcaPrinterAgent() = default; +OrcaCloudServiceAgent* OrcaPrinterAgent::get_orca_cloud_agent() +{ + if (!m_cloud_agent) + return nullptr; + + return dynamic_cast(m_cloud_agent.get()); +} + +OrcaMqttConnection* OrcaPrinterAgent::get_appropriate_mqtt_connection(bool is_lan) +{ + if (is_lan) { + std::lock_guard l(state_mutex); + return lan_mqtt_connection.get(); + } + auto* cloud = get_orca_cloud_agent(); + return cloud ? cloud->get_mqtt_connection() : nullptr; +} + +void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload) +{ + OnMessageFn fn; + QueueOnMainFn q; + { + std::lock_guard l(state_mutex); + fn = on_message_fn; + q = queue_on_main_fn; + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering cloud message dev_id=" << dev_id + << " payload_bytes=" << payload.size() + << " callback=" << (fn ? "set" : "null") + << " queue_on_main=" << (q ? "set" : "null"); + if (!fn) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping cloud message because on_message_fn is not set" + << " dev_id=" << dev_id; + return; + } + if (q) + q([fn, dev_id, payload] { fn(dev_id, payload); }); + else + fn(dev_id, payload); +} + +void OrcaPrinterAgent::deliver_to_local_sink(const std::string& dev_id, const std::string& payload) +{ + OnMessageFn fn; + QueueOnMainFn q; + { + std::lock_guard l(state_mutex); + fn = on_local_message_fn; + q = queue_on_main_fn; + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering LAN message dev_id=" << dev_id + << " payload_bytes=" << payload.size() + << " callback=" << (fn ? "set" : "null") + << " queue_on_main=" << (q ? "set" : "null"); + if (!fn) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping LAN message because on_local_message_fn is not set" + << " dev_id=" << dev_id; + return; + } + if (q) + q([fn, dev_id, payload] { fn(dev_id, payload); }); + else + fn(dev_id, payload); +} + +void OrcaPrinterAgent::dispatch_local_connect(int state, const std::string& dev_id, const std::string& message) +{ + OnLocalConnectedFn callback; + QueueOnMainFn queue; + { + std::lock_guard lock(state_mutex); + callback = on_local_connect_fn; + queue = queue_on_main_fn; + } + + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connection callback state=" << state + << " dev_id=" << dev_id << " message=" << message + << " callback=" << (callback ? "set" : "null") + << " queue_on_main=" << (queue ? "set" : "null"); + if (!callback) + return; + + auto dispatch = [callback, state, dev_id, message] { callback(state, dev_id, message); }; + if (queue) + queue(dispatch); + else + dispatch(); +} + +std::function OrcaPrinterAgent::make_lan_message_handler(uint64_t generation) +{ + return [this, generation](const std::string& id, const std::string& payload) { + if (generation == m_lan_generation.load()) + deliver_to_local_sink(id, payload); + else + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: dropping stale LAN message generation=" << generation + << " current_generation=" << m_lan_generation.load() + << " dev_id=" << id; + }; +} void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud) { @@ -20,30 +364,18 @@ void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud { std::lock_guard lock(state_mutex); m_cloud_agent = cloud; - m_orca_cloud = dynamic_cast(cloud.get()); } - if (!m_orca_cloud) { + if (!get_orca_cloud_agent()) { BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_cloud_agent: cloud is not OrcaCloudServiceAgent"; - return; // BBL provider active - nothing to bridge + return; // BBL provider active - nothing to bridge } // OrcaCloudServiceAgent owns the aggregate MQTT socket; it already strips the // device//report topic and hands us (dev_id, raw_json). Forward to the // standard sink. message_arrive_fn self-marshals to the UI thread via CallAfter, // so being called from the MQTT worker thread is fine. - const int callback_result = m_orca_cloud->set_printer_status_callback([this](std::string dev_id, std::string payload) { - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: received cloud status dev_id=" << dev_id - << " payload_bytes=" << payload.size(); - OnMessageFn fn; - { - std::lock_guard lock(state_mutex); - fn = on_message_fn; - } - if (fn) - fn(std::move(dev_id), std::move(payload)); - else - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: cloud status has no registered on_message callback"; - }); + const int callback_result = get_orca_cloud_agent()->set_printer_status_callback( + [this](std::string dev_id, std::string payload) { deliver_to_sink(dev_id, payload); }); BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_cloud_agent: status callback result=" << callback_result; } @@ -51,73 +383,270 @@ void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud // Communication - All Stubs // ============================================================================ -int OrcaPrinterAgent::send_message(std::string dev_id, std::string json_str, int qos, int flag) +int OrcaPrinterAgent::send_message(std::string dev_id, std::string json_str, int /*qos*/, int /*flag*/) +{ return route_send(/*is_lan=*/false, dev_id, json_str); } + +bool OrcaPrinterAgent::parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port) { - (void) qos; - (void) flag; // MQTT concepts; N/A for the REST command endpoint - - std::shared_ptr cloud; - { - std::lock_guard lock(state_mutex); - cloud = m_cloud_agent; - } - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::send_message: dev_id=" << dev_id - << " payload_bytes=" << json_str.size() << " qos=" << qos << " flag=" << flag - << " cloud=" << (cloud ? cloud->get_id() : ""); - if (!cloud || dev_id.empty()) { - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::send_message: rejected due to missing cloud or device ID"; - return BAMBU_NETWORK_ERR_INVALID_HANDLE; + std::string s = dev_ip; + if (s.rfind("http://", 0) == 0) + s.erase(0, 7); + else if (s.rfind("https://", 0) == 0) + s.erase(0, 8); + if (const auto slash = s.find('/'); slash != std::string::npos) + s.erase(slash); + if (s.empty()) + return false; + port = "8280"; + // split a trailing :port only for host:port, not an unbracketed IPv6 literal + if (const auto colon = s.rfind(':'); colon != std::string::npos && s.find(']') == std::string::npos) { + port = s.substr(colon + 1); + s.erase(colon); } + if (s.empty() || port.empty()) + return false; + host = s; + return true; +} - // Detached worker so the UI thread is never blocked on HTTP. Capture a shared_ptr - // copy (keeps the cloud agent alive) - never `this`. - std::thread([cloud, dev_id, body = std::move(json_str)]() { - if (auto* orca = dynamic_cast(cloud.get())) { - const int result = orca->send_printer_command(dev_id, body); - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::send_message: cloud command result=" << result - << " dev_id=" << dev_id; - } else { - BOOST_LOG_TRIVIAL(error) << "OrcaPrinterAgent::send_message: cloud agent is not OrcaCloudServiceAgent"; - } - }).detach(); +std::string OrcaPrinterAgent::make_lan_client_id(const std::string& dev_id) +{ + static const std::string suffix = [] { + std::random_device rd; + char buf[9]; + std::snprintf(buf, sizeof(buf), "%08x", static_cast(rd())); + return std::string(buf); + }(); + return "orcaslicer-lan-" + dev_id + "-" + suffix; +} - return BAMBU_NETWORK_SUCCESS; +std::string OrcaPrinterAgent::lan_connection_target() const +{ + std::lock_guard l(state_mutex); + return m_lan_url; +} + +std::string OrcaPrinterAgent::seq(int n) { return std::to_string(20000 + (n % 10000)); } + +std::string OrcaPrinterAgent::build_pushing_start(const std::string& sid) +{ return R"({"pushing":{"command":"start","sequence_id":")" + sid + R"("}})"; } +std::string OrcaPrinterAgent::build_pushing_stop(const std::string& sid) +{ return R"({"pushing":{"command":"stop","sequence_id":")" + sid + R"("}})"; } +std::string OrcaPrinterAgent::build_pushall(const std::string& sid) +{ return R"({"pushing":{"command":"pushall","sequence_id":")" + sid + R"(","version":1,"push_target":1}})"; } +std::string OrcaPrinterAgent::build_get_version(const std::string& sid) +{ return R"({"info":{"command":"get_version","sequence_id":")" + sid + R"("}})"; } +std::string OrcaPrinterAgent::build_get_capabilities(const std::string& sid) +{ return R"({"info":{"command":"get_capabilities","sequence_id":")" + sid + R"("}})"; } + +void OrcaPrinterAgent::emit_connect_sequence(const std::string& dev_id, + std::function subscribe, + std::function request) +{ + subscribe(dev_id); + request(build_pushing_start(seq(1))); + request(build_pushall(seq(2))); + request(build_get_version(seq(3))); + request(build_get_capabilities(seq(4))); +} + +void OrcaPrinterAgent::on_connected(const std::string& dev_id, OrcaMqttConnection* conn, uint64_t generation) +{ + // Called from both connect paths with whichever epoch that path captured; the two + // counters are independent, so matching either one means the caller is still live. + if (!conn || (generation != m_lan_generation.load() && generation != m_cloud_generation.load())) + return; + emit_connect_sequence( + dev_id, [conn](const std::string& id) { conn->subscribe(id); }, + [conn, dev_id](const std::string& body) { conn->send_request(dev_id, body); }); // dev_id captured BY VALUE } int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: connect_printer requested dev_id=" << dev_id + << " dev_ip=" << dev_ip << " username=" << (username.empty() ? "" : username) + << " password_present=" << (!password.empty()) << " use_ssl=" << use_ssl; + (void) use_ssl; // OrcaSonar LAN is plaintext ws:// + if (dev_id.empty() || dev_ip.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: connect_printer rejected missing dev_id or dev_ip"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + std::string host, port; + if (!parse_lan_endpoint(dev_ip, host, port)) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: connect_printer rejected unparsable LAN endpoint dev_ip=" << dev_ip; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + disconnect_printer(); + const uint64_t gen = ++m_lan_generation; + + OrcaMqttConnection::Config cfg; + cfg.url = "ws://" + host + ":" + port + "/mqtt"; + cfg.use_tls = false; + cfg.username = username.empty() ? std::string("orcasonar") : username; + cfg.password = password; + cfg.client_id = make_lan_client_id(dev_id); + cfg.keepalive_seconds = 60; + + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connection prepared generation=" << gen + << " host=" << host << " port=" << port << " url=" << cfg.url + << " mqtt_username=" << cfg.username << " password_present=" << (!cfg.password.empty()) + << " client_id=" << cfg.client_id; + + OrcaMqttConnection* conn = nullptr; + { + std::lock_guard l(state_mutex); + m_lan_dev_id = dev_id; + m_lan_url = cfg.url; + m_current_connection = LAN; + lan_mqtt_connection = std::make_unique(); + conn = lan_mqtt_connection.get(); + } + + if (m_lan_connect_thread.joinable()) + m_lan_connect_thread.join(); // disconnect_printer() above already stopped the old conn, so this is fast + m_lan_connect_thread = std::thread([this, conn, cfg, dev_id, gen] { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connect worker started generation=" << gen + << " dev_id=" << dev_id << " url=" << cfg.url; + if (gen != m_lan_generation.load()) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connect worker abandoned before start generation=" << gen + << " current_generation=" << m_lan_generation.load(); + return; // superseded before we ran: never raise a socket nobody will tear down + } + const bool ok = conn->start(cfg, make_lan_message_handler(gen), [this, gen, dev_id, conn](bool connected, bool initial) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN MQTT state callback connected=" << connected + << " initial=" << initial << " generation=" << gen + << " current_generation=" << m_lan_generation.load() + << " connack_rc=" << conn->last_connack_rc(); + if (gen != m_lan_generation.load()) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: ignoring stale LAN MQTT state callback generation=" << gen; + return; + } + if (connected && !initial) { + on_connected(dev_id, conn, gen); + dispatch_local_connect(ConnectStatusOk, dev_id, "0"); + } else if (!connected && !initial) { + dispatch_local_connect(ConnectStatusLost, dev_id, "connection_lost"); + } + }); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN MQTT start returned ok=" << ok + << " generation=" << gen << " current_generation=" << m_lan_generation.load() + << " connected=" << conn->is_connected() << " running=" << conn->is_running() + << " connack_rc=" << conn->last_connack_rc(); + if (ok && gen == m_lan_generation.load()) { + on_connected(dev_id, conn, gen); + dispatch_local_connect(ConnectStatusOk, dev_id, "0"); + } else if (!ok && gen == m_lan_generation.load() && !conn->is_running()) { + // A refusal with rc 4/5 terminates the transport. Network errors keep + // retrying in OrcaMqttConnection, so leave the UI in its connecting state. + const int rc = conn->last_connack_rc(); + const std::string reason = rc >= 0 ? std::to_string(rc) : "initial_connect_failed"; + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: LAN MQTT connection terminated before readiness" + << " generation=" << gen << " connack_rc=" << rc + << " reason=" << reason; + dispatch_local_connect(ConnectStatusFailed, dev_id, reason); + } else if (!ok && gen == m_lan_generation.load()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: LAN MQTT initial attempt failed but worker is retrying" + << " generation=" << gen << " connack_rc=" << conn->last_connack_rc(); + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connect worker exiting generation=" << gen + << " current_generation=" << m_lan_generation.load(); + }); + return BAMBU_NETWORK_SUCCESS; } int OrcaPrinterAgent::disconnect_printer() { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: disconnect_printer requested"; + ++m_lan_generation; // fence stale worker callbacks + std::unique_ptr doomed; + std::string prev_dev; + { + std::lock_guard l(state_mutex); + doomed = std::move(lan_mqtt_connection); + prev_dev = m_lan_dev_id; + m_lan_dev_id.clear(); + if (m_current_connection == LAN) + m_current_connection = NONE; + } + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN disconnect generation=" << m_lan_generation.load() + << " previous_dev_id=" << prev_dev << " had_connection=" << (doomed ? "yes" : "no") + << " connected=" << (doomed && doomed->is_connected() ? "yes" : "no"); + // Tell the printer to stop pushing and drop the report topic before the socket + // goes away (§3.2/§5.4: deselect issues pushing.stop on both transports). + if (doomed && !prev_dev.empty() && doomed->is_connected()) { + doomed->send_request(prev_dev, build_pushing_stop(seq(5))); + doomed->unsubscribe(prev_dev); + } + if (doomed) + doomed->stop(); // joins the OrcaMqttConnection worker; OUTSIDE state_mutex + if (m_lan_connect_thread.joinable()) + m_lan_connect_thread.join(); // start() has returned (doomed->stop above); the thread's raw conn ptr + // is still valid here because `doomed` is not destroyed until we return return BAMBU_NETWORK_SUCCESS; } -int OrcaPrinterAgent::send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) +int OrcaPrinterAgent::send_message_to_printer(std::string dev_id, std::string json_str, int /*qos*/, int /*flag*/) +{ return route_send(/*is_lan=*/true, dev_id, json_str); } + +int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const std::string& json_str) { - return BAMBU_NETWORK_SUCCESS; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send is_lan=" << is_lan << " dev_id=" << dev_id + << " payload_bytes=" << json_str.size(); + if (dev_id.empty()) + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + OrcaMqttConnection* conn = get_appropriate_mqtt_connection(is_lan); + if (!conn) + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + return conn->send_request(dev_id, json_str) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; } // ============================================================================ // Certificates - All Stubs // ============================================================================ -int OrcaPrinterAgent::check_cert() -{ - return BAMBU_NETWORK_SUCCESS; -} +int OrcaPrinterAgent::check_cert() { return BAMBU_NETWORK_SUCCESS; } -void OrcaPrinterAgent::install_device_cert(std::string dev_id, bool lan_only) -{ -} +void OrcaPrinterAgent::install_device_cert(std::string dev_id, bool lan_only) {} // ============================================================================ -// Discovery - Stub +// Discovery // ============================================================================ -bool OrcaPrinterAgent::start_discovery(bool start, bool sending) +bool OrcaPrinterAgent::start_discovery(bool start, bool /*sending*/) { + if (start) { + std::lock_guard lock(state_mutex); + if (!m_discovery) { + m_discovery = std::make_unique([this](const std::string& machine_alive) { + OnMsgArrivedFn ssdp_fn; + QueueOnMainFn queue_fn; + { + std::lock_guard callback_lock(state_mutex); + ssdp_fn = on_ssdp_msg_fn; + queue_fn = queue_on_main_fn; + } + if (!ssdp_fn) + return; + if (queue_fn) + queue_fn([ssdp_fn, machine_alive] { ssdp_fn(machine_alive); }); + else + ssdp_fn(machine_alive); + }); + } + m_discovery->start(); + return true; + } + + // The discovery thread invokes the callback, which takes state_mutex. Move the + // owner out first, then join without holding that mutex. + std::unique_ptr discovery; + { + std::lock_guard lock(state_mutex); + discovery = std::move(m_discovery); + } + if (discovery) + discovery->stop(); return true; } @@ -125,26 +654,20 @@ bool OrcaPrinterAgent::start_discovery(bool start, bool sending) // Binding - All Stubs // ============================================================================ -int OrcaPrinterAgent::ping_bind(std::string ping_code) -{ - return BAMBU_NETWORK_SUCCESS; -} +int OrcaPrinterAgent::ping_bind(std::string ping_code) { return BAMBU_NETWORK_SUCCESS; } -int OrcaPrinterAgent::bind_detect(std::string dev_ip, std::string sec_link, detectResult& detect) -{ - return BAMBU_NETWORK_SUCCESS; -} +int OrcaPrinterAgent::bind_detect(std::string dev_ip, std::string sec_link, detectResult& detect) { return BAMBU_NETWORK_SUCCESS; } -int OrcaPrinterAgent::bind( - std::string dev_ip, std::string dev_id, std::string dev_model, std::string sec_link, std::string timezone, bool improved, OnUpdateStatusFn update_fn) -{ - return BAMBU_NETWORK_SUCCESS; -} +int OrcaPrinterAgent::bind(std::string dev_ip, + std::string dev_id, + std::string dev_model, + std::string sec_link, + std::string timezone, + bool improved, + OnUpdateStatusFn update_fn) +{ return BAMBU_NETWORK_SUCCESS; } -int OrcaPrinterAgent::unbind(std::string dev_id) -{ - return BAMBU_NETWORK_SUCCESS; -} +int OrcaPrinterAgent::unbind(std::string dev_id) { return BAMBU_NETWORK_SUCCESS; } int OrcaPrinterAgent::request_bind_ticket(std::string* ticket) { @@ -181,7 +704,7 @@ std::string OrcaPrinterAgent::get_user_selected_machine() int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) { - std::shared_ptr cloud; + auto* cloud = get_orca_cloud_agent(); std::string previous; { std::lock_guard lock(state_mutex); @@ -191,97 +714,73 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) } previous = selected_machine; selected_machine = dev_id; - cloud = m_cloud_agent; } - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: previous=" << previous - << " new=" << dev_id << " cloud=" << (cloud ? cloud->get_id() : ""); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: previous=" << previous << " new=" << dev_id + << " cloud=" << (cloud ? "set" : ""); if (!cloud) { - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_user_selected_machine: no cloud agent"; + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_user_selected_machine: no Orca cloud agent"; return BAMBU_NETWORK_SUCCESS; } - // One report topic at a time. add_subscribe/del_subscribe only mutate a set and - // wake the MQTT worker, so they are safe to call synchronously on the UI thread. - if (!previous.empty()) { - const int result = cloud->del_subscribe({previous}); - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: unsubscribe dev_id=" << previous - << " result=" << result; - } - if (!dev_id.empty()) { - const int result = cloud->add_subscribe({dev_id}); - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: subscribe dev_id=" << dev_id - << " result=" << result; - // Relay retains nothing: ask the printer for a full snapshot. Async inside - // send_message; returns immediately. - send_message(dev_id, - R"({"pushing":{"command":"pushall","sequence_id":"20001","version":1,"push_target":1}})", - 0, 0); - deliver_mock_get_version(dev_id); - } - return BAMBU_NETWORK_SUCCESS; -} + // Bump ONCE at the top for any change (select or deselect) so a deselect also + // fences an in-flight configure thread started by the previous selection. This is + // the CLOUD epoch only — a cloud selection must not fence a live LAN session. + const uint64_t gen = ++m_cloud_generation; + + auto* conn = cloud->get_mqtt_connection(); + if (!previous.empty() && conn && conn->is_connected()) + conn->send_request(previous, build_pushing_stop(seq(5))); + cloud->teardown_selected_printer_mqtt(); -void OrcaPrinterAgent::deliver_mock_get_version(const std::string& dev_id) -{ - // The printer would answer an info.get_version request with its firmware/module - // list; OrcaCloud does not relay that yet, so MachineObject::module_vers stays - // empty and is_info_ready(check_version) never passes (StatusPanel bails, every - // field renders N/A). Synthesize the reply and push it through the same sink as - // real report messages so parse_json handles it identically. Remove once the - // backend answers info.get_version on device//report. - OnMessageFn fn; { std::lock_guard lock(state_mutex); - fn = on_message_fn; + m_current_connection = dev_id.empty() ? NONE : CLOUD; } - if (!fn) - return; - static const std::string kMockGetVersion = - R"({"info":{"command":"get_version","sequence_id":"0","module":[)" - R"({"name":"ota","product_name":"OrcaCloud Printer","hw_ver":"","sw_ver":"01.00.00.00","sn":""}]}})"; - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: delivering mock info.get_version for dev_id=" << dev_id; - fn(dev_id, kMockGetVersion); + + if (m_cloud_connect_thread.joinable()) + m_cloud_connect_thread.join(); // teardown_selected_printer_mqtt() above stopped the old cloud conn + if (!dev_id.empty()) { + m_cloud_connect_thread = std::thread([this, cloud, dev_id, gen] { + if (gen != m_cloud_generation.load()) + return; // superseded before we ran: do not raise a socket nobody tears down + if (cloud->configure_selected_printer_mqtt(dev_id) == BAMBU_NETWORK_SUCCESS && gen == m_cloud_generation.load()) + on_connected(dev_id, cloud->get_mqtt_connection(), gen); + }); + } else { + // Deselect: the joined thread may have raised a fresh socket between the + // teardown above and the join. stop() is not sticky, so tear down again. + cloud->teardown_selected_printer_mqtt(); + } + return BAMBU_NETWORK_SUCCESS; } // ============================================================================ // Agent Information // ============================================================================ AgentInfo OrcaPrinterAgent::get_agent_info_static() -{ - return AgentInfo{ORCA_PRINTER_AGENT_ID, "Orca", OrcaPrinterAgent_VERSION, "Orca Printer Communication Protocol Agent"}; -} +{ return AgentInfo{ORCA_PRINTER_AGENT_ID, "Orca", OrcaPrinterAgent_VERSION, "Orca Printer Communication Protocol Agent"}; } // ============================================================================ // Print Job Operations - All Stubs // ============================================================================ int OrcaPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) -{ - return BAMBU_NETWORK_SUCCESS; -} +{ return BAMBU_NETWORK_SUCCESS; } -int OrcaPrinterAgent::start_local_print_with_record(PrintParams params, +int OrcaPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, - WasCancelledFn cancel_fn, - OnWaitFn wait_fn) -{ - return BAMBU_NETWORK_SUCCESS; -} + WasCancelledFn cancel_fn, + OnWaitFn wait_fn) +{ return BAMBU_NETWORK_SUCCESS; } int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) -{ - return BAMBU_NETWORK_SUCCESS; -} +{ return BAMBU_NETWORK_SUCCESS; } int OrcaPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) -{ - return BAMBU_NETWORK_SUCCESS; -} +{ return BAMBU_NETWORK_SUCCESS; } int OrcaPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) -{ - return BAMBU_NETWORK_SUCCESS; -} +{ return BAMBU_NETWORK_SUCCESS; } // ============================================================================ // Callback Registration @@ -327,6 +826,7 @@ int OrcaPrinterAgent::set_on_local_connect_fn(OnLocalConnectedFn fn) { std::lock_guard lock(state_mutex); on_local_connect_fn = fn; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_on_local_connect_fn: callback=" << (fn ? "set" : "clear"); return BAMBU_NETWORK_SUCCESS; } @@ -334,6 +834,7 @@ int OrcaPrinterAgent::set_on_local_message_fn(OnMessageFn fn) { std::lock_guard lock(state_mutex); on_local_message_fn = fn; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_on_local_message_fn: callback=" << (fn ? "set" : "clear"); return BAMBU_NETWORK_SUCCESS; } diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index 9cf2b29638..666fd26fe3 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -3,9 +3,15 @@ #include "IPrinterAgent.hpp" #include "ICloudServiceAgent.hpp" +#include "OrcaCloudServiceAgent.hpp" +#include "OrcaMqttConnection.hpp" +#include +#include +#include #include #include #include +#include namespace Slic3r { @@ -79,16 +85,95 @@ public: int set_on_local_message_fn(OnMessageFn fn) override; int set_queue_on_main_fn(QueueOnMainFn fn) override; + // Test-only: drive emit_connect_sequence directly (no socket). + void run_connect_sequence_for_test(const std::string& dev_id) { + emit_connect_sequence(dev_id, [](const std::string&){}, [](const std::string&){}); + } + + // Test-only: advance the LAN connection epoch without a connect/disconnect cycle. + void bump_lan_generation_for_test() { ++m_lan_generation; } + // Test-only: the same for the (independent) cloud selection epoch. + void bump_cloud_generation_for_test() { ++m_cloud_generation; } + +protected: + // Forward one inbound printer message to on_message_fn (marshalled onto the UI + // thread via queue_on_main_fn when set). Body of every connection's MessageHandler. + void deliver_to_sink(const std::string& dev_id, const std::string& payload); + + // LAN has a separate callback in the existing IPrinterAgent contract because the + // GUI parses local reports with the "lan" dialect and looks up local machines. + void deliver_to_local_sink(const std::string& dev_id, const std::string& payload); + + // Report the asynchronous LAN connection state using the same callback contract as + // the other printer agents. The transport result cannot be returned by + // connect_printer(), which only starts the worker. + void dispatch_local_connect(int state, const std::string& dev_id, const std::string& message); + + // The LAN inbound-message handler for one connection generation: forwards to + // deliver_to_sink only while `generation` is still the live epoch. + std::function make_lan_message_handler(uint64_t generation); + + // Pure LAN-address parsing + client-id. protected static so the test Probe reaches them. + static bool parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port); + static std::string make_lan_client_id(const std::string& dev_id); + // Test hook: the ws:// URL connect_printer built for the current LAN session ("" if none). + std::string lan_connection_target() const; + // Shared post-connect sequence: SUBSCRIBE, then pushing.start, pushall, + // info.get_version, info.get_capabilities. Runs identically on LAN and cloud. + void on_connected(const std::string& dev_id, OrcaMqttConnection* conn, uint64_t generation); + + // The post-connect command sequence, factored behind a seam so a test can + // observe the SUBSCRIBE + 4 request payloads without a live OrcaMqttConnection. + virtual void emit_connect_sequence(const std::string& dev_id, + std::function subscribe, + std::function request); + static std::string seq(int n); // decimal string in the OrcaSlicer 20000..29999 band + static std::string build_pushing_start(const std::string& sequence_id); + static std::string build_pushing_stop(const std::string& sequence_id); + static std::string build_pushall(const std::string& sequence_id); + static std::string build_get_version(const std::string& sequence_id); + static std::string build_get_capabilities(const std::string& sequence_id); + private: + class OrcaSonarDiscovery; + std::string log_dir; std::string selected_machine; - std::shared_ptr m_cloud_agent; - OrcaCloudServiceAgent* m_orca_cloud = nullptr; // == m_cloud_agent.get() when the Orca provider is active - // MOCK: OrcaCloud does not yet relay the printer's info.get_version reply, so - // synthesize it and feed it through on_message_fn (same sink as real report - // messages). Delete once the backend answers info.get_version. - void deliver_mock_get_version(const std::string& dev_id); + enum CurrentConn { + NONE, + CLOUD, + LAN + }; + CurrentConn m_current_connection = NONE; + + std::shared_ptr m_cloud_agent; + std::unique_ptr lan_mqtt_connection; + + // Two independent epochs: a cloud (de)selection must not fence the live LAN + // feed, and vice versa. Each transport's connect thread and inbound handler + // compare against their own counter only. + std::atomic m_lan_generation{0}; + std::atomic m_cloud_generation{0}; + + // The short-lived threads that run the blocking initial connect for the current + // LAN / cloud session. Joined members (never detached) so they cannot outlive + // *this or the connection they hold a raw pointer to. + std::thread m_lan_connect_thread; + std::thread m_cloud_connect_thread; + + std::unique_ptr m_discovery; + + std::string m_lan_dev_id; // guarded by state_mutex + std::string m_lan_url; // guarded by state_mutex — the Config.url of the live LAN session + + OrcaCloudServiceAgent* get_orca_cloud_agent(); + + OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan = true); + + // Route one command payload to device//request on the LAN or the cloud + // per-printer connection. The uniform send path for both send_message* overrides. + int route_send(bool is_lan, const std::string& dev_id, const std::string& json_str); // Callbacks OnMsgArrivedFn on_ssdp_msg_fn; diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index edff97ff42..6b39c010b9 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -13,6 +13,8 @@ add_executable(${_TEST_NAME}_tests test_plugin_status.cpp test_printer_agent.cpp test_qidi_printer_agent.cpp + test_orca_mqtt_connection.cpp + test_orca_printer_agent.cpp test_plugin_install.cpp test_plugin_lifecycle.cpp test_slicing_pipeline_bindings.cpp diff --git a/tests/slic3rutils/orca_mqtt_mock_broker.hpp b/tests/slic3rutils/orca_mqtt_mock_broker.hpp new file mode 100644 index 0000000000..8545a4344f --- /dev/null +++ b/tests/slic3rutils/orca_mqtt_mock_broker.hpp @@ -0,0 +1,317 @@ +#pragma once + +// In-process plaintext MQTT-over-WebSocket broker for the OrcaMqtt tests. +// +// It speaks just enough of MQTT 3.1.1 to drive OrcaMqttConnection / +// OrcaPrinterAgent end to end without a real network: CONNECT/CONNACK, +// SUBSCRIBE/SUBACK, UNSUBSCRIBE/UNSUBACK, client PUBLISH (QoS 0), PINGREQ and +// DISCONNECT. The outbound PUBLISH frame is built with the production +// OrcaMqttConnection::make_publish_packet() so the tests never depend on a +// second, hand-rolled MQTT encoder. + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace orca_mqtt_test { + +namespace net = boost::asio; +namespace beast = boost::beast; +namespace ws = boost::beast::websocket; +using tcp = boost::asio::ip::tcp; + +// Decode an MQTT remaining-length varint starting at packet[offset]. +// Returns {value, bytes_consumed}; bytes_consumed == 0 means malformed. +inline std::pair mqtt_decode_remaining_length(const std::string& packet, std::size_t offset) +{ + std::size_t value = 0; + std::size_t multiplier = 1; + std::size_t used = 0; + while (offset + used < packet.size() && used < 4) { + const std::uint8_t byte = static_cast(packet[offset + used]); + value += static_cast(byte & 0x7f) * multiplier; + multiplier *= 128; + ++used; + if ((byte & 0x80) == 0) + return {value, used}; + } + return {0, 0}; +} + +inline bool mqtt_topic_is_request(const std::string& topic) +{ + static const std::string suffix = "/request"; + return topic.size() >= suffix.size() && + topic.compare(topic.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +class MockBroker +{ +public: + // refuse_auth: answer every CONNECT with CONNACK rc 5 (not authorized) and + // close, so the reconnect/refusal paths can be exercised. + explicit MockBroker(bool refuse_auth = false) : m_refuse_auth(refuse_auth), m_acceptor(m_io) + { + const tcp::endpoint endpoint(net::ip::make_address("127.0.0.1"), 0); + m_acceptor.open(endpoint.protocol()); + m_acceptor.set_option(net::socket_base::reuse_address(true)); + m_acceptor.bind(endpoint); + m_acceptor.listen(net::socket_base::max_listen_connections); + m_port = std::to_string(m_acceptor.local_endpoint().port()); + // why: a non-blocking acceptor lets the accept loop poll a stop flag, so + // the destructor never has to interrupt a blocking accept(). + m_acceptor.non_blocking(true); + m_thread = std::thread([this] { run(); }); + } + + ~MockBroker() + { + m_stopping.store(true); + drop_client(); // unblocks the worker's blocking read + if (m_thread.joinable()) + m_thread.join(); + boost::system::error_code ec; + m_acceptor.close(ec); // after join: the acceptor is worker-owned + m_io.stop(); + } + + MockBroker(const MockBroker&) = delete; + MockBroker& operator=(const MockBroker&) = delete; + + std::string ws_url() const { return "ws://127.0.0.1:" + m_port + "/mqtt"; } + + std::pair host_port() const { return {std::string("127.0.0.1"), m_port}; } + + // Server -> client PUBLISH on device//report. + void push_report(const std::string& dev_id, const std::string& payload) + { + const std::vector packet = + Slic3r::OrcaMqttConnection::make_publish_packet("device/" + dev_id + "/report", payload); + std::lock_guard lock(m_mutex); + if (!m_stream || !m_stream_ready) + return; + boost::system::error_code ec; + m_stream->binary(true); + m_stream->write(net::buffer(packet), ec); // a vanished client is not a test failure + } + + // Force-close the live client socket; the worker's read returns an error and + // the accept loop picks up the client's reconnect. + void drop_client() + { + std::lock_guard lock(m_mutex); + close_client_locked(); + } + + // Payloads the client PUBLISHed to any device//request topic. + std::vector received_requests() const + { + std::lock_guard lock(m_mutex); + return m_received_requests; + } + + // MQTT CONNECTs seen; increments again after a reconnect. + int connect_count() const { return m_connect_count.load(); } + +private: + void run() + { + try { + while (!m_stopping.load()) { + tcp::socket socket(m_io); + boost::system::error_code ec; + m_acceptor.accept(socket, ec); + if (ec == net::error::would_block || ec == net::error::try_again) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + continue; + } + if (ec) + return; + try { + serve(std::move(socket)); + } catch (...) { + // a client dying mid-session must not take the broker down + } + std::lock_guard lock(m_mutex); + close_client_locked(); + m_stream.reset(); + } + } catch (...) { + // never let an exception escape the broker thread + } + } + + void serve(tcp::socket socket) + { + ws::stream* stream = nullptr; + { + std::lock_guard lock(m_mutex); + m_stream.emplace(std::move(socket)); + m_stream_ready = false; + stream = &*m_stream; + } + // why: no io_context is ever run here, so a tcp_stream timer would never + // fire; the sync operations below carry no timeout of their own. + beast::get_lowest_layer(*stream).expires_never(); + stream->set_option(ws::stream_base::decorator( + [](ws::response_type& res) { res.set("Sec-WebSocket-Protocol", "mqtt"); })); + + boost::system::error_code ec; + stream->accept(ec); + if (ec) + return; + stream->binary(true); + { + std::lock_guard lock(m_mutex); + m_stream_ready = true; + } + read_loop(*stream); + } + + // The client sends every MQTT packet as one binary WebSocket message, so one + // read yields exactly one packet. + void read_loop(ws::stream& stream) + { + beast::flat_buffer buffer; + while (!m_stopping.load()) { + boost::system::error_code ec; + buffer.clear(); + stream.read(buffer, ec); + if (ec) + return; + const std::string packet = beast::buffers_to_string(buffer.data()); + if (packet.empty()) + continue; + if (!handle_packet(stream, packet)) + return; + } + } + + // Returns false when the session must be closed. + bool handle_packet(ws::stream& stream, const std::string& packet) + { + switch (static_cast(packet[0]) & 0xf0) { + case 0x10: { // CONNECT + ++m_connect_count; + if (m_refuse_auth) { + write_packet(stream, {0x20, 0x02, 0x00, 0x05}); // CONNACK not authorized + return false; + } + write_packet(stream, {0x20, 0x02, 0x00, 0x00}); // CONNACK accepted + return true; + } + case 0x80: { // SUBSCRIBE (0x82) - packet id follows the remaining-length varint + const auto id = packet_id(packet); + if (id) + write_packet(stream, {0x90, 0x03, id->first, id->second, 0x00}); // SUBACK, QoS 0 + return true; + } + case 0xa0: { // UNSUBSCRIBE (0xa2) + const auto id = packet_id(packet); + if (id) + write_packet(stream, {0xb0, 0x02, id->first, id->second}); // UNSUBACK + return true; + } + case 0x30: { // PUBLISH, QoS 0 (no packet identifier) + record_publish(packet); + return true; + } + case 0xc0: // PINGREQ + write_packet(stream, {0xd0, 0x00}); + return true; + case 0xe0: // DISCONNECT + return false; + default: + return true; + } + } + + // The two packet-identifier bytes sitting right after the remaining-length varint. + static std::optional> packet_id(const std::string& packet) + { + const auto varint = mqtt_decode_remaining_length(packet, 1); + if (varint.second == 0) + return std::nullopt; + const std::size_t pos = 1 + varint.second; + if (pos + 2 > packet.size()) + return std::nullopt; + return std::make_pair(static_cast(packet[pos]), static_cast(packet[pos + 1])); + } + + void record_publish(const std::string& packet) + { + const auto varint = mqtt_decode_remaining_length(packet, 1); + if (varint.second == 0) + return; + std::size_t pos = 1 + varint.second; + if (pos + 2 > packet.size()) + return; + const std::size_t topic_len = (static_cast(static_cast(packet[pos])) << 8) | + static_cast(packet[pos + 1]); + pos += 2; + if (pos + topic_len > packet.size()) + return; + const std::string topic = packet.substr(pos, topic_len); + pos += topic_len; + const std::size_t end = std::min(packet.size(), 1 + varint.second + varint.first); + if (end < pos) + return; + if (!mqtt_topic_is_request(topic)) + return; + std::lock_guard lock(m_mutex); + m_received_requests.push_back(packet.substr(pos, end - pos)); + } + + // Every write - the worker's own replies and push_report() from the test + // thread - is serialised by m_mutex. beast permits a writer while the worker + // is blocked in read(), which is the same arrangement OrcaMqttConnection uses. + void write_packet(ws::stream& stream, const std::vector& packet) + { + std::lock_guard lock(m_mutex); + boost::system::error_code ec; + stream.binary(true); + stream.write(net::buffer(packet), ec); + } + + void close_client_locked() + { + if (!m_stream) + return; + m_stream_ready = false; + boost::system::error_code ec; + auto& socket = beast::get_lowest_layer(*m_stream).socket(); + socket.cancel(ec); + // shutdown() before close() is what actually wakes a blocking read on the + // worker thread; close() alone does not on POSIX. + socket.shutdown(tcp::socket::shutdown_both, ec); + socket.close(ec); + } + + const bool m_refuse_auth; + net::io_context m_io; + tcp::acceptor m_acceptor; + std::string m_port; + std::thread m_thread; + std::atomic_bool m_stopping{false}; + std::atomic m_connect_count{0}; + mutable std::mutex m_mutex; + std::optional> m_stream; // guarded by m_mutex + bool m_stream_ready = false; // guarded by m_mutex + std::vector m_received_requests; // guarded by m_mutex +}; + +} // namespace orca_mqtt_test diff --git a/tests/slic3rutils/test_orca_mqtt_connection.cpp b/tests/slic3rutils/test_orca_mqtt_connection.cpp new file mode 100644 index 0000000000..60c4e9303b --- /dev/null +++ b/tests/slic3rutils/test_orca_mqtt_connection.cpp @@ -0,0 +1,229 @@ +#include +#include + +#include "orca_mqtt_mock_broker.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +using Slic3r::OrcaMqttConnection; + +// Offset of the CONNECT variable header: 1 (fixed header) + N remaining-length varint bytes. +static size_t mqtt_varheader_offset(const std::vector& p) { + size_t i = 1; + while (i < p.size() && (p[i] & 0x80)) ++i; // skip varint continuation bytes + return i + 1; // + the final varint byte +} + +TEST_CASE("OrcaMqtt parse_endpoint handles ws and wss", "[OrcaMqtt]") { + OrcaMqttConnection::Endpoint ep; + + REQUIRE(OrcaMqttConnection::parse_endpoint("ws://printer.local:8280/mqtt", ep)); + CHECK(ep.host == "printer.local"); + CHECK(ep.port == "8280"); + CHECK(ep.target == "/mqtt"); + + REQUIRE(OrcaMqttConnection::parse_endpoint("ws://10.0.0.5/mqtt", ep)); + CHECK(ep.port == "80"); + + REQUIRE(OrcaMqttConnection::parse_endpoint("wss://api.example.com/api/v1/printers/abc/mqtt", ep)); + CHECK(ep.host == "api.example.com"); + CHECK(ep.port == "443"); + CHECK(ep.target == "/api/v1/printers/abc/mqtt"); + + CHECK_FALSE(OrcaMqttConnection::parse_endpoint("http://x/y", ep)); +} + +TEST_CASE("OrcaMqtt CONNECT packet - no auth (cloud form)", "[OrcaMqtt]") { + auto p = OrcaMqttConnection::make_connect_packet("OrcaSlicer", "", "", 300); + REQUIRE(p.size() >= 12); + CHECK(p[0] == 0x10); // CONNECT fixed header + const size_t v = mqtt_varheader_offset(p); + CHECK(p[v + 0] == 0x00); CHECK(p[v + 1] == 0x04); // protocol name length + CHECK(p[v + 2] == 'M'); CHECK(p[v + 3] == 'Q'); + CHECK(p[v + 4] == 'T'); CHECK(p[v + 5] == 'T'); + CHECK(p[v + 6] == 0x04); // protocol level 3.1.1 + CHECK(p[v + 7] == 0x02); // connect flags: clean session only + CHECK(((p[v + 8] << 8) | p[v + 9]) == 300); // keepalive +} + +TEST_CASE("OrcaMqtt CONNECT packet - username/password (LAN form)", "[OrcaMqtt]") { + auto p = OrcaMqttConnection::make_connect_packet("orcaslicer-lan-x", "orcasonar", "code123", 60); + CHECK(p[0] == 0x10); + const size_t v = mqtt_varheader_offset(p); + CHECK(p[v + 7] == (0x02 | 0x80 | 0x40)); // clean session + username + password flags + const std::string blob(p.begin(), p.end()); + CHECK(blob.find("orcaslicer-lan-x") != std::string::npos); + CHECK(blob.find("orcasonar") != std::string::npos); + CHECK(blob.find("code123") != std::string::npos); +} + +// Auth precedence (spec O3): when a bearer_provider is configured, connect_and_read +// passes empty CONNECT credentials, so the packet must carry clean-session only and +// no username/password flags or payload fields. (The precedence branch itself lives +// in connect_and_read; the [.integration] cloud-style round trip exercises it live.) +TEST_CASE("OrcaMqtt CONNECT omits creds when a bearer is configured", "[OrcaMqtt]") { + auto p = OrcaMqttConnection::make_connect_packet("cid", "", "", 60); + const size_t v = mqtt_varheader_offset(p); + CHECK(p[v + 7] == 0x02); // clean session only: no 0x80 / 0x40 + const std::string blob(p.begin(), p.end()); + CHECK(blob.find("orcasonar") == std::string::npos); +} + +TEST_CASE("OrcaMqtt topic helpers", "[OrcaMqtt]") { + CHECK(OrcaMqttConnection::request_topic("abc") == "device/abc/request"); + CHECK(OrcaMqttConnection::report_topic("abc") == "device/abc/report"); +} + +TEST_CASE("OrcaMqtt PUBLISH packet QoS0", "[OrcaMqtt]") { + auto p = OrcaMqttConnection::make_publish_packet("device/abc/request", "{\"ok\":1}"); + CHECK((p[0] & 0xf0) == 0x30); // PUBLISH + CHECK((p[0] & 0x06) == 0x00); // QoS 0 + const std::string blob(p.begin(), p.end()); + CHECK(blob.find("device/abc/request") != std::string::npos); + CHECK(blob.find("{\"ok\":1}") != std::string::npos); +} + +TEST_CASE("OrcaMqtt SUBSCRIBE packet", "[OrcaMqtt]") { + auto p = OrcaMqttConnection::make_subscribe_packet(7, "device/abc/report", 1); + CHECK(p[0] == 0x82); // SUBSCRIBE + reserved bit + const size_t v = mqtt_varheader_offset(p); + CHECK(((p[v] << 8) | p[v + 1]) == 7); // packet id + CHECK(p.back() == 1); // requested QoS +} + +TEST_CASE("OrcaMqtt send_request refuses when not connected", "[OrcaMqtt]") { + OrcaMqttConnection conn; + CHECK_FALSE(conn.send_request("abc", "{\"pushing\":{\"command\":\"pushall\",\"sequence_id\":\"20001\"}}")); +} + +TEST_CASE("OrcaMqtt start takes a Config", "[OrcaMqtt]") { + OrcaMqttConnection conn; + OrcaMqttConnection::Config cfg; + cfg.url = "ws://127.0.0.1:1/mqtt"; // nothing listening + cfg.keepalive_seconds = 42; + // start() returns false (no server) but must compile with the Config overload + const bool ok = conn.start(cfg, [](auto, auto){}, [](bool, bool){}); + CHECK_FALSE(ok); + CHECK(conn.last_connack_rc() == -1); + conn.stop(); +} + +TEST_CASE("MockBroker starts and reports a url", "[OrcaMqtt][.integration]") { + orca_mqtt_test::MockBroker b; + CHECK(b.ws_url().rfind("ws://127.0.0.1:", 0) == 0); + CHECK(b.connect_count() == 0); +} + +// --- End-to-end integration: OrcaMqttConnection against the in-process MockBroker. +// All hidden behind [.integration] (run explicitly). These prove a LAN-style config +// (CONNECT username/password) and a cloud-style config (bearer on the WS upgrade, +// no CONNECT creds) drive the *same* OrcaMqttConnection code path with identical +// assertions. + +static void run_round_trip(bool use_tls_flag_only) { + orca_mqtt_test::MockBroker broker; + OrcaMqttConnection conn; + OrcaMqttConnection::Config cfg; + cfg.url = broker.ws_url(); // plaintext regardless + cfg.use_tls = false; // the mock is plaintext; the flag path is unit-tested elsewhere + if (use_tls_flag_only) cfg.bearer_provider = []{ return std::string("tok"); }; + else { cfg.username = "orcasonar"; cfg.password = "code"; } + + // A mutex + condition_variable rather than a promise: the handler runs on the MQTT + // worker thread and a second inbound message would throw std::future_error there. + std::mutex got_mutex; + std::condition_variable got_cv; + bool got_any = false; + std::string got_id, got_payload; + + REQUIRE(conn.start(cfg, + [&](const std::string& id, const std::string& payload){ + { + std::lock_guard l(got_mutex); + if (got_any) return; // keep the first message only + got_any = true; got_id = id; got_payload = payload; + } + got_cv.notify_all(); + }, + [](bool,bool){})); + REQUIRE(conn.subscribe("dev-1")); + REQUIRE(conn.send_request("dev-1", R"({"pushing":{"command":"pushall","sequence_id":"20001"}})")); + + broker.push_report("dev-1", R"({"print":{"command":"push_status","sequence_id":"20001","result":"success"}})"); + std::string id, payload; + { + std::unique_lock l(got_mutex); + REQUIRE(got_cv.wait_for(l, std::chrono::seconds(3), [&]{ return got_any; })); + id = got_id; payload = got_payload; + } + CHECK(id == "dev-1"); + CHECK(payload.find("push_status") != std::string::npos); + + // the client's command reached the broker on the request topic. The mock records + // the PUBLISH on its own read-loop thread, so poll rather than check immediately. + std::vector reqs; + for (int i = 0; i < 200; ++i) { + reqs = broker.received_requests(); + if (!reqs.empty()) break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + REQUIRE(reqs.size() >= 1); + CHECK(reqs.front().find("pushall") != std::string::npos); + conn.stop(); +} + +TEST_CASE("OrcaMqtt round-trip — LAN-style config", "[OrcaMqtt][.integration]") { run_round_trip(false); } +TEST_CASE("OrcaMqtt round-trip — cloud-style config", "[OrcaMqtt][.integration]") { run_round_trip(true); } + +TEST_CASE("OrcaMqtt reconnects and re-subscribes after a socket drop", "[OrcaMqtt][.integration]") { + orca_mqtt_test::MockBroker broker; + OrcaMqttConnection conn; + OrcaMqttConnection::Config cfg; cfg.url = broker.ws_url(); cfg.use_tls = false; cfg.username = "u"; cfg.password = "p"; + + std::mutex m; std::vector got; + REQUIRE(conn.start(cfg, + [&](const std::string&, const std::string& p){ std::lock_guard l(m); got.push_back(p); }, + [](bool,bool){})); + REQUIRE(conn.subscribe("dev-1")); + + broker.drop_client(); + // the worker reconnects with ~1s backoff + for (int i = 0; i < 300 && broker.connect_count() < 2; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + CHECK(broker.connect_count() >= 2); + + // a report after the reconnect must still be delivered -> the SUBSCRIBE was re-sent + broker.push_report("dev-1", R"({"print":{"command":"push_status","sequence_id":"20002"}})"); + bool delivered = false; + for (int i = 0; i < 200 && !delivered; ++i) { + { std::lock_guard l(m); delivered = !got.empty(); } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + CHECK(delivered); + conn.stop(); +} + +TEST_CASE("OrcaMqtt auth rejection is terminal (no retry storm)", "[OrcaMqtt][.integration]") { + orca_mqtt_test::MockBroker broker(/*refuse_auth=*/true); + OrcaMqttConnection conn; + OrcaMqttConnection::Config cfg; cfg.url = broker.ws_url(); cfg.use_tls = false; cfg.username = "u"; cfg.password = "bad"; + + const bool ok = conn.start(cfg, [](const std::string&, const std::string&){}, [](bool,bool){}); + CHECK_FALSE(ok); + CHECK(conn.last_connack_rc() == 5); + // worker must have stopped itself (rc 5 is terminal) — give it a moment + for (int i = 0; i < 100 && conn.is_running(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + CHECK_FALSE(conn.is_running()); + // and it must NOT have hammered the broker with retries + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + CHECK(broker.connect_count() <= 2); + conn.stop(); +} diff --git a/tests/slic3rutils/test_orca_printer_agent.cpp b/tests/slic3rutils/test_orca_printer_agent.cpp new file mode 100644 index 0000000000..b3ad393268 --- /dev/null +++ b/tests/slic3rutils/test_orca_printer_agent.cpp @@ -0,0 +1,170 @@ +#include +#include +#include + +#include "orca_mqtt_mock_broker.hpp" + +#include +#include +#include +#include +#include +#include + +using Slic3r::OrcaPrinterAgent; + +namespace { +// Probe exposes the protected internals the tests drive. +struct Probe : OrcaPrinterAgent { + using OrcaPrinterAgent::OrcaPrinterAgent; + using OrcaPrinterAgent::deliver_to_sink; + using OrcaPrinterAgent::parse_lan_endpoint; + using OrcaPrinterAgent::make_lan_client_id; + using OrcaPrinterAgent::lan_connection_target; +}; +} + +TEST_CASE("OrcaPrinterAgent forwards a status payload to on_message_fn", "[OrcaPrinterAgent]") { + Probe agent("/tmp"); + std::string got_id, got_payload; + agent.set_on_message_fn([&](std::string id, std::string p){ got_id = std::move(id); got_payload = std::move(p); }); + agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status"}})"); + CHECK(got_id == "dev-1"); + CHECK(got_payload.find("push_status") != std::string::npos); +} + +TEST_CASE("OrcaPrinterAgent::parse_lan_endpoint", "[OrcaPrinterAgent]") { + std::string h, p; + REQUIRE(Probe::parse_lan_endpoint("192.168.1.9", h, p)); + CHECK(h == "192.168.1.9"); CHECK(p == "8280"); + REQUIRE(Probe::parse_lan_endpoint("http://host.local:9000/x", h, p)); + CHECK(h == "host.local"); CHECK(p == "9000"); + CHECK_FALSE(Probe::parse_lan_endpoint("", h, p)); +} + +TEST_CASE("OrcaPrinterAgent::make_lan_client_id is stable and prefixed", "[OrcaPrinterAgent]") { + const auto a = Probe::make_lan_client_id("dev-1"); + const auto b = Probe::make_lan_client_id("dev-1"); + CHECK(a == b); // drawn once per process + CHECK(a.rfind("orcaslicer-lan-dev-1-", 0) == 0); +} + +TEST_CASE("connect_printer wires up a LAN Config", "[OrcaPrinterAgent][.integration]") { + Probe agent("/tmp"); + const int rc = agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false); + CHECK(rc == BAMBU_NETWORK_SUCCESS); + CHECK(agent.lan_connection_target() == "ws://10.255.255.1:8280/mqtt"); + CHECK(agent.get_user_selected_machine().empty()); // LAN path must not touch the cloud selection + agent.disconnect_printer(); +} + +TEST_CASE("post-connect sequence is subscribe then 4 requests in order", "[OrcaPrinterAgent]") { + struct SeqProbe : OrcaPrinterAgent { + using OrcaPrinterAgent::OrcaPrinterAgent; + std::vector calls; + void emit_connect_sequence(const std::string& dev_id, + std::function /*sub*/, + std::function /*req*/) override { + OrcaPrinterAgent::emit_connect_sequence(dev_id, + [&](const std::string& id){ calls.push_back("sub:" + id); }, + [&](const std::string& body){ calls.push_back(body); }); + } + } probe("/tmp"); + probe.run_connect_sequence_for_test("dev-1"); + REQUIRE(probe.calls.size() == 5); + CHECK(probe.calls[0] == "sub:dev-1"); + CHECK(probe.calls[1].find("\"pushing\"") != std::string::npos); + CHECK(probe.calls[1].find("\"start\"") != std::string::npos); + CHECK(probe.calls[2].find("pushall") != std::string::npos); + CHECK(probe.calls[3].find("get_version") != std::string::npos); + CHECK(probe.calls[4].find("get_capabilities") != std::string::npos); + for (auto& c : probe.calls) + if (auto pos = c.find("sequence_id"); pos != std::string::npos) + CHECK(c.substr(pos).find("\"2") != std::string::npos); +} + +// Hidden: spawns the connect worker and attempts a real (failing) connect. +TEST_CASE("selecting a cloud printer configures the per-printer socket", "[OrcaPrinterAgent][.integration]") { + auto cloud = std::make_shared("/tmp"); + cloud->set_api_base_url("api.example.com"); + OrcaPrinterAgent agent("/tmp"); + agent.set_cloud_agent(cloud); + + agent.set_user_selected_machine("printer-uuid-1"); + // The configure runs on the connect worker; poll rather than racing it. + std::string url; + for (int i = 0; i < 300; ++i) { + url = cloud->selected_printer_mqtt_url(); + if (!url.empty()) break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + CHECK(url == "wss://api.example.com/api/v1/printers/printer-uuid-1/mqtt"); + + agent.set_user_selected_machine(""); // teardown is synchronous + CHECK(cloud->selected_printer_mqtt_url().empty()); +} + +TEST_CASE("a stale-generation inbound message is dropped", "[OrcaPrinterAgent]") { + struct GenProbe : OrcaPrinterAgent { + using OrcaPrinterAgent::OrcaPrinterAgent; + using OrcaPrinterAgent::make_lan_message_handler; // expose for the test + }; + GenProbe agent("/tmp"); + int hits = 0; + agent.set_on_message_fn([&](std::string, std::string){ ++hits; }); + auto handler_gen1 = agent.make_lan_message_handler(/*generation=*/1); + // m_lan_generation starts at 0; two bumps -> 2, so the epoch-1 handler is stale. + agent.bump_lan_generation_for_test(); + agent.bump_lan_generation_for_test(); + handler_gen1("dev-1", "{}"); // late callback from gen 1 + CHECK(hits == 0); +} + +TEST_CASE("connect_server does not start an MQTT socket", "[OrcaCloud]") { + auto cloud = std::make_shared("/tmp"); + cloud->set_api_base_url("127.0.0.1:1"); // no session -> connect_server short-circuits before any probe + cloud->connect_server(); + REQUIRE(cloud->get_mqtt_connection() != nullptr); // created in the ctor + CHECK_FALSE(cloud->get_mqtt_connection()->is_running()); // never started + CHECK(cloud->selected_printer_mqtt_url().empty()); +} + +TEST_CASE("send_message* reject when there is no connection", "[OrcaPrinterAgent]") { + OrcaPrinterAgent agent("/tmp"); // no cloud agent, no LAN connection + CHECK(agent.send_message("d", "{}", 0, 0) == BAMBU_NETWORK_ERR_INVALID_HANDLE); + CHECK(agent.send_message_to_printer("d", "{}", 0, 0) == BAMBU_NETWORK_ERR_INVALID_HANDLE); + CHECK(agent.send_message("", "{}", 0, 0) == BAMBU_NETWORK_ERR_INVALID_HANDLE); // empty dev_id +} + +TEST_CASE("send_message_to_printer publishes on the LAN connection", "[OrcaPrinterAgent][.integration]") { + orca_mqtt_test::MockBroker broker; + OrcaPrinterAgent agent("/tmp"); + const auto ep = broker.host_port(); + agent.connect_printer("dev-1", ep.first + ":" + ep.second, "orcasonar", "code", false); + + for (int i = 0; i < 150 && broker.connect_count() == 0; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + REQUIRE(broker.connect_count() >= 1); + + CHECK(agent.send_message_to_printer("dev-1", R"({"print":{"command":"pause","sequence_id":"20007"}})", 0, 0) + == BAMBU_NETWORK_SUCCESS); + + // on_connected also publishes 4 requests; poll until "pause" specifically shows up. + bool saw_pause = false; + for (int i = 0; i < 150 && !saw_pause; ++i) { + for (const auto& r : broker.received_requests()) + if (r.find("pause") != std::string::npos) { saw_pause = true; break; } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + CHECK(saw_pause); + agent.disconnect_printer(); +} + +TEST_CASE("destroying an agent mid-connect does not hang or crash", "[OrcaPrinterAgent]") { + for (int i = 0; i < 20; ++i) { + auto agent = std::make_unique("/tmp"); + agent->connect_printer("dev-1", "127.0.0.1:1", "orcasonar", "code", false); // nothing listening: instant ECONNREFUSED + agent.reset(); // ~OrcaPrinterAgent must stop the conn, join the thread, and not hang/crash + } + SUCCEED(); +} From 2f82cfe40feb38c54a2616e9e667ac3ecdf6765a Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 4 Sep 2026 16:18:43 +0800 Subject: [PATCH 11/24] fix: LAN paths and camera stream --- src/slic3r/GUI/DeviceManager.cpp | 87 +++++++ src/slic3r/GUI/DeviceManager.hpp | 1 + src/slic3r/GUI/WebMediaController.cpp | 7 +- src/slic3r/Utils/OrcaMqttConnection.cpp | 103 +++++++- src/slic3r/Utils/OrcaMqttConnection.hpp | 10 +- src/slic3r/Utils/OrcaPrinterAgent.cpp | 330 +++++++++++++++++++++++- src/slic3r/Utils/OrcaPrinterAgent.hpp | 72 ++++-- 7 files changed, 575 insertions(+), 35 deletions(-) diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index 741ed788fc..6c155fe158 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -3046,6 +3046,13 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_ } } catch (...) {} + try { + if (j.contains("info")) + parse_new_info2(j["info"]); + } catch (...) { + BOOST_LOG_TRIVIAL(error) << "parse_json: failed to parse OrcaSonar capability info"; + } + try { if (auto ptr = m_fila_system->GetAmsFirmwareSwitch().lock()) { ptr->ParseFirmwareSwitch(j); @@ -5432,6 +5439,86 @@ void MachineObject::parse_new_info(json print) } } +void MachineObject::parse_new_info2(const json& info) +{ + if (!info.is_object() || info.value("command", "") != "get_capabilities") + return; + + const auto capabilities_it = info.find("capabilities"); + if (capabilities_it == info.end() || !capabilities_it->is_object()) + return; + const auto flags_it = capabilities_it->find("flags"); + if (flags_it == capabilities_it->end() || !flags_it->is_object()) + return; + + const json& flags = *flags_it; + BOOST_LOG_TRIVIAL(info) << "parse_new_info2: OrcaSonar capability flags=" << flags.dump(); + + auto parse_bool = [&flags](const char* name, bool& target) { + const auto it = flags.find(name); + if (it != flags.end() && it->is_boolean()) + target = it->get(); + }; + + parse_bool("support_send_to_sd", is_support_send_to_sdcard); + parse_bool("support_filament_backup", is_support_filament_backup); + parse_bool("support_update_remain", is_support_update_remain); + parse_bool("support_auto_recovery_step_loss", is_support_auto_recovery_step_loss); + parse_bool("support_ams_humidity", is_support_ams_humidity); + parse_bool("support_prompt_sound", is_support_prompt_sound); + parse_bool("support_filament_tangle_detect", is_support_filament_tangle_detect); + parse_bool("support_1080dpi", is_support_1080dpi); + parse_bool("support_cloud_print_only", is_support_cloud_print_only); + parse_bool("support_command_ams_switch", is_support_command_ams_switch); + parse_bool("support_mqtt_alive", is_support_mqtt_alive); + parse_bool("support_motor_noise_cali", is_support_motor_noise_cali); + parse_bool("support_timelapse", is_support_timelapse); + parse_bool("support_user_preset", is_support_user_preset); + parse_bool("support_refresh_nozzle", is_support_refresh_nozzle); + parse_bool("support_flow_calibration", is_support_flow_calibration); + parse_bool("support_build_plate_marker_detect", is_support_build_plate_marker_detect); + parse_bool("support_nozzle_blob_detect", is_support_nozzle_blob_detection); + + if (!m_manager->IsMultiMachineEnabled() && !is_support_agora) + parse_bool("support_tunnel_mqtt", is_support_tunnel_mqtt); + + const auto bed_leveling_it = flags.find("support_bed_leveling"); + if (bed_leveling_it != flags.end() && bed_leveling_it->is_number_integer()) + is_support_bed_leveling = bed_leveling_it->get(); + + auto copy_bool = [&flags](json& target, const char* name) { + const auto it = flags.find(name); + if (it != flags.end() && it->is_boolean()) + target[name] = *it; + }; + + // The capability manifest uses an object for this range, while the legacy + // DeviceCore parser consumes a boolean plus a two-element range array. + json device_config; + copy_bool(device_config, "support_chamber"); + copy_bool(device_config, "support_first_layer_inspect"); + copy_bool(device_config, "support_ai_monitoring"); + copy_bool(device_config, "support_lidar_calibration"); + const auto chamber_edit_it = flags.find("support_chamber_temp_edit"); + if (chamber_edit_it != flags.end() && chamber_edit_it->is_boolean()) { + device_config["support_chamber_temp_edit"] = *chamber_edit_it; + } else if (chamber_edit_it != flags.end() && chamber_edit_it->is_object()) { + const auto min_it = chamber_edit_it->find("min"); + const auto max_it = chamber_edit_it->find("max"); + if (min_it != chamber_edit_it->end() && max_it != chamber_edit_it->end() && min_it->is_number() && max_it->is_number()) { + device_config["support_chamber_temp_edit"] = true; + device_config["support_chamber_temp_edit_range"] = {*min_it, *max_it}; + } + } + + json fan_config; + copy_bool(fan_config, "support_aux_fan"); + copy_bool(fan_config, "support_chamber_fan"); + + m_config->ParseConfig(device_config); + m_fan->ParseV2_0(fan_config); +} + static bool is_hex_digit(char c) { return std::isxdigit(static_cast(c)) != 0; } diff --git a/src/slic3r/GUI/DeviceManager.hpp b/src/slic3r/GUI/DeviceManager.hpp index 8788c2288d..6e4e7d7183 100644 --- a/src/slic3r/GUI/DeviceManager.hpp +++ b/src/slic3r/GUI/DeviceManager.hpp @@ -947,6 +947,7 @@ public: /*for parse new info*/ bool check_enable_np(const json& print) const; void parse_new_info(json print); + void parse_new_info2(const json& info); int get_flag_bits(std::string str, int start, int count = 1) const; uint32_t get_flag_bits_no_border(std::string str, int start_idx, int count = 1) const; int get_flag_bits(int num, int start, int count = 1, int base = 10) const; diff --git a/src/slic3r/GUI/WebMediaController.cpp b/src/slic3r/GUI/WebMediaController.cpp index 13d820c98f..378d83b650 100644 --- a/src/slic3r/GUI/WebMediaController.cpp +++ b/src/slic3r/GUI/WebMediaController.cpp @@ -52,10 +52,13 @@ void WebMediaController::Play() "refreshCameraFrame();" "setInterval(refreshCameraFrame,200);" ""; + m_webview->SetPage(html, url); } else { - html += " src=\"" + url + "\">"; + // Load MJPEG streams as the top-level document. Some embedded WebView + // backends buffer a multipart stream when it is used as an resource, + // which introduces noticeable live-view latency. + m_webview->LoadURL(url); } - m_webview->SetPage(html, url); } void WebMediaController::Stop() diff --git a/src/slic3r/Utils/OrcaMqttConnection.cpp b/src/slic3r/Utils/OrcaMqttConnection.cpp index 5ba0279fc2..2173231310 100644 --- a/src/slic3r/Utils/OrcaMqttConnection.cpp +++ b/src/slic3r/Utils/OrcaMqttConnection.cpp @@ -125,6 +125,9 @@ void OrcaMqttConnection::stop() { { std::lock_guard lock(mutex); connected = false; + acknowledged_subscriptions.clear(); + pending_subscribe_packets.clear(); + pending_requests.clear(); if (!initial_completed) { initial_completed = true; initial_result = false; @@ -173,6 +176,11 @@ bool OrcaMqttConnection::subscribe(const std::string& dev_id) { } { std::lock_guard lock(mutex); + if (subscriptions.count(topic) != 0 && pending_unsubscriptions.count(topic) == 0) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT subscribe already queued or active topic=" << topic + << " acknowledged=" << (acknowledged_subscriptions.count(topic) != 0); + return true; + } subscriptions.insert(topic); pending_unsubscriptions.erase(topic); pending_subscriptions.insert(topic); @@ -190,8 +198,21 @@ bool OrcaMqttConnection::unsubscribe(const std::string& dev_id) { { std::lock_guard lock(mutex); subscriptions.erase(topic); + acknowledged_subscriptions.erase(topic); pending_subscriptions.erase(topic); pending_unsubscriptions.insert(topic); + for (auto it = pending_subscribe_packets.begin(); it != pending_subscribe_packets.end();) { + if (it->second == topic) + it = pending_subscribe_packets.erase(it); + else + ++it; + } + for (auto it = pending_requests.begin(); it != pending_requests.end();) { + if (it->first == dev_id) + it = pending_requests.erase(it); + else + ++it; + } BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT unsubscribe queued topic=" << topic << " total_subscriptions=" << subscriptions.size() << " connected=" << connected.load(); @@ -207,6 +228,9 @@ void OrcaMqttConnection::clear_subscriptions() { subscriptions.clear(); pending_subscriptions.clear(); pending_unsubscriptions.clear(); + acknowledged_subscriptions.clear(); + pending_subscribe_packets.clear(); + pending_requests.clear(); } bool OrcaMqttConnection::parse_endpoint(const std::string& url, Endpoint& endpoint) { @@ -450,6 +474,17 @@ bool OrcaMqttConnection::send_request(const std::string& dev_id, const std::stri << " dev_id=" << dev_id; return false; } + const std::string report = report_topic(dev_id); + { + std::lock_guard lock(mutex); + if (subscriptions.count(report) != 0 && acknowledged_subscriptions.count(report) == 0) { + pending_requests.emplace_back(dev_id, payload); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT request queued until SUBACK" + << " dev_id=" << dev_id << " payload_bytes=" << payload.size() + << " pending_requests=" << pending_requests.size(); + return true; + } + } try { // ws_write() serialises the write via write_mutex; do not lock it here. BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT PUBLISH request dev_id=" << dev_id @@ -531,6 +566,14 @@ void OrcaMqttConnection::connect_and_read() { } m_connection_stage = "reading MQTT messages"; + // The subscription acknowledgement belongs to this MQTT session. Clear + // the previous session's state before notifying the owner, because the + // reconnect callback immediately queues the printer's initial requests. + { + std::lock_guard lock(mutex); + acknowledged_subscriptions.clear(); + pending_subscribe_packets.clear(); + } notify_state(true); reconnect_delay_seconds.store(1); // a fresh CONNACK resets the backoff BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: MQTT connection is ready; sending current subscriptions"; @@ -572,10 +615,18 @@ void OrcaMqttConnection::send_current_subscriptions(Connection& conn) { { std::lock_guard lock(mutex); topics.assign(subscriptions.begin(), subscriptions.end()); + acknowledged_subscriptions.clear(); + pending_subscribe_packets.clear(); + for (const std::string& topic : topics) + pending_subscriptions.erase(topic); } BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending current MQTT subscriptions count=" << topics.size(); for (const std::string& topic : topics) { const uint16_t packet_id = next_packet_id++; + { + std::lock_guard lock(mutex); + pending_subscribe_packets[packet_id] = topic; + } BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending SUBSCRIBE topic=" << topic << " packet_id=" << packet_id; ws_write(conn, make_subscribe_packet(packet_id, topic, 1)); } @@ -593,6 +644,10 @@ void OrcaMqttConnection::send_pending_subscriptions(Connection& conn) { } for (const std::string& topic : subscribe_topics) { const uint16_t packet_id = next_packet_id++; + { + std::lock_guard lock(mutex); + pending_subscribe_packets[packet_id] = topic; + } BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: sending pending SUBSCRIBE topic=" << topic << " packet_id=" << packet_id; ws_write(conn, make_subscribe_packet(packet_id, topic, 1)); @@ -617,6 +672,8 @@ void OrcaMqttConnection::handle_packet(const std::string& packet) { << " bytes=" << packet.size(); if (packet_type != 3) { // Only QoS 0 PUBLISH carries printer status. if (packet_type == 9 && packet.size() >= 5) { + const uint16_t packet_id = (static_cast(static_cast(packet[2])) << 8) | + static_cast(static_cast(packet[3])); std::ostringstream result_codes; for (size_t index = 4; index < packet.size(); ++index) { if (index != 4) @@ -624,9 +681,51 @@ void OrcaMqttConnection::handle_packet(const std::string& packet) { result_codes << "0x" << std::hex << static_cast(static_cast(packet[index])); } BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: received SUBACK packet_id=" - << ((static_cast(static_cast(packet[2])) << 8) | - static_cast(static_cast(packet[3]))) + << packet_id << " result_codes=" << result_codes.str(); + + // Each production SUBSCRIBE packet currently contains one topic. + // MQTT grants QoS 0 or 1 for a requested QoS 1 subscription; 0x80 + // means the subscription was rejected. + const uint8_t result = static_cast(packet[4]); + std::string topic; + std::deque> requests; + { + std::lock_guard lock(mutex); + auto pending = pending_subscribe_packets.find(packet_id); + if (pending != pending_subscribe_packets.end()) { + topic = pending->second; + pending_subscribe_packets.erase(pending); + for (auto it = pending_requests.begin(); it != pending_requests.end();) { + if (report_topic(it->first) == topic) { + requests.push_back(std::move(*it)); + it = pending_requests.erase(it); + } else { + ++it; + } + } + if (result == 0 || result == 1) { + acknowledged_subscriptions.insert(topic); + } + } + } + if (topic.empty()) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: SUBACK has no pending topic packet_id=" << packet_id; + } else if (result == 0 || result == 1) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: report subscription active topic=" << topic + << " granted_qos=" << static_cast(result) + << " releasing_requests=" << requests.size(); + for (const auto& request : requests) { + if (!send_request(request.first, request.second)) { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: queued MQTT request could not be sent" + << " after SUBACK dev_id=" << request.first; + } + } + } else { + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: report subscription rejected topic=" << topic + << " result_code=0x" << std::hex << static_cast(result) << std::dec + << " dropped_requests=" << requests.size(); + } } return; } diff --git a/src/slic3r/Utils/OrcaMqttConnection.hpp b/src/slic3r/Utils/OrcaMqttConnection.hpp index cac1892a68..6131ee86f1 100644 --- a/src/slic3r/Utils/OrcaMqttConnection.hpp +++ b/src/slic3r/Utils/OrcaMqttConnection.hpp @@ -7,13 +7,15 @@ #include #include #include +#include #include +#include #include #include -#include #include #include #include +#include #include #include #include @@ -130,6 +132,12 @@ private: std::set subscriptions; std::set pending_subscriptions; std::set pending_unsubscriptions; + // Requests for a subscribed device wait until the corresponding SUBACK is + // received. Otherwise an immediate pushall response can be published by + // the broker before this client is actually subscribed to the report topic. + std::set acknowledged_subscriptions; + std::map pending_subscribe_packets; + std::deque> pending_requests; std::atomic next_packet_id{1}; std::atomic m_last_connack_rc{-1}; uint64_t m_attempt_number{0}; // worker-thread diagnostic sequence diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 0b2c38ae2a..6c7fed7eab 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -1,4 +1,5 @@ #include "OrcaPrinterAgent.hpp" +#include "IPrinterAgent.hpp" #include "NetworkAgentFactory.hpp" #include "OrcaCloudServiceAgent.hpp" #include @@ -9,6 +10,8 @@ #include #include #include +#include +#include #include #include #include @@ -213,6 +216,16 @@ private: OrcaPrinterAgent::OrcaPrinterAgent(std::string log_dir) : log_dir(std::move(log_dir)) {} +const char* OrcaPrinterAgent::connection_type_name(CurrentConn connection) +{ + switch (connection) { + case LAN: return "LAN"; + case CLOUD: return "cloud"; + case NONE: return "none"; + } + return "unknown"; +} + OrcaPrinterAgent::~OrcaPrinterAgent() { start_discovery(false, false); @@ -300,6 +313,8 @@ void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::str void OrcaPrinterAgent::deliver_to_local_sink(const std::string& dev_id, const std::string& payload) { + parse_ipcam_info(dev_id, payload); + OnMessageFn fn; QueueOnMainFn q; { @@ -380,12 +395,141 @@ void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud } // ============================================================================ -// Communication - All Stubs +// Communication // ============================================================================ int OrcaPrinterAgent::send_message(std::string dev_id, std::string json_str, int /*qos*/, int /*flag*/) { return route_send(/*is_lan=*/false, dev_id, json_str); } +int OrcaPrinterAgent::command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) +{ + int tray_number = 0; + if (!parse_nonnegative_command_id(tray_id, tray_number)) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: invalid RFID tray id=" << tray_id; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + + nlohmann::json j; + j["print"]["command"] = "ams_get_rfid"; + j["print"]["sequence_id"] = std::to_string(sequence_id); + j["print"]["tray_id"] = tray_number; + return route_send(lan_mode, dev_id, j.dump()); +} + +int OrcaPrinterAgent::command_ams_calibrate(std::string /*dev_id*/, int /*ams_id*/, int /*sequence_id*/, bool /*lan_mode*/) +{ + // OrcaSonar has no ams_calibrate command. Do not send the Bambu M620 C + // dialect through the vendor-neutral OrcaSonar gcode_line command. + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: AMS calibration is not part of the OrcaSonar API"; + return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; +} + +int OrcaPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) +{ + int tray_number = 0; + if (!parse_nonnegative_command_id(tray_id, tray_number)) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: invalid AMS target tray id=" << tray_id; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + + nlohmann::json j; + j["print"]["command"] = "ams_change_filament"; + j["print"]["sequence_id"] = std::to_string(sequence_id); + j["print"]["target"] = tray_number; + return route_send(lan_mode, dev_id, j.dump()); +} + +int OrcaPrinterAgent::command_start_camera(std::string /*dev_id*/) +{ + // OrcaSonar exposes camera.ipcam_* controls, not the legacy start_camera + // operation used by the Bambu agent. + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: camera start is not part of the OrcaSonar API"; + return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; +} + +int OrcaPrinterAgent::command_xyz_abs(std::string dev_id, int sequence_id, bool lan_mode) +{ + nlohmann::json j; + j["print"]["command"] = "gcode_line"; + j["print"]["param"] = "G90\n"; + j["print"]["sequence_id"] = std::to_string(sequence_id); + return route_send(lan_mode, dev_id, j.dump()); +} + +int OrcaPrinterAgent::command_auto_leveling(std::string dev_id, int sequence_id, bool lan_mode) +{ + nlohmann::json j; + j["print"]["command"] = "gcode_line"; + j["print"]["param"] = "G29\n"; + j["print"]["sequence_id"] = std::to_string(sequence_id); + return route_send(lan_mode, dev_id, j.dump()); +} + +int OrcaPrinterAgent::command_go_home(std::string dev_id, bool is_printing, bool supports_mqtt_homing, + int sequence_id, bool lan_mode) +{ + nlohmann::json j; + j["print"]["sequence_id"] = std::to_string(sequence_id); + if (supports_mqtt_homing) { + j["print"]["command"] = "back_to_center"; + } else { + // Preserve the existing safety behavior: never home Z/Y during a print. + j["print"]["command"] = "gcode_line"; + j["print"]["param"] = is_printing ? "G28 X\n" : "G28\n"; + } + return route_send(lan_mode, dev_id, j.dump()); +} + +int OrcaPrinterAgent::command_set_bed(std::string dev_id, int temp, bool /*supports_mqtt_bed_ctrl*/, + int sequence_id, bool lan_mode) +{ + nlohmann::json j; + j["print"]["command"] = "set_bed_temp"; + j["print"]["sequence_id"] = std::to_string(sequence_id); + j["print"]["temp"] = temp; + return route_send(lan_mode, dev_id, j.dump()); +} + +int OrcaPrinterAgent::command_set_nozzle(std::string dev_id, int temp, int sequence_id, bool lan_mode) +{ + nlohmann::json j; + j["print"]["command"] = "set_nozzle_temp"; + j["print"]["sequence_id"] = std::to_string(sequence_id); + j["print"]["extruder_index"] = 0; + j["print"]["target_temp"] = temp; + return route_send(lan_mode, dev_id, j.dump()); +} + +int OrcaPrinterAgent::command_axis_control(std::string dev_id, std::string axis, double unit, double input_val, + int /*speed*/, bool is_core_xy, bool /*supports_mqtt_axis_control*/, + int sequence_id, bool lan_mode) +{ + std::transform(axis.begin(), axis.end(), axis.begin(), [](unsigned char c) { return static_cast(std::toupper(c)); }); + if (axis != "X" && axis != "Y" && axis != "Z" && axis != "E") { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: invalid axis control axis=" << axis; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + + const double requested_distance = input_val * unit; + if (!std::isfinite(requested_distance) || requested_distance == 0.0) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: invalid axis control distance input=" << input_val + << " unit=" << unit; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + + int direction = requested_distance > 0.0 ? 1 : -1; + if (!is_core_xy && (axis == "Y" || axis == "Z")) + direction = -direction; + + nlohmann::json j; + j["print"]["command"] = "xyz_ctrl"; + j["print"]["sequence_id"] = std::to_string(sequence_id); + j["print"]["axis"] = axis; + j["print"]["dir"] = direction; + j["print"]["distance"] = requested_distance < 0.0 ? -requested_distance : requested_distance; + return route_send(lan_mode, dev_id, j.dump()); +} + bool OrcaPrinterAgent::parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port) { std::string s = dev_ip; @@ -420,6 +564,97 @@ std::string OrcaPrinterAgent::make_lan_client_id(const std::string& dev_id) return "orcaslicer-lan-" + dev_id + "-" + suffix; } +bool OrcaPrinterAgent::parse_nonnegative_command_id(const std::string& value, int& result) +{ + if (value.empty()) + return false; + try { + std::size_t consumed = 0; + const long long parsed = std::stoll(value, &consumed); + if (consumed != value.size() || parsed < 0 || parsed > std::numeric_limits::max()) + return false; + result = static_cast(parsed); + return true; + } catch (const std::exception&) { + return false; + } +} + +void OrcaPrinterAgent::parse_ipcam_info(const std::string& dev_id, const std::string& payload) +{ + const nlohmann::json envelope = nlohmann::json::parse(payload, nullptr, false); + if (!envelope.is_object()) + return; + + const auto print_it = envelope.find("print"); + if (print_it == envelope.end() || !print_it->is_object()) + return; + + const nlohmann::json& print = *print_it; + const auto command_it = print.find("command"); + const bool is_push_status = command_it != print.end() && command_it->is_string() && command_it->get() == "push_status"; + bool is_full_snapshot = false; + if (is_push_status) { + const auto msg_it = print.find("msg"); + is_full_snapshot = msg_it == print.end() || (msg_it->is_number_integer() && msg_it->get() == 0); + } + + CameraStreamMode stream_mode = CameraStreamMode::none; + std::string stream_url; + bool has_camera_update = false; + const auto ipcam_it = print.find("ipcam"); + if (ipcam_it != print.end() && ipcam_it->is_object()) { + const auto stream_modes_it = ipcam_it->find("stream_mode"); + if (stream_modes_it != ipcam_it->end() && stream_modes_it->is_array()) { + has_camera_update = true; + for (const auto& stream : *stream_modes_it) { + if (!stream.is_object()) + continue; + const auto mode_it = stream.find("mode"); + const auto url_it = stream.find("url"); + if (mode_it == stream.end() || url_it == stream.end() || !mode_it->is_string() || !url_it->is_string()) + continue; + + const std::string mode = mode_it->get(); + if (mode == "rtsp") + stream_mode = CameraStreamMode::rtsp; + else if (mode == "http") + stream_mode = CameraStreamMode::http; + else if (mode == "http_snapshot") + stream_mode = CameraStreamMode::http_snapshot; + else + continue; + + stream_url = url_it->get(); + break; // OrcaSonar orders entries by preference. + } + } + else if (is_full_snapshot) { + has_camera_update = true; + } + } else if (is_full_snapshot) { + // A full push_status without ipcam means the printer has no camera + // stream information. Diff reports omit unchanged domains. + has_camera_update = true; + } + + if (!has_camera_update) + return; + + std::lock_guard lock(state_mutex); + if (m_current_connection != LAN || m_lan_dev_id != dev_id) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: ignoring camera update for inactive LAN printer dev_id=" << dev_id; + return; + } + + m_camera_stream_mode = stream_mode; + m_camera_url = std::move(stream_url); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: updated camera state dev_id=" << dev_id + << " transport=LAN" + << " mode=" << static_cast(m_camera_stream_mode) + << " url=" << m_camera_url; +} + std::string OrcaPrinterAgent::lan_connection_target() const { std::lock_guard l(state_mutex); @@ -493,14 +728,20 @@ int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, st << " client_id=" << cfg.client_id; OrcaMqttConnection* conn = nullptr; + CurrentConn previous_connection; { std::lock_guard l(state_mutex); + previous_connection = m_current_connection; m_lan_dev_id = dev_id; m_lan_url = cfg.url; + m_camera_stream_mode = CameraStreamMode::none; + m_camera_url.clear(); m_current_connection = LAN; lan_mqtt_connection = std::make_unique(); conn = lan_mqtt_connection.get(); } + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: selected LAN printer dev_id=" << dev_id + << " transport=" << connection_type_name(previous_connection) << "->LAN"; if (m_lan_connect_thread.joinable()) m_lan_connect_thread.join(); // disconnect_printer() above already stopped the old conn, so this is fast @@ -561,17 +802,26 @@ int OrcaPrinterAgent::disconnect_printer() ++m_lan_generation; // fence stale worker callbacks std::unique_ptr doomed; std::string prev_dev; + CurrentConn previous_connection; + CurrentConn current_connection; { std::lock_guard l(state_mutex); + previous_connection = m_current_connection; doomed = std::move(lan_mqtt_connection); prev_dev = m_lan_dev_id; m_lan_dev_id.clear(); - if (m_current_connection == LAN) + if (m_current_connection == LAN) { m_current_connection = NONE; + m_camera_stream_mode = CameraStreamMode::none; + m_camera_url.clear(); + } + current_connection = m_current_connection; } BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN disconnect generation=" << m_lan_generation.load() << " previous_dev_id=" << prev_dev << " had_connection=" << (doomed ? "yes" : "no") - << " connected=" << (doomed && doomed->is_connected() ? "yes" : "no"); + << " connected=" << (doomed && doomed->is_connected() ? "yes" : "no") + << " transport=" << connection_type_name(previous_connection) << "->" + << connection_type_name(current_connection); // Tell the printer to stop pushing and drop the report topic before the socket // goes away (§3.2/§5.4: deselect issues pushing.stop on both transports). if (doomed && !prev_dev.empty() && doomed->is_connected()) { @@ -591,14 +841,34 @@ int OrcaPrinterAgent::send_message_to_printer(std::string dev_id, std::string js int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const std::string& json_str) { + std::string command = ""; + try { + const nlohmann::json envelope = nlohmann::json::parse(json_str); + for (const char* namespace_name : {"pushing", "info", "print", "system", "camera", "xcam", "upgrade", "event", "files"}) { + const auto namespace_it = envelope.find(namespace_name); + if (namespace_it != envelope.end() && namespace_it->is_object()) { + const auto command_it = namespace_it->find("command"); + if (command_it != namespace_it->end() && command_it->is_string()) { + command = std::string(namespace_name) + "." + command_it->get(); + break; + } + } + } + } catch (const std::exception&) { + // Preserve the transport's existing behavior for malformed payloads; + // the printer will report the protocol error asynchronously. + } BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send is_lan=" << is_lan << " dev_id=" << dev_id - << " payload_bytes=" << json_str.size(); + << " command=" << command << " payload_bytes=" << json_str.size(); if (dev_id.empty()) return BAMBU_NETWORK_ERR_INVALID_HANDLE; OrcaMqttConnection* conn = get_appropriate_mqtt_connection(is_lan); if (!conn) return BAMBU_NETWORK_ERR_INVALID_HANDLE; - return conn->send_request(dev_id, json_str) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; + const bool queued = conn->send_request(dev_id, json_str); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send command=" << command << " queued=" << queued + << " is_lan=" << is_lan << " dev_id=" << dev_id; + return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; } // ============================================================================ @@ -706,17 +976,40 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) { auto* cloud = get_orca_cloud_agent(); std::string previous; + CurrentConn previous_connection; + CurrentConn current_connection; { std::lock_guard lock(state_mutex); - if (dev_id == selected_machine) { - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: unchanged dev_id=" << dev_id; + previous_connection = m_current_connection; + // An empty cloud selection must not clear an independently active LAN + // selection. Conversely, selecting a cloud machine with the same id + // while LAN is active is still a transport switch and must proceed. + const bool same_selection = dev_id == selected_machine; + const bool same_transport = dev_id.empty() ? m_current_connection != CLOUD : m_current_connection == CLOUD; + if (same_selection && same_transport) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: unchanged dev_id=" << dev_id + << " transport=" << connection_type_name(m_current_connection); return BAMBU_NETWORK_SUCCESS; } previous = selected_machine; selected_machine = dev_id; + if (dev_id.empty()) { + if (m_current_connection == CLOUD) { + m_current_connection = NONE; + m_camera_stream_mode = CameraStreamMode::none; + m_camera_url.clear(); + } + } else { + m_current_connection = CLOUD; + m_camera_stream_mode = CameraStreamMode::none; + m_camera_url.clear(); + } + current_connection = m_current_connection; } BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: previous=" << previous << " new=" << dev_id - << " cloud=" << (cloud ? "set" : ""); + << " cloud=" << (cloud ? "set" : "") << " transport=" + << connection_type_name(previous_connection) << "->" + << connection_type_name(current_connection); if (!cloud) { BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_user_selected_machine: no Orca cloud agent"; return BAMBU_NETWORK_SUCCESS; @@ -732,11 +1025,6 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) conn->send_request(previous, build_pushing_stop(seq(5))); cloud->teardown_selected_printer_mqtt(); - { - std::lock_guard lock(state_mutex); - m_current_connection = dev_id.empty() ? NONE : CLOUD; - } - if (m_cloud_connect_thread.joinable()) m_cloud_connect_thread.join(); // teardown_selected_printer_mqtt() above stopped the old cloud conn if (!dev_id.empty()) { @@ -845,4 +1133,20 @@ int OrcaPrinterAgent::set_queue_on_main_fn(QueueOnMainFn fn) return BAMBU_NETWORK_SUCCESS; } +CameraStreamMode OrcaPrinterAgent::get_camera_stream_mode() const +{ + std::lock_guard lock(state_mutex); + if (m_current_connection == CLOUD) + return CameraStreamMode::webrtc; + return m_camera_stream_mode; +} + +std::string OrcaPrinterAgent::get_camera_url() const +{ + std::lock_guard lock(state_mutex); + if (m_current_connection != LAN) + return {}; + return m_camera_url; +} + } // namespace Slic3r diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index 666fd26fe3..ea7128ff5a 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -18,12 +18,13 @@ namespace Slic3r { class OrcaCloudServiceAgent; /** - * OrcaPrinterAgent - Stub implementation for printer operations. + * OrcaPrinterAgent - OrcaSonar MQTT printer agent. * - * All printer-related operations are currently stubs that return success. - * Actual printer connectivity requires the BBL SDK or future Orca implementation. + * LAN and cloud commands use the same OrcaSonar protocol payloads; only the + * MQTT connection selected by route_send() differs. */ -class OrcaPrinterAgent : public IPrinterAgent { +class OrcaPrinterAgent : public IPrinterAgent +{ public: explicit OrcaPrinterAgent(std::string log_dir); ~OrcaPrinterAgent() override; @@ -50,12 +51,21 @@ public: // Binding int ping_bind(std::string ping_code) override; int bind_detect(std::string dev_ip, std::string sec_link, detectResult& detect) override; - int bind(std::string dev_ip, std::string dev_id, std::string dev_model, std::string sec_link, std::string timezone, bool improved, OnUpdateStatusFn update_fn) override; + int bind(std::string dev_ip, + std::string dev_id, + std::string dev_model, + std::string sec_link, + std::string timezone, + bool improved, + OnUpdateStatusFn update_fn) override; int unbind(std::string dev_id) override; int request_bind_ticket(std::string* ticket) override; int get_hms_snapshot(std::string dev_id, std::string file_name, std::function callback) override; int set_server_callback(OnServerErrFn fn) override; + CameraStreamMode get_camera_stream_mode() const override; + std::string get_camera_url() const override; + // Machine Selection std::string get_user_selected_machine() override; int set_user_selected_machine(std::string dev_id) override; @@ -85,9 +95,29 @@ public: int set_on_local_message_fn(OnMessageFn fn) override; int set_queue_on_main_fn(QueueOnMainFn fn) override; + int command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override; + int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) override; + int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override; + int command_start_camera(std::string dev_id) override; + int command_xyz_abs(std::string dev_id, int sequence_id, bool lan_mode) override; + int command_auto_leveling(std::string dev_id, int sequence_id, bool lan_mode) override; + int command_go_home(std::string dev_id, bool is_printing, bool supports_mqtt_homing, int sequence_id, bool lan_mode) override; + int command_set_bed(std::string dev_id, int temp, bool supports_mqtt_bed_ctrl, int sequence_id, bool lan_mode) override; + int command_set_nozzle(std::string dev_id, int temp, int sequence_id, bool lan_mode) override; + int command_axis_control(std::string dev_id, + std::string axis, + double unit, + double input_val, + int speed, + bool is_core_xy, + bool supports_mqtt_axis_control, + int sequence_id, + bool lan_mode) override; + // Test-only: drive emit_connect_sequence directly (no socket). - void run_connect_sequence_for_test(const std::string& dev_id) { - emit_connect_sequence(dev_id, [](const std::string&){}, [](const std::string&){}); + void run_connect_sequence_for_test(const std::string& dev_id) + { + emit_connect_sequence(dev_id, [](const std::string&) {}, [](const std::string&) {}); } // Test-only: advance the LAN connection epoch without a connect/disconnect cycle. @@ -100,6 +130,10 @@ protected: // thread via queue_on_main_fn when set). Body of every connection's MessageHandler. void deliver_to_sink(const std::string& dev_id, const std::string& payload); + // Extract OrcaSonar's print.ipcam.stream_mode from LAN reports before they + // are forwarded to the GUI. The getters below then read this agent-owned state. + void parse_ipcam_info(const std::string& dev_id, const std::string& payload); + // LAN has a separate callback in the existing IPrinterAgent contract because the // GUI parses local reports with the "lan" dialect and looks up local machines. void deliver_to_local_sink(const std::string& dev_id, const std::string& payload); @@ -114,10 +148,10 @@ protected: std::function make_lan_message_handler(uint64_t generation); // Pure LAN-address parsing + client-id. protected static so the test Probe reaches them. - static bool parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port); + static bool parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port); static std::string make_lan_client_id(const std::string& dev_id); // Test hook: the ws:// URL connect_printer built for the current LAN session ("" if none). - std::string lan_connection_target() const; + std::string lan_connection_target() const; // Shared post-connect sequence: SUBSCRIBE, then pushing.start, pushall, // info.get_version, info.get_capabilities. Runs identically on LAN and cloud. void on_connected(const std::string& dev_id, OrcaMqttConnection* conn, uint64_t generation); @@ -127,7 +161,7 @@ protected: virtual void emit_connect_sequence(const std::string& dev_id, std::function subscribe, std::function request); - static std::string seq(int n); // decimal string in the OrcaSlicer 20000..29999 band + static std::string seq(int n); // decimal string in the OrcaSlicer 20000..29999 band static std::string build_pushing_start(const std::string& sequence_id); static std::string build_pushing_stop(const std::string& sequence_id); static std::string build_pushall(const std::string& sequence_id); @@ -140,11 +174,12 @@ private: std::string log_dir; std::string selected_machine; - enum CurrentConn { - NONE, - CLOUD, - LAN - }; + enum CurrentConn { NONE, CLOUD, LAN }; + static const char* connection_type_name(CurrentConn connection); + + // The transport for the printer currently selected by the UI. LAN and + // cloud sessions have separate connection objects, so this is selection + // state rather than an inference from whichever socket happens to exist. CurrentConn m_current_connection = NONE; std::shared_ptr m_cloud_agent; @@ -164,12 +199,15 @@ private: std::unique_ptr m_discovery; - std::string m_lan_dev_id; // guarded by state_mutex - std::string m_lan_url; // guarded by state_mutex — the Config.url of the live LAN session + std::string m_lan_dev_id; // guarded by state_mutex + std::string m_lan_url; // guarded by state_mutex — the Config.url of the live LAN session + CameraStreamMode m_camera_stream_mode = CameraStreamMode::none; // guarded by state_mutex + std::string m_camera_url; // guarded by state_mutex OrcaCloudServiceAgent* get_orca_cloud_agent(); OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan = true); + static bool parse_nonnegative_command_id(const std::string& value, int& result); // Route one command payload to device//request on the LAN or the cloud // per-printer connection. The uniform send path for both send_message* overrides. From dbcb82075f364526c4b49c4e2ce33a58a6234063 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 4 Sep 2026 16:42:36 +0800 Subject: [PATCH 12/24] feat: use ffmpeg to render http camera stream --- deps/FFMPEG/FFMPEG.cmake | 4 ++-- src/slic3r/GUI/MediaPlayCtrl.cpp | 7 +++---- src/slic3r/GUI/StatusPanel.cpp | 18 +++++++++------- src/slic3r/GUI/wxMediaCtrl3.cpp | 36 +++++++++++++++++++++++++------- src/slic3r/GUI/wxMediaCtrl3.h | 6 ++++-- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/deps/FFMPEG/FFMPEG.cmake b/deps/FFMPEG/FFMPEG.cmake index 3c582f0682..2b148ae3f7 100644 --- a/deps/FFMPEG/FFMPEG.cmake +++ b/deps/FFMPEG/FFMPEG.cmake @@ -69,14 +69,14 @@ else () --disable-filters --enable-filter=*null*,afade,*fifo,*format,*resample,aeval,allrgb,allyuv,atempo,pan,*bars,color,*key,crop,draw*,eq*,framerate,*_qsv,*_vaapi,*v4l2*,hw*,scale,volume,test* --disable-protocols - --enable-protocol=file,fd,pipe,rtp,tcp,udp + --enable-protocol=file,fd,pipe,http,rtp,tcp,udp --disable-muxers --enable-muxer=rtp --disable-encoders --disable-decoders --enable-decoder=*aac*,h264*,mp3*,mjpeg,rv* --disable-demuxers - --enable-demuxer=h264,mp3,mov,rtsp,sdp + --enable-demuxer=h264,mp3,mov,mpjpeg,rtsp,sdp --disable-zlib --disable-avdevice BUILD_IN_SOURCE ON diff --git a/src/slic3r/GUI/MediaPlayCtrl.cpp b/src/slic3r/GUI/MediaPlayCtrl.cpp index 77a183f090..49259bdf5b 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.cpp +++ b/src/slic3r/GUI/MediaPlayCtrl.cpp @@ -290,7 +290,6 @@ void refresh_agora_url(char const* device, char const* dev_ver, char const* chan void MediaPlayCtrl::Play() { switch (current_mode()) { - case CameraStreamMode::http: case CameraStreamMode::http_snapshot: if (!m_next_retry.IsValid() || wxDateTime::Now() < m_next_retry) return; @@ -300,14 +299,13 @@ void MediaPlayCtrl::Play() Stop(_L("Please confirm if the printer is connected.")); return; } - if (auto agent = wxGetApp().getAgent()) - agent->command_start_camera(m_machine); m_button_play->SetIcon("media_stop"); m_web_ctrl->Load(wxURI(m_url), current_mode()); m_web_ctrl->Play(); m_last_state = wxMEDIASTATE_PLAYING; SetStatus(_L("Playing..."), false); return; + case CameraStreamMode::http: case CameraStreamMode::rtsp: if (m_next_retry.IsValid() && wxDateTime::Now() < m_next_retry) return; @@ -769,7 +767,8 @@ void MediaPlayCtrl::load() { m_last_state = MEDIASTATE_LOADING; SetStatus(_L("Loading...")); - if (current_mode() != CameraStreamMode::rtsp) { + const auto mode = current_mode(); + if (mode != CameraStreamMode::rtsp && mode != CameraStreamMode::http) { std::string file_h264 = data_dir() + "/video.h264"; std::string file_info = data_dir() + "/video.info"; BOOST_LOG_TRIVIAL(info) << "MediaPlayCtrl dump video to " << file_h264; diff --git a/src/slic3r/GUI/StatusPanel.cpp b/src/slic3r/GUI/StatusPanel.cpp index b00dee3dca..7099f3fea9 100644 --- a/src/slic3r/GUI/StatusPanel.cpp +++ b/src/slic3r/GUI/StatusPanel.cpp @@ -2308,8 +2308,8 @@ void StatusPanel::update_camera_state(MachineObject* obj) auto agent = wxGetApp().getAgent(); const auto camera_mode = agent ? agent->get_camera_stream_mode() : CameraStreamMode::none; - const bool has_printer_webcam = camera_mode == CameraStreamMode::http || camera_mode == CameraStreamMode::http_snapshot; - if (has_printer_webcam) { + const bool use_webview = camera_mode == CameraStreamMode::http_snapshot; + if (use_webview) { //m_camera_switch_button->Hide(); if (!m_custom_camera_view->IsShown()) { // why: do not reload the WebView URL per tick, or redirects can cause a reload loop. @@ -2317,10 +2317,14 @@ void StatusPanel::update_camera_state(MachineObject* obj) m_custom_camera_view->Show(); m_media_ctrl->Hide(); } - } else if (m_custom_camera_view->IsShown()) { - m_custom_camera_view->Hide(); + } else { + if (m_custom_camera_view->IsShown()) { + m_custom_camera_view->Hide(); + // Stop the snapshot WebView before switching to native playback + // or leaving the camera mode. + m_media_play_ctrl->StopWebStream(); + } m_media_ctrl->Show(); - m_media_play_ctrl->StopWebStream(); } //sdcard @@ -2354,7 +2358,7 @@ void StatusPanel::update_camera_state(MachineObject* obj) m_last_recording = obj->is_recording() ? 1 : 0; } - if (has_printer_webcam) { + if (use_webview) { if (m_bitmap_recording_img->IsShown()) { m_bitmap_recording_img->Hide(); m_panel_monitoring_title->Layout(); @@ -2417,7 +2421,7 @@ void StatusPanel::update_camera_state(MachineObject* obj) m_camera_popup->update(show_vcamera); } - m_setting_button->Show(!has_printer_webcam); + m_setting_button->Show(!use_webview); } StatusPanel::StatusPanel(wxWindow *parent, wxWindowID id, const wxPoint &pos, const wxSize &size, long style, const wxString &name) diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index d0d53072d6..c4b114bb9b 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -188,14 +188,14 @@ void wxMediaCtrl3::bambu_log(void *ctx, int level, tchar const *msg2) BOOST_LOG_TRIVIAL(info) << msg.ToUTF8().data(); } -int wxMediaCtrl3::rtsp_interrupt_callback(void *opaque) +int wxMediaCtrl3::ffmpeg_interrupt_callback(void *opaque) { auto *ctrl = static_cast(opaque); std::lock_guard lock(ctrl->m_mutex); return ctrl->m_url != ctrl->m_active_url; } -int wxMediaCtrl3::PlayRtsp(std::shared_ptr const &url, std::unique_lock &lock) +int wxMediaCtrl3::PlayFfmpeg(std::shared_ptr const &url, std::unique_lock &lock) { if (avformat_network_init() < 0) return 2; @@ -206,7 +206,9 @@ int wxMediaCtrl3::PlayRtsp(std::shared_ptr const &url, std::unique_lockinterrupt_callback = {&wxMediaCtrl3::rtsp_interrupt_callback, this}; + format_context->interrupt_callback = {&wxMediaCtrl3::ffmpeg_interrupt_callback, this}; + format_context->flags |= AVFMT_FLAG_NOBUFFER; + format_context->max_delay = 0; m_active_url = url; auto finish = [&](int error) { @@ -219,8 +221,20 @@ int wxMediaCtrl3::PlayRtsp(std::shared_ptr const &url, std::unique_lockBuildURI().ToUTF8().data(); + const wxString scheme = url->GetScheme(); + const bool http_stream = scheme.CmpNoCase("http") == 0 || scheme.CmpNoCase("https") == 0; AVDictionary *options = nullptr; - av_dict_set(&options, "rtsp_transport", "tcp", 0); + if (http_stream) { + // This is a live multipart MJPEG stream. Keep FFmpeg from building a + // read-ahead buffer, otherwise the UI can display frames several + // seconds behind the camera. + av_dict_set(&options, "fflags", "nobuffer", 0); + av_dict_set(&options, "avioflags", "direct", 0); + av_dict_set(&options, "probesize", "32", 0); + av_dict_set(&options, "analyzeduration", "0", 0); + } else { + av_dict_set(&options, "rtsp_transport", "tcp", 0); + } lock.unlock(); int error = avformat_open_input(&format_context, uri.c_str(), nullptr, &options); av_dict_free(&options); @@ -278,7 +292,12 @@ int wxMediaCtrl3::PlayRtsp(std::shared_ptr const &url, std::unique_lockHasScheme()) break; const wxString scheme = url->GetScheme(); - const bool generic_rtsp = scheme.CmpNoCase("rtsp") == 0 || scheme.CmpNoCase("rtsps") == 0; + const bool generic_ffmpeg = scheme.CmpNoCase("http") == 0 || scheme.CmpNoCase("https") == 0 || + scheme.CmpNoCase("rtsp") == 0 || scheme.CmpNoCase("rtsps") == 0; int error = 0; - if (generic_rtsp) { - error = PlayRtsp(url, lk); + if (generic_ffmpeg) { + error = PlayFfmpeg(url, lk); } else { lk.unlock(); Bambu_Tunnel tunnel = nullptr; diff --git a/src/slic3r/GUI/wxMediaCtrl3.h b/src/slic3r/GUI/wxMediaCtrl3.h index bcc94a17ef..5160030983 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.h +++ b/src/slic3r/GUI/wxMediaCtrl3.h @@ -16,6 +16,7 @@ wxDECLARE_EVENT(EVT_MEDIA_CTRL_STAT, wxCommandEvent); void wxMediaCtrl_OnSize(wxWindow * ctrl, wxSize const & videoSize, int width, int height); #define BAMBU_DYNAMIC +#include #include #include #ifndef _WIN32 @@ -56,10 +57,10 @@ protected: void DoSetSize(int x, int y, int width, int height, int sizeFlags) override; static void bambu_log(void *ctx, int level, tchar const *msg); - static int rtsp_interrupt_callback(void *opaque); + static int ffmpeg_interrupt_callback(void *opaque); void PlayThread(); - int PlayRtsp(std::shared_ptr const &url, std::unique_lock &lock); + int PlayFfmpeg(std::shared_ptr const &url, std::unique_lock &lock); void NotifyStopped(); @@ -83,6 +84,7 @@ private: std::mutex m_mutex; std::condition_variable m_cond; std::thread m_thread; + std::atomic_bool m_refresh_pending{false}; }; #endif /* wxMediaCtrl3_h */ From 3a0fda7d18b4299f4ef1746c17c74cf33d74b138 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Mon, 7 Sep 2026 17:28:40 +0800 Subject: [PATCH 13/24] fix: make model_id/dev_type optional instead of blocking --- .../GUI/CalibrationWizardPresetPage.cpp | 6 +++ src/slic3r/GUI/DeviceCore/DevConfigUtil.h | 17 +++++++- src/slic3r/GUI/DeviceManager.cpp | 2 + src/slic3r/GUI/GUI_App.cpp | 6 +++ src/slic3r/GUI/MultiMachine.cpp | 6 +++ src/slic3r/GUI/Plater.cpp | 22 +++++++--- src/slic3r/GUI/PrePrintChecker.cpp | 3 +- src/slic3r/GUI/PrePrintChecker.hpp | 1 + src/slic3r/GUI/SelectMachine.cpp | 40 +++++++++++++++++-- src/slic3r/GUI/SendToPrinter.cpp | 6 +++ src/slic3r/GUI/SyncAmsInfoDialog.cpp | 13 +++++- 11 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/slic3r/GUI/CalibrationWizardPresetPage.cpp b/src/slic3r/GUI/CalibrationWizardPresetPage.cpp index 5267715439..6d28189c54 100644 --- a/src/slic3r/GUI/CalibrationWizardPresetPage.cpp +++ b/src/slic3r/GUI/CalibrationWizardPresetPage.cpp @@ -6,6 +6,7 @@ #include "libslic3r/Print.hpp" #include "DeviceCore/DevConfig.h" +#include "DeviceCore/DevConfigUtil.h" #include "DeviceCore/DevExtruderSystem.h" #include "DeviceCore/DevFilaBlackList.h" #include "DeviceCore/DevFilaSystem.h" @@ -1648,6 +1649,11 @@ bool CalibrationPresetPage::is_blocking_printing() auto source_model = preset_bundle->printers.get_edited_preset().get_printer_type(preset_bundle); auto target_model = obj_->printer_type; + if (DevPrinterConfigUtil::is_optional_printer_model_id(source_model) || + DevPrinterConfigUtil::is_optional_printer_model_id(target_model)) { + return false; + } + if (source_model != target_model) { std::vector compatible_machine = obj_->get_compatible_machine(); vector::iterator it = find(compatible_machine.begin(), compatible_machine.end(), source_model); diff --git a/src/slic3r/GUI/DeviceCore/DevConfigUtil.h b/src/slic3r/GUI/DeviceCore/DevConfigUtil.h index 2b4af44061..60808a0170 100644 --- a/src/slic3r/GUI/DeviceCore/DevConfigUtil.h +++ b/src/slic3r/GUI/DeviceCore/DevConfigUtil.h @@ -59,6 +59,21 @@ public: /*printer*/ // info static std::map get_all_model_id_with_name(); + // A printer agent may not know the physical model. Keep that case optional so + // model compatibility checks do not turn missing identity into a hard error. + static bool is_optional_printer_model_id(const std::string& model_id) + { + if (model_id.empty()) + return true; + if (model_id.size() != 9) + return false; + + static constexpr char generic_model_id[] = "orcasonar"; + return std::equal(model_id.begin(), model_id.end(), generic_model_id, + [](char lhs, char rhs) { + return static_cast(std::tolower(static_cast(lhs))) == rhs; + }); + } static std::string get_printer_type(const std::string& type_str) { return get_value_from_config(type_str, "printer_type"); } static std::string get_printer_display_name(const std::string& type_str) { return get_value_from_config(type_str, "display_name"); } static std::string get_printer_series_str(std::string type_str) { return get_value_from_config(type_str, "printer_series"); } @@ -227,4 +242,4 @@ static std::string _parse_printer_type(const std::string &type_str) return type_str; } -};// namespace Slic3r \ No newline at end of file +};// namespace Slic3r diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index 6c155fe158..fc66604f7c 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -372,6 +372,8 @@ wxString MachineObject::get_printer_type_display_str() const std::string display_name = DevPrinterConfigUtil::get_printer_display_name(printer_type); if (!display_name.empty()) return display_name; + else if (printer_type == "orcasonar") + return "OrcaSonar Printer"; else return _L("Unknown"); } diff --git a/src/slic3r/GUI/GUI_App.cpp b/src/slic3r/GUI/GUI_App.cpp index 1e9cfab5da..94b36b18f8 100644 --- a/src/slic3r/GUI/GUI_App.cpp +++ b/src/slic3r/GUI/GUI_App.cpp @@ -3,6 +3,7 @@ #include "libslic3r/Technologies.hpp" #include "libslic3r/Platform.hpp" #include "GUI_App.hpp" +#include "DeviceCore/DevConfigUtil.h" #include "GUI_Init.hpp" #include "GUI_ObjectList.hpp" #include "slic3r/GUI/UserManager.hpp" @@ -2393,6 +2394,11 @@ bool GUI_App::is_blocking_printing(MachineObject *obj_) PresetBundle *preset_bundle = wxGetApp().preset_bundle; std::string source_model = preset_bundle->printers.get_edited_preset().get_printer_type(preset_bundle); + if (DevPrinterConfigUtil::is_optional_printer_model_id(source_model) || + DevPrinterConfigUtil::is_optional_printer_model_id(target_model)) { + return false; + } + if (source_model != target_model) { std::vector compatible_machine = obj_->get_compatible_machine(); vector::iterator it = find(compatible_machine.begin(), compatible_machine.end(), source_model); diff --git a/src/slic3r/GUI/MultiMachine.cpp b/src/slic3r/GUI/MultiMachine.cpp index c0e84f40e9..19328c501f 100644 --- a/src/slic3r/GUI/MultiMachine.cpp +++ b/src/slic3r/GUI/MultiMachine.cpp @@ -3,6 +3,7 @@ #include "GUI_App.hpp" #include "MainFrame.hpp" +#include "DeviceCore/DevConfigUtil.h" namespace Slic3r { namespace GUI { @@ -114,6 +115,11 @@ bool DeviceItem::is_blocking_printing(MachineObject* obj_) PresetBundle* preset_bundle = wxGetApp().preset_bundle; source_model = preset_bundle->printers.get_edited_preset().get_printer_type(preset_bundle); + if (DevPrinterConfigUtil::is_optional_printer_model_id(source_model) || + DevPrinterConfigUtil::is_optional_printer_model_id(target_model)) { + return false; + } + if (source_model != target_model) { std::vector compatible_machine = obj_->get_compatible_machine(); vector::iterator it = find(compatible_machine.begin(), compatible_machine.end(), source_model); diff --git a/src/slic3r/GUI/Plater.cpp b/src/slic3r/GUI/Plater.cpp index 6d75be8a3e..7b318c4ffe 100644 --- a/src/slic3r/GUI/Plater.cpp +++ b/src/slic3r/GUI/Plater.cpp @@ -2010,12 +2010,14 @@ bool Sidebar::priv::sync_extruder_list(bool &only_external_material, bool is_man std::string machine_print_name = obj->get_show_printer_type(); PresetBundle *preset_bundle = wxGetApp().preset_bundle; std::string target_model_id = preset_bundle->printers.get_selected_preset().get_printer_type(preset_bundle); - Preset* machine_preset = get_printer_preset(obj); - if (!machine_preset) { + const bool optional_printer_model = DevPrinterConfigUtil::is_optional_printer_model_id(obj->printer_type); + const bool optional_target_model = DevPrinterConfigUtil::is_optional_printer_model_id(target_model_id); + Preset* machine_preset = optional_printer_model ? nullptr : get_printer_preset(obj); + if (!optional_printer_model && !optional_target_model && !machine_preset) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << "check error: machine_preset empty"; return false; } - if (machine_print_name != target_model_id) { + if (!optional_printer_model && !optional_target_model && machine_print_name != target_model_id) { MessageDialog dlg(this->plater, _L("The currently selected machine preset is inconsistent with the connected printer type.\n" "Are you sure to continue syncing?"), _L("Sync printer information"), wxICON_WARNING | wxYES | wxNO); if (dlg.ShowModal() == wxID_NO) { @@ -2207,6 +2209,11 @@ void Sidebar::priv::update_sync_status(const MachineObject *obj) return; } + if (DevPrinterConfigUtil::is_optional_printer_model_id(obj->printer_type)) { + clear_all_sync_status(); + return; + } + bool printer_synced = false; // 1. update printer status const Preset &cur_preset = wxGetApp().preset_bundle->printers.get_edited_preset(); @@ -20490,9 +20497,14 @@ bool Plater::is_same_printer_for_connected_and_selected(bool popup_warning) } if (!check_printer_initialized(obj, true, popup_warning)) return false; - Preset * machine_preset = get_printer_preset(obj); - if (!machine_preset) + const std::string machine_model = obj->printer_type; + PresetBundle *preset_bundle = wxGetApp().preset_bundle; + const std::string selected_model = preset_bundle ? preset_bundle->printers.get_edited_preset().get_printer_type(preset_bundle) : std::string(); + if (!DevPrinterConfigUtil::is_optional_printer_model_id(machine_model) && + !DevPrinterConfigUtil::is_optional_printer_model_id(selected_model) && + !get_printer_preset(obj)) { return false; + } if (wxGetApp().is_blocking_printing()) { if (popup_warning) { diff --git a/src/slic3r/GUI/PrePrintChecker.cpp b/src/slic3r/GUI/PrePrintChecker.cpp index 0575af23ed..7d0ae63cf1 100644 --- a/src/slic3r/GUI/PrePrintChecker.cpp +++ b/src/slic3r/GUI/PrePrintChecker.cpp @@ -63,6 +63,7 @@ std::string PrePrintChecker::get_print_status_info(PrintDialogStatus status) case PrintStatusRackReading: return "PrintStatusRackReading"; case PrintStatusRackNozzleNumUnmeetWarning: return "PrintStatusRackNozzleNumUnmeetWarning"; case PrintStatusHasUnreliableNozzleWarning: return "PrintStatusHasUnreliableNozzleWarning"; + case PrintStatusOptionalPrinterModel: return "PrintStatusOptionalPrinterModel"; case PrintStatusWarningExtFilamentNotMatch: return "PrintStatusWarningExtFilamentNotMatch"; case PrintStatusFilamentWarningNozzleHRC: return "PrintStatusFilamentWarningNozzleHRC"; case PrintStatusTPUUnsupportCaliOn: return "PrintStatusTPUUnsupportCaliOn"; @@ -104,6 +105,7 @@ wxString PrePrintChecker::get_pre_state_msg(PrintDialogStatus status) case PrintStatusNeedConsistencyUpgrading: return _L("Cannot send the print job to a printer whose firmware must be updated."); case PrintStatusBlankPlate: return _L("Cannot send a print job for an empty plate."); case PrintStatusTimelapseNoSdcard: return _L("Storage needs to be inserted to record timelapse."); + case PrintStatusOptionalPrinterModel: return _L("The selected printer model could not be identified, so compatibility with the print file configuration cannot be verified. Please verify the printer preset before sending."); case PrintStatusMixAmsAndVtSlotWarning: return _L("You have selected both external and AMS filaments for an extruder. You will need to manually switch the external filament during printing."); case PrintStatusTPUUnsupportAutoCali: return _L("TPU 90A/TPU 85A is too soft and does not support automatic Flow Dynamics calibration."); case PrintStatusWarningKvalueNotUsed: return _L("Set dynamic flow calibration to 'OFF' to enable custom dynamic flow value."); @@ -379,4 +381,3 @@ bool PrinterMsgPanel::UpdateInfos(const std::vector& infos) } }; - diff --git a/src/slic3r/GUI/PrePrintChecker.hpp b/src/slic3r/GUI/PrePrintChecker.hpp index f629b70a73..bab608348d 100644 --- a/src/slic3r/GUI/PrePrintChecker.hpp +++ b/src/slic3r/GUI/PrePrintChecker.hpp @@ -112,6 +112,7 @@ enum PrintDialogStatus : unsigned int { // Orca: a nozzle diameter that differs from the one the printer remembers is a warning, // not an error, so non-standard nozzles can still be printed with. PrintStatusNozzleDiameterMismatch, + PrintStatusOptionalPrinterModel, PrintStatusPrinterWarningEnd, // Warnings for filament diff --git a/src/slic3r/GUI/SelectMachine.cpp b/src/slic3r/GUI/SelectMachine.cpp index fa4482fcef..546649736d 100644 --- a/src/slic3r/GUI/SelectMachine.cpp +++ b/src/slic3r/GUI/SelectMachine.cpp @@ -21,6 +21,7 @@ #include "Jobs/PlaterWorker.hpp" #include "DeviceCore/DevConfig.h" +#include "DeviceCore/DevConfigUtil.h" #include "DeviceCore/DevNozzleSystem.h" #include "DeviceCore/DevNozzleRack.h" #include "DeviceCore/DevExtensionTool.h" @@ -2315,8 +2316,10 @@ void SelectMachineDialog::show_status(PrintDialogStatus status, std::vector compatible_machine = obj_->get_compatible_machine(); vector::iterator it = find(compatible_machine.begin(), compatible_machine.end(), source_model); @@ -2625,6 +2637,10 @@ bool SelectMachineDialog::is_same_printer_model() if(preset_bundle == nullptr) return result; const auto source_model = preset_bundle->printers.get_edited_preset().get_printer_type(preset_bundle); const auto target_model = obj_->printer_type; + if (DevPrinterConfigUtil::is_optional_printer_model_id(source_model) || + DevPrinterConfigUtil::is_optional_printer_model_id(target_model)) { + return true; + } // Orca: ignore P1P -> P1S if (source_model != target_model) { if ((source_model == "C12" && target_model == "C11") || (source_model == "C11" && target_model == "C12") || @@ -4786,6 +4802,22 @@ void SelectMachineDialog::update_show_status(MachineObject* obj_) return; } + bool has_optional_printer_model = DevPrinterConfigUtil::is_optional_printer_model_id(obj_->printer_type); + if (m_print_type == PrintFromType::FROM_NORMAL) { + PresetBundle* preset_bundle = wxGetApp().preset_bundle; + has_optional_printer_model = has_optional_printer_model || + (preset_bundle && DevPrinterConfigUtil::is_optional_printer_model_id( + preset_bundle->printers.get_edited_preset().get_printer_type(preset_bundle))); + } else if (m_print_type == PrintFromType::FROM_SDCARD_VIEW && !m_required_data_plate_data_list.empty()) { + has_optional_printer_model = has_optional_printer_model || + DevPrinterConfigUtil::is_optional_printer_model_id( + m_required_data_plate_data_list[m_print_plate_idx]->printer_model_id); + } + + if (has_optional_printer_model) { + show_status(PrintDialogStatus::PrintStatusOptionalPrinterModel); + } + if (is_blocking_printing(obj_)) { show_status(PrintDialogStatus::PrintStatusUnsupportedPrinter); return; diff --git a/src/slic3r/GUI/SendToPrinter.cpp b/src/slic3r/GUI/SendToPrinter.cpp index a350bd19ea..1fac389862 100644 --- a/src/slic3r/GUI/SendToPrinter.cpp +++ b/src/slic3r/GUI/SendToPrinter.cpp @@ -24,6 +24,7 @@ #include "BitmapCache.hpp" #include "DeviceCore/DevManager.h" +#include "DeviceCore/DevConfigUtil.h" #include "DeviceCore/DevStorage.h" #include "slic3r/Utils/FileTransferUtils.hpp" @@ -1350,6 +1351,11 @@ bool SendToPrinterDialog::is_blocking_printing(MachineObject* obj_) auto source_model = preset_bundle->printers.get_edited_preset().get_printer_type(preset_bundle); auto target_model = obj_->printer_type; + if (DevPrinterConfigUtil::is_optional_printer_model_id(source_model) || + DevPrinterConfigUtil::is_optional_printer_model_id(target_model)) { + return false; + } + if (source_model != target_model) { std::vector compatible_machine = obj_->get_compatible_machine(); vector::iterator it = find(compatible_machine.begin(), compatible_machine.end(), source_model); diff --git a/src/slic3r/GUI/SyncAmsInfoDialog.cpp b/src/slic3r/GUI/SyncAmsInfoDialog.cpp index 47931e2312..aa203f3577 100644 --- a/src/slic3r/GUI/SyncAmsInfoDialog.cpp +++ b/src/slic3r/GUI/SyncAmsInfoDialog.cpp @@ -1875,6 +1875,11 @@ bool SyncAmsInfoDialog::is_blocking_printing(MachineObject *obj_) if (m_required_data_plate_data_list.size() > 0) { source_model = m_required_data_plate_data_list[m_print_plate_idx]->printer_model_id; } } + if (DevPrinterConfigUtil::is_optional_printer_model_id(source_model) || + DevPrinterConfigUtil::is_optional_printer_model_id(target_model)) { + return false; + } + if (source_model != target_model) { std::vector compatible_machine = obj_->get_compatible_machine(); vector::iterator it = find(compatible_machine.begin(), compatible_machine.end(), source_model); @@ -1931,7 +1936,13 @@ bool SyncAmsInfoDialog::is_same_printer_model() if (obj_ == nullptr) { return result; } PresetBundle *preset_bundle = wxGetApp().preset_bundle; - if (preset_bundle && preset_bundle->printers.get_edited_preset().get_printer_type(preset_bundle) != obj_->printer_type) { + const std::string source_model = preset_bundle ? preset_bundle->printers.get_edited_preset().get_printer_type(preset_bundle) : std::string(); + if (DevPrinterConfigUtil::is_optional_printer_model_id(source_model) || + DevPrinterConfigUtil::is_optional_printer_model_id(obj_->printer_type)) { + return true; + } + + if (preset_bundle && source_model != obj_->printer_type) { if ((obj_->is_support_upgrade_kit && obj_->installed_upgrade_kit) && (preset_bundle->printers.get_edited_preset().get_printer_type(preset_bundle) == "C12")) { return true; } From c4599942908907763292ab73c3f1d84bbf5ce8cc Mon Sep 17 00:00:00 2001 From: peachismomo Date: Tue, 8 Sep 2026 04:40:12 +0800 Subject: [PATCH 14/24] fix: connect via ip dialog --- src/slic3r/Utils/OrcaPrinterAgent.cpp | 329 +++++++++++++++++++++++++- 1 file changed, 323 insertions(+), 6 deletions(-) diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 6c7fed7eab..2017a41642 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -1,13 +1,19 @@ #include "OrcaPrinterAgent.hpp" +#include "Http.hpp" #include "IPrinterAgent.hpp" #include "NetworkAgentFactory.hpp" #include "OrcaCloudServiceAgent.hpp" +#include "bambu_networking.hpp" #include +#include +#include #include +#include #include #include #include #include +#include #include #include #include @@ -24,6 +30,147 @@ namespace Slic3r { const std::string OrcaPrinterAgent_VERSION = "0.0.1"; +namespace { + +namespace fs = boost::filesystem; + +// params.filename is normally the exported .3mf archive; the sliced G-code sits +// beside it with the same stem (".12345.0.3mf" -> ".12345.0.gcode"). params.dst_file, +// when set, already points straight at a file (the "print a file already on the +// card" flow), so it wins. +std::string resolve_local_gcode_path(const PrintParams& params) +{ + if (!params.dst_file.empty()) + return params.dst_file; + + std::string path = params.filename; + if (boost::iends_with(path, ".3mf")) + path.replace(path.size() - 4, 4, ".gcode"); + return path; +} + +// The name the file is stored as under the printer's `gcodes` root, and the value +// passed to OrcaSonar's print.gcode_file `param`. Must be a pure function of params +// so start_local_print's upload and start_sdcard_print's start agree on it. +// OrcaSonar rejects newlines, ';', '#', '*' and NUL in the path, and Klipper's +// SDCARD_PRINT_FILE splits its argument on whitespace, so collapse anything unsafe. +std::string remote_gcode_name(const PrintParams& params) +{ + std::string name = params.project_name.empty() + ? fs::path(resolve_local_gcode_path(params)).filename().string() + : fs::path(params.project_name).filename().string(); + + if (boost::iends_with(name, ".3mf")) // "model.gcode.3mf" -> "model.gcode" + name.erase(name.size() - 4); + + std::replace_if( + name.begin(), name.end(), + [](unsigned char c) { + return std::isspace(c) != 0 || c == ';' || c == '#' || c == '*' || c == '/' || c == '\\'; + }, + '_'); + + if (name.empty()) + name = "orca_print"; + if (!boost::iends_with(name, ".gcode")) + name += ".gcode"; + return name; +} + +// http(s) origin of the Moonraker-compatible upload facade, derived from the live +// LAN MQTT session URL ("ws://host:port/mqtt" -> "http://host:port"). Used only as +// a fallback when the print job carries no dev_ip of its own. +std::string http_origin_from_lan_ws(const std::string& ws_url) +{ + if (ws_url.empty()) + return {}; + std::string s = ws_url; + if (boost::istarts_with(s, "wss://")) + s = "https://" + s.substr(6); + else if (boost::istarts_with(s, "ws://")) + s = "http://" + s.substr(5); + const auto scheme = s.find("://"); + if (scheme != std::string::npos) { + if (const auto slash = s.find('/', scheme + 3); slash != std::string::npos) + s.erase(slash); + } + return s; +} + +// print.gcode_file is non-idempotent and OrcaSonar replays a cached response for a +// reused (namespace, command, sequence_id). Seed from the wall clock so ids do not +// collide across slicer restarts, then bump once per call within a run. +std::string next_gcode_file_sequence_id() +{ + static std::atomic counter{[] { + const auto now = std::chrono::system_clock::now().time_since_epoch(); + return static_cast(std::chrono::duration_cast(now).count()); + }()}; + return std::to_string(counter.fetch_add(1, std::memory_order_relaxed)); +} + +// Ask an OrcaSonar instance at host:port for its MQTT device id. Every command +// topic is keyed on it (device//request), so a manual "connect by IP" has to +// learn it from the printer instead of inventing one from the address. OrcaSonar's +// landing page (GET /) returns "OrcaSonar running\ndevice_id=\nmqtt=\n"; +// /upnp/device.xml carries the same id as uuid: and is the fallback. +bool probe_orcasonar_device_id(const std::string& host, const std::string& port, std::string& device_id) +{ + const std::string origin = "http://" + host + ":" + port; + + auto fetch = [](const std::string& url, std::string& body) { + bool ok = false; + Http::get(url) + .timeout_connect(4) + .timeout_max(6) + .on_complete([&](std::string b, unsigned status) { + if (status == 200) { + body = std::move(b); + ok = true; + } + }) + .on_error([&](std::string, std::string err, unsigned status) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: identity probe " << url + << " failed status=" << status << " err=" << err; + }) + .perform_sync(); + return ok; + }; + + auto extract = [](const std::string& body, const std::string& start_token, char end_char) -> std::string { + const auto pos = body.find(start_token); + if (pos == std::string::npos) + return {}; + const auto value_start = pos + start_token.size(); + const auto value_end = body.find(end_char, value_start); + std::string value = body.substr(value_start, value_end == std::string::npos ? std::string::npos : value_end - value_start); + boost::trim(value); + return value; + }; + + std::string body; + if (fetch(origin + "/", body)) { + std::string id = extract(body, "device_id=", '\n'); + if (!id.empty()) { + device_id = std::move(id); + return true; + } + } + + body.clear(); + if (fetch(origin + "/upnp/device.xml", body)) { + std::string id = extract(body, "uuid:", '<'); + if (!id.empty()) { + device_id = std::move(id); + return true; + } + } + + return false; +} + +} // namespace + class OrcaPrinterAgent::OrcaSonarDiscovery { public: @@ -921,12 +1068,41 @@ bool OrcaPrinterAgent::start_discovery(bool start, bool /*sending*/) } // ============================================================================ -// Binding - All Stubs +// Binding // ============================================================================ int OrcaPrinterAgent::ping_bind(std::string ping_code) { return BAMBU_NETWORK_SUCCESS; } -int OrcaPrinterAgent::bind_detect(std::string dev_ip, std::string sec_link, detectResult& detect) { return BAMBU_NETWORK_SUCCESS; } +// Runs on the "Input IP address" dialog worker thread, before any MachineObject +// exists. Probe the address for a live OrcaSonar and hand its real device id back +// so DeviceManager::insert_local_device keys the machine correctly; connect_type +// and bind_state must be set for is_lan_mode_printer()/is_avaliable() to hold, or +// set_selected_machine never routes to the LAN connect path. +int OrcaPrinterAgent::bind_detect(std::string dev_ip, std::string /*sec_link*/, detectResult& detect) +{ + std::string host, port; + if (!parse_lan_endpoint(dev_ip, host, port)) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::bind_detect: unparsable dev_ip=" << dev_ip; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; // -1: dialog shows "Failed to connect to printer." + } + + std::string device_id; + if (!probe_orcasonar_device_id(host, port, device_id) || device_id.empty()) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::bind_detect: no OrcaSonar reachable at " << host << ":" << port; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + + detect.dev_id = device_id; + detect.dev_name = device_id; + detect.model_id = ""; // unknown; DeviceManager::insert_local_device defaults it + detect.version = ""; + detect.connect_type = "lan"; // required by MachineObject::is_lan_mode_printer() + detect.bind_state = "free"; // required by MachineObject::is_avaliable() + detect.result_msg = ""; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::bind_detect: found OrcaSonar dev_id=" << device_id + << " at " << host << ":" << port; + return BAMBU_NETWORK_SUCCESS; +} int OrcaPrinterAgent::bind(std::string dev_ip, std::string dev_id, @@ -1061,14 +1237,155 @@ int OrcaPrinterAgent::start_local_print_with_record(PrintParams params, OnWaitFn wait_fn) { return BAMBU_NETWORK_SUCCESS; } -int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) -{ return BAMBU_NETWORK_SUCCESS; } +// Upload one G-code file to the printer's `gcodes` root over OrcaSonar's +// Moonraker-compatible HTTP facade. No print is started here (print=false); the +// caller issues print.gcode_file over MQTT separately (start_sdcard_print). +int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn /*wait_fn*/) +{ + if (update_fn) + update_fn(PrintingStageCreate, 0, "Preparing..."); + const std::string local_path = resolve_local_gcode_path(params); + const fs::path source(local_path); + boost::system::error_code ec; + if (!fs::exists(source, ec) || !fs::is_regular_file(source, ec)) { + BOOST_LOG_TRIVIAL(error) << "OrcaPrinterAgent: G-code file does not exist: " << local_path; + return BAMBU_NETWORK_ERR_FILE_NOT_EXIST; + } + + const std::uintmax_t file_size = fs::file_size(source, ec); + if (ec) { + BOOST_LOG_TRIVIAL(error) << "OrcaPrinterAgent: cannot stat G-code file " << local_path << ": " << ec.message(); + return BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED; + } + if (file_size > 1024ull * 1024 * 1024) { // OrcaSonar caps a single upload at 1 GiB + BOOST_LOG_TRIVIAL(error) << "OrcaPrinterAgent: G-code file too large: " << file_size << " bytes"; + return BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED; + } + + std::string host, port, origin; + if (parse_lan_endpoint(params.dev_ip, host, port)) + origin = "http://" + host + ":" + port; + else + origin = http_origin_from_lan_ws(lan_connection_target()); + if (origin.empty()) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: no LAN HTTP endpoint for G-code upload (dev_ip=" << params.dev_ip << ")"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + + const std::string upload_name = remote_gcode_name(params); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: uploading G-code " << local_path << " -> " << origin + << "/server/files/upload as " << upload_name << " (" << file_size << " bytes)"; + + if (update_fn) + update_fn(PrintingStageUpload, 0, "Uploading..."); + + bool canceled = false; + long http_status = 0; + std::string http_error; + std::string response_body; + + auto http = Http::post(origin + "/server/files/upload"); + if (!params.password.empty()) + http.header("X-Api-Key", params.password); // trusted LAN facades may not require it; harmless when they do not + http.form_add("root", "gcodes") + .form_add("print", "false") + .form_add_file("file", source, upload_name) + .timeout_connect(5) + .timeout_max(300) // large G-code over a slow link + .on_complete([&](std::string body, unsigned status) { + http_status = status; + response_body = std::move(body); + }) + .on_error([&](std::string body, std::string err, unsigned status) { + http_status = status; + http_error = std::move(err); + response_body = std::move(body); + }) + .on_progress([&](Http::Progress progress, bool& cancel) { + if (cancel_fn && cancel_fn()) { + cancel = true; + canceled = true; + return; + } + if (update_fn && progress.ultotal > 0) { + const int percent = static_cast((progress.ulnow * 100) / progress.ultotal); + update_fn(PrintingStageUpload, percent, "Uploading..."); + } + }) + .perform_sync(); + + if (canceled) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: G-code upload canceled by user"; + return BAMBU_NETWORK_ERR_CANCELED; + } + + // OrcaSonar's Moonraker facade returns 201 Created on a successful save. + if (http_status != 200 && http_status != 201) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: G-code upload failed http_status=" << http_status + << " error=" << http_error << " body=" << response_body; + return BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED; + } + + if (update_fn) + update_fn(PrintingStageUpload, 100, "File uploaded"); + return BAMBU_NETWORK_SUCCESS; +} + +// Upload the sliced G-code, then start it: the LAN "print now" path. int OrcaPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) -{ return BAMBU_NETWORK_SUCCESS; } +{ + if (cancel_fn && cancel_fn()) + return BAMBU_NETWORK_ERR_CANCELED; + const int upload_rc = start_send_gcode_to_sdcard(params, update_fn, cancel_fn, nullptr); + if (upload_rc != BAMBU_NETWORK_SUCCESS) + return upload_rc; + + if (cancel_fn && cancel_fn()) + return BAMBU_NETWORK_ERR_CANCELED; + + return start_sdcard_print(params, update_fn, cancel_fn); +} + +// Start a file that already lives on the printer by publishing the canonical +// OPCP print.gcode_file command to device//request. The acknowledgement +// and lifecycle progress arrive asynchronously as print.push_status on the +// report topic, which the GUI already consumes. int OrcaPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) -{ return BAMBU_NETWORK_SUCCESS; } +{ + if (params.dev_id.empty()) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: start_sdcard_print rejected missing dev_id"; + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + } + if (cancel_fn && cancel_fn()) + return BAMBU_NETWORK_ERR_CANCELED; + + // dst_file, when set, names a file already on the printer (print-from-SD flow); + // otherwise start what start_send_gcode_to_sdcard just uploaded to `gcodes`. + const std::string target = params.dst_file.empty() ? remote_gcode_name(params) + : fs::path(params.dst_file).filename().string(); + + nlohmann::json j; + j["print"]["command"] = "gcode_file"; + j["print"]["sequence_id"] = next_gcode_file_sequence_id(); + j["print"]["param"] = target; + + if (update_fn) + update_fn(PrintingStageSending, 0, "Starting print..."); + + const bool is_lan = params.connection_type == "lan"; + const int rc = route_send(is_lan, params.dev_id, j.dump()); + if (rc != BAMBU_NETWORK_SUCCESS) { + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: start_sdcard_print publish failed rc=" << rc + << " dev_id=" << params.dev_id << " param=" << target; + return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED; + } + + if (update_fn) + update_fn(PrintingStageFinished, 100, "Print started"); + return BAMBU_NETWORK_SUCCESS; +} // ============================================================================ // Callback Registration From 01c553bad9315d4f7df95e1ea7bb3a11b2cc0314 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Tue, 8 Sep 2026 19:13:48 +0800 Subject: [PATCH 15/24] feat: LAN impl for Orca Printer Agent --- src/slic3r/GUI/DeviceCore/DevStorage.cpp | 4 +- src/slic3r/Utils/OrcaPrinterAgent.cpp | 389 ++++++++++++++--------- 2 files changed, 244 insertions(+), 149 deletions(-) diff --git a/src/slic3r/GUI/DeviceCore/DevStorage.cpp b/src/slic3r/GUI/DeviceCore/DevStorage.cpp index 0c60b096bd..cb17ba9430 100644 --- a/src/slic3r/GUI/DeviceCore/DevStorage.cpp +++ b/src/slic3r/GUI/DeviceCore/DevStorage.cpp @@ -20,8 +20,8 @@ DevStorage::SdcardState Slic3r::DevStorage::set_sdcard_state(int state) if (system) { try { - if (print_json.contains("sdcard")) { - if (print_json["sdcard"].get()) + if (print_json.contains("sdcard") || print_json.contains("support_send_to_sd")) { + if (print_json["sdcard"].get() || print_json["support_send_to_sd"].get()) system->m_sdcard_state = DevStorage::SdcardState::HAS_SDCARD_NORMAL; else system->m_sdcard_state = DevStorage::SdcardState::NO_SDCARD; diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 2017a41642..83d2924d68 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -22,10 +22,14 @@ #include #include #include +#include #include #include #include +#include +#include + namespace Slic3r { const std::string OrcaPrinterAgent_VERSION = "0.0.1"; @@ -56,19 +60,15 @@ std::string resolve_local_gcode_path(const PrintParams& params) // SDCARD_PRINT_FILE splits its argument on whitespace, so collapse anything unsafe. std::string remote_gcode_name(const PrintParams& params) { - std::string name = params.project_name.empty() - ? fs::path(resolve_local_gcode_path(params)).filename().string() - : fs::path(params.project_name).filename().string(); + std::string name = params.project_name.empty() ? fs::path(resolve_local_gcode_path(params)).filename().string() : + fs::path(params.project_name).filename().string(); if (boost::iends_with(name, ".3mf")) // "model.gcode.3mf" -> "model.gcode" name.erase(name.size() - 4); std::replace_if( name.begin(), name.end(), - [](unsigned char c) { - return std::isspace(c) != 0 || c == ';' || c == '#' || c == '*' || c == '/' || c == '\\'; - }, - '_'); + [](unsigned char c) { return std::isspace(c) != 0 || c == ';' || c == '#' || c == '*' || c == '/' || c == '\\'; }, '_'); if (name.empty()) name = "orca_print"; @@ -109,66 +109,154 @@ std::string next_gcode_file_sequence_id() return std::to_string(counter.fetch_add(1, std::memory_order_relaxed)); } -// Ask an OrcaSonar instance at host:port for its MQTT device id. Every command -// topic is keyed on it (device//request), so a manual "connect by IP" has to -// learn it from the printer instead of inventing one from the address. OrcaSonar's -// landing page (GET /) returns "OrcaSonar running\ndevice_id=\nmqtt=\n"; -// /upnp/device.xml carries the same id as uuid: and is the fallback. -bool probe_orcasonar_device_id(const std::string& host, const std::string& port, std::string& device_id) +static constexpr const char* ORCASONAR_FALLBACK = "orcasonar"; + +bool fetch_orcasonar_body(const std::string& url, std::string& body) { - const std::string origin = "http://" + host + ":" + port; + bool ok = false; + Http::get(url) + .timeout_connect(4) + .timeout_max(6) + .on_complete([&](std::string b, unsigned status) { + if (status == 200) { + body = std::move(b); + ok = true; + } + }) + .on_error([&](std::string, std::string err, unsigned status) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: identity probe " << url << " failed status=" << status << " err=" << err; + }) + .perform_sync(); + return ok; +} - auto fetch = [](const std::string& url, std::string& body) { - bool ok = false; - Http::get(url) - .timeout_connect(4) - .timeout_max(6) - .on_complete([&](std::string b, unsigned status) { - if (status == 200) { - body = std::move(b); - ok = true; - } - }) - .on_error([&](std::string, std::string err, unsigned status) { - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: identity probe " << url - << " failed status=" << status << " err=" << err; - }) - .perform_sync(); - return ok; - }; +std::string extract_line_value(const std::string& body, const std::string& key) +{ + const auto pos = body.find(key); + if (pos == std::string::npos) + return {}; + const auto value_start = pos + key.size(); + const auto value_end = body.find('\n', value_start); + std::string value = body.substr(value_start, value_end == std::string::npos ? std::string::npos : value_end - value_start); + boost::trim(value); + return value; +} - auto extract = [](const std::string& body, const std::string& start_token, char end_char) -> std::string { - const auto pos = body.find(start_token); - if (pos == std::string::npos) - return {}; - const auto value_start = pos + start_token.size(); - const auto value_end = body.find(end_char, value_start); - std::string value = body.substr(value_start, value_end == std::string::npos ? std::string::npos : value_end - value_start); - boost::trim(value); - return value; - }; +// Manual binding uses the OrcaSonar landing page as its sole identity source. +// The page returns device_id and may return device_name/model_id. +bool probe_orcasonar_landing_page(const std::string& host, + const std::string& port, + std::string& device_id, + std::string& device_name, + std::string& model_id) +{ + device_name = ORCASONAR_FALLBACK; + model_id = ORCASONAR_FALLBACK; std::string body; - if (fetch(origin + "/", body)) { - std::string id = extract(body, "device_id=", '\n'); - if (!id.empty()) { - device_id = std::move(id); + if (fetch_orcasonar_body("http://" + host + ":" + port + "/", body)) { + device_id = extract_line_value(body, "device_id="); + const std::string name = extract_line_value(body, "device_name="); + const std::string model = extract_line_value(body, "model_id="); + if (!name.empty()) + device_name = name; + if (!model.empty()) + model_id = model; + if (!device_id.empty()) return true; - } - } - - body.clear(); - if (fetch(origin + "/upnp/device.xml", body)) { - std::string id = extract(body, "uuid:", '<'); - if (!id.empty()) { - device_id = std::move(id); - return true; - } } return false; } +// SSDP discovery uses the LOCATION URL's UPnP device description as its sole +// identity source. OrcaSonar maps device_id/device_name/model_id to UDN, +// friendlyName, and modelNumber respectively. +bool parse_orcasonar_device_xml(const std::string& body, + std::string& device_id, + std::string& device_name, + std::string& model_id) +{ + device_name = ORCASONAR_FALLBACK; + model_id = ORCASONAR_FALLBACK; + + try { + boost::property_tree::ptree tree; + std::istringstream stream(body); + boost::property_tree::read_xml(stream, tree, boost::property_tree::xml_parser::trim_whitespace); + const auto device = tree.get_child_optional("root.device"); + if (!device) + return false; + + std::string udn = device->get("UDN", ""); + boost::trim(udn); + if (boost::istarts_with(udn, "uuid:")) + device_id = udn.substr(5); + else + device_id = device->get("device_id", ""); + boost::trim(device_id); + if (device_id.empty()) + return false; + + device_name = device->get("friendlyName", ""); + if (device_name.empty()) + device_name = device->get("device_name", ORCASONAR_FALLBACK); + model_id = device->get("modelNumber", ""); + if (model_id.empty()) + model_id = device->get("model_id", ORCASONAR_FALLBACK); + boost::trim(device_name); + boost::trim(model_id); + if (device_name.empty()) + device_name = ORCASONAR_FALLBACK; + if (model_id.empty()) + model_id = ORCASONAR_FALLBACK; + return true; + } catch (const std::exception& error) { + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: failed to parse OrcaSonar device.xml: " << error.what(); + return false; + } +} + +bool probe_orcasonar_device_xml(const std::string& location, + std::string& device_id, + std::string& device_name, + std::string& model_id) +{ + std::string body; + return fetch_orcasonar_body(location, body) && parse_orcasonar_device_xml(body, device_id, device_name, model_id); +} + +// TEMP MOCK: OrcaSonar's push_status doesn't report storage state yet, so +// DevStorage::ParseV1_0() decodes NO_SDCARD and SelectMachineDialog blocks +// printing with "No SD card". Force the storage-present markers into every +// forwarded status document so the printer reports HAS_SDCARD_NORMAL. +// Remove once the firmware reports real storage state. +std::string force_sdcard_present(const std::string& payload) +{ + nlohmann::json envelope = nlohmann::json::parse(payload, nullptr, false); + if (!envelope.is_object()) + return payload; + + const auto print_it = envelope.find("print"); + if (print_it == envelope.end() || !print_it->is_object()) + return payload; + + // DevStorage::ParseV1_0() reads print.sdcard as a bool -> HAS_SDCARD_NORMAL. + (*print_it)["sdcard"] = true; + + // MachineObject::parse_home_flag() runs afterwards and re-derives the state + // from bits 8-9 of print.home_flag; rewrite them to 01 so it doesn't clobber + // the mock back to NO_SDCARD. + const auto home_flag_it = print_it->find("home_flag"); + if (home_flag_it != print_it->end() && home_flag_it->is_number_integer()) { + int flag = home_flag_it->get(); + flag = (flag & ~(0x3 << 8)) | (0x1 << 8); + *home_flag_it = flag; + } + + return envelope.dump(); +} + } // namespace class OrcaPrinterAgent::OrcaSonarDiscovery @@ -246,8 +334,7 @@ private: const std::size_t type_start = lower_usn.find(device_type, uuid_prefix.size()); if (type_start == std::string::npos) return false; - const std::string device_id = trim_ascii(usn.substr(uuid_prefix.size(), type_start - uuid_prefix.size())); - if (device_id.empty() || host.empty()) + if (host.empty()) return false; const std::string lower_location = lower_ascii(location); @@ -274,17 +361,34 @@ private: if (port.empty() || port.find_first_not_of("0123456789") != std::string::npos) return false; + // SSDP LOCATION commonly advertises the device's mDNS name (for + // example, http://orcasonar-123.local:8280/upnp/device.xml). The + // discovery response already gives us the sender's reachable address, + // so use that address for the HTTP probe instead of requiring the + // platform HTTP client to resolve .local. Keep the advertised path so + // this remains compatible with non-default device-description URLs. + const std::string location_path = authority_end == std::string::npos ? "/" : location.substr(authority_end); + const std::string probe_host = host.find(':') == std::string::npos ? host : "[" + host + "]"; + const std::string probe_url = location.substr(0, scheme_end + 3) + probe_host + ":" + port + location_path; + + std::string device_id; + std::string device_name; + std::string model_id; + if (!probe_orcasonar_device_xml(probe_url, device_id, device_name, model_id)) + return false; + nlohmann::json machine; - machine["dev_name"] = device_id; + machine["dev_name"] = device_name; machine["dev_id"] = device_id; + machine["dev_type"] = model_id; + machine["connection_name"] = device_id; machine["dev_ip"] = host + ":" + port; - machine["dev_type"] = "orcasonar"; + machine["dev_signal"] = "0"; machine["connect_type"] = "lan"; machine["bind_state"] = "free"; machine["sec_link"] = "secure"; machine["ssdp_version"] = "v1"; - machine["connection_name"] = device_id; json = machine.dump(); return true; } @@ -443,10 +547,8 @@ void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::str fn = on_message_fn; q = queue_on_main_fn; } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering cloud message dev_id=" << dev_id - << " payload_bytes=" << payload.size() - << " callback=" << (fn ? "set" : "null") - << " queue_on_main=" << (q ? "set" : "null"); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering cloud message dev_id=" << dev_id << " payload_bytes=" << payload.size() + << " callback=" << (fn ? "set" : "null") << " queue_on_main=" << (q ? "set" : "null"); if (!fn) { BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping cloud message because on_message_fn is not set" << " dev_id=" << dev_id; @@ -469,10 +571,8 @@ void OrcaPrinterAgent::deliver_to_local_sink(const std::string& dev_id, const st fn = on_local_message_fn; q = queue_on_main_fn; } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering LAN message dev_id=" << dev_id - << " payload_bytes=" << payload.size() - << " callback=" << (fn ? "set" : "null") - << " queue_on_main=" << (q ? "set" : "null"); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering LAN message dev_id=" << dev_id << " payload_bytes=" << payload.size() + << " callback=" << (fn ? "set" : "null") << " queue_on_main=" << (q ? "set" : "null"); if (!fn) { BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping LAN message because on_local_message_fn is not set" << " dev_id=" << dev_id; @@ -494,10 +594,8 @@ void OrcaPrinterAgent::dispatch_local_connect(int state, const std::string& dev_ queue = queue_on_main_fn; } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connection callback state=" << state - << " dev_id=" << dev_id << " message=" << message - << " callback=" << (callback ? "set" : "null") - << " queue_on_main=" << (queue ? "set" : "null"); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connection callback state=" << state << " dev_id=" << dev_id << " message=" << message + << " callback=" << (callback ? "set" : "null") << " queue_on_main=" << (queue ? "set" : "null"); if (!callback) return; @@ -515,8 +613,7 @@ std::function OrcaPrinterAgent::ma deliver_to_local_sink(id, payload); else BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: dropping stale LAN message generation=" << generation - << " current_generation=" << m_lan_generation.load() - << " dev_id=" << id; + << " current_generation=" << m_lan_generation.load() << " dev_id=" << id; }; } @@ -612,8 +709,7 @@ int OrcaPrinterAgent::command_auto_leveling(std::string dev_id, int sequence_id, return route_send(lan_mode, dev_id, j.dump()); } -int OrcaPrinterAgent::command_go_home(std::string dev_id, bool is_printing, bool supports_mqtt_homing, - int sequence_id, bool lan_mode) +int OrcaPrinterAgent::command_go_home(std::string dev_id, bool is_printing, bool supports_mqtt_homing, int sequence_id, bool lan_mode) { nlohmann::json j; j["print"]["sequence_id"] = std::to_string(sequence_id); @@ -627,8 +723,7 @@ int OrcaPrinterAgent::command_go_home(std::string dev_id, bool is_printing, bool return route_send(lan_mode, dev_id, j.dump()); } -int OrcaPrinterAgent::command_set_bed(std::string dev_id, int temp, bool /*supports_mqtt_bed_ctrl*/, - int sequence_id, bool lan_mode) +int OrcaPrinterAgent::command_set_bed(std::string dev_id, int temp, bool /*supports_mqtt_bed_ctrl*/, int sequence_id, bool lan_mode) { nlohmann::json j; j["print"]["command"] = "set_bed_temp"; @@ -647,9 +742,15 @@ int OrcaPrinterAgent::command_set_nozzle(std::string dev_id, int temp, int seque return route_send(lan_mode, dev_id, j.dump()); } -int OrcaPrinterAgent::command_axis_control(std::string dev_id, std::string axis, double unit, double input_val, - int /*speed*/, bool is_core_xy, bool /*supports_mqtt_axis_control*/, - int sequence_id, bool lan_mode) +int OrcaPrinterAgent::command_axis_control(std::string dev_id, + std::string axis, + double unit, + double input_val, + int /*speed*/, + bool is_core_xy, + bool /*supports_mqtt_axis_control*/, + int sequence_id, + bool lan_mode) { std::transform(axis.begin(), axis.end(), axis.begin(), [](unsigned char c) { return static_cast(std::toupper(c)); }); if (axis != "X" && axis != "Y" && axis != "Z" && axis != "E") { @@ -659,8 +760,7 @@ int OrcaPrinterAgent::command_axis_control(std::string dev_id, std::string axis, const double requested_distance = input_val * unit; if (!std::isfinite(requested_distance) || requested_distance == 0.0) { - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: invalid axis control distance input=" << input_val - << " unit=" << unit; + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: invalid axis control distance input=" << input_val << " unit=" << unit; return BAMBU_NETWORK_ERR_INVALID_HANDLE; } @@ -716,7 +816,7 @@ bool OrcaPrinterAgent::parse_nonnegative_command_id(const std::string& value, in if (value.empty()) return false; try { - std::size_t consumed = 0; + std::size_t consumed = 0; const long long parsed = std::stoll(value, &consumed); if (consumed != value.size() || parsed < 0 || parsed > std::numeric_limits::max()) return false; @@ -738,18 +838,18 @@ void OrcaPrinterAgent::parse_ipcam_info(const std::string& dev_id, const std::st return; const nlohmann::json& print = *print_it; - const auto command_it = print.find("command"); - const bool is_push_status = command_it != print.end() && command_it->is_string() && command_it->get() == "push_status"; - bool is_full_snapshot = false; + const auto command_it = print.find("command"); + const bool is_push_status = command_it != print.end() && command_it->is_string() && command_it->get() == "push_status"; + bool is_full_snapshot = false; if (is_push_status) { const auto msg_it = print.find("msg"); - is_full_snapshot = msg_it == print.end() || (msg_it->is_number_integer() && msg_it->get() == 0); + is_full_snapshot = msg_it == print.end() || (msg_it->is_number_integer() && msg_it->get() == 0); } CameraStreamMode stream_mode = CameraStreamMode::none; std::string stream_url; bool has_camera_update = false; - const auto ipcam_it = print.find("ipcam"); + const auto ipcam_it = print.find("ipcam"); if (ipcam_it != print.end() && ipcam_it->is_object()) { const auto stream_modes_it = ipcam_it->find("stream_mode"); if (stream_modes_it != ipcam_it->end() && stream_modes_it->is_array()) { @@ -758,7 +858,7 @@ void OrcaPrinterAgent::parse_ipcam_info(const std::string& dev_id, const std::st if (!stream.is_object()) continue; const auto mode_it = stream.find("mode"); - const auto url_it = stream.find("url"); + const auto url_it = stream.find("url"); if (mode_it == stream.end() || url_it == stream.end() || !mode_it->is_string() || !url_it->is_string()) continue; @@ -775,8 +875,7 @@ void OrcaPrinterAgent::parse_ipcam_info(const std::string& dev_id, const std::st stream_url = url_it->get(); break; // OrcaSonar orders entries by preference. } - } - else if (is_full_snapshot) { + } else if (is_full_snapshot) { has_camera_update = true; } } else if (is_full_snapshot) { @@ -795,11 +894,9 @@ void OrcaPrinterAgent::parse_ipcam_info(const std::string& dev_id, const std::st } m_camera_stream_mode = stream_mode; - m_camera_url = std::move(stream_url); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: updated camera state dev_id=" << dev_id - << " transport=LAN" - << " mode=" << static_cast(m_camera_stream_mode) - << " url=" << m_camera_url; + m_camera_url = std::move(stream_url); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: updated camera state dev_id=" << dev_id << " transport=LAN" + << " mode=" << static_cast(m_camera_stream_mode) << " url=" << m_camera_url; } std::string OrcaPrinterAgent::lan_connection_target() const @@ -845,9 +942,9 @@ void OrcaPrinterAgent::on_connected(const std::string& dev_id, OrcaMqttConnectio int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) { - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: connect_printer requested dev_id=" << dev_id - << " dev_ip=" << dev_ip << " username=" << (username.empty() ? "" : username) - << " password_present=" << (!password.empty()) << " use_ssl=" << use_ssl; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: connect_printer requested dev_id=" << dev_id << " dev_ip=" << dev_ip + << " username=" << (username.empty() ? "" : username) << " password_present=" << (!password.empty()) + << " use_ssl=" << use_ssl; (void) use_ssl; // OrcaSonar LAN is plaintext ws:// if (dev_id.empty() || dev_ip.empty()) { BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: connect_printer rejected missing dev_id or dev_ip"; @@ -869,16 +966,15 @@ int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, st cfg.client_id = make_lan_client_id(dev_id); cfg.keepalive_seconds = 60; - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connection prepared generation=" << gen - << " host=" << host << " port=" << port << " url=" << cfg.url - << " mqtt_username=" << cfg.username << " password_present=" << (!cfg.password.empty()) + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connection prepared generation=" << gen << " host=" << host << " port=" << port + << " url=" << cfg.url << " mqtt_username=" << cfg.username << " password_present=" << (!cfg.password.empty()) << " client_id=" << cfg.client_id; OrcaMqttConnection* conn = nullptr; CurrentConn previous_connection; { std::lock_guard l(state_mutex); - previous_connection = m_current_connection; + previous_connection = m_current_connection; m_lan_dev_id = dev_id; m_lan_url = cfg.url; m_camera_stream_mode = CameraStreamMode::none; @@ -893,17 +989,16 @@ int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, st if (m_lan_connect_thread.joinable()) m_lan_connect_thread.join(); // disconnect_printer() above already stopped the old conn, so this is fast m_lan_connect_thread = std::thread([this, conn, cfg, dev_id, gen] { - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connect worker started generation=" << gen - << " dev_id=" << dev_id << " url=" << cfg.url; + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connect worker started generation=" << gen << " dev_id=" << dev_id + << " url=" << cfg.url; if (gen != m_lan_generation.load()) { BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connect worker abandoned before start generation=" << gen << " current_generation=" << m_lan_generation.load(); return; // superseded before we ran: never raise a socket nobody will tear down } const bool ok = conn->start(cfg, make_lan_message_handler(gen), [this, gen, dev_id, conn](bool connected, bool initial) { - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN MQTT state callback connected=" << connected - << " initial=" << initial << " generation=" << gen - << " current_generation=" << m_lan_generation.load() + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN MQTT state callback connected=" << connected << " initial=" << initial + << " generation=" << gen << " current_generation=" << m_lan_generation.load() << " connack_rc=" << conn->last_connack_rc(); if (gen != m_lan_generation.load()) { BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: ignoring stale LAN MQTT state callback generation=" << gen; @@ -916,21 +1011,19 @@ int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, st dispatch_local_connect(ConnectStatusLost, dev_id, "connection_lost"); } }); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN MQTT start returned ok=" << ok - << " generation=" << gen << " current_generation=" << m_lan_generation.load() - << " connected=" << conn->is_connected() << " running=" << conn->is_running() - << " connack_rc=" << conn->last_connack_rc(); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN MQTT start returned ok=" << ok << " generation=" << gen + << " current_generation=" << m_lan_generation.load() << " connected=" << conn->is_connected() + << " running=" << conn->is_running() << " connack_rc=" << conn->last_connack_rc(); if (ok && gen == m_lan_generation.load()) { on_connected(dev_id, conn, gen); dispatch_local_connect(ConnectStatusOk, dev_id, "0"); } else if (!ok && gen == m_lan_generation.load() && !conn->is_running()) { // A refusal with rc 4/5 terminates the transport. Network errors keep // retrying in OrcaMqttConnection, so leave the UI in its connecting state. - const int rc = conn->last_connack_rc(); + const int rc = conn->last_connack_rc(); const std::string reason = rc >= 0 ? std::to_string(rc) : "initial_connect_failed"; BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: LAN MQTT connection terminated before readiness" - << " generation=" << gen << " connack_rc=" << rc - << " reason=" << reason; + << " generation=" << gen << " connack_rc=" << rc << " reason=" << reason; dispatch_local_connect(ConnectStatusFailed, dev_id, reason); } else if (!ok && gen == m_lan_generation.load()) { BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: LAN MQTT initial attempt failed but worker is retrying" @@ -954,8 +1047,8 @@ int OrcaPrinterAgent::disconnect_printer() { std::lock_guard l(state_mutex); previous_connection = m_current_connection; - doomed = std::move(lan_mqtt_connection); - prev_dev = m_lan_dev_id; + doomed = std::move(lan_mqtt_connection); + prev_dev = m_lan_dev_id; m_lan_dev_id.clear(); if (m_current_connection == LAN) { m_current_connection = NONE; @@ -964,8 +1057,8 @@ int OrcaPrinterAgent::disconnect_printer() } current_connection = m_current_connection; } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN disconnect generation=" << m_lan_generation.load() - << " previous_dev_id=" << prev_dev << " had_connection=" << (doomed ? "yes" : "no") + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN disconnect generation=" << m_lan_generation.load() << " previous_dev_id=" << prev_dev + << " had_connection=" << (doomed ? "yes" : "no") << " connected=" << (doomed && doomed->is_connected() ? "yes" : "no") << " transport=" << connection_type_name(previous_connection) << "->" << connection_type_name(current_connection); @@ -1005,16 +1098,16 @@ int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const s // Preserve the transport's existing behavior for malformed payloads; // the printer will report the protocol error asynchronously. } - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send is_lan=" << is_lan << " dev_id=" << dev_id - << " command=" << command << " payload_bytes=" << json_str.size(); + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send is_lan=" << is_lan << " dev_id=" << dev_id << " command=" << command + << " payload_bytes=" << json_str.size(); if (dev_id.empty()) return BAMBU_NETWORK_ERR_INVALID_HANDLE; OrcaMqttConnection* conn = get_appropriate_mqtt_connection(is_lan); if (!conn) return BAMBU_NETWORK_ERR_INVALID_HANDLE; const bool queued = conn->send_request(dev_id, json_str); - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send command=" << command << " queued=" << queued - << " is_lan=" << is_lan << " dev_id=" << dev_id; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send command=" << command << " queued=" << queued << " is_lan=" << is_lan + << " dev_id=" << dev_id; return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; } @@ -1087,20 +1180,21 @@ int OrcaPrinterAgent::bind_detect(std::string dev_ip, std::string /*sec_link*/, } std::string device_id; - if (!probe_orcasonar_device_id(host, port, device_id) || device_id.empty()) { + std::string device_name; + std::string model_id; + if (!probe_orcasonar_landing_page(host, port, device_id, device_name, model_id) || device_id.empty()) { BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::bind_detect: no OrcaSonar reachable at " << host << ":" << port; return BAMBU_NETWORK_ERR_INVALID_HANDLE; } detect.dev_id = device_id; - detect.dev_name = device_id; - detect.model_id = ""; // unknown; DeviceManager::insert_local_device defaults it + detect.dev_name = device_name; + detect.model_id = model_id; detect.version = ""; detect.connect_type = "lan"; // required by MachineObject::is_lan_mode_printer() detect.bind_state = "free"; // required by MachineObject::is_avaliable() detect.result_msg = ""; - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::bind_detect: found OrcaSonar dev_id=" << device_id - << " at " << host << ":" << port; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::bind_detect: found OrcaSonar dev_id=" << device_id << " at " << host << ":" << port; return BAMBU_NETWORK_SUCCESS; } @@ -1161,7 +1255,7 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) // selection. Conversely, selecting a cloud machine with the same id // while LAN is active is still a transport switch and must proceed. const bool same_selection = dev_id == selected_machine; - const bool same_transport = dev_id.empty() ? m_current_connection != CLOUD : m_current_connection == CLOUD; + const bool same_transport = dev_id.empty() ? m_current_connection != CLOUD : m_current_connection == CLOUD; if (same_selection && same_transport) { BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: unchanged dev_id=" << dev_id << " transport=" << connection_type_name(m_current_connection); @@ -1183,8 +1277,7 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) current_connection = m_current_connection; } BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: previous=" << previous << " new=" << dev_id - << " cloud=" << (cloud ? "set" : "") << " transport=" - << connection_type_name(previous_connection) << "->" + << " cloud=" << (cloud ? "set" : "") << " transport=" << connection_type_name(previous_connection) << "->" << connection_type_name(current_connection); if (!cloud) { BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_user_selected_machine: no Orca cloud agent"; @@ -1240,13 +1333,16 @@ int OrcaPrinterAgent::start_local_print_with_record(PrintParams params, // Upload one G-code file to the printer's `gcodes` root over OrcaSonar's // Moonraker-compatible HTTP facade. No print is started here (print=false); the // caller issues print.gcode_file over MQTT separately (start_sdcard_print). -int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn /*wait_fn*/) +int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, + OnUpdateStatusFn update_fn, + WasCancelledFn cancel_fn, + OnWaitFn /*wait_fn*/) { if (update_fn) update_fn(PrintingStageCreate, 0, "Preparing..."); const std::string local_path = resolve_local_gcode_path(params); - const fs::path source(local_path); + const fs::path source(local_path); boost::system::error_code ec; if (!fs::exists(source, ec) || !fs::is_regular_file(source, ec)) { BOOST_LOG_TRIVIAL(error) << "OrcaPrinterAgent: G-code file does not exist: " << local_path; @@ -1265,7 +1361,7 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateSta std::string host, port, origin; if (parse_lan_endpoint(params.dev_ip, host, port)) - origin = "http://" + host + ":" + port; + origin = "http://" + host; else origin = http_origin_from_lan_ws(lan_connection_target()); if (origin.empty()) { @@ -1274,14 +1370,14 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateSta } const std::string upload_name = remote_gcode_name(params); - BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: uploading G-code " << local_path << " -> " << origin - << "/server/files/upload as " << upload_name << " (" << file_size << " bytes)"; + BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: uploading G-code " << local_path << " -> " << origin << "/server/files/upload as " + << upload_name << " (" << file_size << " bytes)"; if (update_fn) update_fn(PrintingStageUpload, 0, "Uploading..."); - bool canceled = false; - long http_status = 0; + bool canceled = false; + long http_status = 0; std::string http_error; std::string response_body; @@ -1322,8 +1418,8 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateSta // OrcaSonar's Moonraker facade returns 201 Created on a successful save. if (http_status != 200 && http_status != 201) { - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: G-code upload failed http_status=" << http_status - << " error=" << http_error << " body=" << response_body; + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: G-code upload failed http_status=" << http_status << " error=" << http_error + << " body=" << response_body; return BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED; } @@ -1363,8 +1459,7 @@ int OrcaPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn up // dst_file, when set, names a file already on the printer (print-from-SD flow); // otherwise start what start_send_gcode_to_sdcard just uploaded to `gcodes`. - const std::string target = params.dst_file.empty() ? remote_gcode_name(params) - : fs::path(params.dst_file).filename().string(); + const std::string target = params.dst_file.empty() ? remote_gcode_name(params) : fs::path(params.dst_file).filename().string(); nlohmann::json j; j["print"]["command"] = "gcode_file"; @@ -1375,10 +1470,10 @@ int OrcaPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn up update_fn(PrintingStageSending, 0, "Starting print..."); const bool is_lan = params.connection_type == "lan"; - const int rc = route_send(is_lan, params.dev_id, j.dump()); + const int rc = route_send(is_lan, params.dev_id, j.dump()); if (rc != BAMBU_NETWORK_SUCCESS) { - BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: start_sdcard_print publish failed rc=" << rc - << " dev_id=" << params.dev_id << " param=" << target; + BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: start_sdcard_print publish failed rc=" << rc << " dev_id=" << params.dev_id + << " param=" << target; return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED; } From 8f4df1aa8c2d3743142f990006f78e9402256cb8 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Wed, 9 Sep 2026 13:40:24 +0800 Subject: [PATCH 16/24] fix: model_id resolution method for non bambu printers --- src/slic3r/GUI/DeviceManager.cpp | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/slic3r/GUI/DeviceManager.cpp b/src/slic3r/GUI/DeviceManager.cpp index fc66604f7c..485583f5ee 100644 --- a/src/slic3r/GUI/DeviceManager.cpp +++ b/src/slic3r/GUI/DeviceManager.cpp @@ -370,6 +370,18 @@ NozzleVolumeType convert_to_nozzle_type(const std::string &str) wxString MachineObject::get_printer_type_display_str() const { std::string display_name = DevPrinterConfigUtil::get_printer_display_name(printer_type); + + // Bambu printers use m_resource_file_path + "/printers/" + type_str + ".json", which is a semantic that only works for their profiles. + // For any other profile, we can simply consult preset bundle if the model_id exists. + if (display_name.empty()) { + for (const auto& [vendor_id, vendor] : GUI::wxGetApp().preset_bundle->vendors) { + for (const auto& model : vendor.models) { + if (printer_type == model.model_id) + display_name = model.name; + } + } + } + if (!display_name.empty()) return display_name; else if (printer_type == "orcasonar") From b0ada2dee57d5614be8ffea6f97de3ca606be6a7 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Wed, 9 Sep 2026 13:40:51 +0800 Subject: [PATCH 17/24] fix: ffmpeg http camera stream jittering due to incomplete frames --- src/slic3r/GUI/AVVideoDecoder.hpp | 8 +++ src/slic3r/GUI/MediaPlayCtrl.cpp | 39 +++++++++- src/slic3r/GUI/wxMediaCtrl3.cpp | 114 ++++++++++++++++++++++++++---- 3 files changed, 146 insertions(+), 15 deletions(-) diff --git a/src/slic3r/GUI/AVVideoDecoder.hpp b/src/slic3r/GUI/AVVideoDecoder.hpp index 4277734e08..d3d16a11c1 100644 --- a/src/slic3r/GUI/AVVideoDecoder.hpp +++ b/src/slic3r/GUI/AVVideoDecoder.hpp @@ -36,6 +36,14 @@ public: bool toWxBitmap(wxBitmap &bitmap, wxSize const & size); + // Native size of the most recently decoded frame, or an unspecified size if + // nothing has decoded yet. Lets a caller learn the video dimensions when the + // container/probe could not report them up front. + wxSize decoded_frame_size() const + { + return got_frame_ && frame_ ? wxSize{frame_->width, frame_->height} : wxSize{}; + } + private: AVCodecContext *codec_ctx_ = nullptr; AVFrame * frame_ = nullptr; diff --git a/src/slic3r/GUI/MediaPlayCtrl.cpp b/src/slic3r/GUI/MediaPlayCtrl.cpp index 42270252a9..69576666e6 100644 --- a/src/slic3r/GUI/MediaPlayCtrl.cpp +++ b/src/slic3r/GUI/MediaPlayCtrl.cpp @@ -10,6 +10,8 @@ #include "slic3r/Utils/BBLNetworkPlugin.hpp" +#include + #include #include #include @@ -166,8 +168,10 @@ void MediaPlayCtrl::SetMachineObject(MachineObject* obj) { const CameraStreamMode mode = current_mode(); if (mode != m_last_mode) { - if (m_last_state != MEDIASTATE_IDLE) + if (m_last_state != MEDIASTATE_IDLE) { + m_failed_code = 0; // a mode switch is not a stream failure - don't arm back-off Stop(" "); + } m_last_mode = mode; } @@ -189,7 +193,12 @@ void MediaPlayCtrl::SetMachineObject(MachineObject* obj) Play(); return; } + // A genuine machine/URL switch: not a failure, so drop any pending + // failure back-off before (re)starting on the new target. m_web_user_stopped = false; + m_failed_code = 0; + m_failed_retry = 0; + m_next_retry = wxDateTime(); if (m_last_state != MEDIASTATE_IDLE) Stop(" "); if (IsEnabled()) @@ -552,19 +561,43 @@ void MediaPlayCtrl::Stop(wxString const &msg, wxString const &msg2) } switch (current_mode()) { case CameraStreamMode::http: - case CameraStreamMode::http_snapshot: + case CameraStreamMode::http_snapshot: { + const bool snapshot = current_mode() == CameraStreamMode::http_snapshot; if (m_last_state != MEDIASTATE_IDLE) { - if (m_web_ctrl) m_web_ctrl->Stop(); + if (snapshot) { + if (m_web_ctrl) m_web_ctrl->Stop(); + } else { + // http mode plays through the ffmpeg backend (m_media_ctrl), not + // the webview - tear its read thread down too, otherwise it keeps + // pulling and painting frames after the UI says "Video Stopped". + boost::unique_lock lock(m_mutex); + m_tasks.push_back(""); + m_cond.notify_all(); + } m_button_play->SetIcon("media_play"); m_last_state = MEDIASTATE_IDLE; if (!msg.IsEmpty()) SetStatus(msg); else SetStatus(_L("Video Stopped."), false); + // SetMachineObject re-drives Play() on every device refresh (~1s). + // This branch returns before the legacy back-off below, so on a real + // failure it has to arm m_next_retry itself or the stream restarts + // once a second forever. Escalate 5s..30s; m_failed_retry is cleared + // on success (onStateChanged) and on a deliberate switch + // (SetMachineObject), and a manual play via TogglePlay resets both. + if (m_failed_code != 0) { + const bool auto_retry = wxGetApp().app_config->get("liveview", "auto_retry") != "false"; + ++m_failed_retry; + m_next_retry = auto_retry + ? wxDateTime::Now() + wxTimeSpan::Seconds(std::min(5 * m_failed_retry, 30)) + : wxDateTime::Now() + wxTimeSpan::Days(1); // "off": wait for a manual retry + } } else if (!msg.IsEmpty()) { SetStatus(msg, false); } return; + } default: break; } diff --git a/src/slic3r/GUI/wxMediaCtrl3.cpp b/src/slic3r/GUI/wxMediaCtrl3.cpp index 83a2b780a5..c04b27b118 100644 --- a/src/slic3r/GUI/wxMediaCtrl3.cpp +++ b/src/slic3r/GUI/wxMediaCtrl3.cpp @@ -4,8 +4,13 @@ #include "libslic3r/Utils.hpp" #include #include +#include +#include +#include +#include extern "C" { #include +#include } #ifdef __WIN32__ #include @@ -250,6 +255,58 @@ void wxMediaCtrl3::bambu_log(void *ctx, int level, tchar const *msg2) BOOST_LOG_TRIVIAL(info) << msg.ToUTF8().data(); } +// FFmpeg's own diagnostics (HTTP status, "Invalid data found", demuxer choice, +// missing stream dimensions, ...) are otherwise swallowed: a failed camera open +// only surfaces as wxMediaCtrl3's generic error code, which MediaPlayCtrl maps to +// the misleading "Player is malfunctioning" string. Forward them to the Orca log +// instead. Verbosity defaults to AV_LOG_VERBOSE and can be raised at runtime with +// ORCA_FFMPEG_LOG_LEVEL=debug|trace|... (or lowered to warning/error/quiet). +static int ffmpeg_log_level_from_env() +{ + const char *env = std::getenv("ORCA_FFMPEG_LOG_LEVEL"); + if (env == nullptr || *env == '\0') + return AV_LOG_VERBOSE; + const wxString v = wxString(env).Lower(); + if (v == "quiet") return AV_LOG_QUIET; + if (v == "panic") return AV_LOG_PANIC; + if (v == "fatal") return AV_LOG_FATAL; + if (v == "error") return AV_LOG_ERROR; + if (v == "warning") return AV_LOG_WARNING; + if (v == "info") return AV_LOG_INFO; + if (v == "verbose") return AV_LOG_VERBOSE; + if (v == "debug") return AV_LOG_DEBUG; + if (v == "trace") return AV_LOG_TRACE; + return AV_LOG_VERBOSE; +} + +static void ffmpeg_log_callback(void *avcl, int level, const char *fmt, va_list vl) +{ + if (level > av_log_get_level()) + return; + thread_local int print_prefix = 1; + char line[1024]; + av_log_format_line2(avcl, level, fmt, vl, line, (int) sizeof(line), &print_prefix); + size_t len = std::strlen(line); + while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r' || line[len - 1] == ' ')) + line[--len] = '\0'; + if (len == 0) + return; + if (level <= AV_LOG_ERROR) + BOOST_LOG_TRIVIAL(error) << "ffmpeg: " << line; + else if (level <= AV_LOG_WARNING) + BOOST_LOG_TRIVIAL(warning) << "ffmpeg: " << line; + else if (level <= AV_LOG_INFO) + BOOST_LOG_TRIVIAL(info) << "ffmpeg: " << line; + else + BOOST_LOG_TRIVIAL(debug) << "ffmpeg: " << line; +} + +static void install_ffmpeg_logger() +{ + av_log_set_level(ffmpeg_log_level_from_env()); + av_log_set_callback(&ffmpeg_log_callback); +} + int wxMediaCtrl3::ffmpeg_interrupt_callback(void *opaque) { auto *ctrl = static_cast(opaque); @@ -259,6 +316,9 @@ int wxMediaCtrl3::ffmpeg_interrupt_callback(void *opaque) int wxMediaCtrl3::PlayFfmpeg(std::shared_ptr const &url, std::unique_lock &lock) { + static std::once_flag logger_once; + std::call_once(logger_once, install_ffmpeg_logger); + if (avformat_network_init() < 0) return 2; @@ -287,13 +347,22 @@ int wxMediaCtrl3::PlayFfmpeg(std::shared_ptr const &url, std::unique_lock const bool http_stream = scheme.CmpNoCase("http") == 0 || scheme.CmpNoCase("https") == 0; AVDictionary *options = nullptr; if (http_stream) { - // This is a live multipart MJPEG stream. Keep FFmpeg from building a - // read-ahead buffer, otherwise the UI can display frames several - // seconds behind the camera. + // Live multipart MJPEG. fflags=nobuffer / AVFMT_FLAG_NOBUFFER / max_delay=0 + // (set above) are the low-latency levers - they disable the demuxer + // read-ahead queue. probesize / analyzeduration only bound the one-off + // avformat_find_stream_info() at open; a 32-byte budget returned before a + // whole JPEG frame was seen, so width/height came back unset and the open + // was rejected. Give it room to identify one frame (a startup cost only). + // rw_timeout / timeout bound a wedged connect or read so a stale stream + // fails fast and is retried, instead of the reader thread hanging. + // avioflags=direct is deliberately NOT set: unbuffered reads make the + // mpjpeg demuxer emit "Packet corrupt" and bail on any short read across + // a multipart boundary. av_dict_set(&options, "fflags", "nobuffer", 0); - av_dict_set(&options, "avioflags", "direct", 0); - av_dict_set(&options, "probesize", "32", 0); - av_dict_set(&options, "analyzeduration", "0", 0); + av_dict_set(&options, "probesize", "5000000", 0); + av_dict_set(&options, "analyzeduration", "1000000", 0); + av_dict_set(&options, "rw_timeout", "5000000", 0); + av_dict_set(&options, "timeout", "5000000", 0); } else { av_dict_set(&options, "rtsp_transport", "tcp", 0); } @@ -318,12 +387,21 @@ int wxMediaCtrl3::PlayFfmpeg(std::shared_ptr const &url, std::unique_lock if (decoder.open(*format_context->streams[video_stream]->codecpar) < 0) return finish(2); - m_video_size = {format_context->streams[video_stream]->codecpar->width, - format_context->streams[video_stream]->codecpar->height}; - if (!m_video_size.IsFullySpecified() || m_video_size.x <= 0 || m_video_size.y <= 0) - return finish(2); - adjust_frame_size(m_frame_size, m_video_size, GetSize()); - NotifyStopped(); + // Prefer the dimensions the container reported. A small probe budget, or a + // camera that doesn't announce a size up front, can leave these unset - in + // that case fill them in from the first frame that decodes (below) rather + // than failing the open outright. + auto apply_video_size = [&](wxSize size) { + if (!size.IsFullySpecified() || size.x <= 0 || size.y <= 0) + return false; + m_video_size = size; + adjust_frame_size(m_frame_size, m_video_size, GetSize()); + NotifyStopped(); + return true; + }; + bool have_size = apply_video_size({format_context->streams[video_stream]->codecpar->width, + format_context->streams[video_stream]->codecpar->height}); + int size_probe_frames = 0; // frames spent still waiting for a usable size AVPacket *packet = av_packet_alloc(); if (!packet) @@ -340,6 +418,18 @@ int wxMediaCtrl3::PlayFfmpeg(std::shared_ptr const &url, std::unique_lock if (packet->stream_index == video_stream) { const int decode_error = decoder.decode(*packet); if (decode_error == 0) { + if (!have_size) { + have_size = apply_video_size(decoder.decoded_frame_size()); + if (!have_size) { + av_packet_unref(packet); + // MJPEG yields a sized frame on the first full packet; if + // several seconds of frames never do, treat it as a bad + // stream instead of sitting in "Loading..." forever. + if (++size_probe_frames > 120) + break; + continue; + } + } auto frame_size = m_frame_size; lock.unlock(); #ifdef _WIN32 From 62791fabfacf327afb06c6f146c3f38f1ef43827 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Wed, 9 Sep 2026 14:10:12 +0800 Subject: [PATCH 18/24] fix: revert sdcard check --- src/slic3r/GUI/DeviceCore/DevStorage.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/slic3r/GUI/DeviceCore/DevStorage.cpp b/src/slic3r/GUI/DeviceCore/DevStorage.cpp index cb17ba9430..0c60b096bd 100644 --- a/src/slic3r/GUI/DeviceCore/DevStorage.cpp +++ b/src/slic3r/GUI/DeviceCore/DevStorage.cpp @@ -20,8 +20,8 @@ DevStorage::SdcardState Slic3r::DevStorage::set_sdcard_state(int state) if (system) { try { - if (print_json.contains("sdcard") || print_json.contains("support_send_to_sd")) { - if (print_json["sdcard"].get() || print_json["support_send_to_sd"].get()) + if (print_json.contains("sdcard")) { + if (print_json["sdcard"].get()) system->m_sdcard_state = DevStorage::SdcardState::HAS_SDCARD_NORMAL; else system->m_sdcard_state = DevStorage::SdcardState::NO_SDCARD; From b0b78c296c469ec078a8181244e2ebf54322ccd0 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Wed, 9 Sep 2026 18:35:38 +0800 Subject: [PATCH 19/24] feat: check printer storage status before sending --- src/slic3r/Utils/OrcaPrinterAgent.cpp | 74 ++++++++++++++++++--------- 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 4501578e93..3b5ae558ff 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -5,6 +5,7 @@ #include "NetworkAgentFactory.hpp" #include "OrcaCloudServiceAgent.hpp" #include "bambu_networking.hpp" +#include "json_diff.hpp" #include #include #include @@ -145,18 +146,15 @@ std::string extract_line_value(const std::string& body, const std::string& key) // Manual binding uses the OrcaSonar landing page as its sole identity source. // The page returns device_id and may return device_name/model_id. -bool probe_orcasonar_landing_page(const std::string& host, - const std::string& port, - std::string& device_id, - std::string& device_name, - std::string& model_id) +bool probe_orcasonar_landing_page( + const std::string& host, const std::string& port, std::string& device_id, std::string& device_name, std::string& model_id) { device_name = ORCASONAR_FALLBACK; model_id = ORCASONAR_FALLBACK; std::string body; if (fetch_orcasonar_body("http://" + host + ":" + port + "/", body)) { - device_id = extract_line_value(body, "device_id="); + device_id = extract_line_value(body, "device_id="); const std::string name = extract_line_value(body, "device_name="); const std::string model = extract_line_value(body, "model_id="); if (!name.empty()) @@ -173,17 +171,14 @@ bool probe_orcasonar_landing_page(const std::string& host, // SSDP discovery uses the LOCATION URL's UPnP device description as its sole // identity source. OrcaSonar maps device_id/device_name/model_id to UDN, // friendlyName, and modelNumber respectively. -bool parse_orcasonar_device_xml(const std::string& body, - std::string& device_id, - std::string& device_name, - std::string& model_id) +bool parse_orcasonar_device_xml(const std::string& body, std::string& device_id, std::string& device_name, std::string& model_id) { device_name = ORCASONAR_FALLBACK; model_id = ORCASONAR_FALLBACK; try { boost::property_tree::ptree tree; - std::istringstream stream(body); + std::istringstream stream(body); boost::property_tree::read_xml(stream, tree, boost::property_tree::xml_parser::trim_whitespace); const auto device = tree.get_child_optional("root.device"); if (!device) @@ -218,10 +213,7 @@ bool parse_orcasonar_device_xml(const std::string& body, } } -bool probe_orcasonar_device_xml(const std::string& location, - std::string& device_id, - std::string& device_name, - std::string& model_id) +bool probe_orcasonar_device_xml(const std::string& location, std::string& device_id, std::string& device_name, std::string& model_id) { std::string body; return fetch_orcasonar_body(location, body) && parse_orcasonar_device_xml(body, device_id, device_name, model_id); @@ -385,12 +377,12 @@ private: machine["connection_name"] = device_id; machine["dev_ip"] = host + ":" + port; - machine["dev_signal"] = "0"; - machine["connect_type"] = "lan"; - machine["bind_state"] = "free"; - machine["sec_link"] = "secure"; - machine["ssdp_version"] = "v1"; - json = machine.dump(); + machine["dev_signal"] = "0"; + machine["connect_type"] = "lan"; + machine["bind_state"] = "free"; + machine["sec_link"] = "secure"; + machine["ssdp_version"] = "v1"; + json = machine.dump(); return true; } @@ -639,8 +631,7 @@ void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_cloud_agent: status callback result=" << callback_result; } -std::unique_ptr -OrcaPrinterAgent::create_camera_signaling_channel(const std::string& dev_id) +std::unique_ptr OrcaPrinterAgent::create_camera_signaling_channel(const std::string& dev_id) { std::lock_guard lock(state_mutex); if (!m_cloud_agent) @@ -1391,6 +1382,43 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, std::string http_error; std::string response_body; + // check if printer has enough storage + Http::get(origin + "/server/files/directory?path=gcodes") + .on_complete([&](std::string body, unsigned status) { + if (body.empty()) { + http_status = 400; + http_error = "Failed to get gcodes directory."; + } + + int free = 0; + + nlohmann::json json = nlohmann::json::parse(body); + if (json.contains("result")) { + json = json["result"]; + if (json.contains("disk_usage")) { + json = json["disk_usage"]; + if (json.contains("free")) + free = json["free"].get(); + } + } + + if (free < file_size) { + http_status = 507; + http_error = "Not enough storage on the printer."; + } + }) + .on_error([&](std::string body, std::string err, unsigned status) { + http_status = status; + http_error = std::move(err); + response_body = std::move(body); + }) + .perform_sync(); + + if (http_status >= 400) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " failed with error code: " << http_status << ", " << http_error; + return BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED; + } + auto http = Http::post(origin + "/server/files/upload"); if (!params.password.empty()) http.header("X-Api-Key", params.password); // trusted LAN facades may not require it; harmless when they do not From 3fe043651bd7f5dba52b0911b0e57b59e8c1b217 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Wed, 9 Sep 2026 18:40:01 +0800 Subject: [PATCH 20/24] fix: moonraker printer agent hang on printer power cut --- src/slic3r/Utils/MoonrakerPrinterAgent.cpp | 28 ++++++++++++++++++++++ src/slic3r/Utils/MoonrakerPrinterAgent.hpp | 7 ++++++ 2 files changed, 35 insertions(+) diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp index d2c8781fbe..0defe014f8 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.cpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.cpp @@ -3,6 +3,7 @@ #include "IPrinterAgent.hpp" #include "libslic3r/Preset.hpp" #include "libslic3r/PresetBundle.hpp" +#include "libslic3r/Utils.hpp" #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/DeviceCore/DevFilaSystem.h" #include "slic3r/GUI/DeviceCore/DevManager.h" @@ -2077,6 +2078,14 @@ void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, const void MoonrakerPrinterAgent::stop_status_stream() { ws_stop.store(true); + { + // Wake a blocked synchronous ws.read()/ws.write() in run_status_stream(); + // ws_stop by itself is only observed between reads. + std::lock_guard lock(ws_abort_mutex); + if (ws_abort_io) { + ws_abort_io(); + } + } if (ws_thread.joinable()) { ws_thread.join(); } @@ -2113,6 +2122,25 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba stream.connect(results); websocket::stream ws{std::move(stream)}; + + // Allow stop_status_stream() to force this socket shut so a blocked + // synchronous ws.read()/ws.write() returns with an error (Beast's + // expires_after() does not bound synchronous operations). Declared + // after `ws` so the hook is cleared before `ws` is destroyed on every + // exit path (fallthrough, break, exception); ws_abort_mutex keeps the + // hook from running against a half-destroyed `ws`. + ScopeGuard ws_abort_guard([this] { + std::lock_guard lock(ws_abort_mutex); + ws_abort_io = nullptr; + }); + { + std::lock_guard lock(ws_abort_mutex); + ws_abort_io = [&ws] { + beast::error_code ec; + ws.next_layer().socket().shutdown(tcp::socket::shutdown_both, ec); + }; + } + ws.set_option(websocket::stream_base::decorator([&](websocket::request_type& req) { req.set(http::field::user_agent, "OrcaSlicer"); if (!api_key.empty()) { diff --git a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp index 8750085181..a662c096df 100644 --- a/src/slic3r/Utils/MoonrakerPrinterAgent.hpp +++ b/src/slic3r/Utils/MoonrakerPrinterAgent.hpp @@ -240,6 +240,13 @@ private: std::atomic ws_last_emit_ms{0}; std::thread ws_thread; + // stop_status_stream() invokes ws_abort_io to wake a blocked synchronous + // ws.read()/ws.write()/handshake in run_status_stream(): ws_stop is only + // observed between reads, and Beast's expires_after() does not bound + // synchronous operations. + std::mutex ws_abort_mutex; + std::function ws_abort_io; // guarded by ws_abort_mutex + // AMS/filament refresh cadence, independent of telemetry dispatch so a steady // stream of status updates can't starve it (ws_last_emit_ms is reset by those). static constexpr uint64_t AMS_REFRESH_INTERVAL_MS = 10000; From 3fc7fd99d6cf6592db85d35b6c80b28479324ae1 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Thu, 10 Sep 2026 21:55:31 +0800 Subject: [PATCH 21/24] fix: shim layer for any compatibiliity changes --- src/slic3r/Utils/OrcaPrinterAgent.cpp | 176 ++++++++++++------ src/slic3r/Utils/OrcaPrinterAgent.hpp | 14 +- tests/slic3rutils/test_orca_printer_agent.cpp | 36 +++- 3 files changed, 163 insertions(+), 63 deletions(-) diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 3b5ae558ff..9459721da2 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -22,11 +22,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include @@ -218,38 +220,6 @@ bool probe_orcasonar_device_xml(const std::string& location, std::string& device std::string body; return fetch_orcasonar_body(location, body) && parse_orcasonar_device_xml(body, device_id, device_name, model_id); } - -// TEMP MOCK: OrcaSonar's push_status doesn't report storage state yet, so -// DevStorage::ParseV1_0() decodes NO_SDCARD and SelectMachineDialog blocks -// printing with "No SD card". Force the storage-present markers into every -// forwarded status document so the printer reports HAS_SDCARD_NORMAL. -// Remove once the firmware reports real storage state. -std::string force_sdcard_present(const std::string& payload) -{ - nlohmann::json envelope = nlohmann::json::parse(payload, nullptr, false); - if (!envelope.is_object()) - return payload; - - const auto print_it = envelope.find("print"); - if (print_it == envelope.end() || !print_it->is_object()) - return payload; - - // DevStorage::ParseV1_0() reads print.sdcard as a bool -> HAS_SDCARD_NORMAL. - (*print_it)["sdcard"] = true; - - // MachineObject::parse_home_flag() runs afterwards and re-derives the state - // from bits 8-9 of print.home_flag; rewrite them to 01 so it doesn't clobber - // the mock back to NO_SDCARD. - const auto home_flag_it = print_it->find("home_flag"); - if (home_flag_it != print_it->end() && home_flag_it->is_number_integer()) { - int flag = home_flag_it->get(); - flag = (flag & ~(0x3 << 8)) | (0x1 << 8); - *home_flag_it = flag; - } - - return envelope.dump(); -} - } // namespace class OrcaPrinterAgent::OrcaSonarDiscovery @@ -531,50 +501,142 @@ OrcaMqttConnection* OrcaPrinterAgent::get_appropriate_mqtt_connection(bool is_la return cloud ? cloud->get_mqtt_connection() : nullptr; } -void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload) +// ============================================================================ +// Orca-dialect -> Bambu-dialect compatibility shim for inbound printer reports. +// +// OrcaSonar and the bridge adapter speak the "Orca Protocol" JSON dialect. +// MachineObject::parse_json (DeviceManager.cpp) only understands the Bambu +// dialect. Until the Orca Protocol is formally specified, this function is the +// ONE place where an inbound Orca-dialect report is rewritten into the Bambu +// shape parse_json already handles. +// +// Rules of this seam: +// 1. parse_json and the rest of DeviceManager are NOT modified to accommodate +// the Orca dialect - every such accommodation is a rule inside this +// function. +// 2. Each rule documents its Orca-dialect source, the Bambu-dialect target +// parse_json expects, and the rewrite between them. Rules are independent +// and can be removed one at a time as parse_json gains native support. +// 3. When parse_json reads the Orca dialect directly, this function and the +// single call in deliver_to_sink can be deleted, state included. Nothing +// else should need to change. +// +// It runs on every inbound report, so it stays cheap for payloads it does not +// touch (substring pre-check, no re-serialize unless something changed) and +// never throws (non-throwing parse, every field access guarded). +// ============================================================================ +std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, const std::string& payload) { - OnMessageFn fn; - QueueOnMainFn q; - { - std::lock_guard l(state_mutex); - fn = on_message_fn; - q = queue_on_main_fn; + if (payload.find("get_capabilities") == std::string::npos && payload.find("push_status") == std::string::npos) + return payload; + + nlohmann::json envelope = nlohmann::json::parse(payload, nullptr, false); + if (!envelope.is_object()) + return payload; + + // Shim-local state, kept here (not on the class) so deleting this function + // removes its storage too. Process-wide and keyed by the globally-unique + // dev_id, with its own mutex; C++11 makes the one-time init thread-safe. + static std::unordered_map nozzle_diameter_cache; + static std::mutex nozzle_diameter_cache_mutex; + + bool modified = false; + + // ---- Rule: nozzle geometry --------------------------------------------- + // Orca dialect : info.capabilities.topology.tools[i].nozzle.diameter_mm, + // delivered once in the get_capabilities reply; push_status + // frames carry no nozzle geometry at all. + // Bambu dialect: parse_json runs DevNozzleSystemParser::ParseV1_0 only for a + // push_status frame that holds BOTH print.nozzle_diameter and + // print.nozzle_type. + // Rewrite : cache the diameter from the capabilities reply per device, + // then stamp print.nozzle_diameter + a neutral print.nozzle_type + // ("N/A" -> NozzleType::ntUndefine, as MoonrakerPrinterAgent + // does; Klipper has no Bambu nozzle type) onto later push_status + // frames that carry no real nozzle data. + const auto info_it = envelope.find("info"); + const bool is_capabilities_reply = info_it != envelope.end() && info_it->is_object() && + info_it->value("command", "") == "get_capabilities"; + + if (is_capabilities_reply) { + double nozzle_dia = 0.0; + const auto caps_it = info_it->find("capabilities"); + if (caps_it != info_it->end() && caps_it->is_object()) { + const auto topology_it = caps_it->find("topology"); + if (topology_it != caps_it->end() && topology_it->is_object()) { + const auto tools_it = topology_it->find("tools"); + if (tools_it != topology_it->end() && tools_it->is_array()) { + // First tool with a usable diameter wins: ParseV1_0 keeps a + // single nozzle (id 0). + for (const auto& tool : *tools_it) { + if (!tool.is_object()) + continue; + const auto nozzle_it = tool.find("nozzle"); + if (nozzle_it == tool.end() || !nozzle_it->is_object()) + continue; + const auto dia_it = nozzle_it->find("diameter_mm"); + if (dia_it != nozzle_it->end() && dia_it->is_number() && dia_it->get() > 0.0) { + nozzle_dia = dia_it->get(); + break; + } + } + } + } + } + if (nozzle_dia > 0.0) { + std::lock_guard l(nozzle_diameter_cache_mutex); + nozzle_diameter_cache[dev_id] = nozzle_dia; + } + // The capabilities reply itself is forwarded unchanged. } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering cloud message dev_id=" << dev_id << " payload_bytes=" << payload.size() - << " callback=" << (fn ? "set" : "null") << " queue_on_main=" << (q ? "set" : "null"); - if (!fn) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping cloud message because on_message_fn is not set" - << " dev_id=" << dev_id; - return; + else { + const auto print_it = envelope.find("print"); + if (print_it != envelope.end() && print_it->is_object() && + print_it->value("command", "") == "push_status" && !print_it->contains("nozzle_diameter")) { + + double nozzle_dia = 0.0; + { + std::lock_guard l(nozzle_diameter_cache_mutex); + const auto it = nozzle_diameter_cache.find(dev_id); + if (it != nozzle_diameter_cache.end()) + nozzle_dia = it->second; + } + if (nozzle_dia > 0.0) { + (*print_it)["nozzle_diameter"] = nozzle_dia; + (*print_it)["nozzle_type"] = "N/A"; + modified = true; + } + } } - if (q) - q([fn, dev_id, payload] { fn(dev_id, payload); }); - else - fn(dev_id, payload); + // ---------------------------------------------------------------------- + + return modified ? envelope.dump() : payload; } -void OrcaPrinterAgent::deliver_to_local_sink(const std::string& dev_id, const std::string& payload) +void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload, bool local) { parse_ipcam_info(dev_id, payload); + std::string merged_payload = merge_capabilities(dev_id, payload); OnMessageFn fn; QueueOnMainFn q; { std::lock_guard l(state_mutex); - fn = on_local_message_fn; + fn = local ? on_local_message_fn : on_message_fn; q = queue_on_main_fn; } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering LAN message dev_id=" << dev_id << " payload_bytes=" << payload.size() + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering " << (local ? "local" : "cloud") << " report payload dev_id=" << dev_id + << " payload=" << merged_payload << " callback=" << (fn ? "set" : "null") << " queue_on_main=" << (q ? "set" : "null"); if (!fn) { - BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping LAN message because on_local_message_fn is not set" + BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping " << (local ? "local" : "cloud") << " message because on_message_fn is not set" << " dev_id=" << dev_id; return; } if (q) - q([fn, dev_id, payload] { fn(dev_id, payload); }); + q([fn, dev_id, merged_payload] { fn(dev_id, merged_payload); }); else - fn(dev_id, payload); + fn(dev_id, merged_payload); } void OrcaPrinterAgent::dispatch_local_connect(int state, const std::string& dev_id, const std::string& message) @@ -603,7 +665,7 @@ std::function OrcaPrinterAgent::ma { return [this, generation](const std::string& id, const std::string& payload) { if (generation == m_lan_generation.load()) - deliver_to_local_sink(id, payload); + deliver_to_sink(id, payload, true); else BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: dropping stale LAN message generation=" << generation << " current_generation=" << m_lan_generation.load() << " dev_id=" << id; @@ -627,7 +689,7 @@ void OrcaPrinterAgent::set_cloud_agent(std::shared_ptr cloud // standard sink. message_arrive_fn self-marshals to the UI thread via CallAfter, // so being called from the MQTT worker thread is fine. const int callback_result = get_orca_cloud_agent()->set_printer_status_callback( - [this](std::string dev_id, std::string payload) { deliver_to_sink(dev_id, payload); }); + [this](std::string dev_id, std::string payload) { deliver_to_sink(dev_id, payload, false); }); BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_cloud_agent: status callback result=" << callback_result; } diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index 67464538b0..0c89dd0a42 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -127,17 +127,21 @@ public: void bump_cloud_generation_for_test() { ++m_cloud_generation; } protected: - // Forward one inbound printer message to on_message_fn (marshalled onto the UI + // Forward one inbound printer message to on_message_fn or on_local_message_fn (marshalled onto the UI // thread via queue_on_main_fn when set). Body of every connection's MessageHandler. - void deliver_to_sink(const std::string& dev_id, const std::string& payload); + void deliver_to_sink(const std::string& dev_id, const std::string& payload, bool local); // Extract OrcaSonar's print.ipcam.stream_mode from LAN reports before they // are forwarded to the GUI. The getters below then read this agent-owned state. void parse_ipcam_info(const std::string& dev_id, const std::string& payload); - // LAN has a separate callback in the existing IPrinterAgent contract because the - // GUI parses local reports with the "lan" dialect and looks up local machines. - void deliver_to_local_sink(const std::string& dev_id, const std::string& payload); + // Orca-dialect -> Bambu-dialect compatibility shim for inbound reports: the single + // place Orca Protocol JSON is rewritten into the shapes MachineObject::parse_json + // already handles, so parse_json needs no Orca-specific changes. Self-contained + // (its cache is a function-local static) and deletable together with its call site + // once parse_json reads the Orca dialect natively. See the definition for the + // per-rule detail. Returns the payload unchanged when no rule applies. + std::string merge_capabilities(const std::string& dev_id, const std::string& payload); // Report the asynchronous LAN connection state using the same callback contract as // the other printer agents. The transport result cannot be returned by diff --git a/tests/slic3rutils/test_orca_printer_agent.cpp b/tests/slic3rutils/test_orca_printer_agent.cpp index b3ad393268..fec41384b2 100644 --- a/tests/slic3rutils/test_orca_printer_agent.cpp +++ b/tests/slic3rutils/test_orca_printer_agent.cpp @@ -28,11 +28,45 @@ TEST_CASE("OrcaPrinterAgent forwards a status payload to on_message_fn", "[OrcaP Probe agent("/tmp"); std::string got_id, got_payload; agent.set_on_message_fn([&](std::string id, std::string p){ got_id = std::move(id); got_payload = std::move(p); }); - agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status"}})"); + agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status"}})", /*local=*/false); CHECK(got_id == "dev-1"); CHECK(got_payload.find("push_status") != std::string::npos); } +TEST_CASE("OrcaPrinterAgent stamps the get_capabilities nozzle diameter onto push_status frames", "[OrcaPrinterAgent]") { + Probe agent("/tmp"); + std::string last_payload; + agent.set_on_message_fn([&](std::string, std::string p){ last_payload = std::move(p); }); + + // Before any capabilities reply, a push_status frame is forwarded untouched. + agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","mc_percent":10}})", /*local=*/false); + CHECK(last_payload.find("nozzle_diameter") == std::string::npos); + + // The get_capabilities reply is forwarded verbatim; its topology nozzle diameter + // is cached for the device. + agent.deliver_to_sink( + "dev-1", + R"({"info":{"command":"get_capabilities","capabilities":{"topology":{"tools":[{"id":"T0","nozzle":{"diameter_mm":0.4}}]}}}})", + /*local=*/false); + CHECK(last_payload.find("\"command\":\"get_capabilities\"") != std::string::npos); + CHECK(last_payload.find("\"print\"") == std::string::npos); + + // Later push_status frames for that device get the cached diameter plus a neutral + // nozzle_type, so MachineObject::parse_json's legacy nozzle parser can run. + agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","mc_percent":20}})", /*local=*/false); + CHECK(last_payload.find("\"nozzle_diameter\":0.4") != std::string::npos); + CHECK(last_payload.find("\"nozzle_type\":\"N/A\"") != std::string::npos); + + // A different device is unaffected. + agent.deliver_to_sink("dev-2", R"({"print":{"command":"push_status"}})", /*local=*/false); + CHECK(last_payload.find("nozzle_diameter") == std::string::npos); + + // A frame that already carries real nozzle data is not overridden. + agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","nozzle_diameter":0.6}})", /*local=*/false); + CHECK(last_payload.find("\"nozzle_diameter\":0.6") != std::string::npos); + CHECK(last_payload.find("N/A") == std::string::npos); +} + TEST_CASE("OrcaPrinterAgent::parse_lan_endpoint", "[OrcaPrinterAgent]") { std::string h, p; REQUIRE(Probe::parse_lan_endpoint("192.168.1.9", h, p)); From af6be5858e074192f9491827a7059b90446ee723 Mon Sep 17 00:00:00 2001 From: Ian Chua Date: Fri, 11 Sep 2026 17:38:56 +0800 Subject: [PATCH 22/24] fix: cloud printers were using the wrong MQTT endpoint --- src/slic3r/Utils/OrcaCloudServiceAgent.cpp | 49 +++++++-- src/slic3r/Utils/OrcaCloudServiceAgent.hpp | 27 +++-- src/slic3r/Utils/OrcaMqttConnection.hpp | 2 +- src/slic3r/Utils/OrcaPrinterAgent.cpp | 103 +++++++++++++++--- src/slic3r/Utils/OrcaPrinterAgent.hpp | 4 +- tests/slic3rutils/test_orca_printer_agent.cpp | 8 +- 6 files changed, 151 insertions(+), 42 deletions(-) diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index 74bb2209d7..d28c392d59 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -971,9 +971,9 @@ int OrcaCloudServiceAgent::connect_server() BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health result=" << result << " http_code=" << http_code << " connected=" << connected << " response_bytes=" << response.size(); - // connect_server() is a REST health probe only. The long-lived MQTT socket is - // per-printer now, driven by set_user_selected_machine -> - // configure_selected_printer_mqtt; this method must not touch mqtt_connection. + // connect_server() remains a REST health probe. The long-lived fleet MQTT socket + // is started lazily by set_user_selected_machine -> configure_selected_printer_mqtt; + // subscriptions queued before that point are replayed when it starts. { std::lock_guard lock(state_mutex); is_connected = connected; @@ -1035,10 +1035,12 @@ int OrcaCloudServiceAgent::del_subscribe(std::vector dev_list) return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; } -int OrcaCloudServiceAgent::configure_selected_printer_mqtt(const std::string& dev_id) +int OrcaCloudServiceAgent::configure_selected_printer_mqtt(const std::string& dev_id, + OrcaMqttConnection::StateHandler state_handler) { + (void) dev_id; OrcaMqttConnection::Config cfg; - cfg.url = "wss://" + api_base_url + "/api/v1/printers/" + dev_id + "/mqtt"; + cfg.url = "wss://" + api_base_url + "/api/v1/printers/mqtt"; cfg.use_tls = true; cfg.bearer_provider = [this] { return get_access_token(); }; cfg.client_id = "OrcaSlicer"; @@ -1048,7 +1050,13 @@ int OrcaCloudServiceAgent::configure_selected_printer_mqtt(const std::string& de std::lock_guard lock(m_selected_url_mutex); m_selected_printer_mqtt_url = cfg.url; } - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: configuring per-printer MQTT endpoint=" << cfg.url; + + if (mqtt_connection->is_running()) { + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: fleet MQTT connection already running"; + return BAMBU_NETWORK_SUCCESS; + } + + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: configuring fleet MQTT endpoint=" << cfg.url; // NOTE: no lock is held across start() — it blocks for the whole initial connect // attempt (up to ~10s), and the message handler below re-enters callback_mutex on @@ -1056,8 +1064,8 @@ int OrcaCloudServiceAgent::configure_selected_printer_mqtt(const std::string& de const bool ok = mqtt_connection->start( cfg, [this](const std::string& id, const std::string& payload) { deliver_cloud_message(id, payload); }, - [this](bool, bool) {}); - BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: per-printer MQTT start returned=" << ok; + std::move(state_handler)); + BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: fleet MQTT start returned=" << ok; return ok ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED; } @@ -1119,6 +1127,23 @@ int OrcaCloudServiceAgent::send_printer_command(const std::string& dev_id, const : BAMBU_NETWORK_ERR_CONNECT_FAILED; } +int OrcaCloudServiceAgent::send_print_job(const std::string& dev_id, + const std::string& filename, + const std::string& gcode, + bool start) +{ + if (dev_id.empty() || filename.empty() || !is_user_login()) + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + + const std::string path = std::string(ORCA_CLOUD_PRINTER) + "/" + Http::url_encode(dev_id) + "/print-jobs?filename=" + + Http::url_encode(filename) + "&start=" + (start ? "true" : "false"); + unsigned int http_code = 0; + const int result = http_post(path, gcode, nullptr, &http_code, "text/plain"); + if (result != BAMBU_NETWORK_SUCCESS) + return result; + return http_code >= 200 && http_code < 300 ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; +} + void OrcaCloudServiceAgent::enable_multi_machine(bool enable) { std::lock_guard lock(state_mutex); @@ -2275,7 +2300,11 @@ int OrcaCloudServiceAgent::http_get(const std::string& path, std::string* respon return (res.success && !suppress) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; } -int OrcaCloudServiceAgent::http_post(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code) +int OrcaCloudServiceAgent::http_post(const std::string& path, + const std::string& body, + std::string* response_body, + unsigned int* http_code, + const std::string& content_type) { std::string url = api_base_url + path; BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: POST " << url; @@ -2299,7 +2328,7 @@ int OrcaCloudServiceAgent::http_post(const std::string& path, const std::string& http.header("Authorization", "Bearer " + token); } - http.header("Content-Type", "application/json"); + http.header("Content-Type", content_type); http.set_post_body(body); http.on_complete([&](std::string resp_body, unsigned resp_status) { diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp index a435c9668f..bd62169904 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp @@ -218,7 +218,7 @@ public: int del_subscribe(std::vector dev_list) override; void enable_multi_machine(bool enable) override; - // The per-printer MQTT socket carries both directions: inbound reports from + // The fleet MQTT socket carries both directions: inbound reports from // device//report and commands PUBLISHed to device//request on this // socket. OrcaPrinterAgent registers its message callback here to receive the // inbound half; pass an empty fn to clear it before the agent is destroyed. @@ -230,6 +230,12 @@ public: // that need non-blocking behaviour run it on their own thread. int send_printer_command(const std::string& dev_id, const std::string& body); + // Submit raw sliced G-code to the cloud printer print-jobs endpoint. + int send_print_job(const std::string& dev_id, + const std::string& filename, + const std::string& gcode, + bool start = true); + // ======================================================================== // ICloudServiceAgent Interface Implementation - Settings Synchronization // ======================================================================== @@ -377,16 +383,19 @@ public: return mqtt_connection.get(); } - // Per-printer cloud socket: wss:///api/v1/printers//mqtt. + // Account-scoped cloud socket: wss:///api/v1/printers/mqtt. // configure_ blocks for the duration of the initial connect attempt, so callers - // drive it off the UI thread; teardown_ is synchronous. - int configure_selected_printer_mqtt(const std::string& dev_id); + // drive it off the UI thread; teardown_ is synchronous. The dev_id argument is + // retained for source compatibility with the printer-agent lifecycle; it does + // not participate in endpoint construction. + int configure_selected_printer_mqtt(const std::string& dev_id, + OrcaMqttConnection::StateHandler state_handler = {}); void teardown_selected_printer_mqtt(); - // Test hook: the wss:// URL of the current per-printer socket ("" when none). + // Test hook: the wss:// URL of the current fleet socket ("" when none). std::string selected_printer_mqtt_url() const; private: - // Fans one inbound per-printer MQTT message out to printer_status_callback. + // Fans one inbound fleet MQTT message out to printer_status_callback. void deliver_cloud_message(const std::string& dev_id, const std::string& payload); // Sync protocol helpers @@ -412,7 +421,11 @@ private: // HTTP request helpers int http_get(const std::string& path, std::string* response_body, unsigned int* http_code); - int http_post(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code); + int http_post(const std::string& path, + const std::string& body, + std::string* response_body, + unsigned int* http_code, + const std::string& content_type = "application/json"); int http_put(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code); int http_delete(const std::string& path, std::string* response_body, unsigned int* http_code); std::map data_headers(); diff --git a/src/slic3r/Utils/OrcaMqttConnection.hpp b/src/slic3r/Utils/OrcaMqttConnection.hpp index 6131ee86f1..e103e90167 100644 --- a/src/slic3r/Utils/OrcaMqttConnection.hpp +++ b/src/slic3r/Utils/OrcaMqttConnection.hpp @@ -23,7 +23,7 @@ namespace Slic3r { // Minimal MQTT 3.1.1 codec + WebSocket transport (ws:// and wss://), shared by the -// LAN (OrcaSonar) and cloud (per-printer) printer connections. Both PUBLISH +// LAN (OrcaSonar) and cloud (fleet) printer connections. Both PUBLISH // commands to device//request and SUBSCRIBE device//report; Config is the // only per-transport difference. class OrcaMqttConnection diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 9459721da2..8f70ce00ef 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -19,11 +19,14 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include #include #include @@ -1353,24 +1356,48 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id) const uint64_t gen = ++m_cloud_generation; auto* conn = cloud->get_mqtt_connection(); - if (!previous.empty() && conn && conn->is_connected()) - conn->send_request(previous, build_pushing_stop(seq(5))); - cloud->teardown_selected_printer_mqtt(); - - if (m_cloud_connect_thread.joinable()) - m_cloud_connect_thread.join(); // teardown_selected_printer_mqtt() above stopped the old cloud conn - if (!dev_id.empty()) { - m_cloud_connect_thread = std::thread([this, cloud, dev_id, gen] { - if (gen != m_cloud_generation.load()) - return; // superseded before we ran: do not raise a socket nobody tears down - if (cloud->configure_selected_printer_mqtt(dev_id) == BAMBU_NETWORK_SUCCESS && gen == m_cloud_generation.load()) - on_connected(dev_id, cloud->get_mqtt_connection(), gen); - }); - } else { - // Deselect: the joined thread may have raised a fresh socket between the - // teardown above and the join. stop() is not sticky, so tear down again. - cloud->teardown_selected_printer_mqtt(); + if (!previous.empty()) { + cloud->del_subscribe({previous}); + if (conn && conn->is_connected()) + conn->send_request(previous, build_pushing_stop(seq(5))); } + + // A previous initial-connect worker may still be blocking in start(). Stop it + // only when no fleet connection has reached CONNACK; an established fleet + // socket survives printer selection changes. + if (m_cloud_connect_thread.joinable()) { + if (conn && !conn->is_connected()) + conn->stop(); + m_cloud_connect_thread.join(); + } + + if (dev_id.empty()) + return BAMBU_NETWORK_SUCCESS; + + if (conn && conn->is_running()) { + on_connected(dev_id, conn, gen); + return BAMBU_NETWORK_SUCCESS; + } + + m_cloud_connect_thread = std::thread([this, cloud, dev_id, gen] { + if (gen != m_cloud_generation.load()) + return; // superseded before we ran: do not raise a socket nobody owns + + auto state_handler = [this](bool connected, bool initial) { + if (!connected || initial) + return; + auto* current_cloud = get_orca_cloud_agent(); + OrcaMqttConnection* current_conn = current_cloud ? current_cloud->get_mqtt_connection() : nullptr; + const std::string selected = get_user_selected_machine(); + if (current_conn && !selected.empty()) + on_connected(selected, current_conn, m_cloud_generation.load()); + }; + + if (cloud->configure_selected_printer_mqtt(dev_id, std::move(state_handler)) == BAMBU_NETWORK_SUCCESS && + gen == m_cloud_generation.load()) { + on_connected(dev_id, cloud->get_mqtt_connection(), gen); + } + }); return BAMBU_NETWORK_SUCCESS; } @@ -1385,7 +1412,47 @@ AgentInfo OrcaPrinterAgent::get_agent_info_static() // ============================================================================ int OrcaPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) -{ return BAMBU_NETWORK_SUCCESS; } +{ + (void) wait_fn; + + if (update_fn) + update_fn(PrintingStageCreate, 0, "Preparing..."); + + if (cancel_fn && cancel_fn()) + return BAMBU_NETWORK_ERR_CANCELED; + + auto* cloud = get_orca_cloud_agent(); + if (!cloud || params.dev_id.empty()) + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + + const std::string local_path = resolve_local_gcode_path(params); + const fs::path source(local_path); + boost::filesystem::ifstream input(source, std::ios::in | std::ios::binary); + if (!input) { + BOOST_LOG_TRIVIAL(error) << "OrcaPrinterAgent: G-code file does not exist: " << local_path; + return BAMBU_NETWORK_ERR_FILE_NOT_EXIST; + } + + const std::string gcode((std::istreambuf_iterator(input)), std::istreambuf_iterator()); + if (input.bad()) { + BOOST_LOG_TRIVIAL(error) << "OrcaPrinterAgent: failed to read G-code file: " << local_path; + return BAMBU_NETWORK_ERR_PRINT_WR_POST_TASK_FAILED; + } + if (cancel_fn && cancel_fn()) + return BAMBU_NETWORK_ERR_CANCELED; + + if (update_fn) + update_fn(PrintingStageUpload, 0, "Uploading G-code..."); + + const int result = cloud->send_print_job(params.dev_id, remote_gcode_name(params), gcode, true); + if (result != BAMBU_NETWORK_SUCCESS) + return result; + + if (update_fn) + update_fn(PrintingStageFinished, 100, "Print started"); + + return BAMBU_NETWORK_SUCCESS; +} int OrcaPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, diff --git a/src/slic3r/Utils/OrcaPrinterAgent.hpp b/src/slic3r/Utils/OrcaPrinterAgent.hpp index 0c89dd0a42..1f50fb7a81 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.hpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.hpp @@ -214,8 +214,8 @@ private: OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan = true); static bool parse_nonnegative_command_id(const std::string& value, int& result); - // Route one command payload to device//request on the LAN or the cloud - // per-printer connection. The uniform send path for both send_message* overrides. + // Route one command payload to device//request on the LAN or the shared + // cloud connection. The uniform send path for both send_message* overrides. int route_send(bool is_lan, const std::string& dev_id, const std::string& json_str); // Callbacks diff --git a/tests/slic3rutils/test_orca_printer_agent.cpp b/tests/slic3rutils/test_orca_printer_agent.cpp index fec41384b2..7ffc4f3d2f 100644 --- a/tests/slic3rutils/test_orca_printer_agent.cpp +++ b/tests/slic3rutils/test_orca_printer_agent.cpp @@ -118,7 +118,7 @@ TEST_CASE("post-connect sequence is subscribe then 4 requests in order", "[OrcaP } // Hidden: spawns the connect worker and attempts a real (failing) connect. -TEST_CASE("selecting a cloud printer configures the per-printer socket", "[OrcaPrinterAgent][.integration]") { +TEST_CASE("selecting a cloud printer configures the fleet socket", "[OrcaPrinterAgent][.integration]") { auto cloud = std::make_shared("/tmp"); cloud->set_api_base_url("api.example.com"); OrcaPrinterAgent agent("/tmp"); @@ -132,10 +132,10 @@ TEST_CASE("selecting a cloud printer configures the per-printer socket", "[OrcaP if (!url.empty()) break; std::this_thread::sleep_for(std::chrono::milliseconds(10)); } - CHECK(url == "wss://api.example.com/api/v1/printers/printer-uuid-1/mqtt"); + CHECK(url == "wss://api.example.com/api/v1/printers/mqtt"); - agent.set_user_selected_machine(""); // teardown is synchronous - CHECK(cloud->selected_printer_mqtt_url().empty()); + agent.set_user_selected_machine(""); // selection changes do not tear down the fleet socket + CHECK(cloud->selected_printer_mqtt_url() == "wss://api.example.com/api/v1/printers/mqtt"); } TEST_CASE("a stale-generation inbound message is dropped", "[OrcaPrinterAgent]") { From 58e000d8427e871a423d8abc214b6e17e86223e5 Mon Sep 17 00:00:00 2001 From: peachismomo Date: Sat, 12 Sep 2026 02:31:27 +0800 Subject: [PATCH 23/24] feat: cloud download via HTTP --- src/slic3r/Utils/OrcaCloudServiceAgent.cpp | 114 +++++++++++++++++++-- src/slic3r/Utils/OrcaCloudServiceAgent.hpp | 28 ++++- src/slic3r/Utils/OrcaPrinterAgent.cpp | 36 +++++-- 3 files changed, 153 insertions(+), 25 deletions(-) diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp index d28c392d59..848d95a08a 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.cpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.cpp @@ -1127,21 +1127,113 @@ int OrcaCloudServiceAgent::send_printer_command(const std::string& dev_id, const : BAMBU_NETWORK_ERR_CONNECT_FAILED; } -int OrcaCloudServiceAgent::send_print_job(const std::string& dev_id, - const std::string& filename, - const std::string& gcode, - bool start) +int OrcaCloudServiceAgent::upload_gcode_via_cloud(const std::string& dev_id, + const std::string& local_gcode_path, + std::string* job_id, + OnUpdateStatusFn update_fn, + WasCancelledFn cancel_fn) { - if (dev_id.empty() || filename.empty() || !is_user_login()) + if (dev_id.empty() || local_gcode_path.empty() || !is_user_login()) + return BAMBU_NETWORK_ERR_INVALID_HANDLE; + if (cancel_fn && cancel_fn()) + return BAMBU_NETWORK_ERR_CANCELED; + + // Step 1: POST print-jobs/uploads -> a short-lived presigned R2 PUT URL. No + // metadata rides this request; filename/start are only relevant to the HTTP + // .../start finalize route, which this MQTT-driven flow does not call. + const std::string uploads_path = std::string(ORCA_CLOUD_PRINTER) + "/" + Http::url_encode(dev_id) + "/print-jobs/uploads"; + std::string response; + unsigned int http_code = 0; + int result = http_post(uploads_path, "{}", &response, &http_code); + if (result != BAMBU_NETWORK_SUCCESS || http_code < 200 || http_code >= 300) { + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: print-jobs/uploads failed http_code=" << http_code; + return BAMBU_NETWORK_ERR_CONNECT_FAILED; + } + + std::string upload_job_id; + std::string upload_url; + try { + const nlohmann::json j = nlohmann::json::parse(response); + upload_job_id = j.value("job_id", ""); + upload_url = j.value("upload_url", ""); + } catch (const std::exception& e) { + BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: failed to parse print-jobs/uploads response: " << e.what(); + return BAMBU_NETWORK_ERR_CONNECT_FAILED; + } + if (upload_job_id.empty() || upload_url.empty()) { + BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: print-jobs/uploads response missing job_id/upload_url"; + return BAMBU_NETWORK_ERR_CONNECT_FAILED; + } + + if (cancel_fn && cancel_fn()) + return BAMBU_NETWORK_ERR_CANCELED; + + // Step 2: PUT the G-code straight to R2 with the one-time URL from step 1. This + // is a scoped, PUT-only, short-TTL capability with no bearer token of its own, + // so it bypasses http_put (which always prefixes api_base_url and attaches the + // cloud session's Authorization header - neither belongs on an R2 PUT). + bool canceled = false; + unsigned put_status = 0; + std::string put_error; + Http::put(upload_url) + .tls_verify(true) + .header("Content-Type", "text/x.gcode") + .set_put_body(boost::filesystem::path(local_gcode_path)) + .timeout_connect(5) + .timeout_max(300) // large G-code over a slow link + .on_progress([&](Http::Progress progress, bool& cancel) { + if (cancel_fn && cancel_fn()) { + cancel = true; + canceled = true; + return; + } + if (update_fn && progress.ultotal > 0) { + const int percent = static_cast((progress.ulnow * 100) / progress.ultotal); + update_fn(PrintingStageUpload, percent, "Uploading..."); + } + }) + .on_complete([&](std::string, unsigned status) { put_status = status; }) + .on_error([&](std::string, std::string err, unsigned status) { + put_status = status; + put_error = std::move(err); + }) + .perform_sync(); + + if (canceled) + return BAMBU_NETWORK_ERR_CANCELED; + if (put_status < 200 || put_status >= 300) { + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: R2 upload failed status=" << put_status << " error=" << put_error; + return BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED; + } + + if (job_id) + *job_id = std::move(upload_job_id); + return BAMBU_NETWORK_SUCCESS; +} + +int OrcaCloudServiceAgent::start_cloud_print_job(const std::string& dev_id, + const std::string& job_id, + const std::string& filename, + bool start) +{ + if (dev_id.empty() || job_id.empty() || !is_user_login()) return BAMBU_NETWORK_ERR_INVALID_HANDLE; - const std::string path = std::string(ORCA_CLOUD_PRINTER) + "/" + Http::url_encode(dev_id) + "/print-jobs?filename=" + - Http::url_encode(filename) + "&start=" + (start ? "true" : "false"); + nlohmann::json body; + if (!filename.empty()) + body["filename"] = filename; + body["start"] = start; + + const std::string path = std::string(ORCA_CLOUD_PRINTER) + "/" + Http::url_encode(dev_id) + "/print-jobs/" + + Http::url_encode(job_id) + "/start"; + std::string response; unsigned int http_code = 0; - const int result = http_post(path, gcode, nullptr, &http_code, "text/plain"); - if (result != BAMBU_NETWORK_SUCCESS) - return result; - return http_code >= 200 && http_code < 300 ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED; + const int result = http_post(path, body.dump(), &response, &http_code); + if (result != BAMBU_NETWORK_SUCCESS || http_code < 200 || http_code >= 300) { + BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: print-jobs/" << job_id << "/start failed http_code=" << http_code; + return BAMBU_NETWORK_ERR_CONNECT_FAILED; + } + return BAMBU_NETWORK_SUCCESS; } void OrcaCloudServiceAgent::enable_multi_machine(bool enable) diff --git a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp index bd62169904..ef282dc132 100644 --- a/src/slic3r/Utils/OrcaCloudServiceAgent.hpp +++ b/src/slic3r/Utils/OrcaCloudServiceAgent.hpp @@ -230,11 +230,29 @@ public: // that need non-blocking behaviour run it on their own thread. int send_printer_command(const std::string& dev_id, const std::string& body); - // Submit raw sliced G-code to the cloud printer print-jobs endpoint. - int send_print_job(const std::string& dev_id, - const std::string& filename, - const std::string& gcode, - bool start = true); + // Upload sliced G-code to the cloud printer's print job storage: requests a + // short-lived presigned URL (POST print-jobs/uploads), then PUTs the file + // straight to R2 with that URL. Does not start the print or wait for + // OrcaSonar to download it - see OrcaPrinterAgent::start_print/start_sdcard_print + // for the MQTT hand-off that follows. *job_id receives the id to correlate + // with that hand-off; update_fn receives upload progress via Http's on_progress. + int upload_gcode_via_cloud(const std::string& dev_id, + const std::string& local_gcode_path, + std::string* job_id, + OnUpdateStatusFn update_fn, + WasCancelledFn cancel_fn); + + // Finalize a presigned print-job upload (step 3 of 3): POST + // print-jobs//start. The gateway HEAD-verifies the object landed in + // R2, then relays a print.project_file command (download URL + filename + + // start) to the printer over the gateway's OWN cloud relay connection to + // OrcaSonar - NOT this agent's MQTT session. See + // CLOUD_PRINT_JOB_MQTT_DESIGN.md for the MQTT-native alternative this + // stands in for until the gap documented there is closed. + int start_cloud_print_job(const std::string& dev_id, + const std::string& job_id, + const std::string& filename, + bool start = true); // ======================================================================== // ICloudServiceAgent Interface Implementation - Settings Synchronization diff --git a/src/slic3r/Utils/OrcaPrinterAgent.cpp b/src/slic3r/Utils/OrcaPrinterAgent.cpp index 8f70ce00ef..fc6d1320ee 100644 --- a/src/slic3r/Utils/OrcaPrinterAgent.cpp +++ b/src/slic3r/Utils/OrcaPrinterAgent.cpp @@ -1411,6 +1411,19 @@ AgentInfo OrcaPrinterAgent::get_agent_info_static() // Print Job Operations - All Stubs // ============================================================================ +// Orchestrates the cloud print workflow: upload the sliced G-code straight to R2 +// via a presigned URL (upload_gcode_via_cloud), then finalize over HTTP +// (start_cloud_print_job). The finalize call is what actually gets the file to +// the printer: the gateway HEAD-verifies the R2 object, then relays +// print.project_file to OrcaSonar over the gateway's OWN cloud relay connection - +// not this agent's MQTT session. See CLOUD_PRINT_JOB_MQTT_DESIGN.md for the +// MQTT-native alternative (publishing print.project_file directly over this +// agent's own cloud connection) and why it isn't used yet. +// +// This deliberately does NOT go through start_sdcard_print/print.gcode_file: +// that command is the generic "start this file already on the printer" primitive +// (also used by the LAN start_local_print path, and meant to stay that way as it +// grows params like filament mapping), and has no download-awareness to give it. int OrcaPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) { (void) wait_fn; @@ -1426,28 +1439,33 @@ int OrcaPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn return BAMBU_NETWORK_ERR_INVALID_HANDLE; const std::string local_path = resolve_local_gcode_path(params); - const fs::path source(local_path); - boost::filesystem::ifstream input(source, std::ios::in | std::ios::binary); - if (!input) { + boost::system::error_code ec; + if (!fs::exists(local_path, ec) || !fs::is_regular_file(local_path, ec)) { BOOST_LOG_TRIVIAL(error) << "OrcaPrinterAgent: G-code file does not exist: " << local_path; return BAMBU_NETWORK_ERR_FILE_NOT_EXIST; } - const std::string gcode((std::istreambuf_iterator(input)), std::istreambuf_iterator()); - if (input.bad()) { - BOOST_LOG_TRIVIAL(error) << "OrcaPrinterAgent: failed to read G-code file: " << local_path; - return BAMBU_NETWORK_ERR_PRINT_WR_POST_TASK_FAILED; - } if (cancel_fn && cancel_fn()) return BAMBU_NETWORK_ERR_CANCELED; if (update_fn) update_fn(PrintingStageUpload, 0, "Uploading G-code..."); - const int result = cloud->send_print_job(params.dev_id, remote_gcode_name(params), gcode, true); + std::string job_id; + int result = cloud->upload_gcode_via_cloud(params.dev_id, local_path, &job_id, update_fn, cancel_fn); if (result != BAMBU_NETWORK_SUCCESS) return result; + if (cancel_fn && cancel_fn()) + return BAMBU_NETWORK_ERR_CANCELED; + + if (update_fn) + update_fn(PrintingStageSending, 0, "Starting print..."); + + const int start_rc = cloud->start_cloud_print_job(params.dev_id, job_id, remote_gcode_name(params), /*start=*/true); + if (start_rc != BAMBU_NETWORK_SUCCESS) + return start_rc; + if (update_fn) update_fn(PrintingStageFinished, 100, "Print started"); From 9daecc59b4cbfc87dee2362801a2300127da2655 Mon Sep 17 00:00:00 2001 From: peachismomo Date: Sat, 12 Sep 2026 02:32:01 +0800 Subject: [PATCH 24/24] temp: doc for intended change --- docs/CLOUD_PRINT_JOB_MQTT_DESIGN.md | 148 ++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/CLOUD_PRINT_JOB_MQTT_DESIGN.md diff --git a/docs/CLOUD_PRINT_JOB_MQTT_DESIGN.md b/docs/CLOUD_PRINT_JOB_MQTT_DESIGN.md new file mode 100644 index 0000000000..fc74e0add8 --- /dev/null +++ b/docs/CLOUD_PRINT_JOB_MQTT_DESIGN.md @@ -0,0 +1,148 @@ +# Cloud print job: MQTT-native design (not yet implemented) + +`OrcaPrinterAgent::start_print` currently finalizes a cloud print job over HTTP +(`OrcaCloudServiceAgent::start_cloud_print_job`, `POST +/api/v1/printers//print-jobs//start`). This document records +the MQTT-native alternative that was designed as the intended replacement, the +gap that blocks it today, and why it should not be built by adding fields to +`print.gcode_file`. + +## Current (implemented) flow + +1. `OrcaCloudServiceAgent::upload_gcode_via_cloud` + - `POST print-jobs/uploads` -> `{job_id, upload_url, expires_at, max_bytes}` + - `PUT upload_url` -> raw G-code straight to R2 (presigned, PUT-only, no + bearer token; must not go through the `http_put` helper, which always + prefixes `api_base_url` and attaches the cloud session's Authorization + header). +2. `OrcaCloudServiceAgent::start_cloud_print_job` + - `POST print-jobs//start` with `{filename, start}`. + - The gateway HEAD-verifies the R2 object landed, mints a short-lived + signed *download* URL (`createPrintJobDownloadToken` in + `apps/gateway/src/services/printer-print-jobs.ts`), and relays a + `print.project_file` command carrying that URL to the printer - + **over the gateway's own relay connection to OrcaSonar, not + OrcaSlicer's MQTT session.** OrcaSonar's `executeProjectFile` + (`internal/cloud/printfile.go`) downloads from that URL and starts the + print. + +This works today and requires no changes to OrcaCloud or OrcaSonar. + +## Why an MQTT-native version is desirable + +The HTTP finalize call means `start_print`'s cloud path depends on the +client's REST session (auth token, network path to the gateway's HTTP API) +in addition to its MQTT session. An MQTT-native version would let OrcaSlicer +trigger the download-and-start entirely over the connection it already +maintains for every other printer command. + +## The key finding: OrcaSonar already supports this, transport-agnostically + +`print.project_file` is not tied to the HTTP `/start` route. OrcaSonar's +cloud MQTT message handler intercepts it purely by command name, before +routing to the generic per-namespace dispatcher: + +```go +// internal/cloud/client.go, makeRequestHandler +if cmd.Namespace == "print" && cmd.Name == "project_file" { + go c.handleProjectFile(ctx, cmd) // downloads `url`, then starts if start=true + return +} +``` + +This fires for **any** message that reaches OrcaSonar's own outbound cloud +MQTT session on `device//request` - regardless of whether it was +published there by the gateway's internal relay (today's `/start` path) or +by a client publishing directly. OrcaSlicer's cloud MQTT connection already +publishes to that same topic for every other cloud command +(`OrcaPrinterAgent::route_send(is_lan=false, ...)` - `set_bed_temp`, +`ams_change_filament`, etc.), via the same relay-shard mechanism +(`apps/gateway/src/lib/relay-router.ts`). So **OrcaSlicer publishing +`print.project_file` itself, over its existing cloud MQTT connection, would +already reach `handleProjectFile` and trigger the identical download-then- +start behavior - with zero new code on OrcaSonar.** + +(This only works over the cloud relay session. OrcaSonar's LAN-side/generic +dispatcher, `internal/bridge/klipper/adapter.go`, treats `print.project_file` +as an unmapped macro call - a no-op in practice. That's fine: R2 upload is a +cloud-only feature to begin with.) + +## The one real gap: no GET-signed download URL is exposed to the client + +`handleProjectFile` needs a URL it can `GET`. The `upload_url` returned by +`POST print-jobs/uploads` is presigned for `PUT` only - S3 SigV4 signatures +are bound to the HTTP method, so it cannot be reused for a download. + +Minting a download URL/token already exists as a function +(`createPrintJobDownloadToken` in +`apps/gateway/src/services/printer-print-jobs.ts`) and the exact URL +template is already built in `dispatchPrintFileCommand` +(`apps/gateway/src/routes/printer.ts`) - it is simply never returned to the +API caller today, only used server-side when `/start` builds the relay +payload itself. + +**Required OrcaCloud change:** have `POST print-jobs/uploads` (or a small +follow-up call) also mint and return a signed download URL alongside +`upload_url`, reusing `createPrintJobDownloadToken` + the existing URL +template. This is on the order of ~10 lines in an existing handler, not a new +permission model - the caller is already an authenticated, authorized user of +that printer, identically to who is authorized to call `/start` today. + +No OrcaSonar change is required at all. + +## Why this should NOT be built into `print.gcode_file` / `start_sdcard_print` + +`print.gcode_file` (sent by `OrcaPrinterAgent::start_sdcard_print`) is the +generic "start this file that is already on the printer" primitive. It is +used by the LAN `start_local_print` path today, is meant to stay usable for +starting any file already on the SD card by filename alone, and is expected +to grow parameters unrelated to cloud upload (e.g. filament mapping) over +time. + +Making `gcode_file` download-aware would require either: +- adding cloud-specific fields (`job_id`, a download `url`, ...) to a command + that has nothing to do with cloud jobs in the LAN case, forcing all of them + to be optional/unused most of the time, or +- giving OrcaSonar a side-channel registry of "filenames currently being + downloaded" that `gcode_file`'s handler consults - solvable, but an + orthogonal change with its own design questions (see "decoupled two-step + option" below). + +Neither is necessary: `print.project_file` already exists as a fully +separate, fully-working command for exactly the "not yet on the printer, +fetch it first" case, so the cloud upload flow does not need to touch +`gcode_file` at all. + +## Intended MQTT-native design, once the gap above is closed + +Replace the HTTP finalize step (`start_cloud_print_job`) with: build and +publish, over the cloud MQTT connection (`route_send(is_lan=false, ...)`), + +```json +{"print": {"command": "project_file", "sequence_id": "...", + "url": "", + "param": "", + "start": true}} +``` + +`start_sdcard_print` / `print.gcode_file` remains untouched and fully +decoupled. + +### Decoupled two-step option + +If a use case ever needs "download now, start later" as an explicit user +action (rather than upload-and-immediately-print), the same +`print.project_file` command already supports it via `start: false` (download +and store only - see `executeProjectFile`'s `start` handling in +`internal/cloud/printfile.go`). The later "start" action would then be a +perfectly ordinary `print.gcode_file` with `param: `, going through +the existing, generic `start_sdcard_print` unmodified. This still requires no +protocol changes beyond the download-URL gap above. + +## Summary of gaps + +| Component | Change needed | +|---|---| +| OrcaCloud (gateway) | Return a signed download URL from `POST print-jobs/uploads` (or a small sibling endpoint), reusing existing `createPrintJobDownloadToken` logic. | +| OrcaSonar | None. `print.project_file` handling already does exactly what's needed, transport-agnostically, on the cloud MQTT session. | +| OrcaSlicer (this repo) | Once the above lands: replace `start_cloud_print_job`'s HTTP call with a `print.project_file` publish over the cloud MQTT connection. `start_sdcard_print` stays untouched either way. |