mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-28 03:11:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adcc470cff | ||
|
|
87eb8de001 | ||
|
|
220508534a | ||
|
|
5a0810365b | ||
|
|
fa98ec5afe | ||
|
|
3c1d8ac8b4 | ||
|
|
936b0bbc17 | ||
|
|
5222a37eff |
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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...");
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user