Compare commits

...
Author SHA1 Message Date
Lam Wei Lun adcc470cff feat: add filament mapping and AMS capability gates for the orca printer agent
Parse connector-scope capabilities from get_capabilities: supported_features, capabilities.protocol.features and supported_commands are read for orca devices even when the reply has no flags block, reset before each parse, and left untouched for Bambu.

Gate macro-backed AMS commands on fms plus the advertised command (change filament, select tray, control, user settings, RFID, drying stop); gate slot metadata writes on filament_slots. Unknown capability is not support.

Serialize PrintParams::ams_mapping2 into print.gcode_file.filament_mapping with the array position as filament_index and {255,255} dropped. The field is omitted when empty, so an unmapped print stays byte-identical. A mapped print is refused visibly while ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED is false or the connector did not advertise filament_mapping; the GUI predicate shares the serializer's definition so both gates agree. Refuse a partially mapped print that leaves a used filament without a target.

Return a non-success result from start_local_print_with_record so PrintJob falls back to start_print instead of treating an unsent print as success.

Tests cover serializer keying and omission, GUI/agent gate agreement, payload shape, the with_record result, capability parsing, and the per-command AMS gate.
2026-09-26 22:50:58 +08:00
Ian Chua 87eb8de001 fix: serialize MQTT ws I/O and dewcouple keepalive 2026-09-24 20:28:13 +08:00
Ian Chua 220508534a fix: cancel inflight cloud signaling requests 2026-09-24 19:03:58 +08:00
Ian Chua 5a0810365b fix: clarify OrcaCloud signaling endpoint contract 2026-09-24 18:18:48 +08:00
Ian Chua fa98ec5afe feat: add ssl and ca_cert for OrcaPrinterAgent 2026-09-24 17:53:46 +08:00
Ian Chua 3c1d8ac8b4 fix: allow input ip address dialog to validate if is valid endpoint 2026-09-24 17:53:12 +08:00
Ian Chua 936b0bbc17 feat: add https compatibility for moonraker based printer agents 2026-09-24 14:53:32 +08:00
Ian Chua 5222a37eff fix: command_ams_refresh_rfid function signature in test_printer_agent.cpp 2026-09-24 12:35:03 +08:00
27 changed files with 1231 additions and 390 deletions
+111 -3
View File
@@ -1676,6 +1676,10 @@ int MachineObject::check_resume_condition()
}
int MachineObject::command_ams_change_filament(bool load, std::string ams_id, std::string slot_id, int old_temp, int new_temp, std::optional<int> extruder_id)
{
if (!orca_ams_command_supported("print.ams_change_filament")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_change_filament: connector does not advertise the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
json j;
try {
auto tray_id = 0;
@@ -1719,6 +1723,10 @@ int MachineObject::command_ams_change_filament(bool load, std::string ams_id, st
int MachineObject::command_ams_user_settings(bool start_read_opt, bool tray_read_opt, bool remain_flag)
{
if (!orca_ams_command_supported("print.ams_user_setting")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_user_settings: connector does not advertise the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
json j;
j["print"]["command"] = "ams_user_setting";
j["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++);
@@ -1741,8 +1749,25 @@ int MachineObject::command_ams_calibrate(int ams_id)
return command_with_dialog(m_agent->command_ams_calibrate(get_dev_id(), ams_id, MachineObject::m_sequence_id++, is_lan_mode_printer()));
}
bool MachineObject::orca_ams_command_supported(const char* command) const
{
if (printer_agent_id != ORCA_PRINTER_AGENT_ID)
return true;
// fms is the AMS axis; the advertised command set is the per-command gate.
if (!is_support_fms)
return false;
return command != nullptr && supported_commands.count(command) != 0;
}
int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max)
{
// OrcaSonar: writing slot metadata is gated on the filament_slots capability.
// Absent/false means unsupported; Bambu keeps the legacy behaviour.
if (printer_agent_id == ORCA_PRINTER_AGENT_ID && !is_support_filament_slots) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_filament_settings: printer does not advertise filament_slots";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
int tag_tray_id = 0;
int tag_ams_id = ams_id;
int tag_slot_id = slot_id;
@@ -1777,6 +1802,13 @@ int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::s
int MachineObject::command_ams_refresh_rfid(int ams_id, int slot_id)
{
if (!m_agent) return -1;
// OrcaSonar: RFID read requires the connector to advertise the
// `print.ams_get_rfid` command (which already implies fms plus the macro).
// Unknown capability is not support; Bambu is untouched.
if (!orca_ams_command_supported("print.ams_get_rfid")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_refresh_rfid: connector does not advertise the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
return command_with_dialog(m_agent->command_ams_refresh_rfid(get_dev_id(), ams_id, slot_id, MachineObject::m_sequence_id++, is_lan_mode_printer()));
}
@@ -1790,11 +1822,21 @@ int MachineObject::command_start_camera()
int MachineObject::command_ams_select_tray(std::string tray_id)
{
if (!m_agent) return -1;
// OrcaSonar: this publishes the same macro-backed print.ams_change_filament
// as command_ams_change_filament, so it takes the same capability gate.
if (!orca_ams_command_supported("print.ams_change_filament")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_select_tray: connector does not advertise the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
return command_with_dialog(m_agent->command_ams_select_tray(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer()));
}
int MachineObject::command_ams_control(std::string action)
{
if (!orca_ams_command_supported("print.ams_control")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_control: connector does not advertise the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
if (action == "resume" && check_resume_condition()) return 0;
//valid actions
@@ -1810,6 +1852,10 @@ int MachineObject::command_ams_control(std::string action)
int MachineObject::command_ams_drying_stop()
{
if (!orca_ams_command_supported("print.auto_stop_ams_dry")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_drying_stop: connector does not advertise the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
json j;
j["print"]["command"] = "auto_stop_ams_dry";
j["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++);
@@ -5490,10 +5536,14 @@ void MachineObject::parse_new_info2(const json& info)
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())
const bool has_flags = flags_it != capabilities_it->end() && flags_it->is_object();
// Bambu keeps the legacy behavior: a reply without flags is ignored. Orca
// connector-scope features/commands must still be parsed, so fall through
// with an empty flags object (every parse_bool below then no-ops).
if (!has_flags && printer_agent_id != ORCA_PRINTER_AGENT_ID)
return;
const json& flags = *flags_it;
static const json empty_flags = json::object();
const json& flags = has_flags ? *flags_it : empty_flags;
BOOST_LOG_TRIVIAL(info) << "parse_new_info2: OrcaSonar capability flags=" << flags.dump();
auto parse_bool = [&flags](const char* name, bool& target) {
@@ -5559,6 +5609,64 @@ void MachineObject::parse_new_info2(const json& info)
m_config->ParseConfig(device_config);
m_fan->ParseV2_0(fan_config);
// OrcaSonar connector-scope capabilities. Absent or non-boolean means
// unsupported. A reply without `flags` still parses them (the fall-through
// above); Bambu (bbl) devices never enter this branch and keep the
// flags-only path.
if (printer_agent_id == ORCA_PRINTER_AGENT_ID) {
auto parse_features = [this](const json& features) {
if (!features.is_object())
return;
auto set_flag = [&features](const char* name, bool& target) {
const auto it = features.find(name);
if (it != features.end() && it->is_boolean())
target = it->get<bool>();
};
set_flag("fms", is_support_fms);
set_flag("filament_slots", is_support_filament_slots);
set_flag("filament_mapping", is_support_filament_mapping);
};
auto parse_commands = [this](const json& commands) {
if (!commands.is_array())
return;
for (const auto& command : commands) {
if (command.is_string())
supported_commands.insert(command.get<std::string>());
}
};
// Clear before filling so a reply without commands does not retain stale values.
supported_commands.clear();
is_support_fms = false;
is_support_filament_slots = false;
is_support_filament_mapping = false;
const auto supported_features_it = info.find("supported_features");
if (supported_features_it != info.end())
parse_features(*supported_features_it);
const auto supported_commands_it = info.find("supported_commands");
if (supported_commands_it != info.end())
parse_commands(*supported_commands_it);
const auto protocol_it = capabilities_it->find("protocol");
if (protocol_it != capabilities_it->end() && protocol_it->is_object()) {
const auto protocol_features_it = protocol_it->find("features");
if (protocol_features_it != protocol_it->end())
parse_features(*protocol_features_it);
const auto protocol_commands_it = protocol_it->find("supported_commands");
if (protocol_commands_it != protocol_it->end())
parse_commands(*protocol_commands_it);
}
BOOST_LOG_TRIVIAL(info) << "parse_new_info2: fms=" << is_support_fms
<< " filament_slots=" << is_support_filament_slots
<< " filament_mapping=" << is_support_filament_mapping
<< " supported_commands=" << supported_commands.size();
}
}
static bool is_hex_digit(char c) {
+12
View File
@@ -8,6 +8,7 @@
#include <string>
#include <memory>
#include <chrono>
#include <set>
#include <unordered_set>
#include <optional>
#include <boost/thread.hpp>
@@ -646,6 +647,13 @@ public:
bool is_support_partskip{false};
bool is_support_refresh_nozzle{false};
// OrcaSonar connector-scope capabilities (printer_agent_id == "orca"). Parsed from the
// get_capabilities reply; never consulted on Bambu paths.
bool is_support_fms{false};
bool is_support_filament_slots{false};
bool is_support_filament_mapping{false};
std::set<std::string> supported_commands;
// refine printer function options
bool is_support_spaghetti_detection{false};
bool is_support_purgechutepileup_detection{false};
@@ -787,6 +795,10 @@ public:
int command_refresh_nozzle();
int command_set_chamber(int temp);
int check_resume_condition();
// OrcaSonar: true when a macro-backed AMS command may be sent. Bambu paths
// always allow; an Orca device must have advertised the command (unknown
// capability is not support).
bool orca_ams_command_supported(const char* command) const;
// ams controls
//int command_ams_switch(int tray_index, int old_temp = 210, int new_temp = 210);
int command_ams_change_filament(bool load, std::string ams_id, std::string slot_id, int old_temp = 210, int new_temp = 210, std::optional<int> extruder_id = std::nullopt);
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "libslic3r/ProjectTask.hpp"
namespace Slic3r {
namespace GUI {
// True when the normalized ams_mapping2 carries any entry the agent's serializer
// would put on the wire: every integer pair except the {255,255} unmatched
// sentinel, external slots ({255,0}/{254,0}) included. This mirrors
// OrcaPrinterAgent::build_filament_mapping exactly, so the GUI gate and the
// agent gate agree; a mismatch lets an entry reach the agent and be refused late
// with a generic publish error instead of the designed message.
inline bool has_engaged_filament_mapping(const std::string& ams_mapping2)
{
const nlohmann::json mapping = nlohmann::json::parse(ams_mapping2, nullptr, false);
if (mapping.is_discarded() || !mapping.is_array())
return false;
for (const auto& entry : mapping) {
if (!entry.is_object())
continue;
const auto ams_id_it = entry.find("ams_id");
const auto slot_id_it = entry.find("slot_id");
if (ams_id_it == entry.end() || slot_id_it == entry.end())
continue;
if (!ams_id_it->is_number_integer() || !slot_id_it->is_number_integer())
continue;
if (ams_id_it->get<int>() == 255 && slot_id_it->get<int>() == 255)
continue; // unmatched/unused sentinel: the serializer drops it
return true;
}
return false;
}
// A used filament with no target would be silently dropped from the wire
// mapping, so the print must be refused rather than run the wrong material.
// m_ams_mapping_result carries exactly the filaments the slice uses.
inline bool has_used_filament_without_target(const std::vector<FilamentInfo>& result)
{
for (const auto& f : result) {
if (f.get_ams_id() < 0 || f.get_slot_id() < 0)
return true;
}
return false;
}
// True when at least one used filament already has a target. The used-unmapped
// refusal applies to a partially mapped print; a print with no mapping at all
// is handled by the existing all-invalid send-path flow.
inline bool has_any_mapped_target(const std::vector<FilamentInfo>& result)
{
for (const auto& f : result) {
if (f.get_ams_id() >= 0 && f.get_slot_id() >= 0)
return true;
}
return false;
}
} // namespace GUI
} // namespace Slic3r
+24 -14
View File
@@ -1777,7 +1777,7 @@ void InputIpAddressDialog::set_machine_obj(MachineObject* obj)
auto str_ip = m_input_ip->GetTextCtrl()->GetValue();
auto str_access_code = m_input_access_code->GetTextCtrl()->GetValue();
// ORCA enabling / disabling buttons with conditions enough to change its style
m_button_ok->Enable(isIp(str_ip.ToStdString()) &&
m_button_ok->Enable(isValidEndpoint(str_ip.ToStdString()) &&
(str_access_code.IsEmpty() || str_access_code.Length() >= 8));
Layout();
@@ -1815,19 +1815,29 @@ void InputIpAddressDialog::update_test_msg(wxString msg,bool connected)
Fit();
}
bool InputIpAddressDialog::isIp(std::string ipstr)
bool InputIpAddressDialog::isValidEndpoint(std::string endpoint)
{
istringstream ipstream(ipstr);
int num[4];
char point[3];
string end;
ipstream >> num[0] >> point[0] >> num[1] >> point[1] >> num[2] >> point[2] >> num[3] >> end;
for (int i = 0; i < 3; ++i) {
if (num[i] < 0 || num[i]>255) return false;
if (point[i] != '.') return false;
}
if (num[3] < 0 || num[3]>255) return false;
if (!end.empty()) return false;
if (endpoint.empty() || std::any_of(endpoint.begin(), endpoint.end(), [](unsigned char c) {
return std::isspace(c) != 0;
}))
return false;
const bool has_http_scheme = endpoint.rfind("http://", 0) == 0;
const bool has_https_scheme = endpoint.rfind("https://", 0) == 0;
const auto scheme_pos = endpoint.find("://");
if (scheme_pos != std::string::npos && !has_http_scheme && !has_https_scheme)
return false;
std::string port;
const std::string host = Http::get_host_from_url(endpoint, &port);
if (host.empty())
return false;
// get_host_from_url returns its input when libcurl cannot parse it. For a
// URL with a scheme, that means a failed parse still needs to be rejected.
if (scheme_pos != std::string::npos && host == endpoint)
return false;
return true;
}
@@ -2130,7 +2140,7 @@ void InputIpAddressDialog::on_text(wxCommandEvent &evt)
// ORCA enabling / disabling buttons with conditions enough to change its style
bool valid_access_code_length = str_access_code.IsEmpty() || str_access_code.Length() >= 8;
bool enable_btns = isIp(str_ip.ToStdString()) && valid_access_code_length && invalid_access_code;
bool enable_btns = isValidEndpoint(str_ip.ToStdString()) && valid_access_code_length && invalid_access_code;
m_button_manual_setup->Enable(enable_btns);
m_button_ok->Enable(enable_btns);
+1 -1
View File
@@ -348,7 +348,7 @@ public:
void update_title(wxString title);
void set_machine_obj(MachineObject* obj);
void update_test_msg(wxString msg, bool connected);
bool isIp(std::string ipstr);
bool isValidEndpoint(std::string endpoint);
void check_ip_address_failed(int result);
void on_check_ip_address_failed(wxCommandEvent& evt);
void on_ok(wxMouseEvent& evt);
+25
View File
@@ -37,6 +37,8 @@
#include "libslic3r/MultiNozzleUtils.hpp" // filament-change-gap model for the best-position popup
#include "BackgroundSlicingProcess.hpp" // complete type for background_process().get_current_gcode_result()
#include "DeviceCore/DevStorage.h"
#include "slic3r/Utils/NetworkAgentFactory.hpp"
#include "FilamentMappingUtils.hpp"
#include <wx/progdlg.h>
#include <wx/clipbrd.h>
@@ -3478,6 +3480,9 @@ void SelectMachineDialog::navigate_to_timelapse_page()
this->EndModal(wxID_CANCEL);
}
// Mapping helpers live in FilamentMappingUtils.hpp (shared with
// SendMultiMachinePage); they mirror the agent serializer exactly.
void SelectMachineDialog::on_send_print()
{
BOOST_LOG_TRIVIAL(info) << "print_job: on_ok to send";
@@ -3532,6 +3537,26 @@ void SelectMachineDialog::on_send_print()
get_ams_mapping_result(ams_mapping_array,ams_mapping_array2, ams_mapping_info);
// OrcaSonar: a mapped print requires the connector to advertise
// filament_mapping and the index correlation to be verified. Refuse rather
// than start with the map silently dropped; Bambu keeps its behavior.
if (obj_->printer_agent_id == ORCA_PRINTER_AGENT_ID) {
const bool mapping_available = obj_->is_support_filament_mapping && ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED;
if (!mapping_available && has_engaged_filament_mapping(ams_mapping_array2)) {
BOOST_LOG_TRIVIAL(warning) << "print_job: filament mapping unavailable (capability=" << obj_->is_support_filament_mapping
<< ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << "); refusing mapped print";
m_status_bar->set_status_text(_L("AMS filament mapping is not available for this printer. Clear the AMS mapping before printing."));
Enable_Send_Button(true);
return;
}
if (has_any_mapped_target(m_ams_mapping_result) && has_used_filament_without_target(m_ams_mapping_result)) {
BOOST_LOG_TRIVIAL(warning) << "print_job: a used filament has no AMS target; refusing print";
m_status_bar->set_status_text(_L("A filament used by this print has no AMS mapping. Assign it before printing."));
Enable_Send_Button(true);
return;
}
}
if (m_print_type == PrintFromType::FROM_NORMAL) {
result = m_plater->send_gcode(m_print_plate_idx, [this](int export_stage, int current, int total, bool& cancel) {
if (this->m_is_canceled) return;
+27
View File
@@ -10,6 +10,8 @@
#include "DeviceCore/DevManager.h"
#include "DeviceCore/DevStorage.h"
#include "slic3r/Utils/NetworkAgentFactory.hpp"
#include "FilamentMappingUtils.hpp"
namespace Slic3r {
namespace GUI {
@@ -692,6 +694,8 @@ bool SendMultiMachinePage::get_ams_mapping_result(std::string &mapping_array_str
return true;
}
// Mapping helpers live in FilamentMappingUtils.hpp, shared with SelectMachine.
void SendMultiMachinePage::on_send(wxCommandEvent& event)
{
event.Skip();
@@ -740,6 +744,29 @@ void SendMultiMachinePage::on_send(wxCommandEvent& event)
if (!wxGetApp().is_blocking_printing(obj)) {
PrintParams params = request_params(obj);
// OrcaSonar: a mapped print requires the connector to advertise
// filament_mapping, and a partially mapped print must not silently drop a
// used filament. Any entry the serializer would put on the wire engages the
// gate, external slots ({255,0}/{254,0}) included; the extra-spool branch
// rewrites to exactly those. Bambu is unchanged.
if (obj->printer_agent_id == ORCA_PRINTER_AGENT_ID) {
const bool mapping_available = obj->is_support_filament_mapping && ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED;
if (!mapping_available && has_engaged_filament_mapping(params.ams_mapping2)) {
BOOST_LOG_TRIVIAL(warning) << "SendMultiMachinePage: filament mapping unavailable (capability=" << obj->is_support_filament_mapping
<< ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << "); refusing mapped print for "
<< obj->get_dev_id();
MessageDialog msg_wingow(nullptr, _L("AMS filament mapping is not available for this printer. Clear the AMS mapping before printing."), "", wxICON_WARNING | wxOK);
msg_wingow.ShowModal();
return;
}
if (params.task_use_ams && has_any_mapped_target(m_ams_mapping_result) &&
has_used_filament_without_target(m_ams_mapping_result)) {
BOOST_LOG_TRIVIAL(warning) << "SendMultiMachinePage: a used filament has no target; refusing print for " << obj->get_dev_id();
MessageDialog msg_wingow(nullptr, _L("A filament used by this print has no AMS mapping. Assign it before printing."), "", wxICON_WARNING | wxOK);
msg_wingow.ShowModal();
return;
}
}
print_params.push_back(params);
}
}
+6
View File
@@ -1373,6 +1373,12 @@ bool SyncAmsInfoDialog::get_ams_mapping_result(std::string &mapping_array_str, s
BOOST_LOG_TRIVIAL(error) << "get_ams_mapping_result, plater is nullptr";
}
// mapping_v1_json is built one entry per filament preset, in preset order, so the
// array position is the logical filament index the generated G-code toolchange
// references (the identifier handed to the Klipper toolchange macro; see
// OrcaPrinterAgent::build_filament_mapping and the index-correlation test in
// tests/slic3rutils/test_orca_printer_agent.cpp). Never re-densify after dropping
// sentinel entries, or a used filament would be aimed at the wrong lane.
for (int i = 0; i < wxGetApp().preset_bundle->filament_presets.size(); i++) {
int tray_id = -1;
json mapping_item_v1;
+6 -5
View File
@@ -1,5 +1,6 @@
#include "Http.hpp"
#include <atomic>
#include <cstdlib>
#include <functional>
#include <thread>
@@ -133,7 +134,7 @@ struct Http::priv
std::string error_buffer; // Used for CURLOPT_ERRORBUFFER
std::string headers;
size_t limit;
bool cancel;
std::atomic_bool cancel;
std::unique_ptr<form_file> putFile;
std::thread io_thread;
@@ -271,9 +272,9 @@ int Http::priv::xfercb(void *userp, curl_off_t dltotal, curl_off_t dlnow, curl_o
self->progressfn(progress, cb_cancel);
}
if (cb_cancel) { self->cancel = true; }
if (cb_cancel) { self->cancel.store(true); }
return self->cancel;
return self->cancel.load();
}
int Http::priv::xfercb_legacy(void *userp, double dltotal, double dlnow, double ultotal, double ulnow)
@@ -484,7 +485,7 @@ void Http::priv::http_perform()
if (res != CURLE_OK) {
if (res == CURLE_ABORTED_BY_CALLBACK) {
if (cancel) {
if (cancel.load()) {
// The abort comes from the request being cancelled programatically
Progress dummyprogress(0, 0, 0, 0, std::string());
bool cancel = true;
@@ -795,7 +796,7 @@ void Http::perform_sync()
void Http::cancel()
{
if (p) { p->cancel = true; }
if (p) { p->cancel.store(true); }
}
void Http::print() const
+20 -5
View File
@@ -38,12 +38,25 @@ wxString Moonraker::get_test_failed_msg(wxString &msg) const
std::string Moonraker::make_url(const std::string &path) const
{
if (m_host.find("http://") == 0 || m_host.find("https://") == 0) {
if (m_host.back() == '/')
return (boost::format("%1%%2%") % m_host % path).str();
return (boost::format("%1%/%2%") % m_host % path).str();
const bool has_scheme = m_host.find("http://") == 0 || m_host.find("https://") == 0;
const bool use_ssl = m_host.find("https://") == 0;
std::string base = has_scheme ? m_host : ("http://" + m_host);
const size_t authority_start = base.find("://") + 3;
const size_t authority_end = base.find('/', authority_start);
const std::string authority = base.substr(authority_start, authority_end - authority_start);
const size_t closing_bracket = authority.rfind(']');
const bool has_port = closing_bracket != std::string::npos
? closing_bracket + 1 < authority.size() && authority[closing_bracket + 1] == ':'
: authority.find(':') != std::string::npos;
if (!has_port) {
const std::string default_port = use_ssl ? ":7130" : ":7125";
base.insert(authority_end == std::string::npos ? base.size() : authority_end, default_port);
}
return (boost::format("http://%1%/%2%") % m_host % path).str();
if (base.back() == '/')
return (boost::format("%1%%2%") % base % path).str();
return (boost::format("%1%/%2%") % base % path).str();
}
void Moonraker::set_auth(Http &http) const
@@ -53,6 +66,8 @@ void Moonraker::set_auth(Http &http) const
// filled the user/password fields — those are PrusaLink/OctoPrint conventions.
if (!m_apikey.empty())
http.header("X-Api-Key", m_apikey);
const bool use_ssl = m_host.find("https://") == 0;
http.tls_verify(use_ssl);
if (!m_cafile.empty())
http.ca_file(m_cafile);
}
+188 -158
View File
@@ -1,6 +1,7 @@
#include "MoonrakerPrinterAgent.hpp"
#include "Http.hpp"
#include "IPrinterAgent.hpp"
#include "bambu_networking.hpp"
#include "libslic3r/Preset.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/Utils.hpp"
@@ -70,7 +71,7 @@ bool parse_ws_endpoint(const std::string& base_url, WsEndpoint& endpoint)
}
endpoint.host = url;
endpoint.port = endpoint.secure ? "443" : "80";
endpoint.port = endpoint.secure ? "7130" : "7125";
if (auto colon = url.rfind(':'); colon != std::string::npos && url.find(']') == std::string::npos) {
endpoint.host = url.substr(0, colon);
endpoint.port = url.substr(colon + 1);
@@ -109,9 +110,16 @@ struct MoonrakerWebsocket::Impl
using PlainPtr = std::unique_ptr<PlainWebsocket>;
using SecurePtr = std::unique_ptr<SecureWebsocket>;
explicit Impl(bool secure, std::string api_key) : secure(secure), api_key(std::move(api_key)), ssl_context(net::ssl::context::tls_client)
explicit Impl(bool secure, std::string api_key, std::string ca_file)
: secure(secure), api_key(std::move(api_key)), ca_file(std::move(ca_file)), ssl_context(net::ssl::context::tls_client)
{
if (this->secure) {
if (!this->ca_file.empty()) {
ssl_context.load_verify_file(this->ca_file);
} else {
ssl_context.set_default_verify_paths();
}
ssl_context.set_verify_mode(net::ssl::verify_peer);
websocket = std::make_unique<SecureWebsocket>(ioc, ssl_context);
} else {
websocket = std::make_unique<PlainWebsocket>(beast::tcp_stream{ioc});
@@ -120,12 +128,15 @@ struct MoonrakerWebsocket::Impl
bool secure;
std::string api_key;
std::string ca_file;
net::io_context ioc;
net::ssl::context ssl_context;
std::variant<PlainPtr, SecurePtr> websocket;
};
MoonrakerWebsocket::MoonrakerWebsocket(bool secure, std::string api_key) : m_impl(std::make_unique<Impl>(secure, std::move(api_key))) {}
MoonrakerWebsocket::MoonrakerWebsocket(bool secure, std::string api_key, std::string ca_file)
: m_impl(std::make_unique<Impl>(secure, std::move(api_key), std::move(ca_file)))
{}
MoonrakerWebsocket::~MoonrakerWebsocket() = default;
@@ -153,9 +164,6 @@ void MoonrakerWebsocket::tls_handshake(const std::string& host)
throw std::runtime_error("Moonraker WSS: failed to set TLS server name");
}
// Match Http's existing printer-host behavior: encrypt the connection
// while accepting self-signed/local printer certificates.
tls_stream.set_verify_mode(net::ssl::verify_none);
tls_stream.handshake(net::ssl::stream_base::client);
}
@@ -347,15 +355,17 @@ int MoonrakerPrinterAgent::connect_printer(const PrinterConnectionParams& params
BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: connect_printer missing dev_id or dev_ip";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
std::string base_url;
std::string api_key;
ConnectionSettings connection;
uint64_t gen;
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
init_device_info(params.dev_id, params.host, params.username, params.password, params.use_ssl, params.port);
init_device_info(params);
gen = ++connect_generation;
base_url = device_info.base_url;
api_key = device_info.api_key;
connection.dev_id = device_info.dev_id;
connection.base_url = device_info.base_url;
connection.api_key = device_info.api_key;
connection.use_ssl = device_info.use_ssl;
connection.ca_file = device_info.ca_file;
if (connect_thread.joinable()) {
connect_thread.detach();
}
@@ -379,7 +389,9 @@ int MoonrakerPrinterAgent::connect_printer(const PrinterConnectionParams& params
// Launch connection in background thread (capture by value to avoid data races)
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
connect_thread = std::thread([this, dev_id = params.dev_id, base_url, api_key, gen]() { perform_connection_async(dev_id, base_url, api_key, gen); });
connect_thread = std::thread([this, dev_id = params.dev_id, connection = std::move(connection), gen]() mutable {
perform_connection_async(dev_id, std::move(connection), gen);
});
}
return BAMBU_NETWORK_SUCCESS;
@@ -427,7 +439,9 @@ int MoonrakerPrinterAgent::bind_detect(std::string dev_ip, std::string sec_link,
// so the name falls back to the IP instead of blank. (matches
// feature/printer-agent-port-pristine; the IP is what shipped before the port)
// note: dummy id/creds; use_ssl false because Moonraker/print-host is http.
init_device_info(dev_ip, dev_ip, "", "", false, "");
init_device_info(PrinterConnectionParams{
dev_ip, dev_ip, "", "", "", false, ""
});
detect.dev_id = device_info.dev_id.empty() ? dev_ip : device_info.dev_id;
detect.model_id = device_info.model_id.empty() ? device_info.model_name : device_info.model_id;
@@ -489,6 +503,7 @@ int MoonrakerPrinterAgent::start_send_gcode_to_sdcard(PrintParams params,
OnWaitFn wait_fn)
{
(void) wait_fn;
const ConnectionSettings connection = get_connection_settings();
if (update_fn)
update_fn(PrintingStageCreate, 0, "Preparing...");
@@ -505,7 +520,7 @@ int MoonrakerPrinterAgent::start_send_gcode_to_sdcard(PrintParams params,
std::string safe_filename = sanitize_filename(filename);
// Upload only, don't start print
if (!upload_gcode(params.filename, safe_filename, device_info.base_url, device_info.api_key, update_fn, cancel_fn)) {
if (!upload_gcode(params.filename, safe_filename, connection, update_fn, cancel_fn)) {
return BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED;
}
@@ -516,6 +531,7 @@ int MoonrakerPrinterAgent::start_send_gcode_to_sdcard(PrintParams params,
int MoonrakerPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
{
const ConnectionSettings connection = get_connection_settings();
if (update_fn)
update_fn(PrintingStageCreate, 0, "Preparing...");
@@ -566,7 +582,7 @@ int MoonrakerPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusF
// Upload file
if (update_fn)
update_fn(PrintingStageUpload, 0, "Uploading G-code...");
if (!upload_gcode(gcode_path, upload_filename, device_info.base_url, device_info.api_key, update_fn, cancel_fn)) {
if (!upload_gcode(gcode_path, upload_filename, connection, update_fn, cancel_fn)) {
return BAMBU_NETWORK_ERR_PRINT_LP_UPLOAD_FTP_FAILED;
}
@@ -579,7 +595,7 @@ int MoonrakerPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusF
if (update_fn)
update_fn(PrintingStageSending, 0, "Starting print...");
std::string gcode = "SDCARD_PRINT_FILE FILENAME=" + upload_filename;
if (!send_gcode_sync(device_info.dev_id, gcode)) {
if (!send_gcode(connection.dev_id, gcode, connection)) {
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
}
@@ -801,11 +817,12 @@ bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
std::vector<AmsTrayData> trays;
int max_lane_index = 0;
const ConnectionSettings connection = get_connection_settings();
// Try Moonraker filament data (more generic, supports any filament changer
// software that reports lane data to Moonraker like AFC and recent Happy
// Hare as of Feb 15, 2026)
if (fetch_moonraker_filament_data(trays, max_lane_index)) {
if (fetch_moonraker_filament_data(connection, trays, max_lane_index)) {
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected Moonraker filament system with "
<< (max_lane_index + 1) << " lanes";
int ams_count = (max_lane_index + 4) / 4;
@@ -814,7 +831,7 @@ bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
}
// Attempt Happy Hare first (more widely adopted, supports more filament changers)
if (fetch_hh_filament_info(trays, max_lane_index)) {
if (fetch_hh_filament_info(connection, trays, max_lane_index)) {
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_filament_info: Detected Happy Hare MMU with "
<< (max_lane_index + 1) << " gates";
int ams_count = (max_lane_index + 4) / 4;
@@ -843,20 +860,22 @@ std::string MoonrakerPrinterAgent::get_camera_url() const
void MoonrakerPrinterAgent::refresh_webcam_info() const
{
std::string base_url;
std::string api_key;
ConnectionSettings connection;
uint64_t generation;
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
base_url = device_info.base_url;
api_key = device_info.api_key;
connection.dev_id = device_info.dev_id;
connection.base_url = device_info.base_url;
connection.api_key = device_info.api_key;
connection.use_ssl = device_info.use_ssl;
connection.ca_file = device_info.ca_file;
generation = connect_generation.load();
}
const uint64_t now_ms = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
if (base_url.empty()) {
if (connection.base_url.empty()) {
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
webcam_stream_url.clear();
webcam_stream_mode = CameraStreamMode::none;
@@ -875,7 +894,7 @@ void MoonrakerPrinterAgent::refresh_webcam_info() const
webcam_info_last_lookup_ms = now_ms;
}
fetch_webcam_info(base_url, api_key, generation);
fetch_webcam_info(connection, generation);
}
std::string MoonrakerPrinterAgent::trim_and_upper(const std::string& input)
@@ -1025,18 +1044,20 @@ std::string MoonrakerPrinterAgent::normalize_color_value(const std::string& colo
}
// Fetch filament info from moonraker database
bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index)
bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(const ConnectionSettings& connection,
std::vector<AmsTrayData>& trays, int& max_lane_index)
{
// Fetch lane data from Moonraker database
std::string url = join_url(device_info.base_url, "/server/database/item?namespace=lane_data");
std::string url = join_url(connection.base_url, "/server/database/item?namespace=lane_data");
std::string response_body;
bool success = false;
std::string http_error;
auto http = Http::get(url);
if (!device_info.api_key.empty()) {
http.header("X-Api-Key", device_info.api_key);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
@@ -1123,18 +1144,20 @@ bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector<AmsTrayDat
}
// Fetch filament info from Happy Hare MMU
bool MoonrakerPrinterAgent::fetch_hh_filament_info(std::vector<AmsTrayData>& trays, int& max_lane_index)
bool MoonrakerPrinterAgent::fetch_hh_filament_info(const ConnectionSettings& connection,
std::vector<AmsTrayData>& trays, int& max_lane_index)
{
// Query Happy Hare MMU status
std::string url = join_url(device_info.base_url, "/printer/objects/query?mmu");
std::string url = join_url(connection.base_url, "/printer/objects/query?mmu");
std::string response_body;
bool success = false;
std::string http_error;
auto http = Http::get(url);
if (!device_info.api_key.empty()) {
http.header("X-Api-Key", device_info.api_key);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
@@ -1253,8 +1276,7 @@ bool MoonrakerPrinterAgent::fetch_hh_filament_info(std::vector<AmsTrayData>& tra
int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std::string& json_str)
{
auto connection_snapshot = [this]() {
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
return std::make_pair(device_info.base_url, device_info.api_key);
return get_connection_settings();
};
auto json = nlohmann::json::parse(json_str, nullptr, false);
if (json.is_discarded()) {
@@ -1332,10 +1354,9 @@ int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std::
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: ledctrl - no light object found, dropping";
return ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE;
}
auto [base_url, api_key] = connection_snapshot();
enqueue_command([this, dev_id, gcode = std::move(gcode), base_url = std::move(base_url),
api_key = std::move(api_key), requested_light_on]() {
if (send_gcode(dev_id, gcode, base_url, api_key)) {
auto connection = connection_snapshot();
enqueue_command([this, dev_id, gcode = std::move(gcode), connection = std::move(connection), requested_light_on]() {
if (send_gcode(dev_id, gcode, connection)) {
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
assumed_light_on = requested_light_on;
}
@@ -1393,23 +1414,23 @@ int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std::
// Print control commands
if (cmd == "pause") {
auto [base_url, api_key] = connection_snapshot();
enqueue_command([this, base_url = std::move(base_url), api_key = std::move(api_key)] {
post_print_action("pause", base_url, api_key);
auto connection = connection_snapshot();
enqueue_command([this, connection = std::move(connection)] {
post_print_action("pause", connection);
});
return BAMBU_NETWORK_SUCCESS;
}
if (cmd == "resume") {
auto [base_url, api_key] = connection_snapshot();
enqueue_command([this, base_url = std::move(base_url), api_key = std::move(api_key)] {
post_print_action("resume", base_url, api_key);
auto connection = connection_snapshot();
enqueue_command([this, connection = std::move(connection)] {
post_print_action("resume", connection);
});
return BAMBU_NETWORK_SUCCESS;
}
if (cmd == "stop") {
auto [base_url, api_key] = connection_snapshot();
enqueue_command([this, base_url = std::move(base_url), api_key = std::move(api_key)] {
post_print_action("cancel", base_url, api_key);
auto connection = connection_snapshot();
enqueue_command([this, connection = std::move(connection)] {
post_print_action("cancel", connection);
});
return BAMBU_NETWORK_SUCCESS;
}
@@ -1472,7 +1493,7 @@ int MoonrakerPrinterAgent::handle_request(const std::string& dev_id, const std::
return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED;
}
bool MoonrakerPrinterAgent::init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port)
bool MoonrakerPrinterAgent::init_device_info(const PrinterConnectionParams& params)
{
device_info = MoonrakerDeviceInfo{};
auto* preset_bundle = GUI::wxGetApp().preset_bundle;
@@ -1483,19 +1504,40 @@ bool MoonrakerPrinterAgent::init_device_info(const std::string& dev_id, const st
auto& preset = preset_bundle->printers.get_edited_preset();
const auto& printer_cfg = preset.config;
device_info.dev_ip = dev_ip;
device_info.api_key = password;
device_info.use_ssl = use_ssl;
device_info.dev_ip = params.host;
device_info.api_key = params.password;
device_info.use_ssl = params.use_ssl;
device_info.model_name = printer_cfg.opt_string("printer_model");
device_info.model_id = preset.get_printer_type(preset_bundle);
device_info.base_url = normalize_base_url(use_ssl, dev_ip, port);
device_info.dev_id = dev_id;
device_info.base_url = normalize_base_url(params.use_ssl, params.host, params.port);
device_info.dev_id = params.dev_id;
device_info.version = "";
device_info.dev_name = device_info.dev_id;
device_info.ca_file = params.ca_file;
return true;
}
MoonrakerPrinterAgent::ConnectionSettings MoonrakerPrinterAgent::get_connection_settings() const
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
ConnectionSettings connection;
connection.dev_id = device_info.dev_id;
connection.base_url = device_info.base_url;
connection.api_key = device_info.api_key;
connection.use_ssl = device_info.use_ssl;
connection.ca_file = device_info.ca_file;
return connection;
}
void MoonrakerPrinterAgent::configure_http(Http& http, const ConnectionSettings& connection) const
{
http.tls_verify(connection.use_ssl);
if (!connection.ca_file.empty()) {
http.ca_file(connection.ca_file);
}
}
float MoonrakerPrinterAgent::parse_nozzle_diameter(const nlohmann::json& response)
{
const nlohmann::json* status = nullptr;
@@ -1541,8 +1583,7 @@ float MoonrakerPrinterAgent::parse_nozzle_diameter(const nlohmann::json& respons
return 0.0f;
}
bool MoonrakerPrinterAgent::fetch_device_info(const std::string& base_url,
const std::string& api_key,
bool MoonrakerPrinterAgent::fetch_device_info(const ConnectionSettings& connection,
MoonrakerDeviceInfo& info,
std::string& error) const
{
@@ -1552,8 +1593,9 @@ bool MoonrakerPrinterAgent::fetch_device_info(const std::string& base_url,
std::string http_error;
auto http = Http::get(url);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
@@ -1587,7 +1629,7 @@ bool MoonrakerPrinterAgent::fetch_device_info(const std::string& base_url,
};
nlohmann::json json;
std::string url = join_url(base_url, "/server/info");
std::string url = join_url(connection.base_url, "/server/info");
if (!fetch_json(url, json, error)) {
return false;
}
@@ -1602,7 +1644,7 @@ bool MoonrakerPrinterAgent::fetch_device_info(const std::string& base_url,
// remain connectable when they do not expose configfile through the API.
nlohmann::json config_response;
std::string config_error;
if (fetch_json(join_url(base_url, "/printer/objects/query?configfile"), config_response, config_error)) {
if (fetch_json(join_url(connection.base_url, "/printer/objects/query?configfile"), config_response, config_error)) {
info.nozzle_diameter = parse_nozzle_diameter(config_response);
} else {
BOOST_LOG_TRIVIAL(debug) << "MoonrakerPrinterAgent: nozzle configuration unavailable: " << config_error;
@@ -1611,20 +1653,20 @@ bool MoonrakerPrinterAgent::fetch_device_info(const std::string& base_url,
return true;
}
bool MoonrakerPrinterAgent::query_printer_status(const std::string& base_url,
const std::string& api_key,
bool MoonrakerPrinterAgent::query_printer_status(const ConnectionSettings& connection,
nlohmann::json& status,
std::string& error) const
{
std::string url = join_url(base_url, "/printer/objects/query?print_stats&virtual_sdcard&extruder&heater_bed&fan");
std::string url = join_url(connection.base_url, "/printer/objects/query?print_stats&virtual_sdcard&extruder&heater_bed&fan");
std::string response_body;
bool success = false;
std::string http_error;
auto http = Http::get(url);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
@@ -1666,18 +1708,12 @@ bool MoonrakerPrinterAgent::query_printer_status(const std::string& base_url,
bool MoonrakerPrinterAgent::send_ws_rpc(const std::string& method, const nlohmann::json& params)
{
std::string base_url;
std::string api_key;
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
base_url = device_info.base_url;
api_key = device_info.api_key;
}
const ConnectionSettings connection = get_connection_settings();
WsEndpoint endpoint;
if (!parse_ws_endpoint(base_url, endpoint)) {
if (!parse_ws_endpoint(connection.base_url, endpoint)) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: send_ws_rpc has no usable websocket for base_url="
<< base_url;
<< connection.base_url;
return false;
}
@@ -1691,18 +1727,19 @@ bool MoonrakerPrinterAgent::send_ws_rpc(const std::string& method, const nlohman
const std::string body = request.dump();
std::vector<std::string> ports{endpoint.port};
if (endpoint.port != "7125") {
ports.emplace_back("7125");
const std::string default_port = endpoint.secure ? "7130" : "7125";
if (endpoint.port != default_port) {
ports.emplace_back(default_port);
}
for (const auto& port : ports) {
try {
MoonrakerWebsocket ws{endpoint.secure, api_key};
MoonrakerWebsocket ws{endpoint.secure, connection.api_key, connection.ca_file};
ws.connect(endpoint.host, port, std::chrono::seconds(5));
ws.tls_handshake(endpoint.host);
std::string host_header = endpoint.host;
if (!port.empty() && port != (endpoint.secure ? "443" : "80")) {
if (!port.empty() && port != default_port) {
host_header += ":" + port;
}
ws.handshake(host_header, endpoint.target);
@@ -1727,7 +1764,7 @@ 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) const
bool MoonrakerPrinterAgent::fetch_webcam_info(const ConnectionSettings& connection, uint64_t generation) const
{
std::string camera_url;
std::string webcam_name;
@@ -1738,9 +1775,10 @@ bool MoonrakerPrinterAgent::fetch_webcam_info(const std::string& base_url, const
bool success = false;
std::string http_error;
auto http = Http::get(join_url(base_url, "/server/webcams/list"));
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
auto http = Http::get(join_url(connection.base_url, "/server/webcams/list"));
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
@@ -1763,6 +1801,7 @@ bool MoonrakerPrinterAgent::fetch_webcam_info(const std::string& base_url, const
if (!success) {
error = http_error.empty() ? "Connection failed" : http_error;
} else {
BOOST_LOG_TRIVIAL(info) << "[Moonraker Diagnostic] " << connection.base_url << ":" << response_body;
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
error = "Invalid JSON response";
@@ -1772,7 +1811,8 @@ 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)) {
if (webcam.is_object())
{
if (webcam.contains("stream_url") && webcam["stream_url"].is_string() &&
!webcam["stream_url"].get<std::string>().empty()) {
camera_url = webcam["stream_url"].get<std::string>();
@@ -1799,11 +1839,11 @@ bool MoonrakerPrinterAgent::fetch_webcam_info(const std::string& base_url, const
if (error.empty()) {
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 scheme_end = connection.base_url.find("://");
const size_t authority_start = scheme_end == std::string::npos ? 0 : scheme_end + 3;
const size_t authority_end = base_url.find('/', authority_start);
const std::string scheme = scheme_end == std::string::npos ? "" : base_url.substr(0, scheme_end + 3);
std::string authority = base_url.substr(authority_start, authority_end - authority_start);
const size_t authority_end = connection.base_url.find('/', authority_start);
const std::string scheme = scheme_end == std::string::npos ? "" : connection.base_url.substr(0, scheme_end + 3);
std::string authority = connection.base_url.substr(authority_start, authority_end - authority_start);
const size_t port_start = authority.rfind(':');
if (port_start != std::string::npos && port_start + 1 < authority.size() &&
std::all_of(authority.begin() + port_start + 1, authority.end(), [](char c) { return c >= '0' && c <= '9'; })) {
@@ -1839,18 +1879,11 @@ bool MoonrakerPrinterAgent::post_print_action(const std::string& action) const
{
// why: snapshot then release - holding connect_mutex across the blocking HTTP call
// would stall any UI-thread handle_request waiting to take its own snapshot.
std::string base_url, api_key;
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
base_url = device_info.base_url;
api_key = device_info.api_key;
}
return post_print_action(action, base_url, api_key);
return post_print_action(action, get_connection_settings());
}
bool MoonrakerPrinterAgent::post_print_action(const std::string& action,
const std::string& base_url,
const std::string& api_key) const
const ConnectionSettings& connection) const
{
// why: /printer/print/{pause,resume,cancel} map to Klipper's pause_resume
// webhook (a direct interrupt). A raw PAUSE/RESUME/CANCEL_PRINT queued through
@@ -1858,13 +1891,14 @@ bool MoonrakerPrinterAgent::post_print_action(const std::string& action,
// printer is busy (long move, heating, inside a macro).
// note: empty JSON body avoids a body-less POST (curl would treat it as a
// streamed upload) - same reason start_print_file sends a body.
const std::string full_url = join_url(base_url, "/printer/print/" + action);
const std::string full_url = join_url(connection.base_url, "/printer/print/" + action);
bool success = false;
std::string http_error;
auto http = Http::post(full_url);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.header("Content-Type", "application/json")
.set_post_body(std::string("{}"))
@@ -1899,17 +1933,12 @@ void MoonrakerPrinterAgent::send_gcode_async(const std::string& dev_id, const st
std::function<void(bool)> on_result) const
{
(void) dev_id;
std::string base_url;
std::string api_key;
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
base_url = device_info.base_url;
api_key = device_info.api_key;
}
const ConnectionSettings connection = get_connection_settings();
auto http = Http::post(join_url(base_url, "/printer/gcode/script"));
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
auto http = Http::post(join_url(connection.base_url, "/printer/gcode/script"));
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.header("Content-Type", "application/json")
.set_post_body(nlohmann::json{{"script", gcode}}.dump())
@@ -1947,17 +1976,11 @@ bool MoonrakerPrinterAgent::send_gcode(const std::string& dev_id, const std::str
bool MoonrakerPrinterAgent::send_gcode_sync(const std::string& dev_id, const std::string& gcode) const
{
// why: snapshot then release - see post_print_action.
std::string base_url, api_key;
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
base_url = device_info.base_url;
api_key = device_info.api_key;
}
return send_gcode(dev_id, gcode, base_url, api_key);
return send_gcode(dev_id, gcode, get_connection_settings());
}
bool MoonrakerPrinterAgent::send_gcode(const std::string& dev_id, const std::string& gcode,
const std::string& base_url, const std::string& api_key) const
const ConnectionSettings& connection) const
{
nlohmann::json payload;
payload["script"] = gcode;
@@ -1967,10 +1990,11 @@ bool MoonrakerPrinterAgent::send_gcode(const std::string& dev_id, const std::str
bool success = false;
std::string http_error;
auto full_url = join_url(base_url, "/printer/gcode/script");
auto full_url = join_url(connection.base_url, "/printer/gcode/script");
auto http = Http::post(full_url);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.header("Content-Type", "application/json")
.set_post_body(payload_str)
@@ -2000,8 +2024,7 @@ bool MoonrakerPrinterAgent::send_gcode(const std::string& dev_id, const std::str
return true;
}
bool MoonrakerPrinterAgent::fetch_object_list(const std::string& base_url,
const std::string& api_key,
bool MoonrakerPrinterAgent::fetch_object_list(const ConnectionSettings& connection,
std::set<std::string>& objects,
std::string& error) const
{
@@ -2009,9 +2032,10 @@ bool MoonrakerPrinterAgent::fetch_object_list(const std::string& base_url,
bool success = false;
std::string http_error;
auto http = Http::get(join_url(base_url, "/printer/objects/list"));
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
auto http = Http::get(join_url(connection.base_url, "/printer/objects/list"));
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
@@ -2103,7 +2127,8 @@ void MoonrakerPrinterAgent::announce_printhost_device()
std::string dev_name;
MoonrakerDeviceInfo info;
std::string fetch_error;
if (fetch_device_info(device_info.base_url, device_info.api_key, info, fetch_error) && !info.dev_name.empty()) {
const ConnectionSettings connection = get_connection_settings();
if (fetch_device_info(connection, info, fetch_error) && !info.dev_name.empty()) {
dev_name = info.dev_name;
} else {
dev_name = device_info.model_name.empty() ? "Moonraker Printer" : device_info.model_name;
@@ -2183,15 +2208,17 @@ void MoonrakerPrinterAgent::dispatch_printer_connected(const std::string& dev_id
}
}
void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key)
void MoonrakerPrinterAgent::start_status_stream(const std::string& dev_id, ConnectionSettings connection)
{
stop_status_stream();
if (base_url.empty()) {
if (connection.base_url.empty()) {
return;
}
ws_stop.store(false);
ws_thread = std::thread([this, dev_id, base_url, api_key]() { run_status_stream(dev_id, base_url, api_key); });
ws_thread = std::thread([this, dev_id, connection = std::move(connection)]() mutable {
run_status_stream(dev_id, std::move(connection));
});
}
void MoonrakerPrinterAgent::stop_status_stream()
@@ -2210,11 +2237,11 @@ void MoonrakerPrinterAgent::stop_status_stream()
}
}
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string base_url, std::string api_key)
void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, ConnectionSettings connection)
{
WsEndpoint endpoint;
if (!parse_ws_endpoint(base_url, endpoint)) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket endpoint invalid for base_url=" << base_url;
if (!parse_ws_endpoint(connection.base_url, endpoint)) {
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent: websocket endpoint invalid for base_url=" << connection.base_url;
return;
}
// Reconnection logic
@@ -2227,7 +2254,7 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
bool connection_lost = false; // Flag to distinguish clean shutdown from unexpected disconnect
try {
MoonrakerWebsocket ws{endpoint.secure, api_key};
MoonrakerWebsocket ws{endpoint.secure, connection.api_key, connection.ca_file};
ws.connect(endpoint.host, endpoint.port, std::chrono::seconds(10));
ws.tls_handshake(endpoint.host);
@@ -2249,7 +2276,7 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
}
std::string host_header = endpoint.host;
if (!endpoint.port.empty() && endpoint.port != (endpoint.secure ? "443" : "80")) {
if (!endpoint.port.empty() && endpoint.port != (endpoint.secure ? "7130" : "7125")) {
host_header += ":" + endpoint.port;
}
ws.handshake(host_header, endpoint.target);
@@ -2269,7 +2296,7 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
std::set<std::string> subscribe_objects = {"print_stats", "virtual_sdcard"};
std::set<std::string> available_objects;
std::string list_error;
if (fetch_object_list(base_url, api_key, available_objects, list_error)) {
if (fetch_object_list(connection, available_objects, list_error)) {
{
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
this->available_objects = std::move(available_objects);
@@ -2367,7 +2394,7 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
ams_last_fetch_ms.store(now_ms);
}
}
handle_ws_message(dev_id, std::move(payload), base_url, api_key);
handle_ws_message(dev_id, std::move(payload), connection);
// Check if handle_ws_message triggered reconnection request`
if (ws_reconnect_requested.exchange(false)) {
connection_lost = true;
@@ -2410,7 +2437,7 @@ void MoonrakerPrinterAgent::run_status_stream(std::string dev_id, std::string ba
}
}
void MoonrakerPrinterAgent::handle_ws_message(std::string dev_id, std::string payload, std::string base_url, std::string api_key)
void MoonrakerPrinterAgent::handle_ws_message(std::string dev_id, std::string payload, ConnectionSettings connection)
{
auto json = nlohmann::json::parse(payload, nullptr, false);
if (json.is_discarded()) {
@@ -2481,7 +2508,7 @@ void MoonrakerPrinterAgent::handle_ws_message(std::string dev_id, std::string pa
}
if (updated) {
refresh_thumbnail_url(base_url, api_key);
refresh_thumbnail_url(connection);
const auto now_ms = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
@@ -2535,7 +2562,7 @@ void MoonrakerPrinterAgent::update_status_cache(const nlohmann::json& updates)
// build_print_payload_locked() - that runs on the ws thread holding payload_mutex, which the UI
// thread also takes. Cached per filename, and a miss caches empty so a printer whose gcode has no
// embedded thumbnail is not re-queried on every push.
void MoonrakerPrinterAgent::refresh_thumbnail_url(std::string base_url, std::string api_key)
void MoonrakerPrinterAgent::refresh_thumbnail_url(const ConnectionSettings& connection)
{
std::string filename;
{
@@ -2560,9 +2587,10 @@ void MoonrakerPrinterAgent::refresh_thumbnail_url(std::string base_url, std::str
std::string response_body;
bool request_succeeded = false;
auto http = Http::get(join_url(base_url, "/server/files/thumbnails?filename=" + Http::url_encode(filename)));
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
auto http = Http::get(join_url(connection.base_url, "/server/files/thumbnails?filename=" + Http::url_encode(filename)));
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.timeout_connect(2)
.timeout_max(4)
@@ -2621,7 +2649,7 @@ void MoonrakerPrinterAgent::refresh_thumbnail_url(std::string base_url, std::str
}
segment_start = segment_end + 1;
}
url = join_url(base_url, root + encoded_path);
url = join_url(connection.base_url, root + encoded_path);
}
std::lock_guard<std::recursive_mutex> lock(payload_mutex);
@@ -2857,8 +2885,7 @@ void MoonrakerPrinterAgent::dispatch_message(const std::string& dev_id, const st
bool MoonrakerPrinterAgent::upload_gcode(const std::string& local_path,
const std::string& filename,
const std::string& base_url,
const std::string& api_key,
const ConnectionSettings& connection,
OnUpdateStatusFn update_fn,
WasCancelledFn cancel_fn)
{
@@ -2885,9 +2912,10 @@ bool MoonrakerPrinterAgent::upload_gcode(const std::string& local_path,
std::string http_error;
// Use Http::form_add and Http::form_add_file
auto http = Http::post(join_url(base_url, "/server/files/upload"));
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
auto http = Http::post(join_url(connection.base_url, "/server/files/upload"));
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.form_add("root", "gcodes") // Upload to gcodes directory
.form_add("print", "false") // Don't auto-start print
@@ -2944,8 +2972,7 @@ int MoonrakerPrinterAgent::cancel_print(const std::string& dev_id)
return post_print_action("cancel") ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_SEND_MSG_FAILED;
}
bool MoonrakerPrinterAgent::start_print_file(const std::string& base_url,
const std::string& api_key,
bool MoonrakerPrinterAgent::start_print_file(const ConnectionSettings& connection,
const std::string& filename,
std::string& error_msg) const
{
@@ -2953,7 +2980,7 @@ bool MoonrakerPrinterAgent::start_print_file(const std::string& base_url,
// sent both as a query parameter and in the JSON body: Moonraker accepts
// either, and sending a body avoids a body-less POST (which curl would treat
// as a streamed upload and try to read via the file-read callback).
std::string url = join_url(base_url, "/printer/print/start") +
std::string url = join_url(connection.base_url, "/printer/print/start") +
"?filename=" + Http::url_encode(filename);
nlohmann::json payload;
@@ -2962,8 +2989,9 @@ bool MoonrakerPrinterAgent::start_print_file(const std::string& base_url,
bool success = false;
auto http = Http::post(url);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.header("Content-Type", "application/json")
.set_post_body(payload.dump())
@@ -3003,7 +3031,7 @@ bool MoonrakerPrinterAgent::start_print_file(const std::string& base_url,
nlohmann::json status;
std::string query_err;
if (!query_printer_status(base_url, api_key, status, query_err)) {
if (!query_printer_status(connection, status, query_err)) {
continue;
}
@@ -3022,7 +3050,9 @@ bool MoonrakerPrinterAgent::start_print_file(const std::string& base_url,
return false;
}
void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id, const std::string& base_url, const std::string& api_key, uint64_t generation)
void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
ConnectionSettings connection,
uint64_t generation)
{
auto is_stale = [&]() { return generation != connect_generation.load(); };
@@ -3036,7 +3066,7 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
try {
MoonrakerDeviceInfo fetched_info;
if (!fetch_device_info(base_url, api_key, fetched_info, error_msg)) {
if (!fetch_device_info(connection, fetched_info, error_msg)) {
BOOST_LOG_TRIVIAL(error) << "MoonrakerPrinterAgent: Failed to fetch server info: " << error_msg;
// Orca todo: revist here, for now don't send error, this is set current MachineObject to null
// dispatch_local_connect(ConnectStatusFailed, dev_id, "server_info_failed");
@@ -3059,7 +3089,7 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
#if 1
// Query initial status
nlohmann::json initial_status;
if (query_printer_status(base_url, api_key, initial_status, error_msg)) {
if (query_printer_status(connection, initial_status, error_msg)) {
{
update_status_cache(initial_status);
}
@@ -3069,7 +3099,7 @@ void MoonrakerPrinterAgent::perform_connection_async(const std::string& dev_id,
}
// Start WebSocket status stream
start_status_stream(dev_id, base_url, api_key);
start_status_stream(dev_id, std::move(connection));
#endif
// Success!
@@ -3101,7 +3131,7 @@ std::string MoonrakerPrinterAgent::normalize_base_url(bool use_ssl, const std::s
{
std::string value = use_ssl ? "https://" : "http://";
value += host;
value += port.empty() ? "" : (":" + port);
value += ":" + (port.empty() ? (use_ssl ? "7130" : "7125") : port);
return value;
}
+31 -18
View File
@@ -18,6 +18,8 @@
namespace Slic3r {
class Http;
bool moonraker_is_light_name(const std::string& name);
class MoonrakerWebsocket
@@ -31,7 +33,7 @@ public:
error,
};
MoonrakerWebsocket(bool secure, std::string api_key);
MoonrakerWebsocket(bool secure, std::string api_key, std::string ca_file);
~MoonrakerWebsocket();
void connect(const std::string& host, const std::string& port, std::chrono::seconds timeout);
@@ -99,6 +101,15 @@ public:
std::string get_camera_url() const override;
protected:
struct ConnectionSettings
{
std::string dev_id;
std::string base_url;
std::string api_key;
bool use_ssl = false;
std::string ca_file;
};
struct MoonrakerDeviceInfo
{
std::string dev_id;
@@ -112,6 +123,7 @@ protected:
std::string klippy_state;
float nozzle_diameter = 0.0f;
bool use_ssl = false;
std::string ca_file;
} device_info;
// Tray data for AMS payload building
@@ -129,8 +141,10 @@ protected:
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays);
// Methods that derived classes may need to override or access
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
virtual bool init_device_info(const PrinterConnectionParams& params);
virtual bool fetch_device_info(const ConnectionSettings& connection, MoonrakerDeviceInfo& info, std::string& error) const;
ConnectionSettings get_connection_settings() const;
void configure_http(Http& http, const ConnectionSettings& connection) const;
static float parse_nozzle_diameter(const nlohmann::json& response);
// State access for derived classes
@@ -157,10 +171,10 @@ protected:
// Send a G-code script via Moonraker (/printer/gcode/script)
bool send_gcode(const std::string& dev_id, const std::string& gcode) const;
bool send_gcode(const std::string& dev_id, const std::string& gcode,
const std::string& base_url, const std::string& api_key) const;
const ConnectionSettings& connection) const;
bool post_print_action(const std::string& action) const;
bool post_print_action(const std::string& action,
const std::string& base_url, const std::string& api_key) const;
const ConnectionSettings& connection) const;
bool send_ws_rpc(const std::string& method, const nlohmann::json& params);
@@ -175,8 +189,8 @@ private:
int send_version_info(const std::string& dev_id);
int send_access_code(const std::string& dev_id);
bool fetch_object_list(const std::string& base_url, const std::string& api_key, std::set<std::string>& 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_object_list(const ConnectionSettings& connection, std::set<std::string>& objects, std::string& error) const;
bool query_printer_status(const ConnectionSettings& connection, nlohmann::json& status, std::string& error) const;
bool send_gcode_sync(const std::string& dev_id, const std::string& gcode) const;
void send_gcode_async(const std::string& dev_id, const std::string& gcode,
std::function<void(bool)> on_result = {}) const;
@@ -185,11 +199,11 @@ private:
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
void dispatch_printer_connected(const std::string& dev_id);
void dispatch_message(const std::string& dev_id, const std::string& payload);
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
void start_status_stream(const std::string& dev_id, ConnectionSettings connection);
void stop_status_stream();
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key);
void handle_ws_message(std::string dev_id, std::string payload, std::string base_url, std::string api_key);
void refresh_thumbnail_url(std::string base_url, std::string api_key);
void run_status_stream(std::string dev_id, ConnectionSettings connection);
void handle_ws_message(std::string dev_id, std::string payload, ConnectionSettings connection);
void refresh_thumbnail_url(const ConnectionSettings& connection);
void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const;
@@ -200,29 +214,28 @@ private:
// File upload
bool upload_gcode(const std::string& local_path, const std::string& filename,
const std::string& base_url, const std::string& api_key,
const ConnectionSettings& connection,
OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn);
// Start a print of a previously uploaded G-code file (path relative to the
// Moonraker gcodes root).
bool start_print_file(const std::string& base_url, const std::string& api_key,
bool start_print_file(const ConnectionSettings& connection,
const std::string& filename, std::string& error_msg) const;
// Connection thread management
void perform_connection_async(const std::string& dev_id,
const std::string& base_url,
const std::string& api_key,
ConnectionSettings connection,
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;
bool fetch_webcam_info(const ConnectionSettings& connection, uint64_t generation) const;
// System-specific filament fetch methods
bool fetch_hh_filament_info(std::vector<AmsTrayData>& trays, int& max_lane_index);
bool fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index);
bool fetch_hh_filament_info(const ConnectionSettings& connection, std::vector<AmsTrayData>& trays, int& max_lane_index);
bool fetch_moonraker_filament_data(const ConnectionSettings& connection, std::vector<AmsTrayData>& trays, int& max_lane_index);
// JSON helper methods
static std::string safe_json_string(const nlohmann::json& obj, const char* key);
+8
View File
@@ -17,6 +17,14 @@ namespace Slic3r {
static constexpr char ORCA_PRINTER_AGENT_ID[] = "orca";
static constexpr char BBL_PRINTER_AGENT_ID[] = "bbl";
// Index-correlation merge gate (plan PR 3). The per-print `filament_mapping`
// serializer is shipped disabled until a slice-level test proves that the
// `ams_mapping2` array position equals the toolchange identifier the generated
// G-code emits (T<filament_id> / next_filament_id). Both the GUI send gates and
// OrcaPrinterAgent::start_sdcard_print consult this, so mapping is refused
// visibly rather than silently dropped. Flip to true only with that test.
static constexpr bool ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED = false;
// Factory function type for creating printer agents
using PrinterAgentFactory =
std::function<std::shared_ptr<IPrinterAgent>(std::shared_ptr<ICloudServiceAgent> cloud_agent, const std::string& log_dir)>;
@@ -163,6 +163,8 @@ public:
// Configuration
void configure_urls(AppConfig* app_config);
// Hostname only; cloud REST, MQTT, and WebRTC signaling use fixed TLS
// endpoints on port 443.
void set_api_base_url(const std::string& url);
void set_auth_base_url(const std::string& url);
void set_cloud_base_url(const std::string& url);
+39 -16
View File
@@ -42,10 +42,16 @@ void OrcaCloudSignalingChannel::close()
{
m_stop.store(true);
std::shared_ptr<Connection> conn;
Http::Ptr request;
{
std::lock_guard<std::mutex> lock(m_mutex);
conn = m_conn;
request = m_inflight_requests;
}
// m_conn is installed only after the live-token request completes, so
// cancel the request independently before waiting for the worker.
if (request)
request->cancel();
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.
@@ -91,17 +97,6 @@ std::string OrcaCloudSignalingChannel::encode_path_component(const std::string&
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)
@@ -117,7 +112,10 @@ void OrcaCloudSignalingChannel::run()
return;
}
const std::string token = m_cloud->get_access_token();
const std::string host = host_without_scheme(m_cloud->get_cloud_service_host());
// OrcaCloud exposes a bare API hostname. WebRTC signaling uses the
// fixed HTTPS/WSS endpoints on port 443; custom schemes, ports, and
// base paths are not supported by this agent.
const std::string host = m_cloud->get_cloud_service_host();
if (token.empty() || host.empty()) {
unavailable(CameraUnavailableReason::Error, "OrcaCloud session is unavailable");
m_open.store(false);
@@ -131,8 +129,9 @@ void OrcaCloudSignalingChannel::run()
nlohmann::json token_response;
std::string token_body;
unsigned int http_code = 0;
auto request = Http::post(live_token_url);
request.set_post_body(std::string("{}"))
auto request = std::make_shared<Slic3r::Http>(Http::post(live_token_url));
request->set_post_body(std::string("{}"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.tls_verify(true)
@@ -143,8 +142,32 @@ void OrcaCloudSignalingChannel::run()
})
.on_error([&http_code](std::string, std::string, unsigned status) {
http_code = status;
})
.perform_sync();
});
{
std::lock_guard<std::mutex> lock(m_mutex);
m_inflight_requests = request;
}
// If close() raced with request setup, make sure this request is
// cancelled before entering the blocking call.
if (m_stop.load())
request->cancel();
try {
request->perform_sync();
} catch (...) {
std::lock_guard<std::mutex> lock(m_mutex);
if (m_inflight_requests == request)
m_inflight_requests.reset();
throw;
}
{
std::lock_guard<std::mutex> lock(m_mutex);
if (m_inflight_requests == request)
m_inflight_requests.reset();
}
try {
token_response = nlohmann::json::parse(token_body);
} catch (const std::exception&) {
@@ -1,8 +1,8 @@
#pragma once
#include "ICameraSignalingChannel.hpp"
#include "ICloudServiceAgent.hpp"
#include "OrcaCloudServiceAgent.hpp"
#include "Http.hpp"
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/tcp.hpp>
@@ -50,9 +50,11 @@ private:
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);
OrcaCloudServiceAgent* m_cloud;
Http::Ptr m_inflight_requests{nullptr};
std::string m_dev_id;
std::atomic<bool> m_stop{false};
std::atomic<bool> m_open{false};
+224 -119
View File
@@ -23,6 +23,12 @@ struct OrcaMqttConnection::Connection {
boost::asio::io_context io_context;
boost::asio::ssl::context ssl_context;
boost::asio::ip::tcp::resolver resolver;
boost::asio::steady_timer keepalive_timer;
boost::beast::flat_buffer read_buffer;
std::deque<std::shared_ptr<std::vector<uint8_t>>> outbound_packets;
boost::system::error_code terminal_error;
std::atomic_bool async_session_started{false};
bool write_in_progress{false};
// Exactly one of these is engaged once ws_handshake() has run: wss for
// wss:// endpoints, ws for plaintext ws://.
std::optional<TlsWebSocket> wss;
@@ -31,6 +37,7 @@ struct OrcaMqttConnection::Connection {
Connection()
: ssl_context(boost::asio::ssl::context::tls_client)
, resolver(io_context)
, keepalive_timer(io_context)
{}
};
@@ -81,19 +88,31 @@ void OrcaMqttConnection::stop() {
{
std::lock_guard<std::mutex> 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 (active_connection->async_session_started.load()) {
// The worker owns the live WebSocket. Stop dispatching its
// asynchronous operations; the worker closes the socket after
// leaving the event loop.
active_connection->io_context.stop();
} else {
// Setup still uses synchronous operations on the worker. Wake a
// pending resolve/connect/CONNACK read without competing with a
// live asynchronous session.
auto shutdown_socket = [](auto& websocket) {
auto& socket = boost::beast::get_lowest_layer(websocket).socket();
boost::system::error_code socket_error;
if (socket.cancel(socket_error))
return;
if (socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, socket_error))
return;
if (socket.close(socket_error))
return;
};
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())
@@ -130,13 +149,10 @@ void OrcaMqttConnection::flush_subscription_change() {
}
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&) {
}
boost::asio::post(conn->io_context, [this, conn] {
if (!stopping.load() && connected.load())
send_pending_subscriptions(conn);
});
}
bool OrcaMqttConnection::subscribe(const std::string& dev_id) {
@@ -157,7 +173,7 @@ bool OrcaMqttConnection::subscribe(const std::string& dev_id) {
pending_subscriptions.insert(topic);
}
state_cv.notify_all();
flush_subscription_change(); // emit SUBSCRIBE now on the live socket (no reconnect)
flush_subscription_change(); // ask the worker to emit SUBSCRIBE now (no reconnect)
return true;
}
@@ -183,7 +199,7 @@ bool OrcaMqttConnection::unsubscribe(const std::string& dev_id) {
}
}
state_cv.notify_all();
flush_subscription_change(); // emit UNSUBSCRIBE now on the live socket (no reconnect)
flush_subscription_change(); // ask the worker to emit UNSUBSCRIBE now (no reconnect)
return true;
}
@@ -298,10 +314,6 @@ void OrcaMqttConnection::ws_write(Connection& conn, const std::vector<uint8_t>&
if (packet.empty()) {
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<std::mutex> lock(write_mutex);
if (conn.wss) {
conn.wss->binary(true);
conn.wss->write(boost::asio::buffer(packet));
@@ -311,6 +323,110 @@ void OrcaMqttConnection::ws_write(Connection& conn, const std::vector<uint8_t>&
}
}
void OrcaMqttConnection::close_connection(Connection& conn) {
boost::system::error_code error;
if (conn.wss) {
auto& socket = boost::beast::get_lowest_layer(*conn.wss).socket();
if (socket.cancel(error))
return;
if (socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, error))
return;
if (socket.close(error))
return;
} else if (conn.ws) {
auto& socket = boost::beast::get_lowest_layer(*conn.ws).socket();
if (socket.cancel(error))
return;
if (socket.shutdown(boost::asio::ip::tcp::socket::shutdown_both, error))
return;
if (socket.close(error))
return;
}
}
void OrcaMqttConnection::enqueue_packet(const std::shared_ptr<Connection>& conn,
std::vector<uint8_t> packet) {
if (!conn || packet.empty())
return;
conn->outbound_packets.emplace_back(std::make_shared<std::vector<uint8_t>>(std::move(packet)));
start_async_write(conn);
}
void OrcaMqttConnection::start_async_write(const std::shared_ptr<Connection>& conn) {
if (!conn || conn->write_in_progress || conn->outbound_packets.empty() || stopping.load())
return;
conn->write_in_progress = true;
const auto packet = conn->outbound_packets.front();
auto on_write = [this, conn](const boost::system::error_code& error, std::size_t) {
conn->write_in_progress = false;
if (error) {
if (!stopping.load())
conn->terminal_error = error;
conn->io_context.stop();
return;
}
conn->outbound_packets.pop_front();
start_async_write(conn);
};
if (conn->wss) {
conn->wss->binary(true);
conn->wss->async_write(boost::asio::buffer(*packet), std::move(on_write));
} else if (conn->ws) {
conn->ws->binary(true);
conn->ws->async_write(boost::asio::buffer(*packet), std::move(on_write));
} else {
conn->write_in_progress = false;
conn->outbound_packets.pop_front();
}
}
void OrcaMqttConnection::start_async_read(const std::shared_ptr<Connection>& conn) {
if (!conn || stopping.load())
return;
auto on_read = [this, conn](const boost::system::error_code& error, std::size_t) {
if (error) {
if (!stopping.load())
conn->terminal_error = error;
conn->io_context.stop();
return;
}
const std::string packet = boost::beast::buffers_to_string(conn->read_buffer.data());
conn->read_buffer.consume(conn->read_buffer.size());
handle_packet(packet);
start_async_read(conn);
};
if (conn->wss)
conn->wss->async_read(conn->read_buffer, std::move(on_read));
else if (conn->ws)
conn->ws->async_read(conn->read_buffer, std::move(on_read));
}
void OrcaMqttConnection::schedule_keepalive(const std::shared_ptr<Connection>& conn) {
const int keepalive = current_config.keepalive_seconds;
if (!conn || keepalive <= 0 || stopping.load())
return;
conn->keepalive_timer.expires_after(std::chrono::seconds(std::max(1, keepalive / 2)));
conn->keepalive_timer.async_wait([this, conn](const boost::system::error_code& error) {
if (error || stopping.load())
return;
enqueue_packet(conn, make_ping_packet());
schedule_keepalive(conn);
});
}
void OrcaMqttConnection::post_packet(const std::shared_ptr<Connection>& conn,
std::vector<uint8_t> packet) {
if (!conn || packet.empty())
return;
boost::asio::post(conn->io_context, [this, conn, packet = std::move(packet)]() mutable {
if (!stopping.load())
enqueue_packet(conn, std::move(packet));
});
}
std::size_t OrcaMqttConnection::ws_read(Connection& conn, boost::beast::flat_buffer& buffer,
boost::system::error_code& ec) {
if (conn.wss)
@@ -321,14 +437,6 @@ std::size_t OrcaMqttConnection::ws_read(Connection& conn, boost::beast::flat_buf
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);
}
void OrcaMqttConnection::ws_handshake(Connection& conn, const Config& config, const Endpoint& endpoint) {
const auto results = conn.resolver.resolve(endpoint.host, endpoint.port);
@@ -361,7 +469,10 @@ void OrcaMqttConnection::ws_handshake(Connection& conn, const Config& config, co
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();
if (!config.ca_file.empty())
conn.ssl_context.load_verify_file(config.ca_file);
else
conn.ssl_context.set_default_verify_paths();
Http::add_platform_root_certificates(conn.ssl_context.native_handle());
tls_stream.set_verify_mode(boost::asio::ssl::verify_peer);
tls_stream.set_verify_callback(boost::asio::ssl::host_name_verification(endpoint.host));
@@ -412,12 +523,7 @@ bool OrcaMqttConnection::send_request(const std::string& dev_id, const std::stri
return true;
}
}
try {
// ws_write() serialises the write via write_mutex; do not lock it here.
ws_write(*conn, make_publish_packet(request_topic(dev_id), payload));
} catch (const std::exception&) {
return false;
}
post_packet(conn, make_publish_packet(request_topic(dev_id), payload));
return true;
}
@@ -426,94 +532,93 @@ void OrcaMqttConnection::connect_and_read() {
{
std::lock_guard<std::mutex> lock(connection_mutex);
active_connection = connection;
if (stopping.load())
if (stopping.load()) {
active_connection.reset();
return;
}
}
Endpoint endpoint;
if (!parse_endpoint(current_config.url, endpoint)) {
throw std::runtime_error("invalid Orca Cloud WebSocket endpoint");
}
auto clear_connection = [this, connection] {
close_connection(*connection);
std::lock_guard<std::mutex> lock(connection_mutex);
if (active_connection == connection)
active_connection.reset();
};
try {
Endpoint endpoint;
if (!parse_endpoint(current_config.url, endpoint)) {
throw std::runtime_error("invalid Orca Cloud WebSocket endpoint");
}
ws_handshake(*connection, current_config, endpoint);
ws_handshake(*connection, current_config, endpoint);
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<bool>(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));
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<bool>(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::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());
// rc: 0 accepted, 1..5 refusal, -1 malformed/not a CONNACK.
const int rc = (connack.size() == 4 && static_cast<uint8_t>(connack[0]) == 0x20)
? static_cast<int>(static_cast<uint8_t>(connack[3]))
: -1;
m_last_connack_rc.store(rc);
if (rc != 0) {
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<std::mutex> lock(mutex);
initial_completed = true;
initial_result = false;
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());
// rc: 0 accepted, 1..5 refusal, -1 malformed/not a CONNACK.
const int rc = (connack.size() == 4 && static_cast<uint8_t>(connack[0]) == 0x20)
? static_cast<int>(static_cast<uint8_t>(connack[3]))
: -1;
m_last_connack_rc.store(rc);
if (rc != 0) {
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<std::mutex> lock(mutex);
initial_completed = true;
initial_result = false;
}
initial_cv.notify_all();
}
initial_cv.notify_all();
throw std::runtime_error("Orca MQTT CONNECT refused rc=" + std::to_string(rc));
}
throw std::runtime_error("Orca MQTT CONNECT refused rc=" + std::to_string(rc));
}
// 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<std::mutex> lock(mutex);
acknowledged_subscriptions.clear();
pending_subscribe_packets.clear();
}
notify_state(true);
reconnect_delay_seconds.store(1); // a fresh CONNACK resets the backoff
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());
next_ping = std::chrono::steady_clock::now() + std::chrono::seconds(30);
// 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<std::mutex> lock(mutex);
acknowledged_subscriptions.clear();
pending_subscribe_packets.clear();
}
buffer.consume(buffer.size());
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) {
throw boost::system::system_error(error, "read Orca MQTT message");
}
handle_packet(boost::beast::buffers_to_string(buffer.data()));
connection->async_session_started.store(true);
notify_state(true);
reconnect_delay_seconds.store(1); // a fresh CONNACK resets the backoff
send_current_subscriptions(connection);
start_async_read(connection);
schedule_keepalive(connection);
const std::size_t handlers_run = connection->io_context.run();
if (handlers_run == 0 && !stopping.load())
throw std::runtime_error("Orca MQTT event loop stopped unexpectedly");
if (connection->terminal_error && !stopping.load())
throw boost::system::system_error(connection->terminal_error, "read Orca MQTT message");
clear_connection();
if (!stopping.load())
notify_state(false);
} catch (...) {
clear_connection();
throw;
}
ws_close(*connection);
if (!stopping.load())
notify_state(false);
}
void OrcaMqttConnection::send_current_subscriptions(Connection& conn) {
void OrcaMqttConnection::send_current_subscriptions(const std::shared_ptr<Connection>& conn) {
std::vector<std::string> topics;
{
std::lock_guard<std::mutex> lock(mutex);
@@ -529,11 +634,11 @@ void OrcaMqttConnection::send_current_subscriptions(Connection& conn) {
std::lock_guard<std::mutex> lock(mutex);
pending_subscribe_packets[packet_id] = topic;
}
ws_write(conn, make_subscribe_packet(packet_id, topic, 1));
enqueue_packet(conn, make_subscribe_packet(packet_id, topic, 1));
}
}
void OrcaMqttConnection::send_pending_subscriptions(Connection& conn) {
void OrcaMqttConnection::send_pending_subscriptions(const std::shared_ptr<Connection>& conn) {
std::vector<std::string> subscribe_topics;
std::vector<std::string> unsubscribe_topics;
{
@@ -549,11 +654,11 @@ void OrcaMqttConnection::send_pending_subscriptions(Connection& conn) {
std::lock_guard<std::mutex> lock(mutex);
pending_subscribe_packets[packet_id] = topic;
}
ws_write(conn, make_subscribe_packet(packet_id, topic, 1));
enqueue_packet(conn, make_subscribe_packet(packet_id, topic, 1));
}
for (const std::string& topic : unsubscribe_topics) {
const uint16_t packet_id = next_packet_id++;
ws_write(conn, make_unsubscribe_packet(packet_id, topic));
enqueue_packet(conn, make_unsubscribe_packet(packet_id, topic));
}
}
+14 -10
View File
@@ -38,6 +38,7 @@ public:
struct Config {
std::string url;
bool use_tls = false;
std::string ca_file;
TokenProvider bearer_provider; // set => bearer on WS upgrade, CONNECT creds omitted
std::string username;
std::string password;
@@ -95,19 +96,23 @@ private:
static std::vector<uint8_t> make_ping_packet();
// Transport dispatch: each forwards to conn.wss (TLS) or conn.ws (plaintext).
void ws_write(Connection& conn, const std::vector<uint8_t>& packet); // locks write_mutex
// These synchronous operations are called only by the MQTT worker during
// connection setup. Once the MQTT session is established, all socket I/O is
// asynchronous and owned by that worker's io_context.
void ws_write(Connection& conn, const std::vector<uint8_t>& packet);
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 close_connection(Connection& conn);
void enqueue_packet(const std::shared_ptr<Connection>& conn, std::vector<uint8_t> packet);
void start_async_write(const std::shared_ptr<Connection>& conn);
void start_async_read(const std::shared_ptr<Connection>& conn);
void schedule_keepalive(const std::shared_ptr<Connection>& conn);
void post_packet(const std::shared_ptr<Connection>& conn, std::vector<uint8_t> packet);
// Ask the MQTT worker to emit subscription changes on its own io_context.
void flush_subscription_change();
void connect_and_read();
void send_current_subscriptions(Connection& conn);
void send_pending_subscriptions(Connection& conn);
void send_current_subscriptions(const std::shared_ptr<Connection>& conn);
void send_pending_subscriptions(const std::shared_ptr<Connection>& conn);
void handle_packet(const std::string& packet);
void notify_state(bool is_now_connected);
void run();
@@ -121,7 +126,6 @@ private:
std::thread worker;
std::mutex mutex;
std::mutex connection_mutex;
std::mutex write_mutex; // serialises every websocket write (worker + caller threads)
std::shared_ptr<Connection> active_connection;
std::condition_variable initial_cv;
std::condition_variable state_cv;
+149 -14
View File
@@ -43,6 +43,23 @@ namespace {
namespace fs = boost::filesystem;
// Per-device filament_mapping capability, mirrored from the get_capabilities
// reply in merge_capabilities and read by start_sdcard_print so the field is
// refused defensively when the connector never advertised it or the
// index-correlation merge gate (ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED) is
// not satisfied.
std::mutex g_filament_mapping_mutex;
std::unordered_map<std::string, bool> g_filament_mapping_cache;
// True only when the connector's get_capabilities reply advertised
// filament_mapping for this device. Unknown is not support.
bool filament_mapping_advertised(const std::string& dev_id)
{
std::lock_guard<std::mutex> l(g_filament_mapping_mutex);
const auto it = g_filament_mapping_cache.find(dev_id);
return it != g_filament_mapping_cache.end() && it->second;
}
// 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
@@ -588,6 +605,33 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons
std::lock_guard<std::mutex> l(nozzle_diameter_cache_mutex);
nozzle_diameter_cache[dev_id] = nozzle_dia;
}
// Per-device filament_mapping capability. Both feature maps carry it;
// either being true means the connector advertised it.
bool mapping_advertised = false;
{
const auto top_features = info_it->find("supported_features");
if (top_features != info_it->end() && top_features->is_object()) {
const auto it = top_features->find("filament_mapping");
if (it != top_features->end() && it->is_boolean())
mapping_advertised = it->get<bool>();
}
if (!mapping_advertised && caps_it != info_it->end() && caps_it->is_object()) {
const auto protocol_it = caps_it->find("protocol");
if (protocol_it != caps_it->end() && protocol_it->is_object()) {
const auto features_it = protocol_it->find("features");
if (features_it != protocol_it->end() && features_it->is_object()) {
const auto it = features_it->find("filament_mapping");
if (it != features_it->end() && it->is_boolean())
mapping_advertised = it->get<bool>();
}
}
}
}
{
std::lock_guard<std::mutex> l(g_filament_mapping_mutex);
g_filament_mapping_cache[dev_id] = mapping_advertised;
}
// The capabilities reply itself is forwarded unchanged.
}
else {
@@ -1002,7 +1046,6 @@ int OrcaPrinterAgent::connect_printer(const PrinterConnectionParams& params)
BOOST_LOG_TRIVIAL(trace) << "Orca diagnostic: connect_printer requested dev_id=" << params.dev_id << " dev_ip=" << params.host
<< " username=" << (params.username.empty() ? "<default>" : params.username) << " password_present=" << (!params.password.empty())
<< " use_ssl=" << params.use_ssl;
(void) params.use_ssl; // OrcaSonar LAN is plaintext ws://
if (params.dev_id.empty() || params.host.empty()) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: connect_printer rejected missing dev_id or dev_ip";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
@@ -1016,8 +1059,9 @@ int OrcaPrinterAgent::connect_printer(const PrinterConnectionParams& params)
const uint64_t gen = ++m_lan_generation;
OrcaMqttConnection::Config cfg;
cfg.url = "ws://" + host + ":" + port + "/mqtt";
cfg.use_tls = false;
cfg.url = (params.use_ssl ? "wss://" : "ws://") + host + ":" + port + "/mqtt";
cfg.use_tls = params.use_ssl;
cfg.ca_file = params.ca_file;
cfg.username = params.username.empty() ? std::string("orcasonar") : params.username;
cfg.password = params.password;
cfg.client_id = make_lan_client_id(params.dev_id);
@@ -1034,6 +1078,8 @@ int OrcaPrinterAgent::connect_printer(const PrinterConnectionParams& params)
previous_connection = m_current_connection;
m_lan_dev_id = params.dev_id;
m_lan_url = cfg.url;
m_lan_use_ssl = params.use_ssl;
m_lan_ca_file = params.ca_file;
m_camera_stream_mode = CameraStreamMode::none;
m_camera_url.clear();
m_current_connection = LAN;
@@ -1107,6 +1153,8 @@ int OrcaPrinterAgent::disconnect_printer()
doomed = std::move(lan_mqtt_connection);
prev_dev = m_lan_dev_id;
m_lan_dev_id.clear();
m_lan_use_ssl = false;
m_lan_ca_file.clear();
if (m_current_connection == LAN) {
m_current_connection = NONE;
m_camera_stream_mode = CameraStreamMode::none;
@@ -1411,11 +1459,17 @@ int OrcaPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn
return BAMBU_NETWORK_SUCCESS;
}
int OrcaPrinterAgent::start_local_print_with_record(PrintParams params,
OnUpdateStatusFn update_fn,
WasCancelledFn cancel_fn,
OnWaitFn wait_fn)
{ return BAMBU_NETWORK_SUCCESS; }
int OrcaPrinterAgent::start_local_print_with_record(PrintParams /*params*/,
OnUpdateStatusFn /*update_fn*/,
WasCancelledFn /*cancel_fn*/,
OnWaitFn /*wait_fn*/)
{
// OrcaSonar has no FTP "send with record" path. Report a non-success result so
// PrintJob falls back to start_print() (cloud upload + start_sdcard_print) instead
// of treating a print that was never sent as successful.
BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: start_local_print_with_record is unimplemented; deferring to start_print";
return BAMBU_NETWORK_ERR_FTP_UPLOAD_FAILED;
}
// 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
@@ -1446,9 +1500,17 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params,
return BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED;
}
bool use_ssl = false;
std::string ca_file;
{
std::lock_guard<std::mutex> lock(state_mutex);
use_ssl = m_lan_use_ssl;
ca_file = m_lan_ca_file;
}
std::string host, port, origin;
if (parse_lan_endpoint(params.dev_ip, host, port))
origin = "http://" + host;
origin = (use_ssl ? "https://" : "http://") + host;
else
origin = http_origin_from_lan_ws(lan_connection_target());
if (origin.empty()) {
@@ -1469,7 +1531,11 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params,
std::string response_body;
// check if printer has enough storage
Http::get(origin + "/server/files/directory?path=gcodes")
auto directory_http = Http::get(origin + "/server/files/directory?path=gcodes");
directory_http.tls_verify(use_ssl);
if (!ca_file.empty())
directory_http.ca_file(ca_file);
directory_http
.on_complete([&](std::string body, unsigned status) {
if (body.empty()) {
http_status = 400;
@@ -1506,6 +1572,9 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params,
}
auto http = Http::post(origin + "/server/files/upload");
http.tls_verify(use_ssl);
if (!ca_file.empty())
http.ca_file(ca_file);
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")
@@ -1568,6 +1637,59 @@ int OrcaPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn upd
return start_sdcard_print(params, update_fn, cancel_fn);
}
// Serialize PrintParams::ams_mapping2 (the dialog's mapping_v1_json, one entry per logical
// filament in preset order) into the print.gcode_file `filament_mapping` array. The array
// position becomes `filament_index`; {255,255} (unmatched/unused) is dropped. Returns an
// empty array when nothing usable remains so the caller can omit the field entirely.
nlohmann::json OrcaPrinterAgent::build_filament_mapping(const std::string& ams_mapping2)
{
nlohmann::json mapping = nlohmann::json::array();
if (ams_mapping2.empty())
return mapping;
const nlohmann::json parsed = nlohmann::json::parse(ams_mapping2, nullptr, false);
if (parsed.is_discarded() || !parsed.is_array()) {
BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: ams_mapping2 is not a JSON array; not sending filament_mapping";
return mapping;
}
for (std::size_t i = 0; i < parsed.size(); ++i) {
const nlohmann::json& entry = parsed[i];
if (!entry.is_object())
continue;
const auto ams_id_it = entry.find("ams_id");
const auto slot_id_it = entry.find("slot_id");
if (ams_id_it == entry.end() || slot_id_it == entry.end())
continue;
if (!ams_id_it->is_number_integer() || !slot_id_it->is_number_integer())
continue;
const int ams_id = ams_id_it->get<int>();
const int slot_id = slot_id_it->get<int>();
if (ams_id == 255 && slot_id == 255)
continue;
mapping.push_back({{"filament_index", static_cast<int>(i)}, {"ams_id", ams_id}, {"slot_id", slot_id}});
}
return mapping;
}
// Pure builder for the print.gcode_file payload: the base command plus, only
// when non-empty, the filament_mapping array. An empty mapping leaves the
// payload byte-identical to today's unmapped command.
nlohmann::json OrcaPrinterAgent::build_gcode_file_payload(const std::string& sequence_id,
const std::string& target,
const nlohmann::json& filament_mapping)
{
nlohmann::json j;
j["print"]["command"] = "gcode_file";
j["print"]["sequence_id"] = sequence_id;
j["print"]["param"] = target;
if (filament_mapping.is_array() && !filament_mapping.empty())
j["print"]["filament_mapping"] = filament_mapping;
return j;
}
// Start a file that already lives on the printer by publishing the canonical
// OPCP print.gcode_file command to device/<dev_id>/request. The acknowledgement
// and lifecycle progress arrive asynchronously as print.push_status on the
@@ -1585,10 +1707,23 @@ int OrcaPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn up
// 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;
// Per-print mapping. A mapped print is refused when the connector did not
// advertise filament_mapping or the index correlation is unverified: the GUI
// send gates make this visible first, and this is the defensive gate for
// callers that bypass them (calibration, plugin). Never start a mapped print
// with the map silently dropped.
const nlohmann::json filament_mapping = build_filament_mapping(params.ams_mapping2);
if (!filament_mapping.empty()) {
const bool mapping_capable = filament_mapping_advertised(params.dev_id);
if (!mapping_capable || !ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED) {
BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: refusing mapped print (capable=" << mapping_capable
<< ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << ")";
return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED;
}
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: start_sdcard_print emitting filament_mapping entries=" << filament_mapping.size();
}
nlohmann::json j = build_gcode_file_payload(next_gcode_file_sequence_id(), target, filament_mapping);
if (update_fn)
update_fn(PrintingStageSending, 0, "Starting print...");
+16
View File
@@ -64,6 +64,8 @@ public:
// Print Job Operations
int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
// Unimplemented on OrcaSonar: reports a non-success result so callers fall back to
// start_print() instead of treating the missing send as success.
int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override;
@@ -140,6 +142,18 @@ protected:
// 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);
// Pure serializer for PrintParams::ams_mapping2 -> print.gcode_file.filament_mapping.
// Entries are re-keyed by their array position; {255,255} is dropped. Empty when the
// input is empty, malformed, or has no usable entries. protected static for the test Probe.
static nlohmann::json build_filament_mapping(const std::string& ams_mapping2);
// Pure builder for the print.gcode_file command payload. A non-empty mapping is
// included as filament_mapping; an empty one is omitted so the payload is
// byte-identical to an unmapped print. protected static for the test Probe.
static nlohmann::json build_gcode_file_payload(const std::string& sequence_id,
const std::string& target,
const nlohmann::json& filament_mapping);
// 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,
@@ -191,6 +205,8 @@ private:
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
bool m_lan_use_ssl = false; // guarded by state_mutex
std::string m_lan_ca_file; // guarded by state_mutex
CameraStreamMode m_camera_stream_mode = CameraStreamMode::none; // guarded by state_mutex
std::string m_camera_url; // guarded by state_mutex
+15 -16
View File
@@ -66,14 +66,13 @@ bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode
// Snapshot only what the fetch needs, rather than reading device_info live from the
// background thread below — device_info can be concurrently rewritten by a reconnect
// on another thread while this fetch is still in flight.
std::string base_url = device_info.base_url;
std::string api_key = device_info.api_key;
ConnectionSettings connection = get_connection_settings();
std::string model_id = device_info.model_id;
std::string model_name = device_info.model_name;
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
std::thread([this, base_url, api_key, model_id, model_name]() {
std::thread([this, connection = std::move(connection), model_id, model_name]() mutable {
InFlightGuard guard{filament_fetch_in_flight};
std::string error;
@@ -82,7 +81,7 @@ bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode
std::string series_id;
{
MoonrakerDeviceInfo info;
if (fetch_device_info(base_url, api_key, info, error)) {
if (fetch_device_info(connection, info, error)) {
series_id = infer_series_id(info.model_id, info.dev_name);
}
}
@@ -93,14 +92,14 @@ bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode
// 2. Fetch filament dictionary
QidiFilamentDict dict;
if (!fetch_filament_dict(base_url, api_key, dict, error)) {
if (!fetch_filament_dict(connection, dict, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch filament dict: " << error;
}
// 3. Fetch slot info and build AmsTrayData directly
std::vector<AmsTrayData> trays;
int box_count = 0;
if (!fetch_slot_info(base_url, api_key, dict, series_id, trays, box_count, error)) {
if (!fetch_slot_info(connection, dict, series_id, trays, box_count, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
return;
}
@@ -186,15 +185,14 @@ int QidiPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn up
return MoonrakerPrinterAgent::start_sdcard_print(std::move(params), update_fn, cancel_fn);
}
bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
const std::string& api_key,
bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
const QidiFilamentDict& dict,
const std::string& series_id,
std::vector<AmsTrayData>& trays,
int& box_count,
std::string& error)
{
std::string url = join_url(base_url, "/printer/objects/query?save_variables=variables");
std::string url = join_url(connection.base_url, "/printer/objects/query?save_variables=variables");
for (int i = 0; i < 16; ++i) {
url += "&box_stepper%20slot" + std::to_string(i) + "=runout_button";
}
@@ -204,8 +202,9 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
std::string http_error;
auto http = Http::get(url);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
@@ -333,20 +332,20 @@ bool QidiPrinterAgent::parse_slot_response(const std::string& response_body,
return true;
}
bool QidiPrinterAgent::fetch_filament_dict(const std::string& base_url,
const std::string& api_key,
bool QidiPrinterAgent::fetch_filament_dict(const ConnectionSettings& connection,
QidiFilamentDict& dict,
std::string& error) const
{
std::string url = join_url(base_url, "/server/files/config/officiall_filas_list.cfg");
std::string url = join_url(connection.base_url, "/server/files/config/officiall_filas_list.cfg");
std::string response_body;
bool success = false;
std::string http_error;
auto http = Http::get(url);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
}
http.timeout_connect(5)
.timeout_max(10)
+2 -3
View File
@@ -47,14 +47,13 @@ private:
};
// Qidi-specific methods
bool fetch_slot_info(const std::string& base_url,
const std::string& api_key,
bool fetch_slot_info(const ConnectionSettings& connection,
const QidiFilamentDict& dict,
const std::string& series_id,
std::vector<AmsTrayData>& trays,
int& box_count,
std::string& error);
bool fetch_filament_dict(const std::string& base_url, const std::string& api_key, QidiFilamentDict& dict, std::string& error) const;
bool fetch_filament_dict(const ConnectionSettings& connection, QidiFilamentDict& dict, std::string& error) const;
std::string normalize_filament_type(const std::string& filament_type);
std::string infer_series_id(const std::string& model_id, const std::string& dev_name);
std::string normalize_model_key(std::string value);
+13 -5
View File
@@ -127,6 +127,9 @@ public:
// MQTT CONNECTs seen; increments again after a reconnect.
int connect_count() const { return m_connect_count.load(); }
// MQTT PINGREQs seen while the client has no other traffic.
int ping_count() const { return m_ping_count.load(); }
private:
void run()
{
@@ -231,6 +234,7 @@ private:
return true;
}
case 0xc0: // PINGREQ
++m_ping_count;
write_packet(stream, {0xd0, 0x00});
return true;
case 0xe0: // DISCONNECT
@@ -277,8 +281,8 @@ private:
}
// 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.
// thread - is serialised by m_mutex. The production client keeps all of its
// WebSocket operations on its MQTT worker instead.
void write_packet(ws::stream<beast::tcp_stream>& stream, const std::vector<std::uint8_t>& packet)
{
std::lock_guard<std::mutex> lock(m_mutex);
@@ -294,11 +298,14 @@ private:
m_stream_ready = false;
boost::system::error_code ec;
auto& socket = beast::get_lowest_layer(*m_stream).socket();
socket.cancel(ec);
if (socket.cancel(ec))
return;
// 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);
if (socket.shutdown(tcp::socket::shutdown_both, ec))
return;
if (socket.close(ec))
return;
}
const bool m_refuse_auth;
@@ -308,6 +315,7 @@ private:
std::thread m_thread;
std::atomic_bool m_stopping{false};
std::atomic<int> m_connect_count{0};
std::atomic<int> m_ping_count{0};
mutable std::mutex m_mutex;
std::optional<ws::stream<beast::tcp_stream>> m_stream; // guarded by m_mutex
bool m_stream_ready = false; // guarded by m_mutex
@@ -157,3 +157,119 @@ TEST_CASE("Device manager filters and rehomes devices by printer-agent ownership
CHECK(manager.get_my_machine_list("integration-agent-a").empty());
CHECK(manager.get_my_machine_list("integration-agent-b").count(machine.dev_id) == 1);
}
TEST_CASE("Orca capability reply populates connector-scope features and commands", "[DeviceManager][integration]")
{
ScopedAppConfig app_config;
NetworkAgent network(nullptr, std::make_shared<TestPrinterAgent>("orca"));
DeviceManager manager(&network, false, &app_config.config);
BBLocalMachine orca_machine;
orca_machine.dev_id = "orca-device";
orca_machine.dev_name = "Orca device";
orca_machine.dev_ip = "192.0.2.21";
orca_machine.printer_type = "C11";
MachineObject* orca_obj = manager.insert_local_device(orca_machine, "lan", "free", "", "access-code");
REQUIRE(orca_obj != nullptr);
orca_obj->printer_agent_id = "orca";
orca_obj->parse_new_info2(json::parse(R"({
"command": "get_capabilities",
"supported_features": {"fms": true, "filament_slots": true, "filament_mapping": true},
"supported_commands": ["print.push_status", "print.ams_get_rfid"],
"capabilities": {
"flags": {},
"protocol": {
"features": {"filament_mapping": true},
"supported_commands": ["print.ams_change_filament"]
}
}
})"));
CHECK(orca_obj->is_support_fms);
CHECK(orca_obj->is_support_filament_slots);
CHECK(orca_obj->is_support_filament_mapping);
CHECK(orca_obj->supported_commands.count("print.push_status") == 1);
CHECK(orca_obj->supported_commands.count("print.ams_get_rfid") == 1);
CHECK(orca_obj->supported_commands.count("print.ams_change_filament") == 1);
// Absent or false reads as unsupported, and a later reply without commands clears the set.
orca_obj->parse_new_info2(json::parse(R"({
"command": "get_capabilities",
"supported_features": {"fms": false, "filament_slots": false, "filament_mapping": false},
"capabilities": {"flags": {}}
})"));
CHECK_FALSE(orca_obj->is_support_fms);
CHECK_FALSE(orca_obj->is_support_filament_slots);
CHECK_FALSE(orca_obj->is_support_filament_mapping);
CHECK(orca_obj->supported_commands.empty());
// A reply without capabilities.flags must still parse the Orca features and
// commands (fail-closed: don't retain stale "supported" values).
orca_obj->parse_new_info2(json::parse(R"({
"command": "get_capabilities",
"supported_features": {"filament_mapping": true},
"capabilities": {
"protocol": {"supported_commands": ["print.ams_get_rfid"]}
}
})"));
CHECK(orca_obj->is_support_filament_mapping);
CHECK_FALSE(orca_obj->is_support_filament_slots);
CHECK(orca_obj->supported_commands.count("print.ams_get_rfid") == 1);
// A non-Orca agent id leaves the connector-scope fields untouched.
BBLocalMachine bbl_machine;
bbl_machine.dev_id = "bbl-device";
bbl_machine.dev_name = "Bambu device";
bbl_machine.dev_ip = "192.0.2.22";
bbl_machine.printer_type = "C11";
MachineObject* bbl_obj = manager.insert_local_device(bbl_machine, "lan", "free", "", "access-code");
REQUIRE(bbl_obj != nullptr);
bbl_obj->printer_agent_id = "bbl";
bbl_obj->parse_new_info2(json::parse(R"({
"command": "get_capabilities",
"supported_features": {"fms": true, "filament_slots": true, "filament_mapping": true},
"supported_commands": ["print.push_status"],
"capabilities": {
"flags": {},
"protocol": {
"features": {"fms": true, "filament_slots": true, "filament_mapping": true},
"supported_commands": ["print.ams_get_rfid"]
}
}
})"));
CHECK_FALSE(bbl_obj->is_support_fms);
CHECK_FALSE(bbl_obj->is_support_filament_slots);
CHECK_FALSE(bbl_obj->is_support_filament_mapping);
CHECK(bbl_obj->supported_commands.empty());
}
TEST_CASE("Orca per-command AMS gate requires fms and the advertised command", "[DeviceManager][integration]")
{
ScopedAppConfig app_config;
NetworkAgent network(nullptr, std::make_shared<TestPrinterAgent>("orca"));
DeviceManager manager(&network, false, &app_config.config);
BBLocalMachine machine;
machine.dev_id = "orca-gate";
machine.dev_name = "Orca gate";
machine.dev_ip = "192.0.2.30";
machine.printer_type = "C11";
MachineObject* obj = manager.insert_local_device(machine, "lan", "free", "", "access-code");
REQUIRE(obj != nullptr);
obj->printer_agent_id = "orca";
// fms off: no macro-backed AMS command is allowed even if listed.
obj->is_support_fms = false;
obj->supported_commands.insert("print.ams_control");
CHECK_FALSE(obj->orca_ams_command_supported("print.ams_control"));
// fms on: only the commands actually advertised are allowed.
obj->is_support_fms = true;
CHECK(obj->orca_ams_command_supported("print.ams_control"));
CHECK_FALSE(obj->orca_ams_command_supported("print.ams_get_rfid"));
// Bambu keeps the legacy permissive path.
obj->printer_agent_id = "bbl";
CHECK(obj->orca_ams_command_supported("print.anything"));
}
@@ -182,6 +182,20 @@ static void run_round_trip(bool use_tls_flag_only) {
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 keepalive runs while the connection is idle", "[OrcaMqtt][.integration]") {
orca_mqtt_test::MockBroker broker;
OrcaMqttConnection conn;
OrcaMqttConnection::Config cfg;
cfg.url = broker.ws_url();
cfg.keepalive_seconds = 2;
REQUIRE(conn.start(cfg, [](const std::string&, const std::string&) {}, [](bool, bool) {}));
for (int i = 0; i < 200 && broker.ping_count() == 0; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
CHECK(broker.ping_count() > 0);
conn.stop();
}
TEST_CASE("OrcaMqtt reconnects and re-subscribes after a socket drop", "[OrcaMqtt][.integration]") {
orca_mqtt_test::MockBroker broker;
OrcaMqttConnection conn;
@@ -1,4 +1,5 @@
#include <catch2/catch_test_macros.hpp>
#include <slic3r/GUI/FilamentMappingUtils.hpp>
#include <slic3r/Utils/IPrinterAgent.hpp>
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp>
#include <slic3r/Utils/OrcaPrinterAgent.hpp>
@@ -22,6 +23,8 @@ struct Probe : OrcaPrinterAgent {
using OrcaPrinterAgent::parse_lan_endpoint;
using OrcaPrinterAgent::make_lan_client_id;
using OrcaPrinterAgent::lan_connection_target;
using OrcaPrinterAgent::build_filament_mapping;
using OrcaPrinterAgent::build_gcode_file_payload;
};
}
@@ -84,6 +87,101 @@ TEST_CASE("OrcaPrinterAgent::make_lan_client_id is stable and prefixed", "[OrcaP
CHECK(a.rfind("orcaslicer-lan-dev-1-", 0) == 0);
}
TEST_CASE("filament mapping is keyed by the ams_mapping2 array position", "[OrcaPrinterAgent]") {
const nlohmann::json mapping = Probe::build_filament_mapping(
R"([{"ams_id":1,"slot_id":5},{"ams_id":255,"slot_id":255},{"ams_id":255,"slot_id":0}])");
REQUIRE(mapping.is_array());
REQUIRE(mapping.size() == 2);
CHECK(mapping[0]["filament_index"] == 0);
CHECK(mapping[0]["ams_id"] == 1);
CHECK(mapping[0]["slot_id"] == 5);
// The unmatched middle entry is dropped; the third entry keeps index 2.
CHECK(mapping[1]["filament_index"] == 2);
CHECK(mapping[1]["ams_id"] == 255);
CHECK(mapping[1]["slot_id"] == 0);
}
// Index-correlation merge gate (plan PR 3). `ams_mapping2` is built one entry
// per logical filament, so its array position is the identifier the generated
// G-code toolchange passes to the Klipper macro (`next_filament_id`). The
// serializer must key `filament_index` by that position and must never
// re-densify after dropping unused/sentinel entries, or a used filament would
// be aimed at the wrong lane. This test covers the plan's matrix: preset order
// differing from used order, a middle filament unused, and external slots.
TEST_CASE("filament mapping index correlates with the ams_mapping2 position", "[OrcaPrinterAgent]") {
// Positions 0..4. Used filaments are 0, 2 and 4; 1 and 3 are unused.
const nlohmann::json mapping = Probe::build_filament_mapping(
R"([{"ams_id":0,"slot_id":1},{"ams_id":255,"slot_id":255},{"ams_id":2,"slot_id":3},{"ams_id":255,"slot_id":255},{"ams_id":255,"slot_id":0}])");
REQUIRE(mapping.size() == 3);
CHECK(mapping[0]["filament_index"] == 0);
CHECK(mapping[1]["filament_index"] == 2);
CHECK(mapping[2]["filament_index"] == 4); // external slot keeps its position
CHECK(mapping[2]["ams_id"] == 255);
CHECK(mapping[2]["slot_id"] == 0);
// No re-densification: a used filament after a dropped sentinel keeps its
// original logical index.
for (const auto& entry : mapping)
CHECK(entry.contains("filament_index"));
}
TEST_CASE("filament mapping is omitted when nothing remains", "[OrcaPrinterAgent]") {
CHECK(Probe::build_filament_mapping("").empty());
CHECK(Probe::build_filament_mapping(R"([{"ams_id":255,"slot_id":255}])").empty());
CHECK(Probe::build_filament_mapping("not json").empty());
CHECK(Probe::build_filament_mapping(R"({"ams_id":1,"slot_id":0})").empty()); // not an array
}
// The GUI capability gate and the agent serializer must classify the same
// entries as engaged. External slots ({255,0}/{254,0}) are normalized as-is, so
// they engage; only the {255,255} unmatched sentinel is dropped. A mismatch lets
// an entry past the GUI and refused late with a generic publish error.
TEST_CASE("the GUI mapping gate engages exactly the entries the serializer sends", "[OrcaPrinterAgent]") {
using Slic3r::GUI::has_engaged_filament_mapping;
CHECK_FALSE(has_engaged_filament_mapping(""));
CHECK_FALSE(has_engaged_filament_mapping("[]"));
CHECK_FALSE(has_engaged_filament_mapping("not json"));
CHECK_FALSE(has_engaged_filament_mapping(R"([{"ams_id":255,"slot_id":255}])"));
CHECK(has_engaged_filament_mapping(R"([{"ams_id":255,"slot_id":0}])")); // external main
CHECK(has_engaged_filament_mapping(R"([{"ams_id":254,"slot_id":0}])")); // external deputy
CHECK(has_engaged_filament_mapping(R"([{"ams_id":0,"slot_id":0}])")); // box slot
CHECK(has_engaged_filament_mapping(R"([{"ams_id":255,"slot_id":255},{"ams_id":1,"slot_id":2}])"));
for (const char* s : {"", "[]", "not json", R"([{"ams_id":255,"slot_id":255}])",
R"([{"ams_id":255,"slot_id":0}])", R"([{"ams_id":254,"slot_id":0}])",
R"([{"ams_id":0,"slot_id":0}])",
R"([{"ams_id":255,"slot_id":255},{"ams_id":1,"slot_id":2}])"}) {
CHECK(has_engaged_filament_mapping(s) == !Probe::build_filament_mapping(s).empty());
}
}
// An empty mapping must leave the gcode_file payload byte-identical to today:
// exactly command, sequence_id and param, with no filament_mapping key.
TEST_CASE("gcode_file payload omits filament_mapping when the map is empty", "[OrcaPrinterAgent]") {
const nlohmann::json empty = Probe::build_gcode_file_payload("7", "job.gcode", nlohmann::json::array());
REQUIRE(empty.contains("print"));
CHECK(empty["print"].size() == 3);
CHECK(empty["print"]["command"] == "gcode_file");
CHECK(empty["print"]["sequence_id"] == "7");
CHECK(empty["print"]["param"] == "job.gcode");
CHECK_FALSE(empty["print"].contains("filament_mapping"));
const nlohmann::json mapping = Probe::build_filament_mapping(R"([{"ams_id":1,"slot_id":0},{"ams_id":255,"slot_id":255}])");
const nlohmann::json with = Probe::build_gcode_file_payload("8", "job.gcode", mapping);
REQUIRE(with["print"].contains("filament_mapping"));
REQUIRE(with["print"]["filament_mapping"].size() == 1);
CHECK(with["print"]["filament_mapping"][0]["filament_index"] == 0);
}
// The FTP "send with record" transport does not exist on OrcaSonar. It must
// report a non-success result so PrintJob falls back to start_print() rather
// than treating a print that was never sent as successful.
TEST_CASE("start_local_print_with_record never reports silent success", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
Slic3r::PrintParams params;
const int rc = agent.start_local_print_with_record(params, {}, {}, {});
CHECK(rc < 0);
}
TEST_CASE("connect_printer wires up a LAN Config", "[OrcaPrinterAgent][.integration]") {
Probe agent("/tmp");
Slic3r::PrinterConnectionParams params{
+1 -1
View File
@@ -73,7 +73,7 @@ TEST_CASE("unit: default AMS commands report not supported", "[unit][moonraker]"
{
MoonrakerPrinterAgent agent("");
CHECK(agent.command_ams_refresh_rfid("dev", "123", 1, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_refresh_rfid("dev", 123, 1, 0, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_calibrate("dev", 1, 2, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_select_tray("dev", "123", 3, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}