Compare commits

..
Author SHA1 Message Date
Hanif Koh fc2ec7de0f Keep a Project's Changed Values on Extruder Variants It Doesn't List
A project's listed settings are carried onto its base preset by update_non_diff_values_to_base_config, which matched variants by exact name and id. A variant the base gained after the project was saved got the base's value, while the same value in a user preset now falls back to the preset's first variant of that extruder. So an old project opened with its printer preset already modified, and saving it wrote the base's values into the 3MF.

The function now maps variants with map_variant_indices, as update_diff_values_to_child_config does: a base variant the project does not list takes the project's first variant of the same extruder. The variant lists themselves stay the base's, so a fallback never writes one variant's name over another's.
2026-10-04 04:54:36 +08:00
HanifKoh d1a3ef68c5 Fix CLI Crashes on Malformed Project, Assemble List and No-Input Runs (#15978)
* Fix CLI Crashes on Malformed Project, Assemble List and No-Input Runs

Four CLI paths indexed vectors without checking their size and crashed
with SIGSEGV on malformed input:

- A project inherits_group whose length is not the filament count plus
  the process and printer entries was split by position. It is now
  ignored with a warning, as if the project had none.
- An assemble list object with an empty filaments list passed validation
  and was then read at index 0. It is now rejected as a config error, as
  is a negative filament id.
- --slice N --arrange 1 on a project without plate metadata read the
  missing plate data. It now falls back to the plate's own filaments,
  like the other plate data reads.
- --assemble with no input model built an object with no volumes. It is
  now rejected as invalid parameters.

A tests/cli script covers each case through the binary, since all four
live inline in CLI::run().

* Move the Assemble List Parser into libslic3r

Behaviour-preserving move of the --load-assemble-list JSON parser and
its plate/object structs from the CLI into libslic3r/Format/AssembleList,
so the format can be unit tested. The parser returns its own
AssembleListResult and takes the plate limit as a parameter; CLI::run
maps the result to the same exit codes as before. Every validation rule
and log message is unchanged.

Adds Catch2 coverage of the valid layout and each validation rule.

* Keep the Process and Printer of an inherits_group of the Wrong Length

A project whose inherits_group did not have one entry per filament plus
the process and printer entries was loaded as if it had none. The CLI
then looked for system presets under the names of the user presets,
found none and refused to slice a project that slices on main.

The group is now read as before: the process first, the printer last
and the filaments in between, up to the filament count. A filament
without an entry counts as a system preset. A group with fewer than two
entries is still ignored. The warning stays.
2026-10-04 03:17:40 +08:00
HanifKoh a80c323614 Let the CLI Resolve Presets on Installs That Ship Preset Caches Only (#16047)
Release builds install each vendor as its preset cache alone. The
read-only preset load the CLI uses to resolve an inheriting user preset
passed allow_cache = false to keep caches from being written, which
also stopped them from being read, so every vendor fell back to JSONs
that are not installed and the CLI failed.

The flag now only gates writing: a read-only load reads caches and
writes none. The filament library is also read from its cache whenever
that is all that is installed, so a vendor updated over the air still
resolves against it.
2026-10-04 00:40:38 +08:00
HanifKoh 52ff374870 Refresh a CLI Project's Filament Settings From Their System Presets (#16038)
* Refresh a CLI Project's Filament Settings From Their System Presets

The CLI loads a project's printer and process settings as the GUI does,
taking every key the project does not list as changed from the current
system preset, but it kept the stored filament values. A project saved
before a profile update then sliced with old filament values on the
command line and with the current ones in the GUI.

Every project filament that no loaded filament replaces is now resolved
by its system preset name and fed to the filament merge the up-to-date
path already uses, which keeps the keys listed in
different_settings_to_system and maps per-variant values onto the
preset's variants. This covers a plain run, --uptodate without
--uptodate-filaments, and the slots --load-filaments leaves empty. The
merge tells refreshed entries from loaded ones per entry instead of by
the global loaded-filament count, and the entries are kept in slot
order. A project filament saved under a name the presets have since
split per nozzle is resolved through the name conversion the GUI uses,
which PresetBundle now exposes.

* Check the Project Refresh Test's Result Directly

Shellcheck SC2181: test the checker's exit status in the if instead of
reading $? afterwards.
2026-10-04 00:36:10 +08:00
Kris AustinandRodrigo Faselli 6e0f04815b perf: speed up G-code export by up to 7% via post-processing fixes (#16031)
Co-authored-by: Rodrigo Faselli <162915171+RF47@users.noreply.github.com>
2026-10-03 13:29:55 -03:00
63 changed files with 2218 additions and 7473 deletions
+61 -169
View File
@@ -685,162 +685,6 @@ static void load_default_gcodes_to_config(DynamicPrintConfig& config, Preset::Ty
}
}
static int load_assemble_plate_list(std::string config_file, std::vector<assemble_plate_info_t> &assemble_plate_info_list)
{
int ret = 0;
boost::filesystem::path directory_path(config_file);
BOOST_LOG_TRIVIAL(info) << boost::format("%1% enter, file %2%")%__FUNCTION__ % config_file;
if (!fs::exists(directory_path)) {
BOOST_LOG_TRIVIAL(error) << boost::format("directory %1% not exist.")%config_file;
return CLI_FILE_NOTFOUND;
}
try {
json root_json;
boost::nowide::ifstream ifs(config_file);
ifs >> root_json;
ifs.close();
int plate_count = root_json[JSON_ASSEMPLE_PLATES].size();
if ((plate_count <= 0) || (plate_count > MAX_PLATE_COUNT)) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__<< boost::format(": invalid plate count %1%")%plate_count;
return CLI_CONFIG_FILE_ERROR;
}
assemble_plate_info_list.resize(plate_count);
for (int plate_index = 0; plate_index < plate_count; plate_index++)
{
assemble_plate_info_t &assemble_plate = assemble_plate_info_list[plate_index];
const json& plate_json = root_json[JSON_ASSEMPLE_PLATES][plate_index];
assemble_plate.plate_name = plate_json[JSON_ASSEMPLE_PLATE_NAME];
assemble_plate.need_arrange = plate_json[JSON_ASSEMPLE_PLATE_NEED_ARRANGE];
if (plate_json.contains(JSON_ASSEMPLE_PLATE_PARAMS)) {
assemble_plate.plate_params = plate_json[JSON_ASSEMPLE_PLATE_PARAMS].get<std::map<std::string, std::string>>();
BOOST_LOG_TRIVIAL(debug) << boost::format("Plate %1%, has %2% plate params") % (plate_index + 1) % assemble_plate.plate_params.size();
}
int object_count = plate_json[JSON_ASSEMPLE_OBJECTS].size();
if (object_count <= 0) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__<< boost::format(": invalid object count %1% in plate %2%")%object_count %(plate_index+1);
return CLI_CONFIG_FILE_ERROR;
}
assemble_plate.assemble_obj_list.resize(object_count);
for (int object_index = 0; object_index < object_count; object_index++)
{
assemble_object_info_t& assemble_object = assemble_plate.assemble_obj_list[object_index];
const json& object_json = plate_json[JSON_ASSEMPLE_OBJECTS][object_index];
assemble_object.path = object_json[JSON_ASSEMPLE_OBJECT_PATH];
assemble_object.count = object_json[JSON_ASSEMPLE_OBJECT_COUNT];
if (assemble_object.count <= 0) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": invalid object clone count %1% in plate %2% Object %3%") % assemble_object.count % (plate_index + 1) % assemble_object.path;
return CLI_CONFIG_FILE_ERROR;
}
assemble_object.filaments = object_json.at(JSON_ASSEMPLE_OBJECT_FILAMENTS).get<std::vector<int>>();
if ((assemble_object.filaments.size() > 0) && (assemble_object.filaments.size() != assemble_object.count) && (assemble_object.filaments.size() != 1))
{
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": object %1%'s filaments count %2% not equal to clone count %3%, also not equal to 1") % assemble_object.path % assemble_object.filaments.size() % assemble_object.count;
return CLI_CONFIG_FILE_ERROR;
}
if (object_json.contains(JSON_ASSEMPLE_OBJECT_ASSEMBLE_INDEX)) {
assemble_object.assemble_index = object_json[JSON_ASSEMPLE_OBJECT_ASSEMBLE_INDEX].get<std::vector<int>>();
if ((assemble_object.assemble_index.size() > 0) && (assemble_object.assemble_index.size() != assemble_object.count) && (assemble_object.assemble_index.size() != 1))
{
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": object %1%'s assemble_index count %2% not equal to clone count %3%, also not equal to 1") % assemble_object.path % assemble_object.assemble_index.size() % assemble_object.count;
return CLI_CONFIG_FILE_ERROR;
}
}
if (object_json.contains(JSON_ASSEMPLE_OBJECT_POS_X)) {
assemble_object.pos_x = object_json[JSON_ASSEMPLE_OBJECT_POS_X].get<std::vector<float>>();
if ((assemble_object.pos_x.size() > 0) && (assemble_object.pos_x.size() != assemble_object.count) && (assemble_object.pos_x.size() != 1))
{
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": object %1%'s pos_x count %2% not equal to clone count %3%, also not equal to 1") % assemble_object.path % assemble_object.pos_x.size() % assemble_object.count;
return CLI_CONFIG_FILE_ERROR;
}
}
if (object_json.contains(JSON_ASSEMPLE_OBJECT_POS_Y)) {
assemble_object.pos_y = object_json[JSON_ASSEMPLE_OBJECT_POS_Y].get<std::vector<float>>();
if ((assemble_object.pos_y.size() > 0) && (assemble_object.pos_y.size() != assemble_object.count) && (assemble_object.pos_y.size() != 1))
{
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": object %1%'s pos_y count %2% not equal to clone count %3%, also not equal to 1") % assemble_object.path % assemble_object.pos_y.size() % assemble_object.count;
return CLI_CONFIG_FILE_ERROR;
}
}
if (object_json.contains(JSON_ASSEMPLE_OBJECT_POS_Z)) {
assemble_object.pos_z = object_json[JSON_ASSEMPLE_OBJECT_POS_Z].get<std::vector<float>>();
if ((assemble_object.pos_z.size() > 0) && (assemble_object.pos_z.size() != assemble_object.count) && (assemble_object.pos_z.size() != 1))
{
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": object %1%'s pos_z count %2% not equal to clone count %3%, also not equal to 1") % assemble_object.path % assemble_object.pos_z.size() % assemble_object.count;
return CLI_CONFIG_FILE_ERROR;
}
}
if (object_json.contains(JSON_ASSEMPLE_OBJECT_PRINT_PARAMS)) {
assemble_object.print_params = object_json[JSON_ASSEMPLE_OBJECT_PRINT_PARAMS].get<std::map<std::string, std::string>>();
BOOST_LOG_TRIVIAL(debug) << boost::format("Plate %1%, object %2% has %3% print params") % (plate_index + 1) %assemble_object.path % assemble_object.print_params.size();
}
if (object_json.contains(JSON_ASSEMPLE_OBJECT_HEIGHT_RANGES)) {
json height_range_json = object_json[JSON_ASSEMPLE_OBJECT_HEIGHT_RANGES];
int range_count = height_range_json.size();
BOOST_LOG_TRIVIAL(debug) << boost::format("Plate %1%, object %2% has %3% height ranges") % (plate_index + 1) %assemble_object.path % range_count;
assemble_object.height_ranges.resize(range_count);
for (int range_index = 0; range_index < range_count; range_index++)
{
height_range_info_t& height_range = assemble_object.height_ranges[range_index];
height_range.min_z = height_range_json[range_index][JSON_ASSEMPLE_OBJECT_MIN_Z];
height_range.max_z = height_range_json[range_index][JSON_ASSEMPLE_OBJECT_MAX_Z];
height_range.range_params = height_range_json[range_index][JSON_ASSEMPLE_OBJECT_RANGE_PARAMS].get<std::map<std::string, std::string>>();
}
}
}
if (plate_json.contains(JSON_ASSEMPLE_ASSEMBLE_PARAMS)) {
json assemble_params_json = plate_json[JSON_ASSEMPLE_ASSEMBLE_PARAMS];
int assemble_count = assemble_params_json.size();
for (int i = 0; i < assemble_count; i++)
{
assembled_param_info_t assembled_param;
int assemble_index = assemble_params_json[i][JSON_ASSEMPLE_OBJECT_ASSEMBLE_INDEX];
if (assemble_params_json[i].contains(JSON_ASSEMPLE_OBJECT_PRINT_PARAMS)) {
assembled_param.print_params = assemble_params_json[i][JSON_ASSEMPLE_OBJECT_PRINT_PARAMS].get<std::map<std::string, std::string>>();
BOOST_LOG_TRIVIAL(debug) << boost::format("Plate %1%, assemble object %2% has %3% print params") % (plate_index + 1) %i % assembled_param.print_params.size();
}
if (assemble_params_json[i].contains(JSON_ASSEMPLE_OBJECT_HEIGHT_RANGES)) {
json height_range_json = assemble_params_json[i][JSON_ASSEMPLE_OBJECT_HEIGHT_RANGES];
int range_count = height_range_json.size();
BOOST_LOG_TRIVIAL(debug) << boost::format("Plate %1%, assemble object %2% has %3% height ranges") % (plate_index + 1) %i % range_count;
assembled_param.height_ranges.resize(range_count);
for (int range_index = 0; range_index < range_count; range_index++)
{
height_range_info_t& height_range = assembled_param.height_ranges[range_index];
height_range.min_z = height_range_json[range_index][JSON_ASSEMPLE_OBJECT_MIN_Z];
height_range.max_z = height_range_json[range_index][JSON_ASSEMPLE_OBJECT_MAX_Z];
height_range.range_params = height_range_json[range_index][JSON_ASSEMPLE_OBJECT_RANGE_PARAMS].get<std::map<std::string, std::string>>();
}
}
assemble_plate.assembled_param_list.emplace(assemble_index, std::move(assembled_param));
}
BOOST_LOG_TRIVIAL(debug) << boost::format("Plate %1%, has %2% plate params") % (plate_index + 1) % assemble_plate.plate_params.size();
}
}
}
catch(std::exception &err) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__<< ": parse file "<<config_file<<" got a generic exception, reason = " << err.what();
ret = CLI_CONFIG_FILE_ERROR;
}
return ret;
}
void merge_or_add_object(assemble_plate_info_t& assemble_plate_info, Model &model, int assemble_index, std::map<int, ModelObject*> &merged_objects, ModelObject *ori_object)
{
if (assemble_index > 0) {
@@ -1906,6 +1750,14 @@ int CLI::run(int argc, char **argv)
BOOST_LOG_TRIVIAL(info) << boost::format("current_printer_name %1%, current_process_name %2%")%current_printer_name %current_process_name;
ConfigOptionStrings* option_strings = config.option<ConfigOptionStrings>("inherits_group");
// One entry for the process, one per filament and one for the printer. A group of another
// length still has the process first and the printer last; one too short for that is ignored.
if (option_strings && option_strings->values.size() != current_filaments_name.size() + 2) {
boost::nowide::cerr << "Warning: inherits_group has " << option_strings->values.size() << " entries, expected "
<< current_filaments_name.size() + 2 << " for " << current_filaments_name.size() << " filaments" << std::endl;
if (option_strings->values.size() < 2)
option_strings = nullptr;
}
if (option_strings) {
current_inherits_group = option_strings->values;
size_t size = current_inherits_group.size();
@@ -1927,14 +1779,11 @@ int CLI::run(int argc, char **argv)
BOOST_LOG_TRIVIAL(info) << boost::format("inherits of process valid, current_process_system_name is %1%") %current_process_system_name;
}
current_filaments_system_name.resize(size - 2);
for (int index = 1; index < (size - 1); index++) {
if (current_inherits_group[index].empty()) {
current_filaments_system_name[index-1] = current_filaments_name[index-1];
}
else {
// A filament without an entry of its own counts as a system preset.
current_filaments_system_name = current_filaments_name;
for (size_t index = 1; index < size - 1 && index <= current_filaments_name.size(); index++) {
if (!current_inherits_group[index].empty())
current_filaments_system_name[index-1] = current_inherits_group[index];
}
}
}
else {
@@ -2067,7 +1916,12 @@ int CLI::run(int argc, char **argv)
//parse the json and assemble object here
Model model;
int ret = load_assemble_plate_list(load_assemble_list, assemble_plate_info_list);
AssembleListResult list_result = load_assemble_plate_list(load_assemble_list, assemble_plate_info_list, MAX_PLATE_COUNT);
int ret = CLI_SUCCESS;
if (list_result == AssembleListResult::FileNotFound)
ret = CLI_FILE_NOTFOUND;
else if (list_result == AssembleListResult::ConfigError)
ret = CLI_CONFIG_FILE_ERROR;
if (ret) {
record_exit_reson(outfile_dir, ret, 0, cli_errors[ret], sliced_info);
flush_and_exit(ret);
@@ -2823,6 +2677,37 @@ int CLI::run(int argc, char **argv)
fetch_compatible_values = true;
}
// Refresh every project filament no loaded filament replaces from its current system preset, as the GUI
// does when it loads the project; the filament merge below keeps the keys the project lists as changed.
// Entries stay in slot order, which the merge's variant bookkeeping relies on.
std::vector<bool> load_filaments_refresh(load_filaments_config.size(), false);
if (is_bbl_3mf && new_printer_name.empty()) {
const ConfigOptionStrings *project_filament_ids = m_print_config.option<ConfigOptionStrings>("filament_ids");
for (size_t index = 0; index < current_filaments_system_name.size(); index++) {
const int slot = static_cast<int>(index) + 1;
if (std::find(load_filaments_index.begin(), load_filaments_index.end(), slot) != load_filaments_index.end())
continue;
std::string system_name = current_filaments_system_name[index];
if (system_name.empty())
continue;
PresetBundle::convert_filament_preset_name(current_printer_name, system_name);
DynamicPrintConfig config;
std::string error;
if (!ensure_system_preset_resolver().resolve_system_preset(config, Preset::TYPE_FILAMENT, system_name, config_substitution_rule, error)) {
BOOST_LOG_TRIVIAL(warning) << boost::format("CLI: system filament preset '%1%' not resolved (%2%); filament %3% keeps its values") % system_name % error % slot;
continue;
}
const size_t at = std::upper_bound(load_filaments_index.begin(), load_filaments_index.end(), slot) - load_filaments_index.begin();
load_filaments_id.insert(load_filaments_id.begin() + at,
project_filament_ids != nullptr && index < project_filament_ids->size() ? project_filament_ids->values[index] : std::string());
load_filaments_name.insert(load_filaments_name.begin() + at, system_name);
load_filaments_config.insert(load_filaments_config.begin() + at, std::move(config));
load_filaments_index.insert(load_filaments_index.begin() + at, slot);
load_filaments_inherit.insert(load_filaments_inherit.begin() + at, system_name);
load_filaments_refresh.insert(load_filaments_refresh.begin() + at, true);
}
}
//fetch upward_compatible_machine
if (fetch_upward_values) {
if (!current_printer_system_name.empty()) {
@@ -3513,7 +3398,7 @@ int CLI::run(int argc, char **argv)
}
//set the filament settings into print config
if ((load_filament_count > 0) || (up_config_to_date))
if ((load_filament_count > 0) || (up_config_to_date) || !load_filaments_config.empty())
{
//std::vector<int> filament_variant_count(filament_count, 1);
std::vector<int> old_start_indice(filament_count, 0);
@@ -3572,6 +3457,8 @@ int CLI::run(int argc, char **argv)
for (int index = 0; index < load_filaments_config.size(); index++) {
DynamicPrintConfig& config = load_filaments_config[index];
int filament_index = load_filaments_index[index];
// A filament given with --load-filaments replaces the slot; a refreshed one keeps the project's changed keys.
const bool loaded = load_filament_count > 0 && !load_filaments_refresh[index];
std::vector<std::string> different_keys;
//ORCA: diff before load_default_gcodes_to_config, the way the process and machine
@@ -3581,12 +3468,12 @@ int CLI::run(int argc, char **argv)
// compared" to "compared as empty against the parent" and land in the column
// as an override the user never made.
std::string filament_different_settings;
if (load_filament_count > 0)
if (loaded)
filament_different_settings = cli_different_settings(config, load_filaments_inherit[index], Preset::TYPE_FILAMENT);
load_default_gcodes_to_config(config, Preset::TYPE_FILAMENT);
if (load_filament_count > 0) {
if (loaded) {
ConfigOptionStrings *opt_filament_settings = static_cast<ConfigOptionStrings *> (m_print_config.option("filament_settings_id", true));
std::string& filament_name = load_filaments_name[index];
ConfigOptionString* filament_name_setting = new ConfigOptionString(filament_name);
@@ -3658,7 +3545,7 @@ int CLI::run(int argc, char **argv)
flush_and_exit(CLI_CONFIG_FILE_ERROR);
}
if ((load_filament_count == 0) && !different_keys_set.empty())
if (!loaded && !different_keys_set.empty())
{
std::set<std::string>::iterator iter = different_keys_set.find(opt_key);
if ( iter != different_keys_set.end()) {
@@ -4848,6 +4735,11 @@ int CLI::run(int argc, char **argv)
record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, 0, cli_errors[CLI_INVALID_PARAMS], sliced_info);
flush_and_exit(CLI_INVALID_PARAMS);
}
if (m_models.empty()) {
boost::nowide::cerr << "Invalid params: --assemble needs at least one input model." << std::endl;
record_exit_reson(outfile_dir, CLI_INVALID_PARAMS, 0, cli_errors[CLI_INVALID_PARAMS], sliced_info);
flush_and_exit(CLI_INVALID_PARAMS);
}
Model m;
ModelObject* new_object = m.add_object();
new_object->name = _u8L("Assembly");
@@ -5788,7 +5680,7 @@ int CLI::run(int argc, char **argv)
float w = dynamic_cast<const ConfigOptionFloat *>(m_print_config.option("prime_tower_width"))->value;
float a = dynamic_cast<const ConfigOptionFloat *>(m_print_config.option("wipe_tower_rotation_angle"))->value;
float v = dynamic_cast<const ConfigOptionFloat *>(m_print_config.option("prime_volume"))->value;
unsigned int filaments_cnt = plate_data_src[plate_to_slice-1]->slice_filaments_info.size();
unsigned int filaments_cnt = (plate_data_src.size() >= static_cast<size_t>(plate_to_slice)) ? plate_data_src[plate_to_slice-1]->slice_filaments_info.size() : 0;
if ((filaments_cnt == 0) || need_skip)
{
// slice filaments info invalid
+1 -58
View File
@@ -12,6 +12,7 @@
#include "libslic3r/Config.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/Format/AssembleList.hpp"
namespace Slic3r {
@@ -26,64 +27,6 @@ namespace IO {
};
}
#define JSON_ASSEMPLE_PLATES "plates"
#define JSON_ASSEMPLE_PLATE_PARAMS "plate_params"
#define JSON_ASSEMPLE_PLATE_NAME "plate_name"
#define JSON_ASSEMPLE_PLATE_NEED_ARRANGE "need_arrange"
#define JSON_ASSEMPLE_OBJECTS "objects"
#define JSON_ASSEMPLE_OBJECT_PATH "path"
#define JSON_ASSEMPLE_OBJECT_COUNT "count"
#define JSON_ASSEMPLE_OBJECT_FILAMENTS "filaments"
#define JSON_ASSEMPLE_OBJECT_POS_X "pos_x"
#define JSON_ASSEMPLE_OBJECT_POS_Y "pos_y"
#define JSON_ASSEMPLE_OBJECT_POS_Z "pos_z"
#define JSON_ASSEMPLE_OBJECT_ASSEMBLE_INDEX "assemble_index"
#define JSON_ASSEMPLE_OBJECT_PRINT_PARAMS "print_params"
#define JSON_ASSEMPLE_ASSEMBLE_PARAMS "assembled_params"
#define JSON_ASSEMPLE_OBJECT_MIN_Z "min_z"
#define JSON_ASSEMPLE_OBJECT_MAX_Z "max_z"
#define JSON_ASSEMPLE_OBJECT_HEIGHT_RANGES "height_ranges"
#define JSON_ASSEMPLE_OBJECT_RANGE_PARAMS "range_params"
typedef struct _height_range_info {
float min_z;
float max_z;
std::map<std::string, std::string> range_params;
}height_range_info_t;
typedef struct _assembled_param_info {
std::map<std::string, std::string> print_params;
std::vector<height_range_info_t> height_ranges;
}assembled_param_info_t;
typedef struct _assemble_object_info {
std::string path;
int count;
std::vector<int> filaments;
std::vector<int> assemble_index;
std::vector<float> pos_x;
std::vector<float> pos_y;
std::vector<float> pos_z;
std::map<std::string, std::string> print_params;
std::vector<height_range_info_t> height_ranges;
}assemble_object_info_t;
typedef struct _assemble_plate_info {
std::string plate_name;
bool need_arrange {false};
int filaments_count {0};
std::map<std::string, std::string> plate_params;
std::vector<assemble_object_info_t> assemble_obj_list;
std::vector<ModelObject *> loaded_obj_list;
std::map<int, assembled_param_info_t> assembled_param_list;
}assemble_plate_info_t;
typedef struct _printer_plate_info {
std::string printer_name;
int printable_width{0};
+2
View File
@@ -202,6 +202,8 @@ set(lisbslic3r_sources
Format/3mf.hpp
Format/AMF.cpp
Format/AMF.hpp
Format/AssembleList.cpp
Format/AssembleList.hpp
Format/DRC.cpp
Format/DRC.hpp
Format/bbs_3mf.cpp
-13
View File
@@ -871,19 +871,6 @@ ConfigSubstitutions ConfigBase::load_from_json(const std::string &file, ForwardC
return std::move(substitutions_ctxt.substitutions);
}
// Case-insensitive compare of a JSON key against a fixed ASCII one, without
// boost::iequals, whose std::locale() takes a lock the whole process shares in the
// MSVC runtime.
static bool ascii_iequals(const std::string &key, const char *literal)
{
auto lower = [](char c) { return (c >= 'A' && c <= 'Z') ? char(c - 'A' + 'a') : c; };
size_t i = 0;
for (; i < key.size() && literal[i] != '\0'; ++ i)
if (lower(key[i]) != lower(literal[i]))
return false;
return i == key.size() && literal[i] == '\0';
}
int ConfigBase::load_from_json(const std::string &file, ConfigSubstitutionContext& substitution_context, bool load_inherits_to_config, std::map<std::string, std::string>& key_values, std::string& reason)
{
json j;
+204
View File
@@ -0,0 +1,204 @@
#include "AssembleList.hpp"
#include <algorithm>
#include <boost/filesystem.hpp>
#include <boost/format.hpp>
#include <boost/log/trivial.hpp>
#include <boost/nowide/fstream.hpp>
#include "nlohmann/json.hpp"
#define JSON_ASSEMPLE_PLATES "plates"
#define JSON_ASSEMPLE_PLATE_PARAMS "plate_params"
#define JSON_ASSEMPLE_PLATE_NAME "plate_name"
#define JSON_ASSEMPLE_PLATE_NEED_ARRANGE "need_arrange"
#define JSON_ASSEMPLE_OBJECTS "objects"
#define JSON_ASSEMPLE_OBJECT_PATH "path"
#define JSON_ASSEMPLE_OBJECT_COUNT "count"
#define JSON_ASSEMPLE_OBJECT_FILAMENTS "filaments"
#define JSON_ASSEMPLE_OBJECT_POS_X "pos_x"
#define JSON_ASSEMPLE_OBJECT_POS_Y "pos_y"
#define JSON_ASSEMPLE_OBJECT_POS_Z "pos_z"
#define JSON_ASSEMPLE_OBJECT_ASSEMBLE_INDEX "assemble_index"
#define JSON_ASSEMPLE_OBJECT_PRINT_PARAMS "print_params"
#define JSON_ASSEMPLE_ASSEMBLE_PARAMS "assembled_params"
#define JSON_ASSEMPLE_OBJECT_MIN_Z "min_z"
#define JSON_ASSEMPLE_OBJECT_MAX_Z "max_z"
#define JSON_ASSEMPLE_OBJECT_HEIGHT_RANGES "height_ranges"
#define JSON_ASSEMPLE_OBJECT_RANGE_PARAMS "range_params"
namespace Slic3r {
using json = nlohmann::json;
AssembleListResult load_assemble_plate_list(const std::string &config_file, std::vector<assemble_plate_info_t> &assemble_plate_info_list, int max_plate_count)
{
AssembleListResult ret = AssembleListResult::Success;
boost::filesystem::path directory_path(config_file);
BOOST_LOG_TRIVIAL(info) << boost::format("%1% enter, file %2%")%__FUNCTION__ % config_file;
if (!boost::filesystem::exists(directory_path)) {
BOOST_LOG_TRIVIAL(error) << boost::format("directory %1% not exist.")%config_file;
return AssembleListResult::FileNotFound;
}
try {
json root_json;
boost::nowide::ifstream ifs(config_file);
ifs >> root_json;
ifs.close();
int plate_count = root_json[JSON_ASSEMPLE_PLATES].size();
if ((plate_count <= 0) || (plate_count > max_plate_count)) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__<< boost::format(": invalid plate count %1%")%plate_count;
return AssembleListResult::ConfigError;
}
assemble_plate_info_list.resize(plate_count);
for (int plate_index = 0; plate_index < plate_count; plate_index++)
{
assemble_plate_info_t &assemble_plate = assemble_plate_info_list[plate_index];
const json& plate_json = root_json[JSON_ASSEMPLE_PLATES][plate_index];
assemble_plate.plate_name = plate_json[JSON_ASSEMPLE_PLATE_NAME];
assemble_plate.need_arrange = plate_json[JSON_ASSEMPLE_PLATE_NEED_ARRANGE];
if (plate_json.contains(JSON_ASSEMPLE_PLATE_PARAMS)) {
assemble_plate.plate_params = plate_json[JSON_ASSEMPLE_PLATE_PARAMS].get<std::map<std::string, std::string>>();
BOOST_LOG_TRIVIAL(debug) << boost::format("Plate %1%, has %2% plate params") % (plate_index + 1) % assemble_plate.plate_params.size();
}
int object_count = plate_json[JSON_ASSEMPLE_OBJECTS].size();
if (object_count <= 0) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__<< boost::format(": invalid object count %1% in plate %2%")%object_count %(plate_index+1);
return AssembleListResult::ConfigError;
}
assemble_plate.assemble_obj_list.resize(object_count);
for (int object_index = 0; object_index < object_count; object_index++)
{
assemble_object_info_t& assemble_object = assemble_plate.assemble_obj_list[object_index];
const json& object_json = plate_json[JSON_ASSEMPLE_OBJECTS][object_index];
assemble_object.path = object_json[JSON_ASSEMPLE_OBJECT_PATH];
assemble_object.count = object_json[JSON_ASSEMPLE_OBJECT_COUNT];
if (assemble_object.count <= 0) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": invalid object clone count %1% in plate %2% Object %3%") % assemble_object.count % (plate_index + 1) % assemble_object.path;
return AssembleListResult::ConfigError;
}
assemble_object.filaments = object_json.at(JSON_ASSEMPLE_OBJECT_FILAMENTS).get<std::vector<int>>();
if (assemble_object.filaments.empty())
{
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": object %1%'s filaments list is empty") % assemble_object.path;
return AssembleListResult::ConfigError;
}
if ((assemble_object.filaments.size() != assemble_object.count) && (assemble_object.filaments.size() != 1))
{
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": object %1%'s filaments count %2% not equal to clone count %3%, also not equal to 1") % assemble_object.path % assemble_object.filaments.size() % assemble_object.count;
return AssembleListResult::ConfigError;
}
// 0 keeps the default filament, as it does for --load-filament-ids.
if (std::any_of(assemble_object.filaments.begin(), assemble_object.filaments.end(), [](int id) { return id < 0; }))
{
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": object %1% has a negative filament id") % assemble_object.path;
return AssembleListResult::ConfigError;
}
if (object_json.contains(JSON_ASSEMPLE_OBJECT_ASSEMBLE_INDEX)) {
assemble_object.assemble_index = object_json[JSON_ASSEMPLE_OBJECT_ASSEMBLE_INDEX].get<std::vector<int>>();
if ((assemble_object.assemble_index.size() > 0) && (assemble_object.assemble_index.size() != assemble_object.count) && (assemble_object.assemble_index.size() != 1))
{
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": object %1%'s assemble_index count %2% not equal to clone count %3%, also not equal to 1") % assemble_object.path % assemble_object.assemble_index.size() % assemble_object.count;
return AssembleListResult::ConfigError;
}
}
if (object_json.contains(JSON_ASSEMPLE_OBJECT_POS_X)) {
assemble_object.pos_x = object_json[JSON_ASSEMPLE_OBJECT_POS_X].get<std::vector<float>>();
if ((assemble_object.pos_x.size() > 0) && (assemble_object.pos_x.size() != assemble_object.count) && (assemble_object.pos_x.size() != 1))
{
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": object %1%'s pos_x count %2% not equal to clone count %3%, also not equal to 1") % assemble_object.path % assemble_object.pos_x.size() % assemble_object.count;
return AssembleListResult::ConfigError;
}
}
if (object_json.contains(JSON_ASSEMPLE_OBJECT_POS_Y)) {
assemble_object.pos_y = object_json[JSON_ASSEMPLE_OBJECT_POS_Y].get<std::vector<float>>();
if ((assemble_object.pos_y.size() > 0) && (assemble_object.pos_y.size() != assemble_object.count) && (assemble_object.pos_y.size() != 1))
{
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": object %1%'s pos_y count %2% not equal to clone count %3%, also not equal to 1") % assemble_object.path % assemble_object.pos_y.size() % assemble_object.count;
return AssembleListResult::ConfigError;
}
}
if (object_json.contains(JSON_ASSEMPLE_OBJECT_POS_Z)) {
assemble_object.pos_z = object_json[JSON_ASSEMPLE_OBJECT_POS_Z].get<std::vector<float>>();
if ((assemble_object.pos_z.size() > 0) && (assemble_object.pos_z.size() != assemble_object.count) && (assemble_object.pos_z.size() != 1))
{
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": object %1%'s pos_z count %2% not equal to clone count %3%, also not equal to 1") % assemble_object.path % assemble_object.pos_z.size() % assemble_object.count;
return AssembleListResult::ConfigError;
}
}
if (object_json.contains(JSON_ASSEMPLE_OBJECT_PRINT_PARAMS)) {
assemble_object.print_params = object_json[JSON_ASSEMPLE_OBJECT_PRINT_PARAMS].get<std::map<std::string, std::string>>();
BOOST_LOG_TRIVIAL(debug) << boost::format("Plate %1%, object %2% has %3% print params") % (plate_index + 1) %assemble_object.path % assemble_object.print_params.size();
}
if (object_json.contains(JSON_ASSEMPLE_OBJECT_HEIGHT_RANGES)) {
json height_range_json = object_json[JSON_ASSEMPLE_OBJECT_HEIGHT_RANGES];
int range_count = height_range_json.size();
BOOST_LOG_TRIVIAL(debug) << boost::format("Plate %1%, object %2% has %3% height ranges") % (plate_index + 1) %assemble_object.path % range_count;
assemble_object.height_ranges.resize(range_count);
for (int range_index = 0; range_index < range_count; range_index++)
{
height_range_info_t& height_range = assemble_object.height_ranges[range_index];
height_range.min_z = height_range_json[range_index][JSON_ASSEMPLE_OBJECT_MIN_Z];
height_range.max_z = height_range_json[range_index][JSON_ASSEMPLE_OBJECT_MAX_Z];
height_range.range_params = height_range_json[range_index][JSON_ASSEMPLE_OBJECT_RANGE_PARAMS].get<std::map<std::string, std::string>>();
}
}
}
if (plate_json.contains(JSON_ASSEMPLE_ASSEMBLE_PARAMS)) {
json assemble_params_json = plate_json[JSON_ASSEMPLE_ASSEMBLE_PARAMS];
int assemble_count = assemble_params_json.size();
for (int i = 0; i < assemble_count; i++)
{
assembled_param_info_t assembled_param;
int assemble_index = assemble_params_json[i][JSON_ASSEMPLE_OBJECT_ASSEMBLE_INDEX];
if (assemble_params_json[i].contains(JSON_ASSEMPLE_OBJECT_PRINT_PARAMS)) {
assembled_param.print_params = assemble_params_json[i][JSON_ASSEMPLE_OBJECT_PRINT_PARAMS].get<std::map<std::string, std::string>>();
BOOST_LOG_TRIVIAL(debug) << boost::format("Plate %1%, assemble object %2% has %3% print params") % (plate_index + 1) %i % assembled_param.print_params.size();
}
if (assemble_params_json[i].contains(JSON_ASSEMPLE_OBJECT_HEIGHT_RANGES)) {
json height_range_json = assemble_params_json[i][JSON_ASSEMPLE_OBJECT_HEIGHT_RANGES];
int range_count = height_range_json.size();
BOOST_LOG_TRIVIAL(debug) << boost::format("Plate %1%, assemble object %2% has %3% height ranges") % (plate_index + 1) %i % range_count;
assembled_param.height_ranges.resize(range_count);
for (int range_index = 0; range_index < range_count; range_index++)
{
height_range_info_t& height_range = assembled_param.height_ranges[range_index];
height_range.min_z = height_range_json[range_index][JSON_ASSEMPLE_OBJECT_MIN_Z];
height_range.max_z = height_range_json[range_index][JSON_ASSEMPLE_OBJECT_MAX_Z];
height_range.range_params = height_range_json[range_index][JSON_ASSEMPLE_OBJECT_RANGE_PARAMS].get<std::map<std::string, std::string>>();
}
}
assemble_plate.assembled_param_list.emplace(assemble_index, std::move(assembled_param));
}
BOOST_LOG_TRIVIAL(debug) << boost::format("Plate %1%, has %2% plate params") % (plate_index + 1) % assemble_plate.plate_params.size();
}
}
}
catch(std::exception &err) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__<< ": parse file "<<config_file<<" got a generic exception, reason = " << err.what();
ret = AssembleListResult::ConfigError;
}
return ret;
}
} // namespace Slic3r
+60
View File
@@ -0,0 +1,60 @@
#ifndef slic3r_Format_AssembleList_hpp_
#define slic3r_Format_AssembleList_hpp_
#include <map>
#include <string>
#include <vector>
namespace Slic3r {
class ModelObject;
typedef struct _height_range_info {
float min_z;
float max_z;
std::map<std::string, std::string> range_params;
}height_range_info_t;
typedef struct _assembled_param_info {
std::map<std::string, std::string> print_params;
std::vector<height_range_info_t> height_ranges;
}assembled_param_info_t;
typedef struct _assemble_object_info {
std::string path;
int count;
std::vector<int> filaments;
std::vector<int> assemble_index;
std::vector<float> pos_x;
std::vector<float> pos_y;
std::vector<float> pos_z;
std::map<std::string, std::string> print_params;
std::vector<height_range_info_t> height_ranges;
}assemble_object_info_t;
typedef struct _assemble_plate_info {
std::string plate_name;
bool need_arrange {false};
int filaments_count {0};
std::map<std::string, std::string> plate_params;
std::vector<assemble_object_info_t> assemble_obj_list;
std::vector<ModelObject *> loaded_obj_list;
std::map<int, assembled_param_info_t> assembled_param_list;
}assemble_plate_info_t;
enum class AssembleListResult {
Success,
FileNotFound,
// Malformed JSON, a missing required field, or a value that fails validation.
ConfigError
};
// Read the JSON assemble list used by the CLI's --load-assemble-list into one entry per plate.
AssembleListResult load_assemble_plate_list(const std::string &config_file, std::vector<assemble_plate_info_t> &assemble_plate_info_list, int max_plate_count);
} // namespace Slic3r
#endif /* slic3r_Format_AssembleList_hpp_ */
+4 -4
View File
@@ -806,13 +806,13 @@ static std::vector<Vec2d> get_path_of_change_filament(const Print& print)
GCodeReader parser;
parser.parse_buffer(gcode, [&changes](GCodeReader &parser, const GCodeReader::GCodeLine &line) {
const std::string_view cmd = line.cmd();
if (boost::iequals(cmd, "M204") || boost::iequals(cmd, "M201") ||
boost::iequals(cmd, "M202"))
if (ascii_iequals(cmd, "M204") || ascii_iequals(cmd, "M201") ||
ascii_iequals(cmd, "M202"))
changes.acceleration = true;
else if ((boost::iequals(cmd, "M205") || boost::iequals(cmd, "M207") || boost::iequals(cmd, "M566")) &&
else if ((ascii_iequals(cmd, "M205") || ascii_iequals(cmd, "M207") || ascii_iequals(cmd, "M566")) &&
custom_gcode_line_has_xy_parameter(line.raw()))
changes.jerk = true;
else if (boost::iequals(cmd, "SET_VELOCITY_LIMIT")) {
else if (ascii_iequals(cmd, "SET_VELOCITY_LIMIT")) {
changes.acceleration |= boost::icontains(line.raw(), "ACCEL=");
changes.jerk |= boost::icontains(line.raw(), "SQUARE_CORNER_VELOCITY=");
}
@@ -41,6 +41,9 @@ AdaptivePAProcessor::AdaptivePAProcessor(GCode &gcodegen)
m_pa_change_pattern(R"(; PA_CHANGE:T(\d+) MM3MM:([0-9]*\.[0-9]+) ACCEL:(\d+) BR:(\d+) RC:(\d+) OV:(\d+))"),
m_g1_f_pattern(R"(G1 F([0-9]+))")
{
const size_t indices = std::max(m_config.adaptive_pressure_advance.size(), m_config.enable_pressure_advance.size());
for (size_t i = 0; i < indices && !m_enabled; ++i)
m_enabled = m_config.adaptive_pressure_advance.get_at(i) && m_config.enable_pressure_advance.get_at(i);
}
// Method to get the interpolator for a specific filament config index.
@@ -66,6 +69,12 @@ AdaptivePAInterpolator* AdaptivePAProcessor::getInterpolator(unsigned int config
* @return A string containing the processed G-code with adaptive pressure advance applied.
*/
std::string AdaptivePAProcessor::process_layer(std::string &&gcode) {
// Without PA_CHANGE tags the loop below would only terminate the layer's last line.
if (!m_enabled && gcode.find("; PA_CHANGE") == std::string::npos) {
if (!gcode.empty() && gcode.back() != '\n')
gcode += '\n';
return std::move(gcode);
}
std::istringstream stream(gcode);
std::string line;
std::ostringstream output;
@@ -78,6 +78,7 @@ private:
double m_next_feedrate; ///< First feed rate (speed) for the upcomming island.
double m_current_feedrate; ///< Current, latest feedrate.
int m_last_config_index; ///< Filament config index of the last PA_CHANGE tag.
bool m_enabled{false}; ///< Whether any filament config index has both PA and adaptive PA on, the only ones that emit PA_CHANGE tags.
std::regex m_pa_change_pattern; ///< Regular expression to detect PA_CHANGE pattern.
std::regex m_g1_f_pattern; ///< Regular expression to detect G1 F pattern.
+5 -5
View File
@@ -1,4 +1,5 @@
#include "../GCode.hpp"
#include "../LocalesUtils.hpp"
#include "libslic3r/Extruder.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/libslic3r.h"
@@ -408,13 +409,13 @@ std::vector<PerExtruderAdjustments> CoolingBuffer::parse_layer_gcode(const std::
if (*c == 0 || *c == ';')
break;
assert(is_decimal_separator_point()); // for atof
//BBS: Parse the axis.
size_t axis = (*c >= 'X' && *c <= 'Z') ? (*c - 'X') :
(*c == 'E') ? 3 : (*c == 'F') ? 4 :
(*c == 'I') ? 5 : (*c == 'J') ? 6 : size_t(-1);
if (axis != size_t(-1)) {
new_pos[axis] = float(atof(++c));
++ c;
new_pos[axis] = float(atof_decimal_point(std::string_view(c, sline.data() + sline.size() - c)));
if (axis == 4) {
// Convert mm/min to mm/sec.
new_pos[4] /= 60.f;
@@ -550,10 +551,9 @@ std::vector<PerExtruderAdjustments> CoolingBuffer::parse_layer_gcode(const std::
line.type = CoolingLine::TYPE_G4;
size_t pos_S = sline.find('S', 3);
size_t pos_P = sline.find('P', 3);
assert(is_decimal_separator_point()); // for atof
line.time = line.time_max = float(
(pos_S > 0) ? atof(sline.c_str() + pos_S + 1) :
(pos_P > 0) ? atof(sline.c_str() + pos_P + 1) * 0.001 : 0.);
(pos_S > 0) ? atof_decimal_point(sline.c_str() + pos_S + 1) :
(pos_P > 0) ? atof_decimal_point(sline.c_str() + pos_P + 1) * 0.001 : 0.);
} else if (boost::starts_with(sline, ";_FORCE_RESUME_FAN_SPEED")) {
line.type = CoolingLine::TYPE_FORCE_RESUME_FAN;
}
+66 -42
View File
@@ -909,8 +909,8 @@ public:
{
// Orca: find start pos by seaching G28/G29/PRINT_START/START_PRINT commands
auto is_start_pos = [](const std::string& curr_cmd) {
return boost::iequals(curr_cmd, "G28") || boost::iequals(curr_cmd, "G29") || boost::iequals(curr_cmd, "PRINT_START") ||
boost::iequals(curr_cmd, "START_PRINT");
return ascii_iequals(curr_cmd, "G28") || ascii_iequals(curr_cmd, "G29") || ascii_iequals(curr_cmd, "PRINT_START") ||
ascii_iequals(curr_cmd, "START_PRINT");
};
assert(!m_lines.empty());
const float time_step = backtrace.time_step();
@@ -1265,6 +1265,9 @@ void GCodeProcessor::run_post_process()
// Process inline placeholders (print_time_total_sec, print_time_day, print_time_hour, print_time_minute, print_time_sec and used_filament_length)
auto process_inline_placeholders = [&](std::string& gcode_line) {
bool processed = false;
// Every inline placeholder contains '@', so a line without one has nothing to replace.
if (gcode_line.find('@') == std::string::npos)
return processed;
const std::string& print_time_total_placeholder = reserved_tag(ETags::Print_Time_Total_Sec_Placeholder);
const std::string& print_time_day_placeholder = reserved_tag(ETags::Print_Time_Day_Placeholder);
@@ -4007,13 +4010,13 @@ void GCodeProcessor::process_gcode_line(const GCodeReader::GCodeLine& line, bool
const std::string_view cmd = line.cmd();
if (m_flavor == gcfKlipper)
{
if (boost::iequals(cmd, "SET_VELOCITY_LIMIT"))
if (ascii_iequals(cmd, "SET_VELOCITY_LIMIT"))
{
process_SET_VELOCITY_LIMIT(line);
return;
}
// ORCA: Add Pressure Advance visualization support
if (boost::iequals(cmd, "SET_PRESSURE_ADVANCE"))
if (ascii_iequals(cmd, "SET_PRESSURE_ADVANCE"))
{
process_SET_PRESSURE_ADVANCE(line);
return;
@@ -7551,51 +7554,72 @@ void GCodeProcessor::calculate_time(GCodeProcessorResult& result, size_t keep_la
actual_speed_moves = std::move(machine.actual_speed_moves);
}
// insert actual speed moves into the move list
unsigned int inserted_actual_speed_moves_count = 0;
std::vector<GCodeProcessorResult::MoveVertex> new_moves;
std::map<unsigned int, unsigned int> id_map;
for (auto it = actual_speed_moves.begin(); it != actual_speed_moves.end(); ++it) {
const unsigned int base_id = it->move_id + inserted_actual_speed_moves_count;
if (it->position.has_value()) {
// insert actual speed move into the move list
// clone from existing move
GCodeProcessorResult::MoveVertex new_move = result.moves[base_id];
// override modified parameters
new_move.time = { 0.0f, 0.0f };
new_move.position = *it->position;
new_move.actual_feedrate = it->actual_feedrate;
new_move.delta_extruder = *it->delta_extruder;
new_move.feedrate = *it->feedrate;
new_move.width = *it->width;
new_move.height = *it->height;
new_move.mm3_per_mm = *it->mm3_per_mm;
new_move.fan_speed = *it->fan_speed;
new_move.temperature = *it->temperature;
new_move.internal_only = true;
new_moves.push_back(new_move);
// actual_speed_moves holds, per block in move order, the moves to insert before the block's move and then an
// entry without a position for that move; positioned entries after the last such entry are dropped.
std::vector<GCodeProcessorResult::MoveVertex>& moves = result.moves;
size_t inserted_actual_speed_moves_count = 0;
size_t kept = 0;
size_t group_start = 0;
for (size_t i = 0; i < actual_speed_moves.size(); ++i) {
if (actual_speed_moves[i].position.has_value())
continue;
const unsigned int move_id = actual_speed_moves[i].move_id;
// A VG1 block has no move of its own, so its id can fall behind the previous block's or point past the list.
if (move_id < moves.size() && (kept == 0 || move_id > actual_speed_moves[kept - 1].move_id)) {
inserted_actual_speed_moves_count += i - group_start;
moves[move_id].actual_feedrate = actual_speed_moves[i].actual_feedrate;
// A seam vertex right after a block's move shares its actual speed.
if (move_id + 1 < moves.size() && moves[move_id + 1].type == EMoveType::Seam)
moves[move_id + 1].actual_feedrate = actual_speed_moves[i].actual_feedrate;
for (size_t j = group_start; j <= i; ++j, ++kept)
if (kept != j)
actual_speed_moves[kept] = std::move(actual_speed_moves[j]);
}
else {
result.moves.insert(result.moves.begin() + base_id, new_moves.begin(), new_moves.end());
id_map[it->move_id] = base_id + new_moves.size();
// update move actual speed
result.moves[base_id + new_moves.size()].actual_feedrate = it->actual_feedrate;
inserted_actual_speed_moves_count += new_moves.size();
// synchronize seams actual speed
if (base_id + new_moves.size() + 1 < result.moves.size()) {
GCodeProcessorResult::MoveVertex& move = result.moves[base_id + new_moves.size() + 1];
if (move.type == EMoveType::Seam)
move.actual_feedrate = it->actual_feedrate;
}
new_moves.clear();
group_start = i + 1;
}
actual_speed_moves.erase(actual_speed_moves.begin() + kept, actual_speed_moves.end());
// Walks the blocks back to front, so each shifted move is moved once, into its final slot.
size_t read = moves.size(); // one past the last move not yet placed
moves.resize(moves.size() + inserted_actual_speed_moves_count);
size_t write = moves.size(); // one past the last free slot
m_actual_speed_id_map.clear();
size_t entry = actual_speed_moves.size();
while (entry > 0) {
const unsigned int block_id = actual_speed_moves[--entry].move_id;
assert(block_id < read);
while (read > block_id + 1)
moves[--write] = moves[--read];
const GCodeProcessorResult::MoveVertex block_move = moves[--read];
moves[--write] = block_move;
m_actual_speed_id_map.emplace_back(block_id, (unsigned int)write);
for (; entry > 0 && actual_speed_moves[entry - 1].position.has_value(); --entry) {
const TimeMachine::ActualSpeedMove& it = actual_speed_moves[entry - 1];
GCodeProcessorResult::MoveVertex new_move = block_move;
new_move.time = { 0.0f, 0.0f };
new_move.position = *it.position;
new_move.actual_feedrate = it.actual_feedrate;
new_move.delta_extruder = *it.delta_extruder;
new_move.feedrate = *it.feedrate;
new_move.width = *it.width;
new_move.height = *it.height;
new_move.mm3_per_mm = *it.mm3_per_mm;
new_move.fan_speed = *it.fan_speed;
new_move.temperature = *it.temperature;
new_move.internal_only = true;
moves[--write] = new_move;
}
}
assert(read == write);
// synchronize blocks' move_ids with after moves for actual speed insertion
std::reverse(m_actual_speed_id_map.begin(), m_actual_speed_id_map.end());
for (size_t i = 0; i < static_cast<size_t>(PrintEstimatedStatistics::ETimeMode::Count); ++i) {
for (GCodeProcessor::TimeBlock& block : m_time_processor.machines[i].blocks) {
auto it = id_map.find(block.move_id);
block.move_id = (it != id_map.end()) ? it->second : block.move_id + inserted_actual_speed_moves_count;
auto it = std::lower_bound(m_actual_speed_id_map.begin(), m_actual_speed_id_map.end(), block.move_id,
[](const std::pair<unsigned int, unsigned int>& entry, unsigned int id) { return entry.first < id; });
block.move_id = (it != m_actual_speed_id_map.end() && it->first == block.move_id) ?
it->second : block.move_id + (unsigned int)inserted_actual_speed_moves_count;
}
}
}
+47 -41
View File
@@ -337,54 +337,57 @@ class Print;
//BBS: add mutex for protection of gcode result
mutable std::mutex result_mutex;
GCodeProcessorResult& operator=(const GCodeProcessorResult &other)
GCodeProcessorResult& operator=(const GCodeProcessorResult &other) { assign(other); return *this; }
// Declared because the user-declared copy assignment suppresses the implicit move.
GCodeProcessorResult& operator=(GCodeProcessorResult &&other) { assign(std::move(other)); return *this; }
// Add a new member here, or neither assignment transfers it.
template<class Other> void assign(Other &&other)
{
filename = other.filename;
id = other.id;
moves = other.moves;
lines_ends = other.lines_ends;
printable_area = other.printable_area;
bed_exclude_area = other.bed_exclude_area;
wrapping_exclude_area = other.wrapping_exclude_area;
toolpath_outside = other.toolpath_outside;
label_object_enabled = other.label_object_enabled;
long_retraction_when_cut = other.long_retraction_when_cut;
timelapse_warning_code = other.timelapse_warning_code;
printable_height = other.printable_height;
settings_ids = other.settings_ids;
filaments_count = other.filaments_count;
extruder_colors = other.extruder_colors;
filament_diameters = other.filament_diameters;
filament_densities = other.filament_densities;
filament_costs = other.filament_costs;
print_statistics = other.print_statistics;
custom_gcode_per_print_z = other.custom_gcode_per_print_z;
spiral_vase_mode = other.spiral_vase_mode;
warnings = other.warnings;
bed_type = other.bed_type;
gcode_check_result = other.gcode_check_result;
limit_filament_maps = other.limit_filament_maps;
filament_printable_reuslt = other.filament_printable_reuslt;
filename = std::forward<Other>(other).filename;
id = std::forward<Other>(other).id;
moves = std::forward<Other>(other).moves;
lines_ends = std::forward<Other>(other).lines_ends;
printable_area = std::forward<Other>(other).printable_area;
bed_exclude_area = std::forward<Other>(other).bed_exclude_area;
wrapping_exclude_area = std::forward<Other>(other).wrapping_exclude_area;
toolpath_outside = std::forward<Other>(other).toolpath_outside;
label_object_enabled = std::forward<Other>(other).label_object_enabled;
long_retraction_when_cut = std::forward<Other>(other).long_retraction_when_cut;
timelapse_warning_code = std::forward<Other>(other).timelapse_warning_code;
printable_height = std::forward<Other>(other).printable_height;
settings_ids = std::forward<Other>(other).settings_ids;
filaments_count = std::forward<Other>(other).filaments_count;
extruder_colors = std::forward<Other>(other).extruder_colors;
filament_diameters = std::forward<Other>(other).filament_diameters;
filament_densities = std::forward<Other>(other).filament_densities;
filament_costs = std::forward<Other>(other).filament_costs;
print_statistics = std::forward<Other>(other).print_statistics;
custom_gcode_per_print_z = std::forward<Other>(other).custom_gcode_per_print_z;
spiral_vase_mode = std::forward<Other>(other).spiral_vase_mode;
warnings = std::forward<Other>(other).warnings;
bed_type = std::forward<Other>(other).bed_type;
gcode_check_result = std::forward<Other>(other).gcode_check_result;
limit_filament_maps = std::forward<Other>(other).limit_filament_maps;
filament_printable_reuslt = std::forward<Other>(other).filament_printable_reuslt;
// Orca: copy the shared grouping result so a copied result keeps it (shared_ptr =>
// memory-safe), rather than leaving a stale pointer on the target. No g-code effect either way.
nozzle_group_result = other.nozzle_group_result;
nozzle_group_result = std::forward<Other>(other).nozzle_group_result;
// Keep the per-extruder hotend types on a copied result (injector input).
extruder_types = other.extruder_types;
printer_extruder_variant = other.printer_extruder_variant;
printer_extruder_id = other.printer_extruder_id;
layer_filaments = other.layer_filaments;
filament_change_sequence = other.filament_change_sequence;
used_mixed_filaments = other.used_mixed_filaments;
nozzle_change_sequence = other.nozzle_change_sequence;
optimal_assignment = other.optimal_assignment;
filament_change_count_map = other.filament_change_count_map;
extruder_types = std::forward<Other>(other).extruder_types;
printer_extruder_variant = std::forward<Other>(other).printer_extruder_variant;
printer_extruder_id = std::forward<Other>(other).printer_extruder_id;
layer_filaments = std::forward<Other>(other).layer_filaments;
filament_change_sequence = std::forward<Other>(other).filament_change_sequence;
used_mixed_filaments = std::forward<Other>(other).used_mixed_filaments;
nozzle_change_sequence = std::forward<Other>(other).nozzle_change_sequence;
optimal_assignment = std::forward<Other>(other).optimal_assignment;
filament_change_count_map = std::forward<Other>(other).filament_change_count_map;
// Keep the SKIPPABLE per-type time on a copied result.
skippable_part_time = other.skippable_part_time;
initial_layer_time = other.initial_layer_time;
skippable_part_time = std::forward<Other>(other).skippable_part_time;
initial_layer_time = std::forward<Other>(other).initial_layer_time;
#if ENABLE_GCODE_VIEWER_STATISTICS
time = other.time;
time = std::forward<Other>(other).time;
#endif
return *this;
}
void lock() const { result_mutex.lock(); }
void unlock() const { result_mutex.unlock(); }
@@ -1216,6 +1219,9 @@ class Print;
EProducer m_producer;
TimeProcessor m_time_processor;
// calculate_time()'s map from each block's move id to its index after the actual speed moves are inserted,
// a member to reuse its capacity.
std::vector<std::pair<unsigned int, unsigned int>> m_actual_speed_id_map;
UsedFilaments m_used_filaments;
Print* m_print{ nullptr };
+15 -16
View File
@@ -465,32 +465,31 @@ std::string GCodeWriter::set_accel_and_jerk(unsigned int acceleration, double je
if (EXTRUDER_LIMIT(m_max_acceleration) > 0 && acceleration > EXTRUDER_LIMIT(m_max_acceleration))
acceleration = EXTRUDER_LIMIT(m_max_acceleration);
bool is_empty = true;
std::ostringstream gcode;
gcode << "SET_VELOCITY_LIMIT";
if (acceleration != 0 && acceleration != m_last_acceleration) {
gcode << " ACCEL=" << acceleration;
if (this->config.accel_to_decel_enable) {
gcode << " ACCEL_TO_DECEL=" << acceleration * this->config.accel_to_decel_factor / 100;
}
m_last_acceleration = acceleration;
is_empty = false;
}
// Clamp the jerk to the allowed maximum.
if (EXTRUDER_LIMIT(m_max_jerk_x) > 0 && jerk > EXTRUDER_LIMIT(m_max_jerk_x))
jerk = EXTRUDER_LIMIT(m_max_jerk_x);
if (EXTRUDER_LIMIT(m_max_jerk_y) > 0 && jerk > EXTRUDER_LIMIT(m_max_jerk_y))
jerk = EXTRUDER_LIMIT(m_max_jerk_y);
if (jerk > 0.01 && !is_approx(jerk, m_last_jerk)) {
const bool set_acceleration = acceleration != 0 && acceleration != m_last_acceleration;
const bool set_jerk = jerk > 0.01 && !is_approx(jerk, m_last_jerk);
if (!set_acceleration && !set_jerk)
return std::string();
std::ostringstream gcode;
gcode << "SET_VELOCITY_LIMIT";
if (set_acceleration) {
gcode << " ACCEL=" << acceleration;
if (this->config.accel_to_decel_enable) {
gcode << " ACCEL_TO_DECEL=" << acceleration * this->config.accel_to_decel_factor / 100;
}
m_last_acceleration = acceleration;
}
if (set_jerk) {
gcode << " SQUARE_CORNER_VELOCITY=" << jerk;
m_last_jerk = jerk;
is_empty = false;
}
if(is_empty)
return std::string();
if (GCodeWriter::full_gcode_comment)
gcode << " ; adjust VELOCITY_LIMIT(accel/jerk)";
gcode << "\n";
+13
View File
@@ -84,6 +84,19 @@ double string_to_double_decimal_point(const std::string_view str, size_t* pos /*
return out;
}
double atof_decimal_point(std::string_view str)
{
size_t i = 0;
while (i < str.size() && (str[i] == ' ' || (str[i] >= '\t' && str[i] <= '\r')))
++i;
if (i < str.size() && str[i] == '+') {
++i;
if (i < str.size() && str[i] == '-')
return 0.;
}
return string_to_double_decimal_point(str.substr(i));
}
std::string float_to_string_decimal_point(double value, int precision/* = -1*/)
{
// Our Windows build server fully supports C++17 std::to_chars. Let's use it.
+3
View File
@@ -48,6 +48,9 @@ bool is_decimal_separator_point();
std::string float_to_string_decimal_point(double value, int precision = -1);
//std::string float_to_string_decimal_point(float value, int precision = -1);
double string_to_double_decimal_point(const std::string_view str, size_t* pos = nullptr);
// Parses like atof in the C locale, skipping leading whitespace and a '+',
// without the C runtime's per-call locale lookup.
double atof_decimal_point(std::string_view str);
} // namespace Slic3r
+13 -11
View File
@@ -626,13 +626,15 @@ const PresetBundle *PresetBundle::load_source_vendor(const boost::filesystem::pa
return it->second.get();
// The library loads with no base of its own, so the tree a vendor inherits from
// is the same one that resolves the library's own presets.
const std::string library_file = std::string(ORCA_FILAMENT_LIBRARY);
const PresetBundle *library = nullptr;
if (vendor_id != ORCA_FILAMENT_LIBRARY &&
(boost::filesystem::is_regular_file(root_dir / (library_file + ".json")) ||
(allow_cache && boost::filesystem::is_regular_file(root_dir / (library_file + ".opc"))))) {
library = load_source_vendor(root_dir, ORCA_FILAMENT_LIBRARY, compatibility_rule, error, allow_cache);
// is the same one that resolves the library's own presets. It is only a base, so
// it comes from its cache whenever that is all that is installed, even when the
// vendor itself is parsed (a vendor updated over the air).
const std::string library_file = std::string(ORCA_FILAMENT_LIBRARY);
const bool library_json = boost::filesystem::is_regular_file(root_dir / (library_file + ".json"));
const bool library_cache_only = !library_json && boost::filesystem::is_regular_file(root_dir / (library_file + ".opc"));
const PresetBundle *library = nullptr;
if (vendor_id != ORCA_FILAMENT_LIBRARY && (library_json || library_cache_only)) {
library = load_source_vendor(root_dir, ORCA_FILAMENT_LIBRARY, compatibility_rule, error, allow_cache || library_cache_only);
if (library == nullptr) {
error = "OrcaFilamentLibrary contains invalid presets";
return nullptr;
@@ -2576,7 +2578,7 @@ void PresetBundle::clear_printer_hold_aliases()
//BBS: add json related logic, load system presets from json
std::pair<PresetsConfigSubstitutions, std::string> PresetBundle::load_system_presets_from_json(
ForwardCompatibilitySubstitutionRule compatibility_rule, bool allow_cache)
ForwardCompatibilitySubstitutionRule compatibility_rule, bool write_caches)
{
//BBS: add config related logs
BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(" enter, compatibility_rule %1%")%compatibility_rule;
@@ -2596,14 +2598,14 @@ std::pair<PresetsConfigSubstitutions, std::string> PresetBundle::load_system_pre
// The vendors below are loaded whole and against each other — the filament
// library first, then every other vendor with it as the base — so each parse
// is complete enough to be worth caching.
m_generate_vendor_caches = allow_cache && (m_generate_vendor_caches || !validation_mode);
m_generate_vendor_caches = write_caches && (m_generate_vendor_caches || !validation_mode);
// Sorted, so any duplicate-preset warning comes out in the same order on every run.
std::vector<VendorSource> vendors;
for (const std::string& name : vendor_names_in(dir))
if (name == ORCA_FILAMENT_LIBRARY || !(validation_mode && !vendor_to_validate.empty() && name != vendor_to_validate))
vendors.push_back({ name, dir });
auto result = this->load_vendors(vendors, compatibility_rule, allow_cache);
auto result = this->load_vendors(vendors, compatibility_rule, true);
this->update_system_maps();
@@ -5310,7 +5312,7 @@ static void apply_receiver_mix_relocations(DynamicPrintConfig&
//convert the old filament preset to new one after split
static void convert_filament_preset_name(std::string& machine_name, std::string& filament_name)
void PresetBundle::convert_filament_preset_name(const std::string& machine_name, std::string& filament_name)
{
auto machine_iter = filament_preset_convert.find(machine_name);
if (machine_iter != filament_preset_convert.end())
+6 -1
View File
@@ -247,6 +247,9 @@ public:
// Keys a project keeps when its presets are loaded: those listed in its escaped
// "different_settings_to_system" entry for the preset, plus the preset bookkeeping keys.
static std::set<std::string> project_different_keys(const std::string &different_settings);
// A project filament saved under a name the current presets split per nozzle (e.g. H2D 0.6) is loaded from
// the preset that now holds its values.
static void convert_filament_preset_name(const std::string& machine_name, std::string& filament_name);
PresetBundle();
PresetBundle(const PresetBundle &rhs);
@@ -848,7 +851,9 @@ private:
//std::pair<PresetsConfigSubstitutions, std::string> load_system_presets(ForwardCompatibilitySubstitutionRule compatibility_rule);
//BBS: add json related logic
std::pair<PresetsConfigSubstitutions, std::string> load_system_presets_from_json(ForwardCompatibilitySubstitutionRule compatibility_rule, bool allow_cache = true);
// Reads each vendor from its preset cache where one covers the profile, as every load does.
// write_caches = false keeps a read-only load from writing caches into the data directory.
std::pair<PresetsConfigSubstitutions, std::string> load_system_presets_from_json(ForwardCompatibilitySubstitutionRule compatibility_rule, bool write_caches = true);
// Update the multicolor information for filaments.
void update_filament_multi_color();
// Update renamed_from and alias maps of system profiles.
+15 -13
View File
@@ -11513,12 +11513,18 @@ void DynamicPrintConfig::update_non_diff_values_to_base_config(DynamicPrintConfi
int cur_variant_count = cur_extruder_variants.size();
int target_variant_count = target_extruder_variants.size();
// A base variant this config does not list (the base gained it after the config was saved, or the
// config lists none) takes this config's first variant of the same extruder, as a user preset's
// values do in update_diff_values_to_child_config. Left unmatched, the base's value would silently
// replace the user's.
variant_index.resize(target_variant_count, -1);
if (cur_variant_count == 0) {
// Defensive: target_variant_count may be 0 if the preset doesn't carry extruder_variant_name.
// In that case keep variant_index empty and let the downstream size checks produce a useful error.
if (!variant_index.empty())
variant_index[0] = 0;
// This config's one value belongs to the extruder of the base's first variant.
variant_index = map_variant_indices(target_extruder_variants, target_extruder_ids, {},
target_extruder_ids.empty() ? std::vector<int>() : std::vector<int>{target_extruder_ids[0]});
}
else if ((cur_extruder_ids.size() > 0) && cur_variant_count != cur_extruder_ids.size()){
//should not happen
@@ -11531,18 +11537,7 @@ void DynamicPrintConfig::update_non_diff_values_to_base_config(DynamicPrintConfi
%extruder_variant_name %target_variant_count %extruder_id_name %target_extruder_ids.size();
}
else {
for (int i = 0; i < target_variant_count; i++)
{
for (int j = 0; j < cur_variant_count; j++)
{
if ((target_extruder_variants[i] == cur_extruder_variants[j])
&&(target_extruder_ids.empty() || (target_extruder_ids[i] == cur_extruder_ids[j])))
{
variant_index[i] = j;
break;
}
}
}
variant_index = map_variant_indices(target_extruder_variants, target_extruder_ids, cur_extruder_variants, cur_extruder_ids);
}
for (auto& opt : keys) {
@@ -11567,6 +11562,13 @@ void DynamicPrintConfig::update_non_diff_values_to_base_config(DynamicPrintConfi
if (cur_variant_count > target_variant_count)
continue;
// The variant lists are the base's layout itself, which every other value is
// carried onto: a variant this config lacks keeps its own name and id.
if (opt == extruder_id_name || opt == extruder_variant_name) {
opt_src->set(opt_target);
continue;
}
int stride = 1;
if (key_set2.find(opt) != key_set2.end())
stride = 2;
+10
View File
@@ -25,6 +25,8 @@
#include <initializer_list>
#include <string_view>
#include <regex>
#include <string_view>
#include <algorithm>
#include <boost/system/error_code.hpp>
#include <boost/algorithm/string.hpp>
@@ -302,6 +304,14 @@ extern bool is_absolute_path_within_root(const boost::filesystem::path &path, co
// Anything unknown is not safe.
extern bool is_safe_to_open_file_name(const std::string &file_name);
// Case-insensitive compare against a fixed ASCII keyword, without boost::iequals, whose
// std::locale() takes a lock the whole process shares in the MSVC runtime.
inline bool ascii_iequals(std::string_view a, std::string_view b)
{
auto lower = [](char c) { return (c >= 'A' && c <= 'Z') ? char(c - 'A' + 'a') : c; };
return a.size() == b.size() && std::equal(a.begin(), a.end(), b.begin(), [&lower](char x, char y) { return lower(x) == lower(y); });
}
// Orca: custom protocal support utils
inline bool is_orca_open(const std::string& url) { return boost::starts_with(url, "orcaslicer://open"); }
inline bool is_prusaslicer_open(const std::string& url) { return boost::starts_with(url, "prusaslicer://open"); }
+1 -5
View File
@@ -782,12 +782,8 @@ set(SLIC3R_GUI_SOURCES
Utils/ICameraSignalingChannel.hpp
Utils/OrcaCloudServiceAgent.cpp
Utils/OrcaCloudServiceAgent.hpp
Utils/OrcaMqttConnection.cpp
Utils/OrcaMqttConnection.hpp
Utils/OrcaPrinterAgent.cpp
Utils/OrcaPrinterAgent.hpp
Utils/OrcaCloudSignalingChannel.cpp
Utils/OrcaCloudSignalingChannel.hpp
Utils/QidiPrinterAgent.cpp
Utils/QidiPrinterAgent.hpp
Utils/SnapmakerPrinterAgent.cpp
@@ -928,7 +924,7 @@ target_include_directories(libslic3r_gui PRIVATE Utils ${CMAKE_CURRENT_BINARY_DI
if (WIN32)
target_include_directories(libslic3r_gui SYSTEM PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../deps/WebView2/include)
target_link_libraries(libslic3r_gui Advapi32 Crypt32)
target_link_libraries(libslic3r_gui Advapi32)
endif()
source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SLIC3R_GUI_SOURCES})
+21 -36
View File
@@ -7,17 +7,16 @@
#include "json_diff.hpp"
#include "slic3r/GUI/DeviceManager.hpp"
#include "slic3r/GUI/I18N.hpp"
#include "libslic3r/libslic3r.h"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/DeviceCore/DevDefs.h"
#include "libslic3r/PrintConfig.hpp"
#include "libslic3r/CommonDefs.hpp"
#include "slic3r/GUI/DeviceCore/DevFirmware.h"
#include "slic3r/GUI/GUI_App.hpp"
#include <boost/log/trivial.hpp>
#include <wx/string.h>
#include <string>
#include "libslic3r/CommonDefs.hpp"
#include "slic3r/GUI/DeviceCore/DevFirmware.h"
#include <cassert>
#include <memory>
#include <map>
@@ -175,48 +174,34 @@ DevFirmwareVersionInfo DevNozzle::GetFirmwareInfo() const
int DevNozzle::GetLogicExtruderId() const
{
int total_ext_count = GetTotalExtruderCount();
if (GUI::wxGetApp().preset_bundle->is_bbl_vendor()) {
if (total_ext_count == 1) {
return LOGIC_UNIQUE_EXTRUDER_ID;
} else if (total_ext_count == 2) {
if (AtLeftExtruder()) {
return LOGIC_L_EXTRUDER_ID;
} else if (AtRightExtruder()) {
return LOGIC_R_EXTRUDER_ID;
}
}
assert(0);
if (total_ext_count == 1) {
return LOGIC_UNIQUE_EXTRUDER_ID;
} else if (total_ext_count == 2) {
if (AtLeftExtruder()) {
return LOGIC_L_EXTRUDER_ID;
} else if (AtRightExtruder()) {
return LOGIC_R_EXTRUDER_ID;
}
}
// For some reason, BBL's logical extruder ID is inverted:
// physical extruder id = 0 (MAIN_EXTRUDER_ID) vs logical extruder id = 1 (LOGIC_R_EXTRUDER_ID)
// Likely because logical id reads from left to right (left = 0, right = 1)
// For generic N extruders, this inversion does not apply.
if (IsOnRack()) return INVALID_EXTRUDER_ID;
return m_nozzle_id;
assert(0);
return LOGIC_UNIQUE_EXTRUDER_ID;
}
int DevNozzle::GetExtruderId() const
{
if (GUI::wxGetApp().preset_bundle->is_bbl_vendor()) {
int total_ext_count = GetTotalExtruderCount();
if (total_ext_count == 1) {
return MAIN_EXTRUDER_ID;
} else if (total_ext_count == 2) {
if (AtRightExtruder()) {
return MAIN_EXTRUDER_ID;
} else if (AtLeftExtruder()) {
return DEPUTY_EXTRUDER_ID;
}
}
int total_ext_count = GetTotalExtruderCount();
if (total_ext_count == 1) {
return MAIN_EXTRUDER_ID;
} else if (total_ext_count == 2) {
if (AtRightExtruder()) {
return MAIN_EXTRUDER_ID;
} else if (AtLeftExtruder()) {
return DEPUTY_EXTRUDER_ID;
}
}
return m_nozzle_id;
return MAIN_EXTRUDER_ID;
}
bool DevNozzle::AtLeftExtruder() const
+12 -17
View File
@@ -1272,24 +1272,19 @@ int MachineObject::get_bed_temperature_limit()
bool MachineObject::is_filament_installed()
{
// if (m_extder_system->GetTotalExtderCount() > 0) {
// // right//or single
// auto ext = m_extder_system->m_extders[MAIN_EXTRUDER_ID];
// if (ext.m_ext_has_filament) {
// return true;
// }
// }
// /*left*/
// if (m_extder_system->GetTotalExtderCount() > 1) {
// auto ext = m_extder_system->m_extders[DEPUTY_EXTRUDER_ID];
// if (ext.m_ext_has_filament) {
// return true;
// }
// }
for (auto& ext : m_extder_system->m_extders) {
if (ext.m_ext_has_filament)
if (m_extder_system->GetTotalExtderCount() > 0) {
// right//or single
auto ext = m_extder_system->m_extders[MAIN_EXTRUDER_ID];
if (ext.m_ext_has_filament) {
return true;
}
}
/*left*/
if (m_extder_system->GetTotalExtderCount() > 1) {
auto ext = m_extder_system->m_extders[DEPUTY_EXTRUDER_ID];
if (ext.m_ext_has_filament) {
return true;
}
}
return false;
}
File diff suppressed because it is too large Load Diff
+5 -28
View File
@@ -1,8 +1,6 @@
#ifndef slic3r_StatusPanel_hpp_
#define slic3r_StatusPanel_hpp_
#include <vector>
#include "libslic3r/ProjectTask.hpp"
#include "DeviceManager.hpp"
#include "MonitorPage.hpp"
@@ -11,6 +9,7 @@
#include "GUI.hpp"
#include "ThermalPreconditioningDialog.hpp"
#include <string>
#include <vector>
#include <set>
#include <utility>
#include <functional>
@@ -67,7 +66,6 @@
#include "StagedBuild.hpp"
class StepIndicator;
class wxChoice;
#define COMMAND_TIMEOUT 5
@@ -142,35 +140,28 @@ class ExtruderImage : public wxWindow
ScalableBitmap *m_left_extruder_active_empty;
ScalableBitmap *m_left_extruder_unactive_filled;
ScalableBitmap *m_left_extruder_unactive_empty;
ScalableBitmap *m_right_extruder_active_filled;
ScalableBitmap *m_right_extruder_active_empty;
ScalableBitmap *m_right_extruder_unactive_filled;
ScalableBitmap *m_right_extruder_unactive_empty;
ScalableBitmap *m_extruder_single_nozzle_empty_load;
ScalableBitmap *m_extruder_single_nozzle_empty_unload;
ScalableBitmap *m_extruder_single_nozzle_filled_load;
ScalableBitmap *m_extruder_single_nozzle_filled_unload;
ExtruderState m_left_ext_state = {ExtruderState::EMPTY_LOAD};
ExtruderState m_right_ext_state = {ExtruderState::EMPTY_LOAD};
ExtruderState m_single_ext_state = {ExtruderState::EMPTY_LOAD};
std::vector<ExtruderState> m_multi_extruder_states;
bool m_generic_nozzle_display{false};
public:
void update(int nozzle_num, int nozzle_id);
void update(ExtruderState single_state);
void update(ExtruderState right_state, ExtruderState left_state);
void update(ExtruderState state, int idx);
void msw_rescale();
void setExtruderCount(int nozzle_num);
void setGenericNozzleDisplay(bool enabled);
void setExtruderUsed(std::string loc);
void setExtruderUsed(int nozzle_idx);
void paintEvent(wxPaintEvent &evt);
void render(wxDC &dc);
@@ -504,8 +495,6 @@ protected:
std::vector<ExtruderImage *> m_extruderImage;
SwitchBoard * m_nozzle_btn_panel;
wxChoice* m_generic_nozzle_selector{nullptr};
int m_generic_nozzle_selector_count{0};
wxStaticText * m_text_tasklist_caption;
@@ -518,18 +507,10 @@ protected:
wxBoxSizer * m_misc_ctrl_sizer;
StaticBox* m_fan_panel;
StaticLine * m_line_nozzle;
wxWindowID m_nozzle_temp_control_id{wxID_ANY};
wxWindow* m_temp_nozzle_parent{nullptr};
wxBoxSizer* m_temp_nozzle_sizer{nullptr};
size_t m_temp_nozzle_active_count{0};
TempInput* m_tempCtrl_nozzle{nullptr};
TempInput* m_tempCtrl_nozzle;
int m_temp_nozzle_timeout{ 0 };
TempInput* m_tempCtrl_nozzle_deputy{nullptr};
TempInput* m_tempCtrl_nozzle_deputy;
int m_temp_nozzle_deputy_timeout{ 0 };
std::vector<TempInput*> m_tempCtrl_nozzles;
std::vector<int> m_temp_nozzle_timeouts;
TempInput * m_tempCtrl_bed;
int m_temp_bed_timeout {0};
TempInput * m_tempCtrl_chamber;
@@ -645,9 +626,6 @@ public:
wxBoxSizer *create_temp_axis_group(wxWindow *parent);
wxBoxSizer *create_temp_control(wxWindow *parent);
TempInput* create_nozzle_temp_control(wxWindow *parent, wxWindowID id);
void set_temp_input_colors(TempInput* temp_ctrl);
void ensure_nozzle_temp_controls(size_t count);
wxBoxSizer *create_misc_control(wxWindow *parent);
wxBoxSizer *create_axis_control(wxWindow *parent);
wxPanel *create_bed_control(wxWindow *parent);
@@ -678,7 +656,6 @@ private:
friend class MonitorPanel;
void wire_controls();
bool load_thumbnail_from_url(const wxString &url, MachineObject *obj);
void sync_nozzle_temp_controls(size_t count);
protected:
std::shared_ptr<SliceInfoPopup> m_slice_info_popup;
+1
View File
@@ -19,6 +19,7 @@
#include <memory>
#include <exception>
#include "slic3r/GUI/Event.hpp"
#include "slic3r/GUI/Plater.hpp"
#include <set>
#include <ostream>
#include <iterator>
+3 -10
View File
@@ -863,21 +863,14 @@ void MultiNozzleStatusTable::UpdateRackInfo(std::weak_ptr<DevNozzleRack> rack)
bool has_right = false;
for (auto& elem : nozzles_in_extruder) {
auto& nozzle = elem.second;
int extruder_id{};
if (wxGetApp().preset_bundle->is_bbl_vendor()) {
extruder_id = nozzle.AtLeftExtruder() ? 0 : 1;
if (nozzle.AtRightExtruder())
has_right = true;
}
else
extruder_id = nozzle.GetExtruderId();
int extruder_id = nozzle.AtLeftExtruder() ? 0 : 1;
if (nozzle.AtRightExtruder())
has_right = true;
NozzleVolumeType volume_type = DevNozzle::ToNozzleVolumeType(nozzle.m_nozzle_flow);
m_badge->SetExtruderInfo(extruder_id, format_diameter_to_str(nozzle.GetNozzleDiameter()), volume_type);
}
// TODO: Update for N extruders
m_badge->SetExtruderValid(has_right);
}
}
-3
View File
@@ -185,9 +185,6 @@ void TempInput::SetFinish()
wxCommandEvent event(wxCUSTOMEVT_SET_TEMP_FINISH);
event.SetInt(temp_type);
event.SetString(wxString::Format("%d", m_input_type));
// N-extruder temp controls all share TEMP_OF_NORMAL_TYPE, so the string payload above can't
// tell them apart; carry widget identity so the listener can find which one fired.
event.SetEventObject(this);
wxPostEvent(this->GetParent(), event);
}
+9 -19
View File
@@ -2,9 +2,9 @@
#include "BBLNetworkPlugin.hpp"
#include "IPrinterAgent.hpp"
#include "NetworkAgentFactory.hpp"
#include "NetworkAgent.hpp"
#include "bambu_networking.hpp"
#include "libslic3r/Utils.hpp"
#include "NetworkAgent.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/DeviceCore/DevManager.h"
@@ -14,6 +14,8 @@
#include <exception>
#include <functional>
#include <nlohmann/json.hpp>
#include <cmath>
#include <slic3r/GUI/DeviceManager.hpp>
#include <string>
#include <utility>
#include <vector>
@@ -22,8 +24,6 @@ using json = nlohmann::json;
#include <type_traits>
#include <unordered_map>
#include <memory>
#include <cmath>
#include <slic3r/GUI/DeviceManager.hpp>
namespace Slic3r {
@@ -203,9 +203,8 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
int dir = input_val > 0 ? 1 : -1;
// i3-arch printers move the bed for Y/Z, so the on-screen direction is
// reversed -- same negation the g-code fallback below applies.
if (!is_core_xy && (axis == "Y" || axis == "Z")) {
if (!is_core_xy && (axis == "Y" || axis == "Z"))
dir = -dir;
}
j["print"]["command"] = "xyz_ctrl";
j["print"]["axis"] = axis;
@@ -215,9 +214,8 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
}
double value = input_val;
if (!is_core_xy && (axis == "Y" || axis == "Z")) {
value = -1.0 * input_val;
}
if (!is_core_xy && (axis == "Y" || axis == "Z"))
value = -input_val;
std::string value_str = (boost::format("%.1f") % (value * unit)).str();
std::string gcode;
@@ -238,11 +236,10 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
int BBLPrinterAgent::publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode)
{
const int rtn = lan_mode ? send_message_to_printer(dev_id, j.dump(), 0, 0) : send_message(dev_id, j.dump(), 0, 0);
if (rtn == 0) {
if (rtn == 0)
BOOST_LOG_TRIVIAL(info) << "publish_json: " << j.dump() << " code: " << rtn;
} else {
else
BOOST_LOG_TRIVIAL(error) << "publish_json: " << j.dump() << " code: " << rtn;
}
return rtn;
}
@@ -588,15 +585,8 @@ int BBLPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateS
int BBLPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
int result = dispatch_start<func_start_send_gcode_to_sdcard_legacy, func_start_send_gcode_to_sdcard_0203>(
return dispatch_start<func_start_send_gcode_to_sdcard_legacy, func_start_send_gcode_to_sdcard_0203>(
BBLNetworkPlugin::instance().get_start_send_gcode_to_sdcard(), params, update_fn, cancel_fn, wait_fn);
if (result != 0) {
BOOST_LOG_TRIVIAL(error) << "start_send_gcode_to_sdcard failed: result=" << result
<< ", try_emmc_print=" << params.try_emmc_print
<< ", legacy_mode=" << BBLNetworkPlugin::instance().use_legacy_network()
<< ", dev_ip=" << params.dev_ip << ", dev_id=" << params.dev_id;
}
return result;
}
int BBLPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
-1
View File
@@ -109,7 +109,6 @@ public:
static std::string from_orca_payload(std::string json_text);
private:
// why: the lan/cloud DECISION stays machine-side; keep this mechanical branch in sync with publish_json.
int publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode);
};
+1 -4
View File
@@ -289,11 +289,8 @@ bool CrealityPrintAgent::parse_cfs_response(const std::string& response,
return true;
}
bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/)
{
if (sync_mode != get_filament_sync_mode())
return false;
if (device_info.dev_ip.empty()) {
BOOST_LOG_TRIVIAL(warning)
<< "CrealityPrintAgent::fetch_filament_info: no device IP, falling back to base agent";
+1 -51
View File
@@ -31,19 +31,8 @@
#include <curl/curl.h>
#include <utility>
#include <openssl/err.h>
#include <openssl/ssl.h>
#ifdef OPENSSL_CERT_OVERRIDE
#include <openssl/x509.h>
#include <openssl/x509err.h>
#ifdef _WIN32
# ifndef NOMINMAX
# define NOMINMAX
# endif
# include <windows.h>
# include <wincrypt.h>
// wincrypt.h uses this token for a certificate-name property identifier.
# undef X509_NAME
#endif
namespace fs = boost::filesystem;
@@ -967,45 +956,6 @@ std::string Http::tls_system_cert_store()
return ret;
}
void Http::add_platform_root_certificates(SSL_CTX* ssl_context)
{
#ifdef _WIN32
X509_STORE* openssl_store = SSL_CTX_get_cert_store(ssl_context);
if (!openssl_store)
throw std::runtime_error("unable to get OpenSSL certificate store");
const auto load_store = [&](DWORD location) {
HCERTSTORE windows_store = CertOpenStore(CERT_STORE_PROV_SYSTEM_W, 0, 0,
location | CERT_STORE_OPEN_EXISTING_FLAG | CERT_STORE_READONLY_FLAG,
L"ROOT");
if (!windows_store)
return;
PCCERT_CONTEXT windows_certificate = nullptr;
while ((windows_certificate = CertEnumCertificatesInStore(windows_store, windows_certificate)) != nullptr) {
const unsigned char* encoded = windows_certificate->pbCertEncoded;
X509* certificate = d2i_X509(nullptr, &encoded, static_cast<long>(windows_certificate->cbCertEncoded));
if (!certificate) {
ERR_clear_error();
continue;
}
ERR_clear_error();
if (X509_STORE_add_cert(openssl_store, certificate) != 1)
ERR_clear_error();
X509_free(certificate);
}
CertCloseStore(windows_store, 0);
};
load_store(CERT_SYSTEM_STORE_CURRENT_USER);
load_store(CERT_SYSTEM_STORE_LOCAL_MACHINE);
#else
(void)ssl_context;
#endif
}
std::string Http::url_encode(const std::string &str)
{
::CURL *curl = ::curl_easy_init();
-6
View File
@@ -13,8 +13,6 @@
#include "libslic3r/Exception.hpp"
#include "libslic3r_version.h"
typedef struct ssl_ctx_st SSL_CTX;
#define MAX_SIZE_TO_FILE 3*1024
namespace Slic3r {
@@ -202,10 +200,6 @@ public:
// Return empty string on success or error message on fail.
static std::string tls_global_init();
static std::string tls_system_cert_store();
// Add platform root certificates to a standalone OpenSSL context. This
// supplements set_default_verify_paths() on platforms where OpenSSL does
// not use the native certificate store.
static void add_platform_root_certificates(SSL_CTX* ssl_context);
// converts the given string to an url_encoded_string
static std::string url_encode(const std::string &str);
File diff suppressed because it is too large Load Diff
+20 -128
View File
@@ -5,6 +5,7 @@
#include "ICloudServiceAgent.hpp"
#include "bambu_networking.hpp"
#include <functional>
#include <cstdint>
#include <atomic>
#include <memory>
@@ -12,49 +13,12 @@
#include <set>
#include <string>
#include <thread>
#include <chrono>
#include <condition_variable>
#include <deque>
#include <functional>
#include <nlohmann/json.hpp>
#include <vector>
namespace Slic3r {
class Http;
bool moonraker_is_light_name(const std::string& name);
class MoonrakerWebsocket
{
public:
enum class ReadResult
{
message,
timeout,
closed,
error,
};
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);
void tls_handshake(const std::string& host);
void handshake(const std::string& host, const std::string& target);
void text(bool enabled);
void write(const std::string& body);
ReadResult read(std::string& payload, std::string& error_message);
void close();
void expires_after(std::chrono::seconds timeout);
void abort();
private:
struct Impl;
std::unique_ptr<Impl> m_impl;
};
class MoonrakerPrinterAgent : public IPrinterAgent
{
public:
@@ -100,20 +64,12 @@ public:
int set_on_local_connect_fn(OnLocalConnectedFn fn) override;
int set_on_local_message_fn(OnMessageFn fn) override;
int set_queue_on_main_fn(QueueOnMainFn fn) override;
// Pull-mode agent (on-demand filament sync)
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; }
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
CameraStreamMode get_camera_stream_mode() const override;
std::string get_camera_url() const override;
protected:
struct 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;
@@ -125,9 +81,7 @@ protected:
std::string dev_name;
std::string version;
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
@@ -145,21 +99,12 @@ 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 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);
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;
// State access for derived classes
mutable std::recursive_mutex state_mutex;
// Counts detached fetch_filament_info() background threads currently touching `this`
// (see QidiPrinterAgent::fetch_filament_info). Those threads hold a raw `this` with no
// other lifetime protection, so the destructor waits for this to reach 0 before any part
// of the object is torn down — see ~MoonrakerPrinterAgent().
std::atomic<int> filament_fetch_in_flight{0};
// Helpers
bool is_numeric(const std::string& value);
std::string normalize_base_url(bool use_ssl, const std::string& host, const std::string& port);
@@ -172,29 +117,13 @@ protected:
// Map filament type to OrcaFilamentLibrary preset ID for AMS sync compatibility
static std::string map_filament_type_to_generic_id(const std::string& filament_type);
// 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 ConnectionSettings& connection) const;
bool post_print_action(const std::string& action) const;
bool post_print_action(const std::string& action,
const ConnectionSettings& connection) const;
bool send_ws_rpc(const std::string& method, const nlohmann::json& params);
virtual void on_status_loop_tick(const std::string& dev_id) {}
// Queue work that may use agent state. The command worker is joined during
// destruction, so queued commands cannot outlive the agent.
void enqueue_command(std::function<void()> fn);
private:
int handle_request(const std::string& dev_id, const std::string& json_str);
int send_version_info(const std::string& dev_id);
int send_access_code(const std::string& dev_id);
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 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 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;
@@ -203,11 +132,10 @@ 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, ConnectionSettings connection);
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
void stop_status_stream();
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 run_status_stream(std::string dev_id, std::string base_url, std::string api_key);
void handle_ws_message(const std::string& dev_id, const std::string& payload);
void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const;
@@ -218,28 +146,22 @@ private:
// File upload
bool upload_gcode(const std::string& local_path, const std::string& filename,
const ConnectionSettings& connection,
const std::string& base_url, const std::string& api_key,
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 ConnectionSettings& connection,
const std::string& filename, std::string& error_msg) const;
// JSON-RPC helper
bool send_jsonrpc_command(const std::string& base_url, const std::string& api_key,
const nlohmann::json& request, std::string& response) const;
// Connection thread management
void perform_connection_async(const std::string& dev_id,
ConnectionSettings connection,
const std::string& base_url,
const std::string& api_key,
uint64_t generation);
// why: a printer with no /server/webcams/list entry can still name its stream directly;
// subclasses (e.g. printers with a fixed webcam path) can override this instead.
virtual std::string webcam_stream_override(const std::string& base_url) const { return {}; }
void refresh_webcam_info() const;
bool fetch_webcam_info(const ConnectionSettings& connection, uint64_t generation) const;
// System-specific filament fetch methods
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);
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);
// JSON helper methods
static std::string safe_json_string(const nlohmann::json& obj, const char* key);
@@ -265,38 +187,15 @@ private:
mutable std::recursive_mutex payload_mutex;
nlohmann::json status_cache;
// note: guarded by payload_mutex; filled by refresh_thumbnail_url(), empty url = looked up, none found
std::string thumbnail_filename;
std::string thumbnail_url;
mutable std::string webcam_stream_url;
mutable CameraStreamMode webcam_stream_mode = CameraStreamMode::none;
mutable uint64_t webcam_info_last_lookup_ms = 0;
mutable uint64_t webcam_info_generation = 0;
unsigned thumbnail_lookup_attempts = 0;
static constexpr uint64_t WEBCAM_INFO_REFRESH_INTERVAL_MS = 1000;
std::atomic<int> next_jsonrpc_id{1};
std::set<std::string> available_objects; // Track for feature detection
bool assumed_light_on = false;
std::atomic<bool> ws_stop{false};
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
std::atomic<uint64_t> ws_last_emit_ms{0};
std::thread ws_thread;
// stop_status_stream() invokes ws_abort_io to wake a blocked synchronous
// ws.read()/ws.write()/handshake in run_status_stream(): ws_stop is only
// observed between reads, and Beast's expires_after() does not bound
// synchronous operations.
std::mutex ws_abort_mutex;
std::function<void()> ws_abort_io; // guarded by ws_abort_mutex
// AMS/filament refresh cadence, independent of telemetry dispatch so a steady
// stream of status updates can't starve it (ws_last_emit_ms is reset by those).
static constexpr uint64_t AMS_REFRESH_INTERVAL_MS = 10000;
std::atomic<uint64_t> ams_last_fetch_ms{0};
// Throttling configuration for WebSocket updates
// Critical changes (state transitions) dispatch immediately; telemetry is throttled
static constexpr uint64_t STATUS_UPDATE_INTERVAL_MS = 1000; // 1 update/sec for telemetry
@@ -306,14 +205,7 @@ private:
// Connection thread management
std::atomic<uint64_t> connect_generation{0};
std::thread connect_thread;
mutable std::recursive_mutex connect_mutex;
void run_command_worker();
std::thread cmd_thread;
std::deque<std::function<void()>> cmd_queue;
std::mutex cmd_mutex;
std::condition_variable cmd_cv;
bool cmd_stop = false;
std::recursive_mutex connect_mutex;
};
} // namespace Slic3r
+12 -296
View File
@@ -1,8 +1,6 @@
#include "OrcaCloudServiceAgent.hpp"
#include "CloudProvider.hpp"
#include "Http.hpp"
#include "ICameraSignalingChannel.hpp"
#include "OrcaCloudSignalingChannel.hpp"
#include "bambu_networking.hpp"
#include "ICloudServiceAgent.hpp"
#include "libslic3r/Utils.hpp"
@@ -14,8 +12,6 @@
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/beast/core/detail/base64.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/websocket.hpp>
#include <boost/filesystem.hpp>
#include <boost/filesystem/operations.hpp>
#include <boost/log/trivial.hpp>
@@ -41,18 +37,14 @@
#include <openssl/hmac.h>
#include <openssl/rand.h>
#include <openssl/sha.h>
#include <openssl/ssl.h>
#include <algorithm>
#include <cctype>
#include <condition_variable>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <iomanip>
#include <optional>
#include <random>
#include <set>
#include <sstream>
#include <string>
@@ -520,7 +512,6 @@ OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir)
, api_base_url(ORCA_DEFAULT_API_URL)
, auth_base_url(ORCA_DEFAULT_AUTH_URL)
, cloud_base_url(ORCA_DEFAULT_CLOUD_URL)
, mqtt_connection(std::make_unique<OrcaMqttConnection>())
{
auth_headers["apikey"] = ORCA_DEFAULT_PUB_KEY;
pkce_bundle.loopback_port = choose_loopback_port();
@@ -531,8 +522,6 @@ OrcaCloudServiceAgent::OrcaCloudServiceAgent(std::string log_dir)
OrcaCloudServiceAgent::~OrcaCloudServiceAgent()
{
if (mqtt_connection)
mqtt_connection->stop();
if (refresh_thread.joinable()) {
refresh_thread.join();
}
@@ -975,45 +964,22 @@ bool OrcaCloudServiceAgent::ensure_token_fresh(const std::string& reason) { retu
int OrcaCloudServiceAgent::connect_server()
{
const bool logged_in = is_user_login();
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::connect_server logged_in=" << logged_in
<< " api_base_url=" << api_base_url;
if (!logged_in) {
if (mqtt_connection)
mqtt_connection->stop();
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
is_connected = false;
}
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: connect_server requires a logged-in user";
invoke_server_connected_callback(-1, 401);
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
std::string response;
unsigned int http_code = 0;
int result = http_get(ORCA_HEALTH_PATH, &response, &http_code);
bool connected = (result == BAMBU_NETWORK_SUCCESS && http_code >= 200 && http_code < 300);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: cloud health result=" << result << " http_code=" << http_code
<< " connected=" << connected << " response_bytes=" << response.size();
// connect_server() remains a REST health probe. The long-lived fleet MQTT socket
// is started lazily by set_user_selected_machine -> configure_selected_printer_mqtt;
// subscriptions queued before that point are replayed when it starts.
{
std::lock_guard<std::recursive_mutex> lock(state_mutex);
is_connected = connected;
}
invoke_server_connected_callback(connected ? 0 : -1, http_code);
return connected ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
}
bool OrcaCloudServiceAgent::is_server_connected()
{
// The REST health probe is the signal; the per-printer MQTT socket does not gate
// whole-cloud connectivity (one printer reconnecting must not report the whole
// cloud as lost).
std::lock_guard<std::recursive_mutex> lock(state_mutex);
return is_connected;
}
@@ -1034,238 +1000,13 @@ int OrcaCloudServiceAgent::stop_subscribe(std::string module)
int OrcaCloudServiceAgent::add_subscribe(std::vector<std::string> dev_list)
{
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::add_subscribe count=" << dev_list.size()
<< " logged_in=" << is_user_login() << " mqtt_connection=" << (mqtt_connection ? "set" : "null");
if (!is_user_login() || !mqtt_connection) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: add_subscribe rejected because cloud is not ready";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
bool queued = true;
for (const std::string& dev_id : dev_list)
queued = mqtt_connection->subscribe(dev_id) && queued;
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: add_subscribe queued=" << queued;
return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
(void) dev_list;
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::del_subscribe(std::vector<std::string> dev_list)
{
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: OrcaCloudServiceAgent::del_subscribe count=" << dev_list.size()
<< " logged_in=" << is_user_login() << " mqtt_connection=" << (mqtt_connection ? "set" : "null");
if (!is_user_login() || !mqtt_connection) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: del_subscribe rejected because cloud is not ready";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
bool queued = true;
for (const std::string& dev_id : dev_list)
queued = mqtt_connection->unsubscribe(dev_id) && queued;
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: del_subscribe queued=" << queued;
return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
int OrcaCloudServiceAgent::configure_selected_printer_mqtt(const std::string& dev_id,
OrcaMqttConnection::StateHandler state_handler)
{
(void) dev_id;
if (!ensure_token_fresh("configure_selected_printer_mqtt"))
{
BOOST_LOG_TRIVIAL(warning) << "ensure_token_fresh returned false";
return BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
}
OrcaMqttConnection::Config cfg;
cfg.url = "wss://" + api_base_url + "/api/v1/printers/mqtt";
cfg.use_tls = true;
cfg.bearer_provider = [this] { return get_access_token(); };
cfg.client_id = "OrcaSlicer";
cfg.keepalive_seconds = 300;
{
std::lock_guard<std::mutex> lock(m_selected_url_mutex);
m_selected_printer_mqtt_url = cfg.url;
}
if (mqtt_connection->is_running()) {
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: fleet MQTT connection already running";
return BAMBU_NETWORK_SUCCESS;
}
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: configuring fleet MQTT endpoint=" << cfg.url;
// NOTE: no lock is held across start() — it blocks for the whole initial connect
// attempt (up to ~10s), and the message handler below re-enters callback_mutex on
// the MQTT worker thread.
const bool ok = mqtt_connection->start(
cfg,
[this](const std::string& id, const std::string& payload) { deliver_cloud_message(id, payload); },
std::move(state_handler));
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: fleet MQTT start returned=" << ok;
return ok ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
}
void OrcaCloudServiceAgent::teardown_selected_printer_mqtt()
{
if (mqtt_connection) {
mqtt_connection->stop();
// The connection object is reused for the next printer; drop this printer's
// report topic so its 1:1 socket does not re-subscribe the previous device.
mqtt_connection->clear_subscriptions();
}
std::lock_guard<std::mutex> lock(m_selected_url_mutex);
m_selected_printer_mqtt_url.clear();
}
std::string OrcaCloudServiceAgent::selected_printer_mqtt_url() const
{
std::lock_guard<std::mutex> lock(m_selected_url_mutex);
return m_selected_printer_mqtt_url;
}
void OrcaCloudServiceAgent::deliver_cloud_message(const std::string& dev_id, const std::string& payload)
{
OnMessageFn callback;
{
std::lock_guard<std::mutex> lock(callback_mutex);
callback = printer_status_callback;
}
if (callback)
callback(dev_id, payload);
}
int OrcaCloudServiceAgent::set_printer_status_callback(OnMessageFn fn)
{
std::lock_guard<std::mutex> lock(callback_mutex);
printer_status_callback = std::move(fn);
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: printer status callback=" << (printer_status_callback ? "set" : "clear");
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::send_printer_command(const std::string& dev_id, const std::string& body)
{
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: send_printer_command dev_id=" << dev_id
<< " body_bytes=" << body.size() << " logged_in=" << is_user_login();
if (dev_id.empty() || !is_user_login()) {
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: send_printer_command rejected";
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
}
const std::string path = std::string(ORCA_CLOUD_PRINTER) + "/" + dev_id + "/commands";
std::string response;
unsigned int http_code = 0;
int result = http_post(path, body, &response, &http_code);
BOOST_LOG_TRIVIAL(info) << "OrcaCloudServiceAgent: command dev=" << dev_id
<< " http=" << http_code << " result=" << result
<< " response_bytes=" << response.size();
return (result == BAMBU_NETWORK_SUCCESS && http_code >= 200 && http_code < 300)
? BAMBU_NETWORK_SUCCESS
: BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
int OrcaCloudServiceAgent::upload_gcode_via_cloud(const std::string& dev_id,
const std::string& local_gcode_path,
std::string* job_id,
OnUpdateStatusFn update_fn,
WasCancelledFn cancel_fn)
{
if (dev_id.empty() || local_gcode_path.empty() || !is_user_login())
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
if (cancel_fn && cancel_fn())
return BAMBU_NETWORK_ERR_CANCELED;
// Step 1: POST print-jobs/uploads -> a short-lived presigned R2 PUT URL. No
// metadata rides this request; filename/start are only relevant to the HTTP
// .../start finalize route, which this MQTT-driven flow does not call.
const std::string uploads_path = std::string(ORCA_CLOUD_PRINTER) + "/" + Http::url_encode(dev_id) + "/print-jobs/uploads";
std::string response;
unsigned int http_code = 0;
int result = http_post(uploads_path, "{}", &response, &http_code);
if (result != BAMBU_NETWORK_SUCCESS || http_code < 200 || http_code >= 300) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: print-jobs/uploads failed http_code=" << http_code;
return BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
std::string upload_job_id;
std::string upload_url;
try {
const nlohmann::json j = nlohmann::json::parse(response);
upload_job_id = j.value("job_id", "");
upload_url = j.value("upload_url", "");
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: failed to parse print-jobs/uploads response: " << e.what();
return BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
if (upload_job_id.empty() || upload_url.empty()) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: print-jobs/uploads response missing job_id/upload_url";
return BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
if (cancel_fn && cancel_fn())
return BAMBU_NETWORK_ERR_CANCELED;
// Step 2: PUT the G-code straight to R2 with the one-time URL from step 1. This
// is a scoped, PUT-only, short-TTL capability with no bearer token of its own,
// so it bypasses http_put (which always prefixes api_base_url and attaches the
// cloud session's Authorization header - neither belongs on an R2 PUT).
bool canceled = false;
unsigned put_status = 0;
std::string put_error;
Http::put(upload_url)
.tls_verify(true)
.header("Content-Type", "text/x.gcode")
.set_put_body(boost::filesystem::path(local_gcode_path))
.timeout_connect(5)
.timeout_max(300) // large G-code over a slow link
.on_progress([&](Http::Progress progress, bool& cancel) {
if (cancel_fn && cancel_fn()) {
cancel = true;
canceled = true;
return;
}
if (update_fn && progress.ultotal > 0) {
const int percent = static_cast<int>((progress.ulnow * 100) / progress.ultotal);
update_fn(PrintingStageUpload, percent, "Uploading...");
}
})
.on_complete([&](std::string, unsigned status) { put_status = status; })
.on_error([&](std::string, std::string err, unsigned status) {
put_status = status;
put_error = std::move(err);
})
.perform_sync();
if (canceled)
return BAMBU_NETWORK_ERR_CANCELED;
if (put_status < 200 || put_status >= 300) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: R2 upload failed status=" << put_status << " error=" << put_error;
return BAMBU_NETWORK_ERR_PRINT_SG_UPLOAD_FTP_FAILED;
}
if (job_id)
*job_id = std::move(upload_job_id);
return BAMBU_NETWORK_SUCCESS;
}
int OrcaCloudServiceAgent::start_cloud_print_job(const std::string& dev_id,
const std::string& job_id,
const std::string& filename,
bool start)
{
if (dev_id.empty() || job_id.empty() || !is_user_login())
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
nlohmann::json body;
if (!filename.empty())
body["filename"] = filename;
body["start"] = start;
const std::string path = std::string(ORCA_CLOUD_PRINTER) + "/" + Http::url_encode(dev_id) + "/print-jobs/" +
Http::url_encode(job_id) + "/start";
std::string response;
unsigned int http_code = 0;
const int result = http_post(path, body.dump(), &response, &http_code);
if (result != BAMBU_NETWORK_SUCCESS || http_code < 200 || http_code >= 300) {
BOOST_LOG_TRIVIAL(warning) << "OrcaCloudServiceAgent: print-jobs/" << job_id << "/start failed http_code=" << http_code;
return BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
(void) dev_list;
return BAMBU_NETWORK_SUCCESS;
}
@@ -2300,10 +2041,6 @@ bool OrcaCloudServiceAgent::set_user_session(const json& session_json, bool noti
void OrcaCloudServiceAgent::clear_session(bool all_backends)
{
if (mqtt_connection) {
mqtt_connection->stop();
mqtt_connection->clear_subscriptions();
}
{
std::lock_guard<std::mutex> lock(session_mutex);
session = SessionInfo{};
@@ -2419,11 +2156,7 @@ int OrcaCloudServiceAgent::http_get(const std::string& path, std::string* respon
return (res.success && !suppress) ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECT_FAILED;
}
int OrcaCloudServiceAgent::http_post(const std::string& path,
const std::string& body,
std::string* response_body,
unsigned int* http_code,
const std::string& content_type)
int OrcaCloudServiceAgent::http_post(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code)
{
std::string url = api_base_url + path;
BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: POST " << url;
@@ -2447,7 +2180,7 @@ int OrcaCloudServiceAgent::http_post(const std::string& path,
http.header("Authorization", "Bearer " + token);
}
http.header("Content-Type", content_type);
http.header("Content-Type", "application/json");
http.set_post_body(body);
http.on_complete([&](std::string resp_body, unsigned resp_status) {
@@ -2914,28 +2647,19 @@ int OrcaCloudServiceAgent::get_user_print_info(unsigned int* http_code, std::str
if (http_code)
*http_code = code;
if (result != 0 || code != 200) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: get_user_print_info failed - http_code=" << code << ", response=" << response;
if (result != 0 || code != 200)
return result != 0 ? result : BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED;
}
BOOST_LOG_TRIVIAL(trace) << "OrcaCloudServiceAgent: get_user_print_info fetched - http_code=" << code << ", response=" << response;
try {
auto resp_json = nlohmann::json::parse(response);
nlohmann::json devices = nlohmann::json::array();
for (const auto& printer : resp_json.value("data", nlohmann::json::array())) {
nlohmann::json device;
std::string role = printer.value("access_role", "");
// A printer with the role "view" only has monitoring access for orca cloud.
// The printer is owned by a different person and was shared to the current user without
// any permission to control the printer so we discard this printer. Comment this out if
// OrcaSlicer wants to support view only printers.
const std::string role = printer.value("access_role", "");
if (role.empty() || role == "viewer")
continue;
nlohmann::json device;
device["dev_id"] = printer.value("id", "");
device["dev_name"] = printer.value("name", "");
if (printer.contains("model") && printer["model"].is_string())
@@ -2949,20 +2673,15 @@ int OrcaCloudServiceAgent::get_user_print_info(unsigned int* http_code, std::str
device["task_status"] = status["job"].value("state", "");
}
device["dev_online"] = online;
devices.push_back(device);
devices.push_back(std::move(device));
}
if (http_body) {
nlohmann::json out;
out["devices"] = devices;
out["devices"] = std::move(devices);
*http_body = out.dump();
}
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_user_print_info parsed - device_count=" << devices.size()
<< ", devices=" << devices.dump();
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "OrcaCloudServiceAgent: get_user_print_info parse exception - " << e.what();
} catch (const std::exception&) {
return BAMBU_NETWORK_ERR_GET_SETTING_LIST_FAILED;
}
@@ -3047,9 +2766,6 @@ int OrcaCloudServiceAgent::get_camera_url(std::string dev_id, std::function<void
return BAMBU_NETWORK_SUCCESS;
}
std::unique_ptr<ICameraSignalingChannel> OrcaCloudServiceAgent::create_camera_signaling_channel(const std::string& dev_id)
{ return std::make_unique<OrcaCloudSignalingChannel>(this, dev_id); }
int OrcaCloudServiceAgent::get_design_staffpick(int offset, int limit, std::function<void(std::string)> callback)
{
BOOST_LOG_TRIVIAL(debug) << "OrcaCloudServiceAgent: get_design_staffpick (stub)";
+2 -58
View File
@@ -5,12 +5,6 @@
#include "ICameraSignalingChannel.hpp"
#include "ICloudServiceAgent.hpp"
#include "bambu_networking.hpp"
#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/websocket.hpp>
#include <cstdlib>
#include "libslic3r/ProjectTask.hpp"
#include <string>
@@ -19,9 +13,6 @@
#include <atomic>
#include <chrono>
#include <functional>
#include <condition_variable>
#include <cstdint>
#include <set>
#include <memory>
#include <thread>
#include <unordered_map>
@@ -29,8 +20,6 @@
#include <vector>
#include <nlohmann/json.hpp>
#include "OrcaMqttConnection.hpp"
class wxSecretStore;
namespace Slic3r {
@@ -167,8 +156,6 @@ 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);
@@ -224,19 +211,6 @@ public:
int add_subscribe(std::vector<std::string> dev_list) override;
int del_subscribe(std::vector<std::string> dev_list) override;
void enable_multi_machine(bool enable) override;
int set_printer_status_callback(OnMessageFn fn);
int send_printer_command(const std::string& dev_id, const std::string& body);
int upload_gcode_via_cloud(const std::string& dev_id,
const std::string& local_gcode_path,
std::string* job_id,
OnUpdateStatusFn update_fn,
WasCancelledFn cancel_fn);
int start_cloud_print_job(const std::string& dev_id,
const std::string& job_id,
const std::string& filename,
bool start = true);
// ========================================================================
// ICloudServiceAgent Interface Implementation - Settings Synchronization
@@ -272,7 +246,7 @@ public:
// ICloudServiceAgent Interface Implementation - Model Mall & Publishing
// ========================================================================
int get_camera_url(std::string dev_id, std::function<void(std::string)> callback) override;
std::unique_ptr<ICameraSignalingChannel> create_camera_signaling_channel(const std::string& dev_id) override;
// std::unique_ptr<ICameraSignalingChannel> create_camera_signaling_channel(const std::string& dev_id) override;
int get_design_staffpick(int offset, int limit, std::function<void(std::string)> callback) override;
int start_publish(PublishParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, std::string* out) override;
int get_model_publish_url(std::string* url) override;
@@ -378,29 +352,7 @@ public:
static std::string generate_uuid_for_setting_id(const std::string& name, const std::string& user_id = "");
OrcaMqttConnection* get_mqtt_connection() noexcept {
return mqtt_connection.get();
}
const OrcaMqttConnection* get_mqtt_connection() const noexcept {
return mqtt_connection.get();
}
// Account-scoped cloud socket: wss://<api_base_url>/api/v1/printers/mqtt.
// configure_ blocks for the duration of the initial connect attempt, so callers
// drive it off the UI thread; teardown_ is synchronous. The dev_id argument is
// retained for source compatibility with the printer-agent lifecycle; it does
// not participate in endpoint construction.
int configure_selected_printer_mqtt(const std::string& dev_id,
OrcaMqttConnection::StateHandler state_handler = {});
void teardown_selected_printer_mqtt();
// Test hook: the wss:// URL of the current fleet socket ("" when none).
std::string selected_printer_mqtt_url() const;
private:
// Fans one inbound fleet MQTT message out to printer_status_callback.
void deliver_cloud_message(const std::string& dev_id, const std::string& payload);
// Sync protocol helpers
int sync_pull(
std::function<void(const SyncPullResponse&)> on_success,
@@ -424,11 +376,7 @@ private:
// HTTP request helpers
int http_get(const std::string& path, std::string* response_body, unsigned int* http_code);
int http_post(const std::string& path,
const std::string& body,
std::string* response_body,
unsigned int* http_code,
const std::string& content_type = "application/json");
int http_post(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code);
int http_put(const std::string& path, const std::string& body, std::string* response_body, unsigned int* http_code);
int http_delete(const std::string& path, std::string* response_body, unsigned int* http_code);
std::map<std::string, std::string> data_headers();
@@ -486,9 +434,6 @@ private:
std::chrono::system_clock::now().time_since_epoch()).count()};
// Member variables - connection state
std::unique_ptr<OrcaMqttConnection> mqtt_connection;
std::string m_selected_printer_mqtt_url; // guarded by m_selected_url_mutex
mutable std::mutex m_selected_url_mutex;
bool is_connected{false};
bool enable_track{false};
bool multi_machine_enabled{false};
@@ -502,7 +447,6 @@ private:
AppOnHttpErrorFn on_http_error_fn;
GetCountryCodeFn get_country_code_fn;
QueueOnMainFn queue_on_main_fn;
OnMessageFn printer_status_callback;
mutable std::mutex callback_mutex;
// Thread safety
@@ -1,337 +0,0 @@
#include "OrcaCloudSignalingChannel.hpp"
#include "Http.hpp"
#include <boost/asio/connect.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/post.hpp>
#include <boost/beast/core.hpp>
#include <boost/log/trivial.hpp>
#include <nlohmann/json.hpp>
#include <openssl/ssl.h>
#include <cctype>
#include <iomanip>
#include <sstream>
#include <stdexcept>
namespace Slic3r {
OrcaCloudSignalingChannel::OrcaCloudSignalingChannel(OrcaCloudServiceAgent* cloud, std::string dev_id)
: m_cloud(cloud)
, m_dev_id(std::move(dev_id))
{
}
OrcaCloudSignalingChannel::~OrcaCloudSignalingChannel()
{
close();
}
void OrcaCloudSignalingChannel::open()
{
bool expected = false;
if (!m_open.compare_exchange_strong(expected, true))
return;
m_stop.store(false);
m_thread = std::thread([this] { run(); });
}
void OrcaCloudSignalingChannel::close()
{
m_stop.store(true);
std::shared_ptr<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.
boost::asio::post(conn->io_context, [conn] {
boost::system::error_code ec;
boost::beast::get_lowest_layer(conn->websocket).cancel(ec);
boost::beast::get_lowest_layer(conn->websocket).close(ec);
});
// Pre-run() phase (still in the synchronous connect/handshake): best-effort
// direct interruption.
boost::system::error_code ec;
boost::beast::get_lowest_layer(conn->websocket).cancel(ec);
boost::beast::get_lowest_layer(conn->websocket).close(ec);
}
if (m_thread.joinable())
m_thread.join();
m_open.store(false);
}
void OrcaCloudSignalingChannel::send_offer(std::string sdp)
{
send_json(nlohmann::json{{"type", "webrtc.offer"}, {"sdp", std::move(sdp)}}.dump());
}
void OrcaCloudSignalingChannel::send_ice(std::string candidate, std::string mid)
{
send_json(nlohmann::json{{"type", "webrtc.ice"},
{"candidate", std::move(candidate)},
{"sdpMid", std::move(mid)}}
.dump());
}
std::string OrcaCloudSignalingChannel::encode_path_component(const std::string& value)
{
std::ostringstream encoded;
encoded << std::uppercase << std::hex;
for (unsigned char c : value) {
if (std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~')
encoded << c;
else
encoded << '%' << std::setw(2) << std::setfill('0') << static_cast<unsigned int>(c);
}
return encoded.str();
}
void OrcaCloudSignalingChannel::unavailable(CameraUnavailableReason reason, std::string detail)
{
if (on_unavailable)
on_unavailable(reason, std::move(detail));
}
void OrcaCloudSignalingChannel::run()
{
try {
if (!m_cloud || !m_cloud->ensure_token_fresh("camera")) {
unavailable(CameraUnavailableReason::Error, "Unable to refresh OrcaCloud credentials");
m_open.store(false);
return;
}
const std::string token = m_cloud->get_access_token();
// 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);
return;
}
const std::string live_token_url =
"https://" + host + "/api/v1/printers/" + encode_path_component(m_dev_id) + "/live-token";
BOOST_LOG_TRIVIAL(info) << "signaling: POST " << live_token_url << " (dev_id=" << m_dev_id << ")";
nlohmann::json token_response;
std::string token_body;
unsigned int http_code = 0;
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)
.timeout_max(30)
.on_complete([&token_body, &http_code](std::string body, unsigned status) {
http_code = status;
token_body = std::move(body);
})
.on_error([&http_code](std::string, std::string, unsigned status) {
http_code = status;
});
{
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&) {
}
if (http_code < 200 || http_code >= 300 || !token_response.contains("token")) {
unavailable(CameraUnavailableReason::Error, "Unable to mint camera live token");
m_open.store(false);
return;
}
std::vector<CameraIceServer> ice_servers;
if (token_response.contains("ice_servers") && token_response["ice_servers"].is_array()) {
for (const auto& entry : token_response["ice_servers"]) {
if (entry.is_string()) {
ice_servers.push_back({entry.get<std::string>(), {}, {}});
} else if (entry.is_object()) {
// RTCIceServer.urls is "string | string[]" (Cloudflare
// Realtime returns an array). Emit one CameraIceServer per
// URL, sharing the credentials.
const std::string username = entry.value("username", std::string{});
const std::string credential = entry.value("credential", std::string{});
const auto add_url = [&](const nlohmann::json& url) {
if (url.is_string() && !url.get<std::string>().empty())
ice_servers.push_back({url.get<std::string>(), username, credential});
};
const auto urls = entry.find("urls");
if (urls != entry.end()) {
if (urls->is_array()) {
for (const auto& url : *urls)
add_url(url);
} else {
add_url(*urls);
}
}
}
}
}
auto conn = std::make_shared<Connection>();
conn->ssl_context.set_default_verify_paths();
Http::add_platform_root_certificates(conn->ssl_context.native_handle());
{
std::lock_guard<std::mutex> lock(m_mutex);
m_conn = conn;
}
auto& websocket = conn->websocket;
boost::asio::ip::tcp::resolver resolver(conn->io_context);
const auto endpoints = resolver.resolve(host, "443");
boost::asio::connect(boost::beast::get_lowest_layer(websocket), endpoints);
if (!SSL_set_tlsext_host_name(websocket.next_layer().native_handle(), host.c_str()))
throw std::runtime_error("Unable to configure TLS server name");
websocket.next_layer().set_verify_mode(boost::asio::ssl::verify_peer);
websocket.next_layer().set_verify_callback(boost::asio::ssl::host_name_verification(host));
websocket.next_layer().handshake(boost::asio::ssl::stream_base::client);
const std::string ws_target = "/api/v1/printers/" + encode_path_component(m_dev_id) +
"/camera/live?token=" +
encode_path_component(token_response["token"].get<std::string>());
websocket.handshake(host, ws_target);
BOOST_LOG_TRIVIAL(info) << "signaling: websocket handshake ok (" << ice_servers.size()
<< " ice servers)";
if (on_ready)
on_ready(std::move(ice_servers));
send_json(nlohmann::json{{"type", "camera.mode"}, {"mode", "webrtc"}}.dump());
// Async read loop, driven by the connection's own io_context. run()
// returns once close() has shut the socket down, giving a bounded,
// deadlock-free teardown from any thread.
do_read(conn);
conn->io_context.run();
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "signaling: run() exception: " << e.what();
if (!m_stop.load())
unavailable(CameraUnavailableReason::Closed, e.what());
}
{
std::lock_guard<std::mutex> lock(m_mutex);
m_conn.reset();
}
m_open.store(false);
}
void OrcaCloudSignalingChannel::do_read(std::shared_ptr<Connection> conn)
{
auto buffer = std::make_shared<boost::beast::flat_buffer>();
conn->websocket.async_read(
*buffer, [this, conn, buffer](boost::system::error_code ec, std::size_t) {
if (ec) {
if (!m_stop.load())
unavailable(CameraUnavailableReason::Closed, ec.message());
return; // do not re-arm; io_context.run() unwinds
}
const std::string raw = boost::beast::buffers_to_string(buffer->data());
// A malformed or unexpectedly-shaped message must not tear down the
// session: parse/dispatch is guarded.
try {
dispatch_message(nlohmann::json::parse(raw), raw);
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(warning) << "signaling: ignoring malformed message: " << e.what()
<< " raw=" << raw.substr(0, 256);
}
if (!m_stop.load())
do_read(conn);
});
}
// Returns the string at key, or "" if absent or not a string (JSON null included).
static std::string json_string(const nlohmann::json& object, const char* key)
{
const auto it = object.find(key);
return (it != object.end() && it->is_string()) ? it->get<std::string>() : std::string{};
}
void OrcaCloudSignalingChannel::dispatch_message(const nlohmann::json& message, const std::string& raw)
{
const std::string type = json_string(message, "type");
BOOST_LOG_TRIVIAL(info) << "signaling: recv type=" << type << " raw=" << raw.substr(0, 256);
if (type == "webrtc.answer") {
const std::string sdp = json_string(message, "sdp");
if (on_answer && !sdp.empty())
on_answer(sdp);
} else if (type == "webrtc.ice" && message.contains("candidate")) {
// The peer may send "candidate" as a flat string or as a nested
// RTCIceCandidateInit object { candidate, sdpMid, sdpMLineIndex }.
const nlohmann::json& candidate = message["candidate"];
std::string sdp_candidate;
std::string mid = json_string(message, "sdpMid");
if (candidate.is_string()) {
sdp_candidate = candidate.get<std::string>();
} else if (candidate.is_object()) {
sdp_candidate = json_string(candidate, "candidate");
std::string nested_mid = json_string(candidate, "sdpMid");
if (!nested_mid.empty())
mid = std::move(nested_mid);
}
if (on_ice && !sdp_candidate.empty())
on_ice(sdp_candidate, mid);
} else if (type == "webrtc.unavailable") {
const std::string reason = json_string(message, "reason");
unavailable(reason == "busy" ? CameraUnavailableReason::Busy
: reason == "disabled" ? CameraUnavailableReason::Disabled
: CameraUnavailableReason::Error,
reason.empty() ? "error" : reason);
}
}
void OrcaCloudSignalingChannel::send_json(const std::string& message)
{
std::shared_ptr<Connection> conn;
{
std::lock_guard<std::mutex> lock(m_mutex);
conn = m_conn;
}
if (!conn || m_stop.load())
return;
// Serialize the write onto the io_context thread (same thread that runs
// async_read), so reads and writes never touch the stream concurrently.
auto payload = std::make_shared<std::string>(message);
boost::asio::post(conn->io_context, [this, conn, payload] {
if (m_stop.load())
return;
boost::system::error_code ec;
conn->websocket.write(boost::asio::buffer(*payload), ec);
if (ec && !m_stop.load())
unavailable(CameraUnavailableReason::Closed, ec.message());
});
}
} // namespace Slic3r
@@ -1,66 +0,0 @@
#pragma once
#include "ICameraSignalingChannel.hpp"
#include "OrcaCloudServiceAgent.hpp"
#include "Http.hpp"
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/websocket.hpp>
#include <nlohmann/json_fwd.hpp>
#include <atomic>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
namespace Slic3r {
class OrcaCloudSignalingChannel : public ICameraSignalingChannel {
public:
OrcaCloudSignalingChannel(OrcaCloudServiceAgent* cloud, std::string dev_id);
~OrcaCloudSignalingChannel() override;
void open() override;
void close() override;
void send_offer(std::string sdp) override;
void send_ice(std::string candidate, std::string mid) override;
private:
using WebSocket = boost::beast::websocket::stream<
boost::beast::ssl_stream<boost::asio::ip::tcp::socket>>;
// The io_context and ssl_context must outlive the websocket stream that
// references them. Bundling them here with the stream declared last makes
// the destruction order correct (stream first, then contexts), and lets a
// single shared_ptr own the whole set.
struct Connection {
boost::asio::io_context io_context;
boost::asio::ssl::context ssl_context{boost::asio::ssl::context::tls_client};
WebSocket websocket{io_context, ssl_context};
};
void run();
void do_read(std::shared_ptr<Connection> conn);
void dispatch_message(const nlohmann::json& message, const std::string& raw);
void send_json(const std::string& message);
void unavailable(CameraUnavailableReason reason, std::string detail);
static std::string encode_path_component(const std::string& value);
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};
std::thread m_thread;
mutable std::mutex m_mutex;
std::shared_ptr<Connection> m_conn;
};
} // namespace Slic3r
-805
View File
@@ -1,805 +0,0 @@
#include "OrcaMqttConnection.hpp"
#include "Http.hpp"
#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/websocket.hpp>
#include <openssl/ssl.h>
#include <algorithm>
#include <chrono>
#include <memory>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <utility>
namespace Slic3r {
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;
std::optional<PlainWebSocket> ws;
Connection()
: ssl_context(boost::asio::ssl::context::tls_client)
, resolver(io_context)
, keepalive_timer(io_context)
{}
};
namespace {
// Apply / clear a tcp_stream timeout on whichever websocket is engaged.
// Templated on the connection type only because Connection is a private nested
// type: a deduced parameter needs no (inaccessible) name for it.
template<class Conn> void expires_after(Conn& conn, std::chrono::seconds timeout) {
if (conn.wss) boost::beast::get_lowest_layer(*conn.wss).expires_after(timeout);
else if (conn.ws) boost::beast::get_lowest_layer(*conn.ws).expires_after(timeout);
}
template<class Conn> void expires_never(Conn& conn) {
if (conn.wss) boost::beast::get_lowest_layer(*conn.wss).expires_never();
else if (conn.ws) boost::beast::get_lowest_layer(*conn.ws).expires_never();
}
} // namespace
OrcaMqttConnection::~OrcaMqttConnection() { stop(); }
bool OrcaMqttConnection::start(const Config& config, MessageHandler on_message, StateHandler on_state) {
std::lock_guard<std::recursive_mutex> lifecycle_lock(lifecycle_mutex);
stop();
{
std::lock_guard<std::mutex> lock(mutex);
current_config = config;
this->on_message = std::move(on_message);
this->on_state = std::move(on_state);
initial_result = false;
initial_completed = false;
connected = false;
m_last_connack_rc.store(-1);
}
stopping.store(false);
worker = std::thread(&OrcaMqttConnection::run, this);
std::unique_lock<std::mutex> lock(mutex);
if (!initial_cv.wait_for(lock, std::chrono::seconds(10), [this] { return initial_completed; })) {
initial_completed = true;
initial_result = false;
}
return initial_result;
}
void OrcaMqttConnection::stop() {
std::lock_guard<std::recursive_mutex> lifecycle_lock(lifecycle_mutex);
stopping.store(true);
state_cv.notify_all();
{
std::lock_guard<std::mutex> lock(connection_mutex);
if (active_connection) {
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())
worker.join();
{
std::lock_guard<std::mutex> lock(mutex);
connected = false;
acknowledged_subscriptions.clear();
pending_subscribe_packets.clear();
pending_requests.clear();
if (!initial_completed) {
initial_completed = true;
initial_result = false;
}
}
initial_cv.notify_all();
}
bool OrcaMqttConnection::is_running() const {
return worker.joinable() && !stopping.load();
}
void OrcaMqttConnection::flush_subscription_change() {
std::shared_ptr<Connection> conn;
{
std::lock_guard<std::mutex> lock(connection_mutex);
conn = active_connection;
}
bool connacked;
{
std::lock_guard<std::mutex> lock(mutex);
connacked = connected;
}
if (!conn || !connacked)
return; // no live MQTT session yet — the worker sends the set on CONNACK
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) {
if (dev_id.empty()) {
return false;
}
const std::string topic = report_topic(dev_id);
if (topic.size() > 96) { // MQTT topic filter cap enforced by the service
return false;
}
{
std::lock_guard<std::mutex> lock(mutex);
if (subscriptions.count(topic) != 0 && pending_unsubscriptions.count(topic) == 0) {
return true;
}
subscriptions.insert(topic);
pending_unsubscriptions.erase(topic);
pending_subscriptions.insert(topic);
}
state_cv.notify_all();
flush_subscription_change(); // ask the worker to emit SUBSCRIBE now (no reconnect)
return true;
}
bool OrcaMqttConnection::unsubscribe(const std::string& dev_id) {
const std::string topic = report_topic(dev_id);
{
std::lock_guard<std::mutex> lock(mutex);
subscriptions.erase(topic);
acknowledged_subscriptions.erase(topic);
pending_subscriptions.erase(topic);
pending_unsubscriptions.insert(topic);
for (auto it = pending_subscribe_packets.begin(); it != pending_subscribe_packets.end();) {
if (it->second == topic)
it = pending_subscribe_packets.erase(it);
else
++it;
}
for (auto it = pending_requests.begin(); it != pending_requests.end();) {
if (it->first == dev_id)
it = pending_requests.erase(it);
else
++it;
}
}
state_cv.notify_all();
flush_subscription_change(); // ask the worker to emit UNSUBSCRIBE now (no reconnect)
return true;
}
void OrcaMqttConnection::clear_subscriptions() {
std::lock_guard<std::mutex> lock(mutex);
subscriptions.clear();
pending_subscriptions.clear();
pending_unsubscriptions.clear();
acknowledged_subscriptions.clear();
pending_subscribe_packets.clear();
pending_requests.clear();
}
bool OrcaMqttConnection::parse_endpoint(const std::string& url, Endpoint& endpoint) {
std::string rest;
std::string default_port;
if (url.rfind("wss://", 0) == 0) { rest = url.substr(6); default_port = "443"; }
else if (url.rfind("ws://", 0) == 0) { rest = url.substr(5); default_port = "80"; }
else return false;
const auto slash = rest.find('/');
const std::string authority = rest.substr(0, slash);
endpoint.target = (slash == std::string::npos) ? "/" : rest.substr(slash);
// host[:port] — leave an unbracketed IPv6 literal alone
const auto colon = authority.rfind(':');
if (colon != std::string::npos && authority.find(']') == std::string::npos) {
endpoint.host = authority.substr(0, colon);
endpoint.port = authority.substr(colon + 1);
} else {
endpoint.host = authority;
endpoint.port = default_port;
}
return !endpoint.host.empty() && !endpoint.port.empty() && !endpoint.target.empty();
}
void OrcaMqttConnection::append_string(std::vector<uint8_t>& packet, const std::string& value) {
if (value.size() > 0xffff)
throw std::runtime_error("MQTT string is too long");
packet.push_back(static_cast<uint8_t>(value.size() >> 8));
packet.push_back(static_cast<uint8_t>(value.size() & 0xff));
packet.insert(packet.end(), value.begin(), value.end());
}
void OrcaMqttConnection::prepend_remaining_length(std::vector<uint8_t>& packet, size_t length) {
std::vector<uint8_t> encoded;
do {
uint8_t byte = static_cast<uint8_t>(length % 128);
length /= 128;
if (length != 0)
byte |= 0x80;
encoded.push_back(byte);
} while (length != 0);
packet.insert(packet.begin() + 1, encoded.begin(), encoded.end());
}
std::vector<uint8_t> OrcaMqttConnection::make_connect_packet(
const std::string& client_id, const std::string& username,
const std::string& password, int keepalive_seconds) {
std::vector<uint8_t> packet{0x10};
append_string(packet, "MQTT");
packet.push_back(4); // protocol level 3.1.1
uint8_t flags = 0x02; // clean session
if (!username.empty()) { flags |= 0x80; if (!password.empty()) flags |= 0x40; }
packet.push_back(flags);
packet.push_back(static_cast<uint8_t>(keepalive_seconds >> 8));
packet.push_back(static_cast<uint8_t>(keepalive_seconds & 0xff));
append_string(packet, client_id.empty() ? "OrcaSlicer" : client_id);
if (!username.empty()) {
append_string(packet, username);
if (!password.empty()) append_string(packet, password);
}
prepend_remaining_length(packet, packet.size() - 1);
return packet;
}
std::string OrcaMqttConnection::report_topic(const std::string& device_id) { return "device/" + device_id + "/report"; }
std::string OrcaMqttConnection::request_topic(const std::string& id) { return "device/" + id + "/request"; }
std::vector<uint8_t> OrcaMqttConnection::make_publish_packet(const std::string& topic, const std::string& payload) {
std::vector<uint8_t> packet{0x30}; // PUBLISH, QoS 0, no retain
append_string(packet, topic); // no packet id at QoS 0
packet.insert(packet.end(), payload.begin(), payload.end());
prepend_remaining_length(packet, packet.size() - 1);
return packet;
}
std::vector<uint8_t> OrcaMqttConnection::make_subscribe_packet(uint16_t id, const std::string& topic, uint8_t qos) {
std::vector<uint8_t> packet{0x82};
packet.push_back(id >> 8); packet.push_back(id & 0xff);
append_string(packet, topic);
packet.push_back(qos);
prepend_remaining_length(packet, packet.size() - 1);
return packet;
}
std::vector<uint8_t> OrcaMqttConnection::make_unsubscribe_packet(uint16_t id, const std::string& topic) {
std::vector<uint8_t> packet{0xA2};
packet.push_back(id >> 8); packet.push_back(id & 0xff);
append_string(packet, topic);
prepend_remaining_length(packet, packet.size() - 1);
return packet;
}
std::vector<uint8_t> OrcaMqttConnection::make_ping_packet() { return {0xc0, 0}; }
void OrcaMqttConnection::ws_write(Connection& conn, const std::vector<uint8_t>& packet) {
if (packet.empty()) {
return;
}
if (conn.wss) {
conn.wss->binary(true);
conn.wss->write(boost::asio::buffer(packet));
} else if (conn.ws) {
conn.ws->binary(true);
conn.ws->write(boost::asio::buffer(packet));
}
}
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)
return conn.wss->read(buffer, ec);
if (conn.ws)
return conn.ws->read(buffer, ec);
ec = boost::asio::error::not_connected;
return 0;
}
void OrcaMqttConnection::ws_handshake(Connection& conn, const Config& config, const Endpoint& endpoint) {
const auto results = conn.resolver.resolve(endpoint.host, endpoint.port);
std::string token;
if (config.bearer_provider) {
token = config.bearer_provider();
}
auto decorator = [token](boost::beast::websocket::request_type& request) {
request.set(boost::beast::http::field::user_agent, "OrcaSlicer");
if (!token.empty())
request.set(boost::beast::http::field::authorization, "Bearer " + token);
request.set("Sec-WebSocket-Protocol", "mqtt");
};
boost::beast::http::response<boost::beast::http::string_body> response;
boost::system::error_code handshake_error;
if (config.use_tls) {
// stop() inspects the engaged optional under connection_mutex; publish it
// under the same lock, then release before the blocking connect.
{
std::lock_guard<std::mutex> lock(connection_mutex);
conn.wss.emplace(conn.io_context, conn.ssl_context);
}
auto& websocket = *conn.wss;
auto& stream = boost::beast::get_lowest_layer(websocket);
stream.expires_after(std::chrono::seconds(10));
stream.connect(results);
// Set SNI before the TLS handshake so the cloud edge selects the correct
// certificate.
auto& tls_stream = websocket.next_layer();
if (!SSL_set_tlsext_host_name(tls_stream.native_handle(), endpoint.host.c_str()))
throw std::runtime_error("failed to set Orca Cloud TLS server name");
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));
tls_stream.handshake(boost::asio::ssl::stream_base::client);
websocket.set_option(boost::beast::websocket::stream_base::decorator(decorator));
websocket.handshake(response, endpoint.host, endpoint.target, handshake_error);
} else {
{
std::lock_guard<std::mutex> lock(connection_mutex);
conn.ws.emplace(conn.io_context);
}
auto& websocket = *conn.ws;
auto& stream = boost::beast::get_lowest_layer(websocket);
stream.expires_after(std::chrono::seconds(10));
stream.connect(results);
websocket.set_option(boost::beast::websocket::stream_base::decorator(decorator));
websocket.handshake(response, endpoint.host, endpoint.target, handshake_error);
}
if (handshake_error) {
throw boost::system::system_error(handshake_error, "Orca WebSocket handshake");
}
if (response["Sec-WebSocket-Protocol"] != "mqtt") {
throw std::runtime_error("Orca WebSocket did not negotiate MQTT");
}
}
bool OrcaMqttConnection::send_request(const std::string& dev_id, const std::string& payload) {
if (dev_id.empty()) {
return false;
}
if (!connected.load()) {
return false;
}
std::shared_ptr<Connection> conn;
{
std::lock_guard<std::mutex> lock(connection_mutex);
conn = active_connection;
}
if (!conn) {
return false;
}
const std::string report = report_topic(dev_id);
{
std::lock_guard<std::mutex> lock(mutex);
if (subscriptions.count(report) != 0 && acknowledged_subscriptions.count(report) == 0) {
pending_requests.emplace_back(dev_id, payload);
return true;
}
}
post_packet(conn, make_publish_packet(request_topic(dev_id), payload));
return true;
}
void OrcaMqttConnection::connect_and_read() {
auto connection = std::make_shared<Connection>();
{
std::lock_guard<std::mutex> lock(connection_mutex);
active_connection = connection;
if (stopping.load()) {
active_connection.reset();
return;
}
}
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);
// 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");
// Beast leaves this expiry armed after the synchronous CONNACK read above;
// disable it before starting the long-lived async WebSocket session.
expires_never(*connection);
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();
}
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();
}
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;
}
}
void OrcaMqttConnection::send_current_subscriptions(const std::shared_ptr<Connection>& conn) {
std::vector<std::string> topics;
{
std::lock_guard<std::mutex> lock(mutex);
topics.assign(subscriptions.begin(), subscriptions.end());
acknowledged_subscriptions.clear();
pending_subscribe_packets.clear();
for (const std::string& topic : topics)
pending_subscriptions.erase(topic);
}
for (const std::string& topic : topics) {
const uint16_t packet_id = next_packet_id++;
{
std::lock_guard<std::mutex> lock(mutex);
pending_subscribe_packets[packet_id] = topic;
}
enqueue_packet(conn, make_subscribe_packet(packet_id, topic, 1));
}
}
void OrcaMqttConnection::send_pending_subscriptions(const std::shared_ptr<Connection>& conn) {
std::vector<std::string> subscribe_topics;
std::vector<std::string> unsubscribe_topics;
{
std::lock_guard<std::mutex> lock(mutex);
subscribe_topics.assign(pending_subscriptions.begin(), pending_subscriptions.end());
unsubscribe_topics.assign(pending_unsubscriptions.begin(), pending_unsubscriptions.end());
pending_subscriptions.clear();
pending_unsubscriptions.clear();
}
for (const std::string& topic : subscribe_topics) {
const uint16_t packet_id = next_packet_id++;
{
std::lock_guard<std::mutex> lock(mutex);
pending_subscribe_packets[packet_id] = topic;
}
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++;
enqueue_packet(conn, make_unsubscribe_packet(packet_id, topic));
}
}
void OrcaMqttConnection::handle_packet(const std::string& packet) {
if (packet.size() < 2) {
return;
}
const uint8_t header = static_cast<uint8_t>(packet[0]);
const uint8_t packet_type = header >> 4;
if (packet_type != 3) { // Only QoS 0 PUBLISH carries printer status.
if (packet_type == 9 && packet.size() >= 5) {
const uint16_t packet_id = (static_cast<unsigned int>(static_cast<uint8_t>(packet[2])) << 8) |
static_cast<unsigned int>(static_cast<uint8_t>(packet[3]));
std::ostringstream result_codes;
for (size_t index = 4; index < packet.size(); ++index) {
if (index != 4)
result_codes << ',';
result_codes << "0x" << std::hex << static_cast<unsigned int>(static_cast<uint8_t>(packet[index]));
}
// Each production SUBSCRIBE packet currently contains one topic.
// MQTT grants QoS 0 or 1 for a requested QoS 1 subscription; 0x80
// means the subscription was rejected.
const uint8_t result = static_cast<uint8_t>(packet[4]);
std::string topic;
std::deque<std::pair<std::string, std::string>> requests;
{
std::lock_guard<std::mutex> lock(mutex);
auto pending = pending_subscribe_packets.find(packet_id);
if (pending != pending_subscribe_packets.end()) {
topic = pending->second;
pending_subscribe_packets.erase(pending);
for (auto it = pending_requests.begin(); it != pending_requests.end();) {
if (report_topic(it->first) == topic) {
requests.push_back(std::move(*it));
it = pending_requests.erase(it);
} else {
++it;
}
}
if (result == 0 || result == 1) {
acknowledged_subscriptions.insert(topic);
}
}
}
if (topic.empty()) {
} else if (result == 0 || result == 1) {
for (const auto& request : requests) {
if (!send_request(request.first, request.second)) {
}
}
} else {
}
}
return;
}
size_t index = 1;
size_t multiplier = 1;
size_t remaining = 0;
uint8_t encoded = 0;
do {
if (index >= packet.size() || multiplier > 128 * 128 * 128) {
return;
}
encoded = static_cast<uint8_t>(packet[index++]);
remaining += (encoded & 0x7f) * multiplier;
multiplier *= 128;
} while ((encoded & 0x80) != 0);
const size_t remaining_end = index + remaining;
if (remaining_end > packet.size() || remaining < 2 || index + 2 > remaining_end) {
return;
}
const uint16_t topic_length = (static_cast<uint8_t>(packet[index]) << 8) |
static_cast<uint8_t>(packet[index + 1]);
index += 2;
if (topic_length > packet.size() - index) {
return;
}
const std::string topic(packet.data() + index, topic_length);
index += topic_length;
if (((header >> 1) & 0x03) != 0) {
if (index + 2 > remaining_end) {
return;
}
index += 2; // QoS 1/2 packet identifier; the service currently sends QoS 0.
}
const size_t payload_size = remaining_end - index;
// topic is "device/<id>/report" (or "/request"); hand the id up, drop anything else.
std::string dev_id;
if (topic.rfind("device/", 0) == 0) {
const size_t id_start = 7;
const size_t id_end = topic.rfind('/');
if (id_end != std::string::npos && id_end > id_start)
dev_id = topic.substr(id_start, id_end - id_start);
}
if (dev_id.empty()) {
} else if (on_message) {
on_message(dev_id, packet.substr(index, remaining_end - index));
} else {
}
}
void OrcaMqttConnection::notify_state(bool is_now_connected) {
StateHandler callback;
bool initial = false;
{
std::lock_guard<std::mutex> lock(mutex);
connected = is_now_connected;
initial = !initial_completed;
if (initial) {
initial_result = is_now_connected;
initial_completed = true;
}
callback = on_state;
}
if (initial)
initial_cv.notify_all();
else if (callback)
callback(is_now_connected, false);
}
void OrcaMqttConnection::run() {
while (!stopping.load()) {
const int retry_seconds = reconnect_delay_seconds.load();
const uint64_t attempt = ++m_attempt_number;
try {
connect_and_read();
} catch (const std::exception& error) {
if (!stopping.load())
notify_state(false);
}
if (stopping.load())
break;
// Grow the backoff only across attempts that never reached CONNACK; a
// successful connection resets reconnect_delay_seconds to 1 (connect_and_read).
reconnect_delay_seconds.store(std::min(retry_seconds * 2, 30));
std::unique_lock<std::mutex> lock(mutex);
state_cv.wait_for(lock, std::chrono::seconds(retry_seconds), [this] { return stopping.load(); });
}
}
} // namespace Slic3r
-155
View File
@@ -1,155 +0,0 @@
#ifndef slic3r_OrcaMqttConnection_hpp_
#define slic3r_OrcaMqttConnection_hpp_
#include <boost/asio/ssl.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/ssl.hpp>
#include <boost/beast/websocket.hpp>
#include <atomic>
#include <condition_variable>
#include <deque>
#include <functional>
#include <map>
#include <memory>
#include <mutex>
#include <set>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include <cstddef>
#include <cstdint>
namespace Slic3r {
// Minimal MQTT 3.1.1 codec + WebSocket transport (ws:// and wss://), shared by the
// LAN (OrcaSonar) and cloud (fleet) printer connections. Both PUBLISH
// commands to device/<id>/request and SUBSCRIBE device/<id>/report; Config is the
// only per-transport difference.
class OrcaMqttConnection
{
public:
using TokenProvider = std::function<std::string()>;
using MessageHandler = std::function<void(const std::string&, const std::string&)>;
using StateHandler = std::function<void(bool connected, bool initial)>;
struct Endpoint { std::string host; std::string port; std::string target; };
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;
std::string client_id = "OrcaSlicer";
int keepalive_seconds = 60;
};
static bool parse_endpoint(const std::string& url, Endpoint& endpoint);
// Build an MQTT 3.1.1 CONNECT packet. Clean-session is always set; the
// username/password connect flags and payload fields are added only when
// username is non-empty (the cloud form authenticates via a bearer on the
// WebSocket upgrade and omits CONNECT credentials). Public for unit tests.
static std::vector<uint8_t> make_connect_packet(const std::string& client_id,
const std::string& username,
const std::string& password,
int keepalive_seconds);
// Topic-string helpers for the per-device request/report channels and the
// MQTT 3.1.1 PUBLISH / SUBSCRIBE / UNSUBSCRIBE packet builders. All public
// for unit tests. make_publish_packet emits QoS 0 (no packet identifier).
static std::string request_topic(const std::string& dev_id); // "device/<id>/request"
static std::string report_topic(const std::string& dev_id); // "device/<id>/report"
static std::vector<uint8_t> make_publish_packet(const std::string& topic, const std::string& payload);
static std::vector<uint8_t> make_subscribe_packet(uint16_t packet_id, const std::string& topic, uint8_t qos);
static std::vector<uint8_t> make_unsubscribe_packet(uint16_t packet_id, const std::string& topic);
~OrcaMqttConnection();
bool start(const Config& config, MessageHandler on_message, StateHandler on_state);
void stop();
// True while the worker thread is alive (connected OR retrying). Lets callers
// avoid restarting a healthy connection.
bool is_running() const;
// True once CONNACK has been received and the socket has not since dropped.
bool is_connected() const { return connected.load(); }
bool subscribe(const std::string& dev_id);
bool unsubscribe(const std::string& dev_id);
// Last MQTT CONNACK return code: 0 ok, 1..5 refusal, -1 none seen this attempt.
int last_connack_rc() const { return m_last_connack_rc.load(); }
void clear_subscriptions();
bool send_request(const std::string& dev_id, const std::string& payload);
private:
// The endpoint may be either a TLS (wss://) or a plaintext (ws://) WebSocket;
// Connection holds whichever one is engaged and the ws_* helpers below
// dispatch on it.
using TlsWebSocket = boost::beast::websocket::stream<
boost::asio::ssl::stream<boost::beast::tcp_stream>>;
using PlainWebSocket = boost::beast::websocket::stream<boost::beast::tcp_stream>;
struct Connection;
static void append_string(std::vector<uint8_t>& packet, const std::string& value);
static void prepend_remaining_length(std::vector<uint8_t>& packet, size_t length);
static std::vector<uint8_t> make_ping_packet();
// Transport dispatch: each forwards to conn.wss (TLS) or conn.ws (plaintext).
// 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 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(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();
std::atomic_bool stopping{true};
std::atomic_int reconnect_delay_seconds{1};
// Serialises the whole of start() and stop() against each other, so the UI
// thread's stop() (disconnect / dtor) cannot race the connect thread's start()
// into a concurrent worker.join(). Recursive because start() calls stop().
std::recursive_mutex lifecycle_mutex;
std::thread worker;
std::mutex mutex;
std::mutex connection_mutex;
std::shared_ptr<Connection> active_connection;
std::condition_variable initial_cv;
std::condition_variable state_cv;
Config current_config;
MessageHandler on_message;
StateHandler on_state;
// Full report-topic strings ("device/<id>/report"), not bare device ids.
std::set<std::string> subscriptions;
std::set<std::string> pending_subscriptions;
std::set<std::string> pending_unsubscriptions;
// Requests for a subscribed device wait until the corresponding SUBACK is
// received. Otherwise an immediate pushall response can be published by
// the broker before this client is actually subscribed to the report topic.
std::set<std::string> acknowledged_subscriptions;
std::map<uint16_t, std::string> pending_subscribe_packets;
std::deque<std::pair<std::string, std::string>> pending_requests;
std::atomic<uint16_t> next_packet_id{1};
std::atomic<int> m_last_connack_rc{-1};
uint64_t m_attempt_number{0}; // worker-thread diagnostic sequence
bool initial_result{false};
bool initial_completed{false};
std::atomic_bool connected{false};
};
} // namespace Slic3r
#endif // slic3r_OrcaMqttConnection_hpp_
File diff suppressed because it is too large Load Diff
+4 -136
View File
@@ -4,28 +4,19 @@
#include "IPrinterAgent.hpp"
#include "ICloudServiceAgent.hpp"
#include "bambu_networking.hpp"
#include "OrcaCloudServiceAgent.hpp"
#include "OrcaMqttConnection.hpp"
#include <atomic>
#include <cstdint>
#include <functional>
#include <string>
#include <mutex>
#include <memory>
#include <thread>
namespace Slic3r {
class OrcaCloudServiceAgent;
/**
* OrcaPrinterAgent - OrcaSonar MQTT printer agent.
* OrcaPrinterAgent - Stub implementation for printer operations.
*
* LAN and cloud commands use the same OrcaSonar protocol payloads; only the
* MQTT connection selected by route_send() differs.
* All printer-related operations are currently stubs that return success.
* Actual printer connectivity requires the BBL SDK or future Orca implementation.
*/
class OrcaPrinterAgent : public IPrinterAgent
{
class OrcaPrinterAgent : public IPrinterAgent {
public:
explicit OrcaPrinterAgent(std::string log_dir);
~OrcaPrinterAgent() override;
@@ -35,8 +26,6 @@ public:
// ========================================================================
void set_cloud_agent(std::shared_ptr<ICloudServiceAgent> cloud) override;
CameraStreamMode get_camera_stream_mode() const override;
std::string get_camera_url() const override;
// Communication
int send_message(std::string dev_id, std::string json_str, int qos, int flag) override;
@@ -80,131 +69,10 @@ public:
int set_on_local_message_fn(OnMessageFn fn) override;
int set_queue_on_main_fn(QueueOnMainFn fn) override;
int command_ams_refresh_rfid(std::string dev_id, int ams_id, int tray_id, int sequence_id, bool lan_mode) override;
int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) override;
int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override;
int command_start_camera(std::string dev_id) override;
int command_xyz_abs(std::string dev_id, int sequence_id, bool lan_mode) override;
int command_auto_leveling(std::string dev_id, int sequence_id, bool lan_mode) override;
int command_go_home(std::string dev_id, bool is_printing, bool supports_mqtt_homing, int sequence_id, bool lan_mode) override;
int command_set_bed(std::string dev_id, int temp, bool supports_mqtt_bed_ctrl, int sequence_id, bool lan_mode) override;
int command_set_nozzle(std::string dev_id, int temp, int sequence_id, bool lan_mode) override;
int command_axis_control(std::string dev_id,
std::string axis,
double unit,
double input_val,
int speed,
bool is_core_xy,
bool supports_mqtt_axis_control,
int sequence_id,
bool lan_mode) override;
// Test-only: drive emit_connect_sequence directly (no socket).
void run_connect_sequence_for_test(const std::string& dev_id)
{
emit_connect_sequence(dev_id, [](const std::string&) {}, [](const std::string&) {});
}
// Test-only: advance the LAN connection epoch without a connect/disconnect cycle.
void bump_lan_generation_for_test() { ++m_lan_generation; }
// Test-only: the same for the (independent) cloud selection epoch.
void bump_cloud_generation_for_test() { ++m_cloud_generation; }
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::subscription; }
protected:
// Forward one inbound printer message to on_message_fn or on_local_message_fn (marshalled onto the UI
// thread via queue_on_main_fn when set). Body of every connection's MessageHandler.
void deliver_to_sink(const std::string& dev_id, const std::string& payload, bool local);
// Extract OrcaSonar's print.ipcam.stream_mode from LAN reports before they
// are forwarded to the GUI. The getters below then read this agent-owned state.
void parse_ipcam_info(const std::string& dev_id, const std::string& payload);
// Orca-dialect -> Bambu-dialect compatibility shim for inbound reports: the single
// place Orca Protocol JSON is rewritten into the shapes MachineObject::parse_json
// already handles, so parse_json needs no Orca-specific changes. Self-contained
// (its cache is a function-local static) and deletable together with its call site
// once parse_json reads the Orca dialect natively. See the definition for the
// per-rule detail. Returns the payload unchanged when no rule applies.
std::string merge_capabilities(const std::string& dev_id, const std::string& payload);
// Report the asynchronous LAN connection state using the same callback contract as
// the other printer agents. The transport result cannot be returned by
// connect_printer(), which only starts the worker.
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& message);
// The LAN inbound-message handler for one connection generation: forwards to
// deliver_to_sink only while `generation` is still the live epoch.
std::function<void(const std::string&, const std::string&)> make_lan_message_handler(uint64_t generation);
// Pure LAN-address parsing + client-id. protected static so the test Probe reaches them.
static bool parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port);
static std::string make_lan_client_id(const std::string& dev_id);
// Test hook: the ws:// URL connect_printer built for the current LAN session ("" if none).
std::string lan_connection_target() const;
// Shared post-connect sequence: SUBSCRIBE, then pushing.start, pushall,
// info.get_version, info.get_capabilities. Runs identically on LAN and cloud.
void on_connected(const std::string& dev_id, OrcaMqttConnection* conn, uint64_t generation);
// The post-connect command sequence, factored behind a seam so a test can
// observe the SUBSCRIBE + 4 request payloads without a live OrcaMqttConnection.
virtual void emit_connect_sequence(const std::string& dev_id,
std::function<void(const std::string&)> subscribe,
std::function<void(const std::string&)> request);
static std::string seq(int n); // decimal string in the OrcaSlicer 20000..29999 band
static std::string build_pushing_start(const std::string& sequence_id);
static std::string build_pushing_stop(const std::string& sequence_id);
static std::string build_pushall(const std::string& sequence_id);
static std::string build_get_version(const std::string& sequence_id);
static std::string build_get_capabilities(const std::string& sequence_id);
private:
class OrcaSonarDiscovery;
std::string log_dir;
std::string selected_machine;
enum CurrentConn { NONE, CLOUD, LAN };
static const char* connection_type_name(CurrentConn connection);
// The transport for the printer currently selected by the UI. LAN and
// cloud sessions have separate connection objects, so this is selection
// state rather than an inference from whichever socket happens to exist.
CurrentConn m_current_connection = NONE;
std::shared_ptr<ICloudServiceAgent> m_cloud_agent;
std::unique_ptr<OrcaMqttConnection> lan_mqtt_connection;
// Two independent epochs: a cloud (de)selection must not fence the live LAN
// feed, and vice versa. Each transport's connect thread and inbound handler
// compare against their own counter only.
std::atomic<uint64_t> m_lan_generation{0};
std::atomic<uint64_t> m_cloud_generation{0};
// The short-lived threads that run the blocking initial connect for the current
// LAN / cloud session. Joined members (never detached) so they cannot outlive
// *this or the connection they hold a raw pointer to.
std::thread m_lan_connect_thread;
std::thread m_cloud_connect_thread;
std::unique_ptr<OrcaSonarDiscovery> m_discovery;
std::string m_lan_dev_id; // guarded by state_mutex
std::string m_lan_url; // guarded by state_mutex — the Config.url of the live LAN session
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
OrcaCloudServiceAgent* get_orca_cloud_agent();
OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan = true);
static bool parse_nonnegative_command_id(const std::string& value, int& result);
// Route one command payload to device/<dev_id>/request on the LAN or the shared
// cloud connection. The uniform send path for both send_message* overrides.
int route_send(bool is_lan, const std::string& dev_id, const std::string& json_str);
// Callbacks
OnMsgArrivedFn on_ssdp_msg_fn;
+52 -175
View File
@@ -17,7 +17,6 @@
#include <string>
#include <utility>
#include <vector>
#include <thread>
using json = nlohmann::json;
@@ -37,15 +36,6 @@ bool has_visible_base_preset(const PresetCollection& filaments, const std::strin
return false;
}
// RAII decrement for MoonrakerPrinterAgent::filament_fetch_in_flight — guarantees the
// counter drops back down on every exit path (early return or fall-through) inside the
// detached fetch thread below, so ~MoonrakerPrinterAgent()'s wait loop can't stall forever.
struct InFlightGuard
{
std::atomic<int>& counter;
~InFlightGuard() { counter.fetch_sub(1, std::memory_order_relaxed); }
};
} // anonymous namespace
const std::string QidiPrinterAgent_VERSION = "0.0.1";
@@ -59,148 +49,51 @@ AgentInfo QidiPrinterAgent::get_agent_info_static()
return AgentInfo{"qidi", "Qidi", QidiPrinterAgent_VERSION, "Qidi printer agent"};
}
FilamentSyncMode QidiPrinterAgent::get_filament_sync_mode() const
bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/)
{
if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
return FilamentSyncMode::subscription;
return FilamentSyncMode::pull;
}
std::string error;
bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
{
if (sync_mode != get_filament_sync_mode())
// 1. Fetch device info and infer series_id
std::string series_id;
{
MoonrakerDeviceInfo info;
if (fetch_device_info(device_info.base_url, device_info.api_key, info, error)) {
series_id = infer_series_id(info.model_id, info.dev_name);
}
}
if (series_id.empty()) {
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
series_id = infer_series_id(device_info.model_id, device_info.model_name);
}
// 2. Fetch filament dictionary
QidiFilamentDict dict;
if (!fetch_filament_dict(device_info.base_url, device_info.api_key, 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(device_info.base_url, device_info.api_key, dict, series_id, trays, box_count, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
return false;
}
// 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.
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, connection = std::move(connection), model_id, model_name]() mutable {
InFlightGuard guard{filament_fetch_in_flight};
std::string error;
// 1. Fetch device info and infer series_id
std::string series_id;
{
MoonrakerDeviceInfo info;
if (fetch_device_info(connection, info, error)) {
series_id = infer_series_id(info.model_id, info.dev_name);
}
}
if (series_id.empty()) {
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
series_id = infer_series_id(model_id, model_name);
}
// 2. Fetch filament dictionary
QidiFilamentDict dict;
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(connection, dict, series_id, trays, box_count, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
return;
}
// 4. Build the AMS payload
build_ams_payload(box_count, box_count * 4 - 1, trays);
}).detach();
// 4. Build the AMS payload
build_ams_payload(box_count, box_count * 4 - 1, trays);
return true;
}
bool QidiPrinterAgent::apply_box_mapping(const PrintParams& params) const
{
// enable_box mirrors task_use_ams: engage the multi-color box only when this
// job actually routes filament through it. (See qidi-ams-findings.md §2/§8.3 —
// if firmware treats enable_box as "a box exists" rather than "use it this job",
// switch this gate to HasAms()/box_count instead.)
const int enable = params.task_use_ams ? 1 : 0;
if (!send_gcode(device_info.dev_id, "SAVE_VARIABLE VARIABLE=enable_box VALUE=" + std::to_string(enable))) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to set enable_box";
return false;
}
// When the box isn't used this job, leave the existing value_t<tool> slot
// assignments untouched (enable_box=0 is enough to disengage it).
if (!enable)
return true;
if (params.ams_mapping.empty()) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::apply_box_mapping: enable_box set but ams_mapping is empty";
return true;
}
// ams_mapping (v0) is a JSON array indexed by filament/tool; each value is the
// physical box slot (-1 = unmapped). Mirror it onto the printer's value_t<tool>
// variables: SAVE_VARIABLE VARIABLE=value_t<tool> VALUE='slot<n>'.
auto mapping = nlohmann::json::parse(params.ams_mapping, nullptr, /*allow_exceptions*/ false);
if (mapping.is_discarded() || !mapping.is_array()) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: invalid ams_mapping: " << params.ams_mapping;
return false;
}
for (size_t tool = 0; tool < mapping.size(); ++tool) {
if (!mapping[tool].is_number_integer())
continue;
const int slot = mapping[tool].get<int>();
if (slot < 0)
continue; // unmapped filament — skip
const std::string gcode = "SAVE_VARIABLE VARIABLE=value_t" + std::to_string(tool) +
" VALUE=\"'slot" + std::to_string(slot) + "'\"";
if (!send_gcode(device_info.dev_id, gcode)) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to set value_t" << tool;
return false;
}
}
return true;
}
int QidiPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_local_print(std::move(params), update_fn, cancel_fn);
}
int QidiPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_print(std::move(params), update_fn, cancel_fn, wait_fn);
}
int QidiPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED;
return MoonrakerPrinterAgent::start_local_print_with_record(std::move(params), update_fn, cancel_fn, wait_fn);
}
int QidiPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_sdcard_print(std::move(params), update_fn, cancel_fn);
}
bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
const std::string& api_key,
const QidiFilamentDict& dict,
const std::string& series_id,
std::vector<AmsTrayData>& trays,
int& box_count,
std::string& error)
{
std::string url = join_url(connection.base_url, "/printer/objects/query?save_variables=variables");
std::string url = join_url(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";
}
@@ -210,9 +103,8 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
std::string http_error;
auto http = Http::get(url);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
}
http.timeout_connect(5)
.timeout_max(10)
@@ -237,10 +129,20 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
return false;
}
nlohmann::json status;
nlohmann::json variables;
if (!parse_slot_response(response_body, status, variables, error))
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
error = "Invalid JSON response";
return false;
}
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("save_variables") ||
!json["result"]["status"]["save_variables"].contains("variables")) {
error = "Unexpected JSON structure";
return false;
}
auto& variables = json["result"]["status"]["save_variables"]["variables"];
auto& status = json["result"]["status"];
box_count = variables.value("box_count", 1);
if (box_count < 0) {
@@ -315,45 +217,20 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
return true;
}
bool QidiPrinterAgent::parse_slot_response(const std::string& response_body,
nlohmann::json& status,
nlohmann::json& variables,
std::string& error)
{
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
error = "Invalid JSON response";
return false;
}
if (!json.is_object() || !json.contains("result") || !json["result"].is_object() || !json["result"].contains("status") ||
!json["result"]["status"].is_object() || !json["result"]["status"].contains("save_variables") ||
!json["result"]["status"]["save_variables"].is_object() || !json["result"]["status"]["save_variables"].contains("variables") ||
!json["result"]["status"]["save_variables"]["variables"].is_object()) {
// why: Qidi firmware may send null here, but json::value() throws for it.
error = "Unexpected JSON structure: save_variables.variables must be an object";
return false;
}
status = json["result"]["status"];
variables = status["save_variables"]["variables"];
return true;
}
bool QidiPrinterAgent::fetch_filament_dict(const ConnectionSettings& connection,
bool QidiPrinterAgent::fetch_filament_dict(const std::string& base_url,
const std::string& api_key,
QidiFilamentDict& dict,
std::string& error) const
{
std::string url = join_url(connection.base_url, "/server/files/config/officiall_filas_list.cfg");
std::string url = join_url(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);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
}
http.timeout_connect(5)
.timeout_max(10)
+3 -19
View File
@@ -3,7 +3,6 @@
#include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp"
#include "nlohmann/json_fwd.hpp"
#include <map>
#include <string>
@@ -23,23 +22,7 @@ public:
// Override filament sync (Qidi-specific implementation)
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
static bool parse_slot_response(const std::string& response_body,
nlohmann::json& status,
nlohmann::json& variables,
std::string& error);
// Print operations — emit QiDi multi-color box config, then delegate to base.
int start_print(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;
int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override;
FilamentSyncMode get_filament_sync_mode() const override;
private:
// Push enable_box + value_t<tool> SAVE_VARIABLEs before a print starts.
// Returns false if any command fails (caller should abort the print).
bool apply_box_mapping(const PrintParams& params) const;
struct QidiFilamentDict
{
std::map<int, std::string> colors;
@@ -47,13 +30,14 @@ private:
};
// Qidi-specific methods
bool fetch_slot_info(const ConnectionSettings& connection,
bool fetch_slot_info(const std::string& base_url,
const std::string& api_key,
const QidiFilamentDict& dict,
const std::string& series_id,
std::vector<AmsTrayData>& trays,
int& box_count,
std::string& error);
bool fetch_filament_dict(const ConnectionSettings& connection, QidiFilamentDict& dict, std::string& error) const;
bool fetch_filament_dict(const std::string& base_url, const std::string& api_key, 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);
+106 -164
View File
@@ -4,18 +4,14 @@
#include "IPrinterAgent.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "libslic3r/Preset.hpp"
#include "nlohmann/json.hpp"
#include <boost/log/trivial.hpp>
#include <vector>
#include <string>
#include "libslic3r/Preset.hpp"
#include <cstddef>
#include <utility>
#include <chrono>
#include <sstream>
#include <thread>
using json = nlohmann::json;
@@ -24,13 +20,6 @@ namespace Slic3r {
namespace {
constexpr const char* SNAPMAKER_AGENT_VERSION = "0.0.1";
constexpr int64_t CAMERA_REFRESH_INTERVAL_MS = 300'000;
int64_t now_ms()
{
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
}
// Safely access a parallel array by index, returning a fallback if out of bounds.
template<typename T>
@@ -87,31 +76,6 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection&
SnapmakerPrinterAgent::SnapmakerPrinterAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
void SnapmakerPrinterAgent::start_camera_monitor()
{
enqueue_command([this] {
send_ws_rpc("camera.start_monitor",
{{"domain", "lan"}, {"interval", 0}, {"expect_pw", false}});
});
m_camera_last_fire_ms.store(now_ms());
}
void SnapmakerPrinterAgent::on_status_loop_tick(const std::string& dev_id)
{
(void) dev_id;
const int64_t last = m_camera_last_fire_ms.load();
if (last == 0 || now_ms() - last >= CAMERA_REFRESH_INTERVAL_MS) {
start_camera_monitor();
}
}
int SnapmakerPrinterAgent::command_start_camera(std::string dev_id)
{
(void) dev_id;
start_camera_monitor();
return BAMBU_NETWORK_SUCCESS;
}
AgentInfo SnapmakerPrinterAgent::get_agent_info_static()
{
return AgentInfo{"snapmaker", "Snapmaker", SNAPMAKER_AGENT_VERSION, "Snapmaker printer agent"};
@@ -147,149 +111,127 @@ std::string SnapmakerPrinterAgent::combine_filament_type(const std::string& type
return base;
}
bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/)
{
(void) dev_id;
if (sync_mode != get_filament_sync_mode())
std::string url = join_url(device_info.base_url, "/printer/objects/query?print_task_config&filament_detect");
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);
}
http.timeout_connect(5)
.timeout_max(10)
.on_complete([&](std::string body, unsigned status) {
if (status == 200) {
response_body = body;
success = true;
} else {
http_error = "HTTP error: " + std::to_string(status);
}
})
.on_error([&](std::string body, std::string err, unsigned status) {
http_error = err;
if (status > 0) {
http_error += " (HTTP " + std::to_string(status) + ")";
}
})
.perform_sync();
if (!success) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error;
return false;
}
const std::string base_url = device_info.base_url;
const std::string api_key = device_info.api_key;
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response";
return false;
}
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
// Navigate to result.status.print_task_config
if (!json.contains("result") || !json["result"].contains("status") ||
!json["result"]["status"].contains("print_task_config")) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response";
return false;
}
std::thread([this, base_url, api_key]() {
struct InFlightGuard
{
std::atomic<int>& counter;
~InFlightGuard() { counter.fetch_sub(1, std::memory_order_relaxed); }
} guard{filament_fetch_in_flight};
auto& ptc = json["result"]["status"]["print_task_config"];
const std::string url = join_url(base_url, "/printer/objects/query?print_task_config&filament_detect");
// Read parallel arrays from print_task_config
auto filament_exist = ptc.value("filament_exist", std::vector<bool>{});
auto filament_type = ptc.value("filament_type", std::vector<std::string>{});
auto filament_sub_type = ptc.value("filament_sub_type", std::vector<std::string>{});
auto filament_color = ptc.value("filament_color_rgba", std::vector<std::string>{});
auto filament_vendor = ptc.value("filament_vendor", std::vector<std::string>{});
std::string response_body;
bool success = false;
std::string http_error;
const int slot_count = static_cast<int>(filament_exist.size());
if (slot_count == 0) {
BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported";
return false;
}
auto http = Http::get(url);
if (!api_key.empty()) {
http.header("X-Api-Key", api_key);
}
http.timeout_connect(5)
.timeout_max(10)
.on_complete([&](std::string body, unsigned status) {
if (status == 200) {
response_body = body;
success = true;
// Read NFC filament_detect data for temperature info (optional)
nlohmann::json nfc_info;
if (json["result"]["status"].contains("filament_detect") &&
json["result"]["status"]["filament_detect"].contains("info")) {
nfc_info = json["result"]["status"]["filament_detect"]["info"];
}
static const std::string empty_str;
static const std::string default_color = "FFFFFFFF";
std::vector<AmsTrayData> trays;
trays.reserve(slot_count);
for (int i = 0; i < slot_count; ++i) {
AmsTrayData tray;
tray.slot_index = i;
tray.has_filament = filament_exist[i];
if (tray.has_filament) {
tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str),
safe_at(filament_sub_type, i, empty_str));
tray.tray_color = safe_at(filament_color, i, default_color);
auto* bundle = GUI::wxGetApp().preset_bundle;
// Try to find a matching preset for this filament based on vendor, type and color.
// If not found, default to traditional search by type only or generic type mapping.
if (bundle) {
std::string vendor = safe_at(filament_vendor, i, empty_str);
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type,
tray.tray_color);
if (!filament_id.empty()) {
tray.tray_info_idx = filament_id;
BOOST_LOG_TRIVIAL(warning) << "Filament sync: Found manufacturer-specific profile for slot " << i << ": "
<< filament_id;
} else {
http_error = "HTTP error: " + std::to_string(status);
}
})
.on_error([&](std::string body, std::string err, unsigned status) {
http_error = err;
if (status > 0) {
http_error += " (HTTP " + std::to_string(status) + ")";
}
})
.perform_sync();
if (!success) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error;
return;
}
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response";
return;
}
// Navigate to result.status.print_task_config
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("print_task_config")) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response";
return;
}
auto& ptc = json["result"]["status"]["print_task_config"];
// Read parallel arrays from print_task_config
auto filament_exist = ptc.value("filament_exist", std::vector<bool>{});
auto filament_type = ptc.value("filament_type", std::vector<std::string>{});
auto filament_sub_type = ptc.value("filament_sub_type", std::vector<std::string>{});
auto filament_color = ptc.value("filament_color_rgba", std::vector<std::string>{});
auto filament_vendor = ptc.value("filament_vendor", std::vector<std::string>{});
const int slot_count = static_cast<int>(filament_exist.size());
if (slot_count == 0) {
BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported";
return;
}
// Read NFC filament_detect data for temperature info (optional)
nlohmann::json nfc_info;
if (json["result"]["status"].contains("filament_detect") && json["result"]["status"]["filament_detect"].contains("info")) {
nfc_info = json["result"]["status"]["filament_detect"]["info"];
}
static const std::string empty_str;
static const std::string default_color = "FFFFFFFF";
std::vector<AmsTrayData> trays;
trays.reserve(slot_count);
for (int i = 0; i < slot_count; ++i) {
AmsTrayData tray;
tray.slot_index = i;
tray.has_filament = filament_exist[i];
if (tray.has_filament) {
tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str), safe_at(filament_sub_type, i, empty_str));
tray.tray_color = safe_at(filament_color, i, default_color);
auto* bundle = GUI::wxGetApp().preset_bundle;
// Try to find a matching preset for this filament based on vendor, type and color.
// If not found, default to traditional search by type only or generic type mapping.
if (bundle) {
std::string vendor = safe_at(filament_vendor, i, empty_str);
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type,
tray.tray_color);
if (!filament_id.empty()) {
tray.tray_info_idx = filament_id;
BOOST_LOG_TRIVIAL(warning)
<< "Filament sync: Found manufacturer-specific profile for slot " << i << ": " << filament_id;
} else {
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
}
} else {
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
}
// Extract NFC temperature data if available
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
auto& nfc_slot = nfc_info[i];
std::string vendor = nfc_slot.value("VENDOR", "NONE");
if (vendor != "NONE" && !vendor.empty()) {
tray.bed_temp = nfc_slot.value("BED_TEMP", 0);
tray.nozzle_temp = nfc_slot.value("FIRST_LAYER_TEMP", 0);
}
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
}
} else {
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
}
trays.emplace_back(std::move(tray));
// Extract NFC temperature data if available
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
auto& nfc_slot = nfc_info[i];
std::string vendor = nfc_slot.value("VENDOR", "NONE");
if (vendor != "NONE" && !vendor.empty()) {
tray.bed_temp = nfc_slot.value("BED_TEMP", 0);
tray.nozzle_temp = nfc_slot.value("FIRST_LAYER_TEMP", 0);
}
}
}
build_ams_payload(1, slot_count - 1, trays);
}).detach();
trays.emplace_back(std::move(tray));
}
build_ams_payload(1, slot_count - 1, trays);
return true;
}
FilamentSyncMode SnapmakerPrinterAgent::get_filament_sync_mode() const
{
if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
return FilamentSyncMode::subscription;
return FilamentSyncMode::pull;
}
} // namespace Slic3r
@@ -3,8 +3,6 @@
#include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp"
#include <atomic>
#include <cstdint>
#include <string>
namespace Slic3r {
@@ -19,19 +17,10 @@ public:
AgentInfo get_agent_info() override { return get_agent_info_static(); }
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
FilamentSyncMode get_filament_sync_mode() const override;
int command_start_camera(std::string dev_id) override;
CameraStreamMode get_camera_stream_mode() const override { return CameraStreamMode::http_snapshot; }
std::string get_camera_url() const override { return device_info.base_url + "/server/files/camera/monitor.jpg"; }
private:
// Combine filament_type + filament_sub_type into a unified type string
static std::string combine_filament_type(const std::string& type, const std::string& sub_type);
void start_camera_monitor();
void on_status_loop_tick(const std::string& dev_id) override;
std::atomic<int64_t> m_camera_last_fire_ms{0};
};
} // namespace Slic3r
+7
View File
@@ -23,3 +23,10 @@ set_tests_properties(cli_project_missing_keys PROPERTIES
LABELS "CLI;RequiresApp"
SKIP_RETURN_CODE 77
TIMEOUT 900)
add_test(NAME cli_malformed_input
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_malformed_input.sh $<TARGET_FILE:OrcaSlicer> ${ORCA_CLI_TEST_PYTHON})
set_tests_properties(cli_malformed_input PROPERTIES
LABELS "CLI;RequiresApp"
SKIP_RETURN_CODE 77
TIMEOUT 900)
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env bash
# End-to-end checks that malformed CLI input fails cleanly, or loads, instead of crashing the
# orca-slicer binary. Each case lives inline in CLI::run(), so only the binary can reach it.
#
# - A project whose inherits_group does not have one entry per filament plus the process and
# printer entries still loads.
# - --slice N --arrange 1 on a project without plate metadata slices plate N.
# - An assemble list object with an empty filament list, or a negative filament id, is rejected
# as a config error.
# - --assemble with no input model is rejected as invalid parameters.
#
# usage: test_cli_malformed_input.sh <orca-slicer binary> <python3>
set -u
BIN="${1:-}"
PY="${2:-python3}"
# 77 is the test's SKIP_RETURN_CODE.
[ -x "$BIN" ] || { echo "SKIP: orca-slicer binary not found: $BIN"; exit 77; }
# From src/libslic3r/Utils.hpp. main() returns them, so the shell sees them modulo 256.
CLI_SUCCESS=0
CLI_INVALID_PARAMS=-2
CLI_CONFIG_FILE_ERROR=-5
WORK="$(mktemp -d "${TMPDIR:-/tmp}/orca-cli-malformed.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/datadir"
# Standalone presets: without "inherits" the CLI loads them as-is, with no preset bundle.
cat > "$WORK/machine.json" <<'EOF'
{
"type": "machine",
"from": "User",
"name": "CLI malformed input test printer",
"printable_area": ["0x0", "200x0", "200x200", "0x200"],
"printable_height": "100",
"layer_change_gcode": "G92 E0"
}
EOF
cat > "$WORK/process.json" <<'EOF'
{
"type": "process",
"from": "User",
"name": "CLI malformed input test process"
}
EOF
cat > "$WORK/filament.json" <<'EOF'
{
"type": "filament",
"from": "User",
"name": "CLI malformed input test filament"
}
EOF
"$PY" - "$WORK/cube.stl" <<'EOF'
import sys
v = [(x, y, z) for z in (0, 10) for y in (0, 10) for x in (0, 10)]
with open(sys.argv[1], "w") as f:
f.write("solid cube\n")
# Faces wound counter-clockwise seen from outside: -z, +z, -y, +y, -x, +x.
for a, b, c, d in ((0, 2, 3, 1), (4, 5, 7, 6), (0, 1, 5, 4), (2, 6, 7, 3), (0, 4, 6, 2), (1, 3, 7, 5)):
for tri in ((a, b, c), (a, c, d)):
f.write("facet normal 0 0 0\nouter loop\n")
for i in tri:
f.write("vertex %g %g %g\n" % v[i])
f.write("endloop\nendfacet\n")
f.write("endsolid cube\n")
EOF
fails=0
fail() { echo "FAIL: $*"; fails=$((fails + 1)); }
# run <tag> [option...]: run into $WORK/<tag>, keeping the log and the shell status there.
run() {
local out="$WORK/$1"; shift
mkdir -p "$out"
timeout 300 "$BIN" --datadir "$WORK/datadir" --outputdir "$out" "$@" > "$out/log" 2>&1
echo $? > "$out/status"
}
# run_presets <tag> [option...]: run with the standalone presets loaded.
run_presets() {
local tag="$1"; shift
run "$tag" --load-settings "$WORK/machine.json;$WORK/process.json" --load-filaments "$WORK/filament.json" "$@"
}
# expect_status <tag> <cli code>
expect_status() {
local got; got="$(cat "$WORK/$1/status")"
[ "$got" -eq $(( $2 & 255 )) ] || fail "$1: shell status $got, want $(( $2 & 255 )) (code $2)"
}
# expect_result <tag> <return_code>: a failing run must also carry an error_string.
expect_result() {
"$PY" - "$WORK/$1/result.json" "$2" <<'EOF' || fail "$1: result.json"
import json, sys
try:
with open(sys.argv[1]) as f:
result = json.load(f)
except (OSError, ValueError) as e:
sys.exit("cannot read %s: %s" % (sys.argv[1], e))
want_rc = int(sys.argv[2])
if result.get("return_code") != want_rc:
sys.exit("return_code %r, want %d" % (result.get("return_code"), want_rc))
if want_rc != 0 and not result.get("error_string"):
sys.exit("no error_string")
EOF
}
# expect_log <tag> <text>
expect_log() {
grep -qF -- "$2" "$WORK/$1/log" || fail "$1: log does not mention \"$2\""
}
# expect_gcode <tag>
expect_gcode() {
compgen -G "$WORK/$1/*.gcode" > /dev/null || fail "$1: no G-code was exported"
}
# rewrite_3mf <in> <out> inherits <json list> | no-plates
rewrite_3mf() {
"$PY" - "$@" <<'EOF'
import json, re, sys, zipfile
src, dst, mode = sys.argv[1:4]
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
for info in zin.infolist():
data = zin.read(info.filename)
if mode == "inherits" and info.filename == "Metadata/project_settings.config":
config = json.loads(data)
config["inherits_group"] = json.loads(sys.argv[4])
data = json.dumps(config, indent=4).encode()
elif mode == "no-plates":
if re.match(r"Metadata/plate_\d+\.", info.filename):
continue
if info.filename == "Metadata/model_settings.config":
data = re.sub(rb"\s*<plate>.*?</plate>", b"", data, flags=re.S)
zout.writestr(info, data)
EOF
}
# assemble_list <file> <filaments json>
assemble_list() {
cat > "$1" <<EOF
{"plates": [{"plate_name": "p", "need_arrange": false,
"objects": [{"path": "$WORK/cube.stl", "count": 1, "filaments": $2,
"pos_x": [100], "pos_y": [100]}]}]}
EOF
}
echo "== a one-filament project exported by the CLI is the base for the project cases"
run_presets export --slice 0 --export-3mf project.3mf "$WORK/cube.stl"
expect_status export $CLI_SUCCESS
[ -f "$WORK/export/project.3mf" ] || { echo "FAIL: project export failed"; tail -n 40 "$WORK/export/log"; exit 1; }
echo "== an inherits_group of the wrong length still loads"
for group in '[]' '[""]' '["", "", "", "", ""]'; do
tag="inherits_$("$PY" -c 'import json, sys; print(len(json.loads(sys.argv[1])))' "$group")"
rewrite_3mf "$WORK/export/project.3mf" "$WORK/$tag.3mf" inherits "$group"
run "$tag" --info "$WORK/$tag.3mf"
expect_status "$tag" $CLI_SUCCESS
expect_log "$tag" "inherits_group"
done
echo "== --slice 1 --arrange 1 slices a project without plate metadata"
rewrite_3mf "$WORK/export/project.3mf" "$WORK/no_plates.3mf" no-plates
run_presets no_plates --slice 1 --arrange 1 "$WORK/no_plates.3mf"
expect_status no_plates $CLI_SUCCESS
expect_result no_plates $CLI_SUCCESS
expect_gcode no_plates
echo "== an assemble list with a valid filament id slices"
assemble_list "$WORK/assemble_valid.json" '[1]'
run_presets assemble_valid --slice 0 --load-assemble-list "$WORK/assemble_valid.json"
expect_status assemble_valid $CLI_SUCCESS
expect_gcode assemble_valid
echo "== an assemble list with an empty filament list or a negative filament id is rejected"
for filaments in '[]' '[-1]'; do
if [ "$filaments" = '[]' ]; then tag=assemble_empty; else tag=assemble_negative; fi
assemble_list "$WORK/$tag.json" "$filaments"
run_presets "$tag" --slice 0 --load-assemble-list "$WORK/$tag.json"
expect_status "$tag" $CLI_CONFIG_FILE_ERROR
expect_result "$tag" $CLI_CONFIG_FILE_ERROR
done
echo "== --assemble with no input model is rejected"
for action in "--slice 0" "--export-3mf out.3mf"; do
tag="assemble_no_input_${action%% *}"
tag="${tag//-/}"
# shellcheck disable=SC2086
run_presets "$tag" --assemble $action
expect_status "$tag" $CLI_INVALID_PARAMS
expect_result "$tag" $CLI_INVALID_PARAMS
expect_log "$tag" "--assemble"
done
if [ "$fails" -ne 0 ]; then
for log in "$WORK"/*/log; do
echo "--- $log"
tail -n 40 "$log"
done
exit 1
fi
echo "PASS"
+29 -10
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env bash
# End-to-end check that the CLI loads a project's printer and process settings as the GUI does.
# End-to-end check that the CLI loads a project's printer, process and filament settings as the GUI does.
#
# The GUI takes every key a project does not list as changed from the project's current system preset:
# keys saved before an option existed, and keys holding an older system value. Keys the project lists
# in different_settings_to_system keep the project's value. A project is exported from the shipped
# Bambu Lab P1S presets; one printer key and one process key are removed, one printer key and one
# process key are changed without being listed, one key is changed and listed, and it is sliced again.
# Bambu Lab P1S presets with two filaments; one printer key and one process key are removed, one printer
# key, one process key and two filament keys (one per filament, one per extruder variant) are changed
# without being listed, one process key and the first filament's density are changed and listed, and it
# is sliced again: as is, with --uptodate, and with --load-filaments replacing only the second filament.
#
# usage: test_cli_project_missing_keys.sh <orca-slicer binary> <python3> <resources/profiles/BBL>
set -u
@@ -45,7 +47,7 @@ slice() {
slice base "$WORK/cube.stl" \
--load-settings "$PROFILES/machine/Bambu Lab P1S 0.4 nozzle.json;$PROFILES/process/0.20mm Standard @BBL X1C.json" \
--load-filaments "$PROFILES/filament/Bambu PLA Basic @BBL P1S 0.4 nozzle.json"
--load-filaments "$PROFILES/filament/Bambu PLA Basic @BBL P1S 0.4 nozzle.json;$PROFILES/filament/Bambu PLA Basic @BBL P1S 0.4 nozzle.json"
# The removed keys, with their option defaults from PrintConfig.cpp; stale keys changed without being
# listed as different, which must come back with the system value; and a listed key the project keeps.
@@ -67,30 +69,47 @@ with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED
for key in ("top_shell_layers", "extruder_clearance_height_to_rod"):
expected[key] = config[key]
config[key] = str(int(float(config[key])) + 1)
for key in ("filament_cost", "filament_max_volumetric_speed"):
expected[key] = config[key]
config[key] = [str(float(v) + 1) for v in config[key]]
expected["wall_loops"] = str(int(config["wall_loops"]) + 1)
config["wall_loops"] = expected["wall_loops"]
expected["filament_density"] = [str(float(config["filament_density"][0]) + 1)] + config["filament_density"][1:]
config["filament_density"] = [expected["filament_density"][0]] + [str(float(v) + 1) for v in config["filament_density"][1:]]
# One entry for the process, one per filament, one for the printer.
different = config["different_settings_to_system"]
different[0] = ";".join([k for k in different[0].split(";") if k] + ["wall_loops"])
different[1] = ";".join([k for k in different[1].split(";") if k] + ["filament_density"])
data = json.dumps(config, indent=4)
zout.writestr(item, data)
with open(dst + ".expected.json", "w") as f:
json.dump(expected, f)
EOF
slice project "$WORK/old.3mf"
"$PY" - "$WORK/project/out.3mf" "$WORK/old.3mf.expected.json" <<'EOF'
check() {
if ! "$PY" - "$WORK/$1/out.3mf" "$WORK/old.3mf.expected.json" "$1" <<'EOF'
import json, sys, zipfile
with zipfile.ZipFile(sys.argv[1]) as z:
config = json.loads(z.read("Metadata/project_settings.config"))
with open(sys.argv[2]) as f:
expected = json.load(f)
errors = ["%s is %r, want %r" % (key, config.get(key), want) for key, want in expected.items() if config.get(key) != want]
errors = ["%s: %s is %r, want %r" % (sys.argv[3], key, config.get(key), want) for key, want in expected.items() if config.get(key) != want]
for e in errors:
print("FAIL: " + e)
sys.exit(1 if errors else 0)
EOF
status=$?
[ "$status" -eq 0 ] || { tail -n 40 "$WORK/project/log"; exit 1; }
then
tail -n 40 "$WORK/$1/log"
exit 1
fi
}
slice project "$WORK/old.3mf"
check project
slice uptodate "$WORK/old.3mf" --uptodate
check uptodate
# The replaced second filament takes the system values the refresh would have given it.
slice partial "$WORK/old.3mf" --load-filaments ";$PROFILES/filament/Bambu PLA Basic @BBL P1S 0.4 nozzle.json"
check partial
echo "PASS"
+92
View File
@@ -4,7 +4,12 @@
#include <catch2/generators/catch_generators.hpp>
#include "libslic3r/libslic3r.h"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/Utils.hpp"
#include "test_utils.hpp"
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
@@ -79,3 +84,90 @@ TEST_CASE("Reserved keyword detection reports every offending line", "[GCodeProc
CHECK(tags.empty());
}
}
namespace {
// Closed outer-wall squares, each after a fast travel and before an inner-wall move, so the processor
// records seams and inserts actual speed moves. virtual_moves adds a VG1 move after each square.
void process_squares(int squares, GCodeProcessorResult &result, bool virtual_moves = false)
{
std::ostringstream gcode;
gcode << "M83\nG90\n";
for (int i = 0; i < squares; ++i) {
gcode << "G1 X10 Y10 Z" << 0.2 * (i + 1) << " F12000\n"
<< "; FEATURE: Outer wall\n"
<< "G1 X50 Y10 E2 F3000\nG1 X50 Y50 E2\nG1 X10 Y50 E2\nG1 X10 Y10 E2\n"
<< "; FEATURE: Inner wall\n"
<< "G1 X12 Y12 E0.1\nG1 X30 Y12 E1\n";
if (virtual_moves)
gcode << "VG1 X20 Y30 F12000\n";
}
FullPrintConfig config;
config.gcode_flavor.value = gcfMarlinFirmware;
// s_IsBBLPrinter selects the "; FEATURE: " role tags this G-code uses.
const bool was_bbl_printer = GCodeProcessor::s_IsBBLPrinter;
const ScopeGuard restore_bbl_printer([was_bbl_printer] { GCodeProcessor::s_IsBBLPrinter = was_bbl_printer; });
GCodeProcessor::s_IsBBLPrinter = true;
ScopedTemporaryFile temp(".gcode");
std::ofstream(temp.string()) << gcode.str();
GCodeProcessor processor;
processor.apply_config(config);
processor.process_file(temp.string());
result = std::move(processor.extract_result());
}
bool is_block_move(const GCodeProcessorResult::MoveVertex &move)
{
return !move.internal_only && (move.type == EMoveType::Extrude || move.type == EMoveType::Travel);
}
} // namespace
TEST_CASE("Actual speed moves are inserted on their block's segment just before its move", "[GCodeProcessor]")
{
// 60 squares take several planner passes, which remap the blocks kept between passes.
const int squares = GENERATE(10, 60);
const bool virtual_moves = GENERATE(false, true);
GCodeProcessorResult result;
process_squares(squares, result, virtual_moves);
const auto &moves = result.moves;
constexpr size_t normal = size_t(PrintEstimatedStatistics::ETimeMode::Normal);
size_t inserted = 0;
for (size_t i = 1; i < moves.size(); ++i) {
if (!moves[i].internal_only)
continue;
++inserted;
// Inserted moves have zero time, but a VG1 block's time is written to whatever move its move_id names.
if (!virtual_moves)
CHECK(moves[i].time[normal] == 0.f);
size_t block = i + 1;
while (block < moves.size() && moves[block].internal_only)
++block;
size_t previous = i - 1;
while (previous > 0 && moves[previous].internal_only)
--previous;
REQUIRE(block < moves.size());
CHECK(moves[block].gcode_id == moves[i].gcode_id);
const Vec3f segment = moves[block].position - moves[previous].position;
const Vec3f offset = moves[i].position - moves[previous].position;
CHECK(segment.cross(offset).norm() / segment.norm() < 1e-3f);
}
REQUIRE(inserted > 0);
}
TEST_CASE("A seam takes the actual speed of the move it follows", "[GCodeProcessor]")
{
GCodeProcessorResult result;
// 10 squares fit in one planner pass, so the seam's move and the block after it are timed together.
process_squares(10, result);
const auto &moves = result.moves;
size_t seams = 0;
for (size_t i = 1; i < moves.size(); ++i)
if (moves[i].type == EMoveType::Seam && is_block_move(moves[i - 1])) {
++seams;
CHECK_THAT(moves[i].actual_feedrate, Catch::Matchers::WithinAbs(moves[i - 1].actual_feedrate, 1e-4));
}
REQUIRE(seams > 0);
}
+120 -36
View File
@@ -22,6 +22,7 @@
#include "libslic3r/Print.hpp"
#include <limits>
#include <optional>
#include <regex>
#include <set>
#include <sstream>
#include <string>
@@ -842,6 +843,47 @@ TEST_CASE("Each filament sets the pressure advance of its extruder variant", "[M
}
}
// A two-extruder printer, Standard nozzle on extruder 1 and High Flow on extruder 2, whose per-variant arrays
// hold filament 1 Standard, filament 1 High Flow, filament 2 Standard and filament 2 High Flow.
static DynamicPrintConfig two_extruder_pressure_advance_config(const std::string &filament_map, const std::string &adaptive_pressure_advance,
int wall_filament, int infill_filament)
{
DynamicPrintConfig config = multifilament_config(2, {
{ "gcode_flavor", "klipper" },
{ "single_extruder_multi_material", 0 },
{ "nozzle_diameter", "0.4,0.4" },
{ "extruder_printable_height", "0,0" },
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard;Direct Drive High Flow" },
{ "extruder_variant_list", "Direct Drive Standard;Direct Drive High Flow" },
{ "filament_map", filament_map },
{ "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard;Direct Drive High Flow" },
{ "filament_self_index", "1,1,2,2" },
{ "enable_pressure_advance", "1,1,1,1" },
{ "pressure_advance", "0.021,0.037,0.043,0.049" },
{ "adaptive_pressure_advance", adaptive_pressure_advance },
{ "sparse_infill_filament_id", infill_filament },
{ "internal_solid_filament_id", infill_filament },
{ "top_surface_filament_id", infill_filament },
{ "bottom_surface_filament_id", infill_filament },
{ "outer_wall_filament_id", wall_filament },
{ "inner_wall_filament_id", wall_filament },
{ "enable_prime_tower", 0 },
{ "skirt_loops", 0 },
{ "brim_type", "no_brim" },
// custom G-code indexes the per-filament arrays by filament
{ "machine_start_gcode", "; start pressure advance {pressure_advance[initial_extruder]}" },
});
config.option<ConfigOptionStrings>("adaptive_pressure_advance_model")->values = {
constant_pressure_advance_model("0.012"), constant_pressure_advance_model("0.034"),
constant_pressure_advance_model("0.056"), constant_pressure_advance_model("0.078") };
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = { etDirectDrive, etDirectDrive };
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = { nvtStandard, nvtHighFlow };
// print each filament on the extruder filament_map gives it
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = fmmManual;
return config;
}
// On a printer with two extruders, a filament takes the pressure advance of the variant of the extruder
// it is mapped to, whichever filament and extruder that is.
TEST_CASE("Each filament sets the pressure advance of its extruder variant on a two-extruder printer", "[MultiFilament]")
@@ -857,42 +899,8 @@ TEST_CASE("Each filament sets the pressure advance of its extruder variant on a
// the other filament goes on the other extruder
const std::string filament_map = filament == 1 ? std::to_string(extruder) + "," + std::to_string(3 - extruder) :
std::to_string(3 - extruder) + "," + std::to_string(extruder);
DynamicPrintConfig config = multifilament_config(2, {
{ "gcode_flavor", "klipper" },
{ "single_extruder_multi_material", 0 },
{ "nozzle_diameter", "0.4,0.4" },
{ "extruder_printable_height", "0,0" },
// extruder 1 has a Standard nozzle, extruder 2 a High Flow one
{ "printer_extruder_id", "1,2" },
{ "printer_extruder_variant", "Direct Drive Standard;Direct Drive High Flow" },
{ "extruder_variant_list", "Direct Drive Standard;Direct Drive High Flow" },
{ "filament_map", filament_map },
// both filaments define Standard and High Flow
{ "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard;Direct Drive High Flow" },
{ "filament_self_index", "1,1,2,2" },
{ "enable_pressure_advance", "1,1,1,1" },
{ "pressure_advance", "0.021,0.037,0.043,0.049" },
{ "adaptive_pressure_advance", adaptive ? "1,1,1,1" : "0,0,0,0" },
{ "sparse_infill_filament_id", filament },
{ "internal_solid_filament_id", filament },
{ "top_surface_filament_id", filament },
{ "bottom_surface_filament_id", filament },
{ "outer_wall_filament_id", filament },
{ "inner_wall_filament_id", filament },
{ "enable_prime_tower", 0 },
{ "skirt_loops", 0 },
{ "brim_type", "no_brim" },
// custom G-code indexes the per-filament arrays by filament
{ "machine_start_gcode", "; start pressure advance {pressure_advance[initial_extruder]}" },
});
config.option<ConfigOptionStrings>("adaptive_pressure_advance_model")->values = {
constant_pressure_advance_model("0.012"), constant_pressure_advance_model("0.034"),
constant_pressure_advance_model("0.056"), constant_pressure_advance_model("0.078") };
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = { etDirectDrive, etDirectDrive };
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = { nvtStandard, nvtHighFlow };
// keep the mapping above rather than grouping the filaments automatically
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = fmmManual;
const std::string gcode = slice({ cube(20) }, config);
const std::string gcode = slice({ cube(20) },
two_extruder_pressure_advance_config(filament_map, adaptive ? "1,1,1,1" : "0,0,0,0", filament, filament));
std::set<std::string> expected{ pressure_advance };
if (adaptive)
@@ -902,6 +910,82 @@ TEST_CASE("Each filament sets the pressure advance of its extruder variant on a
}
}
// Filament 1 prints the walls on extruder 1 (variant index 0), filament 2 the infill on extruder 2 (variant index 3).
TEST_CASE("Adaptive pressure advance on one extruder leaves the other extruder's pressure advance alone", "[MultiFilament]")
{
auto [adaptive, expected] = GENERATE(table<std::string, std::set<std::string>>({
{ "1,0,0,0", { "0.021", "0.049", "0.012" } },
{ "0,0,0,1", { "0.021", "0.049", "0.078" } },
}));
DYNAMIC_SECTION("adaptive " << adaptive) {
const std::string gcode = slice({ cube(20) }, two_extruder_pressure_advance_config("1,2", adaptive, 1, 2));
CHECK(pressure_advance_values(gcode) == expected);
}
}
// The pressure advance values a Klipper G-code sets while `tool` is active, in order, without repeating the value already set.
static std::vector<std::string> pressure_advance_sequence(const std::string &gcode, int tool)
{
const std::string token = "SET_PRESSURE_ADVANCE ADVANCE=";
std::vector<std::string> values;
int current_tool = 0;
std::istringstream stream(gcode);
for (std::string line; std::getline(stream, line);) {
if (line.size() > 1 && line[0] == 'T' && std::isdigit((unsigned char) line[1]))
current_tool = std::stoi(line.substr(1));
else if (current_tool == tool && line.rfind(token, 0) == 0) {
std::string value = line.substr(token.size(), line.find(';') - token.size());
if (values.empty() || values.back() != value)
values.push_back(std::move(value));
}
}
return values;
}
TEST_CASE("Adaptive pressure advance predicts the same values after layers only a non-adaptive extruder prints", "[MultiFilament]")
{
auto slice_with = [](const std::string &adaptive) {
DynamicPrintConfig config = two_extruder_pressure_advance_config("1,2", adaptive, 1, 1);
config.set_deserialize_strict({
{ "print_sequence", "by object" },
// extruder 2 moves at one speed on its first layer, so no G1 F follows its first PA_CHANGE tag
{ "filament_max_volumetric_speed", "100" },
{ "slow_down_for_layer_cooling", "0" },
{ "travel_speed", "120,40" },
{ "retraction_speed", "30,40" },
{ "deretraction_speed", "30,40" },
{ "initial_layer_speed", "30,40" },
{ "initial_layer_infill_speed", "60,40" },
});
auto &models = config.option<ConfigOptionStrings>("adaptive_pressure_advance_model")->values;
// with adaptive pressure advance on, filament 1 gets PA_CHANGE tags on every layer but keeps its pressure advance
models[0] = constant_pressure_advance_model("0.021");
// a prediction that rises with flow, so it depends on the print speed
models[3] = "0.01,1,1000\n0.09,40,1000\n0.01,1,100000\n0.09,40,100000";
return slice_with_object_overrides({ cube(20), cube(20) }, config,
{ {}, { { "outer_wall_filament_id", 2 }, { "inner_wall_filament_id", 2 }, { "sparse_infill_filament_id", 2 },
{ "internal_solid_filament_id", 2 }, { "top_surface_filament_id", 2 }, { "bottom_surface_filament_id", 2 } } });
};
const std::vector<std::string> expected = pressure_advance_sequence(slice_with("1,0,0,1"), 1);
REQUIRE(expected.size() > 2);
CHECK(pressure_advance_sequence(slice_with("0,0,0,1"), 1) == expected);
}
TEST_CASE("Adaptive pressure advance on an unused extruder variant leaves the G-code unchanged", "[MultiFilament]")
{
const std::string adaptive = GENERATE("0,1,0,0", "0,0,1,0");
DYNAMIC_SECTION("adaptive " << adaptive) {
// the time and object ids differ between any two slices, and the config block lists the setting itself
auto masked = [](const std::string &gcode) {
return std::regex_replace(gcode.substr(0, gcode.find("; CONFIG_BLOCK_START")), std::regex("; generated by .*| id:\\d+"), "");
};
const std::string reference = masked(slice({ cube(20) }, two_extruder_pressure_advance_config("1,2", "0,0,0,0", 1, 2)));
const std::string gcode = masked(slice({ cube(20) }, two_extruder_pressure_advance_config("1,2", adaptive, 1, 2)));
REQUIRE(reference.find("SET_PRESSURE_ADVANCE") != std::string::npos);
CHECK(gcode == reference);
}
}
// The speeds, in percent, a G-code turns a fan on at: the part cooling fan for `M106 S`, the auxiliary
// fan for `M106 P2 S`.
static std::set<int> fan_speeds(const std::string &gcode, const std::string &command)
+1
View File
@@ -9,6 +9,7 @@ add_executable(${_TEST_NAME}_tests
test_appconfig.cpp
test_arachne_walls.cpp
test_arrange.cpp
test_assemble_list.cpp
test_bambu_networking.cpp
test_buildvolume.cpp
test_calib.cpp
+266
View File
@@ -0,0 +1,266 @@
#include <catch2/catch_all.hpp>
#include "libslic3r/Format/AssembleList.hpp"
#include "test_utils.hpp"
#include <boost/nowide/fstream.hpp>
#include <nlohmann/json.hpp>
#include <string>
#include <vector>
using namespace Slic3r;
using Catch::Matchers::WithinAbs;
using nlohmann::json;
static constexpr int max_plates = 36;
static AssembleListResult load_text(const std::string &text, std::vector<assemble_plate_info_t> &plates)
{
ScopedTemporaryFile file(".json");
{
boost::nowide::ofstream out(file.string());
out << text;
}
return load_assemble_plate_list(file.string(), plates, max_plates);
}
static AssembleListResult load_json(const json &root)
{
std::vector<assemble_plate_info_t> plates;
return load_text(root.dump(), plates);
}
// One plate with one object of three clones, which every optional field accepts.
static json valid_list()
{
return json::parse(R"({
"plates": [{
"plate_name": "plate",
"need_arrange": false,
"objects": [{
"path": "cube.stl",
"count": 3,
"filaments": [1],
"height_ranges": [{ "min_z": 0, "max_z": 5, "range_params": { "layer_height": "0.1" } }]
}],
"assembled_params": [{
"assemble_index": 1,
"height_ranges": [{ "min_z": 0, "max_z": 5, "range_params": { "layer_height": "0.1" } }]
}]
}]
})");
}
TEST_CASE("A valid assemble list parses into its plates and objects", "[AssembleList]")
{
const std::string text = R"({
"plates": [
{
"plate_name": "first",
"need_arrange": true,
"plate_params": { "curr_bed_type": "Textured PEI Plate" },
"objects": [
{
"path": "a.stl",
"count": 2,
"filaments": [1, 3],
"assemble_index": [1],
"pos_x": [10.5, 20.5],
"pos_y": [30],
"pos_z": [0, 1],
"print_params": { "sparse_infill_density": "30%" },
"height_ranges": [{ "min_z": 1.5, "max_z": 4, "range_params": { "layer_height": "0.12" } }]
},
{ "path": "b.stl", "count": 1, "filaments": [0] }
],
"assembled_params": [{ "assemble_index": 1, "print_params": { "wall_loops": "4" } }]
},
{
"plate_name": "second",
"need_arrange": false,
"objects": [{ "path": "c.stl", "count": 1, "filaments": [2] }]
}
]
})";
std::vector<assemble_plate_info_t> plates;
REQUIRE(load_text(text, plates) == AssembleListResult::Success);
REQUIRE(plates.size() == 2);
const assemble_plate_info_t &first = plates[0];
CHECK(first.plate_name == "first");
CHECK(first.need_arrange);
CHECK(first.plate_params.at("curr_bed_type") == "Textured PEI Plate");
REQUIRE(first.assemble_obj_list.size() == 2);
const assemble_object_info_t &a = first.assemble_obj_list[0];
CHECK(a.path == "a.stl");
CHECK(a.count == 2);
CHECK(a.filaments == std::vector<int>{1, 3});
CHECK(a.assemble_index == std::vector<int>{1});
REQUIRE(a.pos_x.size() == 2);
CHECK_THAT(a.pos_x[0], WithinAbs(10.5, 1e-6));
CHECK_THAT(a.pos_x[1], WithinAbs(20.5, 1e-6));
REQUIRE(a.pos_y.size() == 1);
CHECK_THAT(a.pos_y[0], WithinAbs(30., 1e-6));
REQUIRE(a.pos_z.size() == 2);
CHECK_THAT(a.pos_z[1], WithinAbs(1., 1e-6));
CHECK(a.print_params.at("sparse_infill_density") == "30%");
REQUIRE(a.height_ranges.size() == 1);
CHECK_THAT(a.height_ranges[0].min_z, WithinAbs(1.5, 1e-6));
CHECK_THAT(a.height_ranges[0].max_z, WithinAbs(4., 1e-6));
CHECK(a.height_ranges[0].range_params.at("layer_height") == "0.12");
const assemble_object_info_t &b = first.assemble_obj_list[1];
CHECK(b.path == "b.stl");
CHECK(b.count == 1);
CHECK(b.filaments == std::vector<int>{0});
CHECK(b.pos_x.empty());
CHECK(b.assemble_index.empty());
REQUIRE(first.assembled_param_list.count(1) == 1);
CHECK(first.assembled_param_list.at(1).print_params.at("wall_loops") == "4");
const assemble_plate_info_t &second = plates[1];
CHECK(second.plate_name == "second");
CHECK_FALSE(second.need_arrange);
REQUIRE(second.assemble_obj_list.size() == 1);
CHECK(second.assemble_obj_list[0].path == "c.stl");
CHECK(second.assemble_obj_list[0].filaments == std::vector<int>{2});
}
TEST_CASE("The unmodified fixture used by the rule tests is accepted", "[AssembleList]")
{
CHECK(load_json(valid_list()) == AssembleListResult::Success);
}
TEST_CASE("An object with an empty filament list is rejected", "[AssembleList]")
{
json root = valid_list();
root["plates"][0]["objects"][0]["filaments"] = json::array();
CHECK(load_json(root) == AssembleListResult::ConfigError);
}
TEST_CASE("An object with a negative filament id is rejected", "[AssembleList]")
{
json root = valid_list();
root["plates"][0]["objects"][0]["filaments"] = GENERATE(json::array({-1}), json::array({1, -2, 1}));
CAPTURE(root["plates"][0]["objects"][0]["filaments"].dump());
CHECK(load_json(root) == AssembleListResult::ConfigError);
}
TEST_CASE("Filament id 0 is accepted", "[AssembleList]")
{
json root = valid_list();
root["plates"][0]["objects"][0]["filaments"] = GENERATE(json::array({0}), json::array({0, 1, 0}));
CAPTURE(root["plates"][0]["objects"][0]["filaments"].dump());
CHECK(load_json(root) == AssembleListResult::Success);
}
TEST_CASE("Per-clone lists need one entry or one per clone", "[AssembleList]")
{
// The fixture object has 3 clones.
const std::string key = GENERATE("filaments", "assemble_index", "pos_x", "pos_y", "pos_z");
const size_t size = GENERATE(1, 2, 3, 4);
CAPTURE(key, size);
json root = valid_list();
root["plates"][0]["objects"][0][key] = json(std::vector<int>(size, 1));
const AssembleListResult expected = (size == 1 || size == 3) ? AssembleListResult::Success : AssembleListResult::ConfigError;
CHECK(load_json(root) == expected);
}
TEST_CASE("An empty optional per-clone list is accepted", "[AssembleList]")
{
const std::string key = GENERATE("assemble_index", "pos_x", "pos_y", "pos_z");
CAPTURE(key);
json root = valid_list();
root["plates"][0]["objects"][0][key] = json::array();
CHECK(load_json(root) == AssembleListResult::Success);
}
// Fields read through a const reference (plate_name, need_arrange, objects, path, count) are
// looked up without a presence check, so only their wrong-type case is covered here.
TEST_CASE("A missing required field is rejected", "[AssembleList]")
{
const std::string pointer = GENERATE("/plates",
"/plates/0/objects/0/filaments",
"/plates/0/objects/0/height_ranges/0/min_z",
"/plates/0/objects/0/height_ranges/0/max_z",
"/plates/0/objects/0/height_ranges/0/range_params",
"/plates/0/assembled_params/0/assemble_index",
"/plates/0/assembled_params/0/height_ranges/0/min_z",
"/plates/0/assembled_params/0/height_ranges/0/max_z",
"/plates/0/assembled_params/0/height_ranges/0/range_params");
CAPTURE(pointer);
json root = valid_list();
const json::json_pointer ptr(pointer);
root[ptr.parent_pointer()].erase(ptr.back());
CHECK(load_json(root) == AssembleListResult::ConfigError);
}
TEST_CASE("A field of the wrong type is rejected", "[AssembleList]")
{
const std::string pointer = GENERATE("/plates/0/plate_name",
"/plates/0/need_arrange",
"/plates/0/objects/0/path",
"/plates/0/objects/0/count",
"/plates/0/objects/0/filaments",
"/plates/0/objects/0/pos_x");
CAPTURE(pointer);
json root = valid_list();
root[json::json_pointer(pointer)] = json::object();
CHECK(load_json(root) == AssembleListResult::ConfigError);
}
TEST_CASE("A plate or clone count out of range is rejected", "[AssembleList]")
{
SECTION("no plates")
{
json root = valid_list();
root["plates"] = json::array();
CHECK(load_json(root) == AssembleListResult::ConfigError);
}
SECTION("more plates than the limit")
{
json root = valid_list();
const json plate = root["plates"][0];
for (int i = 1; i < max_plates; ++i)
root["plates"].push_back(plate);
CHECK(load_json(root) == AssembleListResult::Success);
root["plates"].push_back(plate);
CHECK(load_json(root) == AssembleListResult::ConfigError);
}
SECTION("a plate with no objects")
{
json root = valid_list();
root["plates"][0]["objects"] = json::array();
CHECK(load_json(root) == AssembleListResult::ConfigError);
}
SECTION("a clone count below 1")
{
json root = valid_list();
root["plates"][0]["objects"][0]["count"] = GENERATE(0, -1);
CAPTURE(root["plates"][0]["objects"][0]["count"].dump());
CHECK(load_json(root) == AssembleListResult::ConfigError);
}
}
TEST_CASE("Malformed JSON is rejected", "[AssembleList]")
{
const std::string text = GENERATE(std::string(), std::string("{\"plates\": ["), std::string("not json"));
CAPTURE(text);
std::vector<assemble_plate_info_t> plates;
CHECK(load_text(text, plates) == AssembleListResult::ConfigError);
}
TEST_CASE("A missing file is reported as not found", "[AssembleList]")
{
ScopedTemporaryFile file(".json");
std::vector<assemble_plate_info_t> plates;
CHECK(load_assemble_plate_list(file.string(), plates, max_plates) == AssembleListResult::FileNotFound);
}
+104
View File
@@ -536,6 +536,110 @@ SCENARIO("update_diff_values_to_child_config keeps a child's values on variants
}
}
SCENARIO("update_non_diff_values_to_base_config keeps a project's changed values on variants it does not list",
"[Config][Variant]") {
std::set<std::string> no_keys;
auto variants = [](std::initializer_list<std::string> names) { return new Slic3r::ConfigOptionStrings(names); };
GIVEN("A filament base with three variants") {
Slic3r::DynamicPrintConfig base;
base.set_key_value("filament_extruder_variant",
variants({"Direct Drive Standard", "Bowden Standard", "Direct Drive High Flow"}));
base.set_deserialize_strict("nozzle_temperature", "220,220,220");
WHEN("the project was saved when the base had only its first variant") {
Slic3r::DynamicPrintConfig project;
project.set_key_value("filament_extruder_variant", variants({"Direct Drive Standard"}));
project.set_deserialize_strict("nozzle_temperature", "199");
AND_WHEN("the project lists the value as changed") {
project.update_non_diff_values_to_base_config(base, project.keys(), {"nozzle_temperature"}, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("the project's value applies to every variant") {
REQUIRE(project.opt_serialize("nozzle_temperature") == "199,199,199");
}
}
AND_WHEN("the project does not list the value as changed") {
project.update_non_diff_values_to_base_config(base, project.keys(), {}, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("the base's values replace it") {
REQUIRE(project.opt_serialize("nozzle_temperature") == "220,220,220");
}
}
}
WHEN("the project lists every variant, in another order") {
Slic3r::DynamicPrintConfig project;
project.set_key_value("filament_extruder_variant",
variants({"Bowden Standard", "Direct Drive High Flow", "Direct Drive Standard"}));
project.set_deserialize_strict("nozzle_temperature", "190,205,199");
project.update_non_diff_values_to_base_config(base, project.keys(), {"nozzle_temperature"}, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("each variant keeps its own value") {
REQUIRE(project.opt_serialize("nozzle_temperature") == "199,190,205");
}
}
WHEN("the project lists no variants") {
Slic3r::DynamicPrintConfig project;
project.set_deserialize_strict("nozzle_temperature", "199");
project.update_non_diff_values_to_base_config(base, project.keys(), {"nozzle_temperature"}, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("the project's value applies to every variant") {
REQUIRE(project.opt_serialize("nozzle_temperature") == "199,199,199");
}
}
}
GIVEN("A two-extruder printer base with two variants per extruder") {
Slic3r::DynamicPrintConfig base;
base.set_key_value("printer_extruder_variant",
variants({"Direct Drive Standard", "Direct Drive High Flow", "Direct Drive Standard", "Direct Drive High Flow"}));
base.set_key_value("printer_extruder_id", new Slic3r::ConfigOptionInts({1, 1, 2, 2}));
base.set_deserialize_strict("retraction_length", "0.8,0.8,0.8,0.8");
WHEN("the project lists only the Standard variant of each extruder") {
Slic3r::DynamicPrintConfig project;
project.set_key_value("printer_extruder_variant", variants({"Direct Drive Standard", "Direct Drive Standard"}));
project.set_key_value("printer_extruder_id", new Slic3r::ConfigOptionInts({1, 2}));
project.set_deserialize_strict("retraction_length", "1.1,2.2");
project.update_non_diff_values_to_base_config(base, project.keys(), {"retraction_length"}, "printer_extruder_id", "printer_extruder_variant",
Slic3r::printer_options_with_variant_1,
Slic3r::printer_options_with_variant_2);
THEN("each extruder's High Flow variant takes that extruder's value") {
REQUIRE(project.opt_serialize("retraction_length") == "1.1,1.1,2.2,2.2");
}
}
WHEN("the project lists only the Standard variant of each extruder, and the variant lists as changed") {
Slic3r::DynamicPrintConfig project;
project.set_key_value("printer_extruder_variant", variants({"Direct Drive Standard", "Direct Drive Standard"}));
project.set_key_value("printer_extruder_id", new Slic3r::ConfigOptionInts({1, 2}));
project.set_deserialize_strict("machine_max_speed_x", "300,100,400,150");
base.set_deserialize_strict("machine_max_speed_x", "500,200,500,200,500,200,500,200");
project.update_non_diff_values_to_base_config(base, project.keys(),
{"machine_max_speed_x", "printer_extruder_id", "printer_extruder_variant"},
"printer_extruder_id", "printer_extruder_variant",
Slic3r::printer_options_with_variant_1,
Slic3r::printer_options_with_variant_2);
THEN("the variant lists are the base's") {
REQUIRE(project.opt_serialize("printer_extruder_variant") == base.opt_serialize("printer_extruder_variant"));
REQUIRE(project.opt_serialize("printer_extruder_id") == "1,1,2,2");
}
THEN("each extruder's High Flow variant takes that extruder's pair of limits") {
REQUIRE(project.opt_serialize("machine_max_speed_x") == "300,100,300,100,400,150,400,150");
}
}
WHEN("the project lists no variants") {
Slic3r::DynamicPrintConfig project;
project.set_deserialize_strict("retraction_length", "1.1");
project.update_non_diff_values_to_base_config(base, project.keys(), {"retraction_length"}, "printer_extruder_id", "printer_extruder_variant",
Slic3r::printer_options_with_variant_1,
Slic3r::printer_options_with_variant_2);
THEN("only the first extruder's variants take the project's value") {
REQUIRE(project.opt_serialize("retraction_length") == "1.1,1.1,0.8,0.8");
}
}
}
}
// SCENARIO("DynamicPrintConfig JSON serialization", "[Config]") {
// WHEN("DynamicPrintConfig is serialized and deserialized") {
// auto now = std::chrono::high_resolution_clock::now();
+19
View File
@@ -69,3 +69,22 @@ TEST_CASE("a setter nested in another sets C again when the locale changed betwe
CHECK_FALSE(is_decimal_separator_point());
}
}
TEST_CASE("atof_decimal_point parses what atof parses in the C locale", "[LocalesUtils]")
{
const auto [text, value] = GENERATE(table<const char*, double>({
{ "5", 5. },
{ " 12.5", 12.5 },
{ "\t+3", 3. },
{ "\r\n7", 7. },
{ "-1.25", -1.25 },
{ "1e2", 100. },
{ ".5", 0.5 },
{ "12.5;comment", 12.5 },
{ "+-5", 0. },
{ "", 0. },
{ "abc", 0. },
}));
INFO(text);
CHECK_THAT(atof_decimal_point(text), Catch::Matchers::WithinAbs(value, 1e-12));
}
+128 -2
View File
@@ -5816,6 +5816,131 @@ TEST_CASE("A system preset no vendor lists is not resolved", "[Preset][Bundle]")
namespace {
// Writes each vendor's preset cache into dir, then deletes its profile JSONs: what a release build installs.
void reduce_vendors_to_caches(const fs::path &dir, const std::vector<std::string> &vendor_ids)
{
const std::string lib(PresetBundle::ORCA_FILAMENT_LIBRARY);
PresetBundle library;
if (fs::exists(dir / (lib + ".json"))) {
library.set_generate_vendor_caches(true);
library.load_vendor_configs_from_json(dir.string(), lib, PresetBundle::LoadSystem,
ForwardCompatibilitySubstitutionRule::EnableSilent);
}
for (const std::string &vendor_id : vendor_ids) {
if (vendor_id == lib)
continue;
PresetBundle writer;
writer.set_generate_vendor_caches(true);
writer.load_vendor_configs_from_json(dir.string(), vendor_id, PresetBundle::LoadSystem,
ForwardCompatibilitySubstitutionRule::EnableSilent, &library);
}
for (const std::string &vendor_id : vendor_ids) {
REQUIRE(fs::exists(dir / (vendor_id + ".opc")));
fs::remove(dir / (vendor_id + ".json"));
fs::remove_all(dir / vendor_id);
}
}
// The filament library with one abstract base filament, and an "Acme" vendor whose one filament inherits it.
void write_library_and_acme_filament(const fs::path &root)
{
const std::string lib(PresetBundle::ORCA_FILAMENT_LIBRARY);
fs::create_directories(root / lib / "filament");
std::ofstream((root / (lib + ".json")).string())
<< R"({"version":"1.0.0","name":")" << lib << R"(",)"
<< R"("filament_list":[{"name":"Generic PLA","sub_path":"filament/generic_pla.json"}]})";
std::ofstream((root / lib / "filament" / "generic_pla.json").string())
<< R"({"type":"filament","name":"Generic PLA","from":"system","instantiation":"false","filament_id":"GFL99","filament_cost":"27"})";
fs::create_directories(root / "Acme" / "filament");
std::ofstream((root / "Acme.json").string())
<< R"({"version":"1.0.0","name":"Acme","filament_list":[{"name":"Acme PLA","sub_path":"filament/pla.json"}]})";
std::ofstream((root / "Acme" / "filament" / "pla.json").string())
<< R"({"type":"filament","name":"Acme PLA","from":"system","instantiation":"true","inherits":"Generic PLA"})";
}
} // namespace
TEST_CASE("A read-only load resolves a user preset against vendors installed as their cache alone", "[Preset][Bundle][Regression]")
{
ScopedTemporaryDir temp_dir;
const fs::path data = temp_dir.path() / "data";
const fs::path system = data / PRESET_SYSTEM_DIR;
ScopedDataDir scoped_data(data);
ScopedResourcesDir scoped_resources(temp_dir.path() / "resources");
write_acme_printer_vendor(system, 33.);
reduce_vendors_to_caches(system, {"Acme"});
fs::create_directories(data / PRESET_USER_DIR / DEFAULT_USER_FOLDER_NAME / PRESET_PRINTER_NAME);
std::ofstream((data / PRESET_USER_DIR / DEFAULT_USER_FOLDER_NAME / PRESET_PRINTER_NAME / "My Acme.json").string())
<< R"({"type":"machine","name":"My Acme","from":"User","version":"2.3.0.0","inherits":"Acme Printer","printable_height":"123"})";
AppConfig app_config;
PresetBundle bundle;
std::string errors;
bundle.load_presets(app_config, ForwardCompatibilitySubstitutionRule::EnableSilent, PresetBundle::PresetPreferences(),
&errors, true);
CHECK(errors.empty());
const Preset *preset = bundle.printers.find_preset("My Acme");
REQUIRE(preset != nullptr);
CHECK_THAT(preset->config.opt_float("printable_height"), Catch::Matchers::WithinAbs(123., 1e-6));
CHECK_THAT(preset->config.opt_float("extruder_clearance_dist_to_rod"), Catch::Matchers::WithinAbs(33., 1e-6));
}
TEST_CASE("A read-only load writes no preset cache", "[Preset][Bundle][Regression]")
{
ScopedTemporaryDir temp_dir;
const fs::path system = temp_dir.path() / "data" / PRESET_SYSTEM_DIR;
ScopedDataDir scoped_data(temp_dir.path() / "data");
ScopedResourcesDir scoped_resources(temp_dir.path() / "resources");
write_acme_printer_vendor(system, 33.);
AppConfig app_config;
PresetBundle bundle;
std::string errors;
bundle.load_presets(app_config, ForwardCompatibilitySubstitutionRule::EnableSilent, PresetBundle::PresetPreferences(),
&errors, true);
CHECK(errors.empty());
CHECK(bundle.printers.find_preset("Acme Printer") != nullptr);
CHECK_FALSE(fs::exists(system / "Acme.opc"));
}
TEST_CASE("A vendor updated over the air resolves against the library installed as its cache alone", "[Preset][Bundle][Regression]")
{
ScopedTemporaryDir temp_dir;
const fs::path system = temp_dir.path() / "data" / PRESET_SYSTEM_DIR;
ScopedDataDir scoped_data(temp_dir.path() / "data");
ScopedResourcesDir scoped_resources(temp_dir.path() / "resources");
// System presets are found by name through the bundled profiles.
write_library_and_acme_filament(temp_dir.path() / "resources" / PRESET_PROFILES_DIR);
// The release install, then an update that brings Acme back as JSONs while the library stays a cache.
write_library_and_acme_filament(system);
reduce_vendors_to_caches(system, {PresetBundle::ORCA_FILAMENT_LIBRARY, "Acme"});
write_library_and_acme_filament(temp_dir.path() / "update");
fs::copy_file(temp_dir.path() / "update" / "Acme.json", system / "Acme.json");
fs::create_directories(system / "Acme" / "filament");
fs::copy_file(temp_dir.path() / "update" / "Acme" / "filament" / "pla.json", system / "Acme" / "filament" / "pla.json");
SECTION("by name") {
PresetBundle bundle;
DynamicPrintConfig config;
std::string error;
REQUIRE(bundle.resolve_system_preset(config, Preset::TYPE_FILAMENT, "Acme PLA",
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
CHECK_THAT(config.opt<ConfigOptionFloats>("filament_cost")->values.front(), Catch::Matchers::WithinAbs(27., 1e-6));
}
SECTION("by its source file") {
PresetBundle bundle;
DynamicPrintConfig config;
config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "Generic PLA";
std::string error;
REQUIRE(bundle.resolve_preset_config(config, Preset::TYPE_FILAMENT, (system / "Acme" / "filament" / "pla.json").string(),
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
CHECK_THAT(config.opt<ConfigOptionFloats>("filament_cost")->values.front(), Catch::Matchers::WithinAbs(27., 1e-6));
}
}
namespace {
// A default preset config for type, built the way PresetBundle builds its default presets.
DynamicPrintConfig external_default_config(Preset::Type type)
{
@@ -5956,8 +6081,9 @@ TEST_CASE("A per-variant project value maps onto its base preset's variant layou
base_finder(&base, calls));
CHECK(config.option<ConfigOptionStrings>("print_extruder_variant")->values ==
std::vector<std::string>{"Direct Drive Standard", "Direct Drive High Flow"});
// The listed key keeps the project's Standard value and takes High Flow from the base.
check_double_vector(config.option<ConfigOptionFloats>("outer_wall_speed")->values, {100., 300.});
// The listed key keeps the project's Standard value, and High Flow, which the project does not
// list, takes it too, as a user preset's value does.
check_double_vector(config.option<ConfigOptionFloats>("outer_wall_speed")->values, {100., 100.});
check_double_vector(config.option<ConfigOptionFloats>("inner_wall_speed")->values, {250., 350.});
}
+12
View File
@@ -509,3 +509,15 @@ TEST_CASE("is_safe_to_open_file_name rejects programs and anything it does not k
INFO(unsafe);
CHECK_FALSE(is_safe_to_open_file_name(unsafe));
}
TEST_CASE("ascii_iequals compares ASCII letters regardless of case", "[Utils]") {
CHECK(ascii_iequals("set_velocity_limit", "SET_VELOCITY_LIMIT"));
CHECK(ascii_iequals("G28", "g28"));
CHECK(ascii_iequals("", ""));
CHECK_FALSE(ascii_iequals("G28", "G29"));
CHECK_FALSE(ascii_iequals("G2", "G28"));
CHECK_FALSE(ascii_iequals("G28", "G2"));
// Non-letters 0x20 apart are not equal.
CHECK_FALSE(ascii_iequals("[", "{"));
CHECK_FALSE(ascii_iequals("@", "`"));
}
-3
View File
@@ -24,9 +24,6 @@ add_executable(${_TEST_NAME}_tests
test_plugin_capabilities_in_use.cpp
test_plugin_status.cpp
test_printer_agent.cpp
test_qidi_printer_agent.cpp
test_orca_mqtt_connection.cpp
test_orca_printer_agent.cpp
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_plugin_printer_agent.cpp
-325
View File
@@ -1,325 +0,0 @@
#pragma once
// In-process plaintext MQTT-over-WebSocket broker for the OrcaMqtt tests.
//
// It speaks just enough of MQTT 3.1.1 to drive OrcaMqttConnection /
// OrcaPrinterAgent end to end without a real network: CONNECT/CONNACK,
// SUBSCRIBE/SUBACK, UNSUBSCRIBE/UNSUBACK, client PUBLISH (QoS 0), PINGREQ and
// DISCONNECT. The outbound PUBLISH frame is built with the production
// OrcaMqttConnection::make_publish_packet() so the tests never depend on a
// second, hand-rolled MQTT encoder.
#include <slic3r/Utils/OrcaMqttConnection.hpp>
#include <boost/asio.hpp>
#include <boost/beast/core.hpp>
#include <boost/beast/websocket.hpp>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <utility>
#include <vector>
namespace orca_mqtt_test {
namespace net = boost::asio;
namespace beast = boost::beast;
namespace ws = boost::beast::websocket;
using tcp = boost::asio::ip::tcp;
// Decode an MQTT remaining-length varint starting at packet[offset].
// Returns {value, bytes_consumed}; bytes_consumed == 0 means malformed.
inline std::pair<std::size_t, std::size_t> mqtt_decode_remaining_length(const std::string& packet, std::size_t offset)
{
std::size_t value = 0;
std::size_t multiplier = 1;
std::size_t used = 0;
while (offset + used < packet.size() && used < 4) {
const std::uint8_t byte = static_cast<std::uint8_t>(packet[offset + used]);
value += static_cast<std::size_t>(byte & 0x7f) * multiplier;
multiplier *= 128;
++used;
if ((byte & 0x80) == 0)
return {value, used};
}
return {0, 0};
}
inline bool mqtt_topic_is_request(const std::string& topic)
{
static const std::string suffix = "/request";
return topic.size() >= suffix.size() &&
topic.compare(topic.size() - suffix.size(), suffix.size(), suffix) == 0;
}
class MockBroker
{
public:
// refuse_auth: answer every CONNECT with CONNACK rc 5 (not authorized) and
// close, so the reconnect/refusal paths can be exercised.
explicit MockBroker(bool refuse_auth = false) : m_refuse_auth(refuse_auth), m_acceptor(m_io)
{
const tcp::endpoint endpoint(net::ip::make_address("127.0.0.1"), 0);
m_acceptor.open(endpoint.protocol());
m_acceptor.set_option(net::socket_base::reuse_address(true));
m_acceptor.bind(endpoint);
m_acceptor.listen(net::socket_base::max_listen_connections);
m_port = std::to_string(m_acceptor.local_endpoint().port());
// why: a non-blocking acceptor lets the accept loop poll a stop flag, so
// the destructor never has to interrupt a blocking accept().
m_acceptor.non_blocking(true);
m_thread = std::thread([this] { run(); });
}
~MockBroker()
{
m_stopping.store(true);
drop_client(); // unblocks the worker's blocking read
if (m_thread.joinable())
m_thread.join();
boost::system::error_code ec;
m_acceptor.close(ec); // after join: the acceptor is worker-owned
m_io.stop();
}
MockBroker(const MockBroker&) = delete;
MockBroker& operator=(const MockBroker&) = delete;
std::string ws_url() const { return "ws://127.0.0.1:" + m_port + "/mqtt"; }
std::pair<std::string, std::string> host_port() const { return {std::string("127.0.0.1"), m_port}; }
// Server -> client PUBLISH on device/<dev_id>/report.
void push_report(const std::string& dev_id, const std::string& payload)
{
const std::vector<std::uint8_t> packet =
Slic3r::OrcaMqttConnection::make_publish_packet("device/" + dev_id + "/report", payload);
std::lock_guard<std::mutex> lock(m_mutex);
if (!m_stream || !m_stream_ready)
return;
boost::system::error_code ec;
m_stream->binary(true);
m_stream->write(net::buffer(packet), ec); // a vanished client is not a test failure
}
// Force-close the live client socket; the worker's read returns an error and
// the accept loop picks up the client's reconnect.
void drop_client()
{
std::lock_guard<std::mutex> lock(m_mutex);
close_client_locked();
}
// Payloads the client PUBLISHed to any device/<id>/request topic.
std::vector<std::string> received_requests() const
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_received_requests;
}
// 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()
{
try {
while (!m_stopping.load()) {
tcp::socket socket(m_io);
boost::system::error_code ec;
m_acceptor.accept(socket, ec);
if (ec == net::error::would_block || ec == net::error::try_again) {
std::this_thread::sleep_for(std::chrono::milliseconds(5));
continue;
}
if (ec)
return;
try {
serve(std::move(socket));
} catch (...) {
// a client dying mid-session must not take the broker down
}
std::lock_guard<std::mutex> lock(m_mutex);
close_client_locked();
m_stream.reset();
}
} catch (...) {
// never let an exception escape the broker thread
}
}
void serve(tcp::socket socket)
{
ws::stream<beast::tcp_stream>* stream = nullptr;
{
std::lock_guard<std::mutex> lock(m_mutex);
m_stream.emplace(std::move(socket));
m_stream_ready = false;
stream = &*m_stream;
}
// why: no io_context is ever run here, so a tcp_stream timer would never
// fire; the sync operations below carry no timeout of their own.
beast::get_lowest_layer(*stream).expires_never();
stream->set_option(ws::stream_base::decorator(
[](ws::response_type& res) { res.set("Sec-WebSocket-Protocol", "mqtt"); }));
boost::system::error_code ec;
stream->accept(ec);
if (ec)
return;
stream->binary(true);
{
std::lock_guard<std::mutex> lock(m_mutex);
m_stream_ready = true;
}
read_loop(*stream);
}
// The client sends every MQTT packet as one binary WebSocket message, so one
// read yields exactly one packet.
void read_loop(ws::stream<beast::tcp_stream>& stream)
{
beast::flat_buffer buffer;
while (!m_stopping.load()) {
boost::system::error_code ec;
buffer.clear();
stream.read(buffer, ec);
if (ec)
return;
const std::string packet = beast::buffers_to_string(buffer.data());
if (packet.empty())
continue;
if (!handle_packet(stream, packet))
return;
}
}
// Returns false when the session must be closed.
bool handle_packet(ws::stream<beast::tcp_stream>& stream, const std::string& packet)
{
switch (static_cast<std::uint8_t>(packet[0]) & 0xf0) {
case 0x10: { // CONNECT
++m_connect_count;
if (m_refuse_auth) {
write_packet(stream, {0x20, 0x02, 0x00, 0x05}); // CONNACK not authorized
return false;
}
write_packet(stream, {0x20, 0x02, 0x00, 0x00}); // CONNACK accepted
return true;
}
case 0x80: { // SUBSCRIBE (0x82) - packet id follows the remaining-length varint
const auto id = packet_id(packet);
if (id)
write_packet(stream, {0x90, 0x03, id->first, id->second, 0x00}); // SUBACK, QoS 0
return true;
}
case 0xa0: { // UNSUBSCRIBE (0xa2)
const auto id = packet_id(packet);
if (id)
write_packet(stream, {0xb0, 0x02, id->first, id->second}); // UNSUBACK
return true;
}
case 0x30: { // PUBLISH, QoS 0 (no packet identifier)
record_publish(packet);
return true;
}
case 0xc0: // PINGREQ
++m_ping_count;
write_packet(stream, {0xd0, 0x00});
return true;
case 0xe0: // DISCONNECT
return false;
default:
return true;
}
}
// The two packet-identifier bytes sitting right after the remaining-length varint.
static std::optional<std::pair<std::uint8_t, std::uint8_t>> packet_id(const std::string& packet)
{
const auto varint = mqtt_decode_remaining_length(packet, 1);
if (varint.second == 0)
return std::nullopt;
const std::size_t pos = 1 + varint.second;
if (pos + 2 > packet.size())
return std::nullopt;
return std::make_pair(static_cast<std::uint8_t>(packet[pos]), static_cast<std::uint8_t>(packet[pos + 1]));
}
void record_publish(const std::string& packet)
{
const auto varint = mqtt_decode_remaining_length(packet, 1);
if (varint.second == 0)
return;
std::size_t pos = 1 + varint.second;
if (pos + 2 > packet.size())
return;
const std::size_t topic_len = (static_cast<std::size_t>(static_cast<std::uint8_t>(packet[pos])) << 8) |
static_cast<std::uint8_t>(packet[pos + 1]);
pos += 2;
if (pos + topic_len > packet.size())
return;
const std::string topic = packet.substr(pos, topic_len);
pos += topic_len;
const std::size_t end = std::min(packet.size(), 1 + varint.second + varint.first);
if (end < pos)
return;
if (!mqtt_topic_is_request(topic))
return;
std::lock_guard<std::mutex> lock(m_mutex);
m_received_requests.push_back(packet.substr(pos, end - pos));
}
// Every write - the worker's own replies and push_report() from the test
// thread - is serialised by m_mutex. 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);
boost::system::error_code ec;
stream.binary(true);
stream.write(net::buffer(packet), ec);
}
void close_client_locked()
{
if (!m_stream)
return;
m_stream_ready = false;
boost::system::error_code ec;
auto& socket = beast::get_lowest_layer(*m_stream).socket();
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.
if (socket.shutdown(tcp::socket::shutdown_both, ec))
return;
if (socket.close(ec))
return;
}
const bool m_refuse_auth;
net::io_context m_io;
tcp::acceptor m_acceptor;
std::string m_port;
std::thread m_thread;
std::atomic_bool m_stopping{false};
std::atomic<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
std::vector<std::string> m_received_requests; // guarded by m_mutex
};
} // namespace orca_mqtt_test
@@ -1,243 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include <slic3r/Utils/OrcaMqttConnection.hpp>
#include "orca_mqtt_mock_broker.hpp"
#include <chrono>
#include <condition_variable>
#include <cstddef>
#include <cstdint>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
using Slic3r::OrcaMqttConnection;
// Offset of the CONNECT variable header: 1 (fixed header) + N remaining-length varint bytes.
static size_t mqtt_varheader_offset(const std::vector<uint8_t>& p) {
size_t i = 1;
while (i < p.size() && (p[i] & 0x80)) ++i; // skip varint continuation bytes
return i + 1; // + the final varint byte
}
TEST_CASE("OrcaMqtt parse_endpoint handles ws and wss", "[OrcaMqtt]") {
OrcaMqttConnection::Endpoint ep;
REQUIRE(OrcaMqttConnection::parse_endpoint("ws://printer.local:8280/mqtt", ep));
CHECK(ep.host == "printer.local");
CHECK(ep.port == "8280");
CHECK(ep.target == "/mqtt");
REQUIRE(OrcaMqttConnection::parse_endpoint("ws://10.0.0.5/mqtt", ep));
CHECK(ep.port == "80");
REQUIRE(OrcaMqttConnection::parse_endpoint("wss://api.example.com/api/v1/printers/abc/mqtt", ep));
CHECK(ep.host == "api.example.com");
CHECK(ep.port == "443");
CHECK(ep.target == "/api/v1/printers/abc/mqtt");
CHECK_FALSE(OrcaMqttConnection::parse_endpoint("http://x/y", ep));
}
TEST_CASE("OrcaMqtt CONNECT packet - no auth (cloud form)", "[OrcaMqtt]") {
auto p = OrcaMqttConnection::make_connect_packet("OrcaSlicer", "", "", 300);
REQUIRE(p.size() >= 12);
CHECK(p[0] == 0x10); // CONNECT fixed header
const size_t v = mqtt_varheader_offset(p);
CHECK(p[v + 0] == 0x00); CHECK(p[v + 1] == 0x04); // protocol name length
CHECK(p[v + 2] == 'M'); CHECK(p[v + 3] == 'Q');
CHECK(p[v + 4] == 'T'); CHECK(p[v + 5] == 'T');
CHECK(p[v + 6] == 0x04); // protocol level 3.1.1
CHECK(p[v + 7] == 0x02); // connect flags: clean session only
CHECK(((p[v + 8] << 8) | p[v + 9]) == 300); // keepalive
}
TEST_CASE("OrcaMqtt CONNECT packet - username/password (LAN form)", "[OrcaMqtt]") {
auto p = OrcaMqttConnection::make_connect_packet("orcaslicer-lan-x", "orcasonar", "code123", 60);
CHECK(p[0] == 0x10);
const size_t v = mqtt_varheader_offset(p);
CHECK(p[v + 7] == (0x02 | 0x80 | 0x40)); // clean session + username + password flags
const std::string blob(p.begin(), p.end());
CHECK(blob.find("orcaslicer-lan-x") != std::string::npos);
CHECK(blob.find("orcasonar") != std::string::npos);
CHECK(blob.find("code123") != std::string::npos);
}
// Auth precedence (spec O3): when a bearer_provider is configured, connect_and_read
// passes empty CONNECT credentials, so the packet must carry clean-session only and
// no username/password flags or payload fields. (The precedence branch itself lives
// in connect_and_read; the [.integration] cloud-style round trip exercises it live.)
TEST_CASE("OrcaMqtt CONNECT omits creds when a bearer is configured", "[OrcaMqtt]") {
auto p = OrcaMqttConnection::make_connect_packet("cid", "", "", 60);
const size_t v = mqtt_varheader_offset(p);
CHECK(p[v + 7] == 0x02); // clean session only: no 0x80 / 0x40
const std::string blob(p.begin(), p.end());
CHECK(blob.find("orcasonar") == std::string::npos);
}
TEST_CASE("OrcaMqtt topic helpers", "[OrcaMqtt]") {
CHECK(OrcaMqttConnection::request_topic("abc") == "device/abc/request");
CHECK(OrcaMqttConnection::report_topic("abc") == "device/abc/report");
}
TEST_CASE("OrcaMqtt PUBLISH packet QoS0", "[OrcaMqtt]") {
auto p = OrcaMqttConnection::make_publish_packet("device/abc/request", "{\"ok\":1}");
CHECK((p[0] & 0xf0) == 0x30); // PUBLISH
CHECK((p[0] & 0x06) == 0x00); // QoS 0
const std::string blob(p.begin(), p.end());
CHECK(blob.find("device/abc/request") != std::string::npos);
CHECK(blob.find("{\"ok\":1}") != std::string::npos);
}
TEST_CASE("OrcaMqtt SUBSCRIBE packet", "[OrcaMqtt]") {
auto p = OrcaMqttConnection::make_subscribe_packet(7, "device/abc/report", 1);
CHECK(p[0] == 0x82); // SUBSCRIBE + reserved bit
const size_t v = mqtt_varheader_offset(p);
CHECK(((p[v] << 8) | p[v + 1]) == 7); // packet id
CHECK(p.back() == 1); // requested QoS
}
TEST_CASE("OrcaMqtt send_request refuses when not connected", "[OrcaMqtt]") {
OrcaMqttConnection conn;
CHECK_FALSE(conn.send_request("abc", "{\"pushing\":{\"command\":\"pushall\",\"sequence_id\":\"20001\"}}"));
}
TEST_CASE("OrcaMqtt start takes a Config", "[OrcaMqtt]") {
OrcaMqttConnection conn;
OrcaMqttConnection::Config cfg;
cfg.url = "ws://127.0.0.1:1/mqtt"; // nothing listening
cfg.keepalive_seconds = 42;
// start() returns false (no server) but must compile with the Config overload
const bool ok = conn.start(cfg, [](auto, auto){}, [](bool, bool){});
CHECK_FALSE(ok);
CHECK(conn.last_connack_rc() == -1);
conn.stop();
}
TEST_CASE("MockBroker starts and reports a url", "[OrcaMqtt][.integration]") {
orca_mqtt_test::MockBroker b;
CHECK(b.ws_url().rfind("ws://127.0.0.1:", 0) == 0);
CHECK(b.connect_count() == 0);
}
// --- End-to-end integration: OrcaMqttConnection against the in-process MockBroker.
// All hidden behind [.integration] (run explicitly). These prove a LAN-style config
// (CONNECT username/password) and a cloud-style config (bearer on the WS upgrade,
// no CONNECT creds) drive the *same* OrcaMqttConnection code path with identical
// assertions.
static void run_round_trip(bool use_tls_flag_only) {
orca_mqtt_test::MockBroker broker;
OrcaMqttConnection conn;
OrcaMqttConnection::Config cfg;
cfg.url = broker.ws_url(); // plaintext regardless
cfg.use_tls = false; // the mock is plaintext; the flag path is unit-tested elsewhere
if (use_tls_flag_only) cfg.bearer_provider = []{ return std::string("tok"); };
else { cfg.username = "orcasonar"; cfg.password = "code"; }
// A mutex + condition_variable rather than a promise: the handler runs on the MQTT
// worker thread and a second inbound message would throw std::future_error there.
std::mutex got_mutex;
std::condition_variable got_cv;
bool got_any = false;
std::string got_id, got_payload;
REQUIRE(conn.start(cfg,
[&](const std::string& id, const std::string& payload){
{
std::lock_guard<std::mutex> l(got_mutex);
if (got_any) return; // keep the first message only
got_any = true; got_id = id; got_payload = payload;
}
got_cv.notify_all();
},
[](bool,bool){}));
REQUIRE(conn.subscribe("dev-1"));
REQUIRE(conn.send_request("dev-1", R"({"pushing":{"command":"pushall","sequence_id":"20001"}})"));
broker.push_report("dev-1", R"({"print":{"command":"push_status","sequence_id":"20001","result":"success"}})");
std::string id, payload;
{
std::unique_lock<std::mutex> l(got_mutex);
REQUIRE(got_cv.wait_for(l, std::chrono::seconds(3), [&]{ return got_any; }));
id = got_id; payload = got_payload;
}
CHECK(id == "dev-1");
CHECK(payload.find("push_status") != std::string::npos);
// the client's command reached the broker on the request topic. The mock records
// the PUBLISH on its own read-loop thread, so poll rather than check immediately.
std::vector<std::string> reqs;
for (int i = 0; i < 200; ++i) {
reqs = broker.received_requests();
if (!reqs.empty()) break;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
REQUIRE(reqs.size() >= 1);
CHECK(reqs.front().find("pushall") != std::string::npos);
conn.stop();
}
TEST_CASE("OrcaMqtt round-trip — LAN-style config", "[OrcaMqtt][.integration]") { run_round_trip(false); }
TEST_CASE("OrcaMqtt round-trip — cloud-style config", "[OrcaMqtt][.integration]") { run_round_trip(true); }
TEST_CASE("OrcaMqtt 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;
OrcaMqttConnection::Config cfg; cfg.url = broker.ws_url(); cfg.use_tls = false; cfg.username = "u"; cfg.password = "p";
std::mutex m; std::vector<std::string> got;
REQUIRE(conn.start(cfg,
[&](const std::string&, const std::string& p){ std::lock_guard<std::mutex> l(m); got.push_back(p); },
[](bool,bool){}));
REQUIRE(conn.subscribe("dev-1"));
broker.drop_client();
// the worker reconnects with ~1s backoff
for (int i = 0; i < 300 && broker.connect_count() < 2; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(20));
CHECK(broker.connect_count() >= 2);
// a report after the reconnect must still be delivered -> the SUBSCRIBE was re-sent
broker.push_report("dev-1", R"({"print":{"command":"push_status","sequence_id":"20002"}})");
bool delivered = false;
for (int i = 0; i < 200 && !delivered; ++i) {
{ std::lock_guard<std::mutex> l(m); delivered = !got.empty(); }
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
CHECK(delivered);
conn.stop();
}
TEST_CASE("OrcaMqtt auth rejection is terminal (no retry storm)", "[OrcaMqtt][.integration]") {
orca_mqtt_test::MockBroker broker(/*refuse_auth=*/true);
OrcaMqttConnection conn;
OrcaMqttConnection::Config cfg; cfg.url = broker.ws_url(); cfg.use_tls = false; cfg.username = "u"; cfg.password = "bad";
const bool ok = conn.start(cfg, [](const std::string&, const std::string&){}, [](bool,bool){});
CHECK_FALSE(ok);
CHECK(conn.last_connack_rc() == 5);
// worker must have stopped itself (rc 5 is terminal) — give it a moment
for (int i = 0; i < 100 && conn.is_running(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
CHECK_FALSE(conn.is_running());
// and it must NOT have hammered the broker with retries
std::this_thread::sleep_for(std::chrono::milliseconds(200));
CHECK(broker.connect_count() <= 2);
conn.stop();
}
@@ -1,208 +0,0 @@
#include <catch2/catch_test_macros.hpp>
#include <slic3r/Utils/IPrinterAgent.hpp>
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp>
#include <slic3r/Utils/OrcaPrinterAgent.hpp>
#include "orca_mqtt_mock_broker.hpp"
#include <chrono>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <vector>
using Slic3r::OrcaPrinterAgent;
namespace {
// Probe exposes the protected internals the tests drive.
struct Probe : OrcaPrinterAgent {
using OrcaPrinterAgent::OrcaPrinterAgent;
using OrcaPrinterAgent::deliver_to_sink;
using OrcaPrinterAgent::parse_lan_endpoint;
using OrcaPrinterAgent::make_lan_client_id;
using OrcaPrinterAgent::lan_connection_target;
};
}
TEST_CASE("OrcaPrinterAgent forwards a status payload to on_message_fn", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
std::string got_id, got_payload;
agent.set_on_message_fn([&](std::string id, std::string p){ got_id = std::move(id); got_payload = std::move(p); });
agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status"}})", /*local=*/false);
CHECK(got_id == "dev-1");
CHECK(got_payload.find("push_status") != std::string::npos);
}
TEST_CASE("OrcaPrinterAgent stamps the get_capabilities nozzle diameter onto push_status frames", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
std::string last_payload;
agent.set_on_message_fn([&](std::string, std::string p){ last_payload = std::move(p); });
// Before any capabilities reply, a push_status frame is forwarded untouched.
agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","mc_percent":10}})", /*local=*/false);
CHECK(last_payload.find("nozzle_diameter") == std::string::npos);
// The get_capabilities reply is forwarded verbatim; its topology nozzle diameter
// is cached for the device.
agent.deliver_to_sink(
"dev-1",
R"({"info":{"command":"get_capabilities","capabilities":{"topology":{"tools":[{"id":"T0","nozzle":{"diameter_mm":0.4}}]}}}})",
/*local=*/false);
CHECK(last_payload.find("\"command\":\"get_capabilities\"") != std::string::npos);
CHECK(last_payload.find("\"print\"") == std::string::npos);
// Later push_status frames for that device get the cached diameter plus a neutral
// nozzle_type, so MachineObject::parse_json's legacy nozzle parser can run.
agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","mc_percent":20}})", /*local=*/false);
CHECK(last_payload.find("\"nozzle_diameter\":0.4") != std::string::npos);
CHECK(last_payload.find("\"nozzle_type\":\"N/A\"") != std::string::npos);
// A different device is unaffected.
agent.deliver_to_sink("dev-2", R"({"print":{"command":"push_status"}})", /*local=*/false);
CHECK(last_payload.find("nozzle_diameter") == std::string::npos);
// A frame that already carries real nozzle data is not overridden.
agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","nozzle_diameter":0.6}})", /*local=*/false);
CHECK(last_payload.find("\"nozzle_diameter\":0.6") != std::string::npos);
CHECK(last_payload.find("N/A") == std::string::npos);
}
TEST_CASE("OrcaPrinterAgent::parse_lan_endpoint", "[OrcaPrinterAgent]") {
std::string h, p;
REQUIRE(Probe::parse_lan_endpoint("192.168.1.9", h, p));
CHECK(h == "192.168.1.9"); CHECK(p == "8280");
REQUIRE(Probe::parse_lan_endpoint("http://host.local:9000/x", h, p));
CHECK(h == "host.local"); CHECK(p == "9000");
CHECK_FALSE(Probe::parse_lan_endpoint("", h, p));
}
TEST_CASE("OrcaPrinterAgent::make_lan_client_id is stable and prefixed", "[OrcaPrinterAgent]") {
const auto a = Probe::make_lan_client_id("dev-1");
const auto b = Probe::make_lan_client_id("dev-1");
CHECK(a == b); // drawn once per process
CHECK(a.rfind("orcaslicer-lan-dev-1-", 0) == 0);
}
TEST_CASE("connect_printer wires up a LAN Config", "[OrcaPrinterAgent][.integration]") {
Probe agent("/tmp");
Slic3r::PrinterConnectionParams params{
"dev-1", "10.255.255.1", "orcasonar", "code", "", false, ""
};
const int rc = agent.connect_printer(params);
CHECK(rc == BAMBU_NETWORK_SUCCESS);
CHECK(agent.lan_connection_target() == "ws://10.255.255.1:8280/mqtt");
CHECK(agent.get_user_selected_machine().empty()); // LAN path must not touch the cloud selection
agent.disconnect_printer();
}
TEST_CASE("post-connect sequence is subscribe then 4 requests in order", "[OrcaPrinterAgent]") {
struct SeqProbe : OrcaPrinterAgent {
using OrcaPrinterAgent::OrcaPrinterAgent;
std::vector<std::string> calls;
void emit_connect_sequence(const std::string& dev_id,
std::function<void(const std::string&)> /*sub*/,
std::function<void(const std::string&)> /*req*/) override {
OrcaPrinterAgent::emit_connect_sequence(dev_id,
[&](const std::string& id){ calls.push_back("sub:" + id); },
[&](const std::string& body){ calls.push_back(body); });
}
} probe("/tmp");
probe.run_connect_sequence_for_test("dev-1");
REQUIRE(probe.calls.size() == 5);
CHECK(probe.calls[0] == "sub:dev-1");
CHECK(probe.calls[1].find("\"pushing\"") != std::string::npos);
CHECK(probe.calls[1].find("\"start\"") != std::string::npos);
CHECK(probe.calls[2].find("pushall") != std::string::npos);
CHECK(probe.calls[3].find("get_version") != std::string::npos);
CHECK(probe.calls[4].find("get_capabilities") != std::string::npos);
for (auto& c : probe.calls)
if (auto pos = c.find("sequence_id"); pos != std::string::npos)
CHECK(c.substr(pos).find("\"2") != std::string::npos);
}
// Hidden: spawns the connect worker and attempts a real (failing) connect.
TEST_CASE("selecting a cloud printer configures the fleet socket", "[OrcaPrinterAgent][.integration]") {
auto cloud = std::make_shared<Slic3r::OrcaCloudServiceAgent>("/tmp");
cloud->set_api_base_url("api.example.com");
OrcaPrinterAgent agent("/tmp");
agent.set_cloud_agent(cloud);
agent.set_user_selected_machine("printer-uuid-1");
// The configure runs on the connect worker; poll rather than racing it.
std::string url;
for (int i = 0; i < 300; ++i) {
url = cloud->selected_printer_mqtt_url();
if (!url.empty()) break;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
CHECK(url == "wss://api.example.com/api/v1/printers/mqtt");
agent.set_user_selected_machine(""); // selection changes do not tear down the fleet socket
CHECK(cloud->selected_printer_mqtt_url() == "wss://api.example.com/api/v1/printers/mqtt");
}
TEST_CASE("a stale-generation inbound message is dropped", "[OrcaPrinterAgent]") {
struct GenProbe : OrcaPrinterAgent {
using OrcaPrinterAgent::OrcaPrinterAgent;
using OrcaPrinterAgent::make_lan_message_handler; // expose for the test
};
GenProbe agent("/tmp");
int hits = 0;
agent.set_on_message_fn([&](std::string, std::string){ ++hits; });
auto handler_gen1 = agent.make_lan_message_handler(/*generation=*/1);
// m_lan_generation starts at 0; two bumps -> 2, so the epoch-1 handler is stale.
agent.bump_lan_generation_for_test();
agent.bump_lan_generation_for_test();
handler_gen1("dev-1", "{}"); // late callback from gen 1
CHECK(hits == 0);
}
TEST_CASE("connect_server does not start an MQTT socket", "[OrcaCloud]") {
auto cloud = std::make_shared<Slic3r::OrcaCloudServiceAgent>("/tmp");
cloud->set_api_base_url("127.0.0.1:1"); // no session -> connect_server short-circuits before any probe
cloud->connect_server();
REQUIRE(cloud->get_mqtt_connection() != nullptr); // created in the ctor
CHECK_FALSE(cloud->get_mqtt_connection()->is_running()); // never started
CHECK(cloud->selected_printer_mqtt_url().empty());
}
TEST_CASE("send_message* reject when there is no connection", "[OrcaPrinterAgent]") {
OrcaPrinterAgent agent("/tmp"); // no cloud agent, no LAN connection
CHECK(agent.send_message("d", "{}", 0, 0) == BAMBU_NETWORK_ERR_INVALID_HANDLE);
CHECK(agent.send_message_to_printer("d", "{}", 0, 0) == BAMBU_NETWORK_ERR_INVALID_HANDLE);
CHECK(agent.send_message("", "{}", 0, 0) == BAMBU_NETWORK_ERR_INVALID_HANDLE); // empty dev_id
}
TEST_CASE("send_message_to_printer publishes on the LAN connection", "[OrcaPrinterAgent][.integration]") {
orca_mqtt_test::MockBroker broker;
OrcaPrinterAgent agent("/tmp");
const auto ep = broker.host_port();
agent.connect_printer(Slic3r::PrinterConnectionParams{"dev-1", ep.first + ":" + ep.second, "orcasonar", "code", "", false, ""});
for (int i = 0; i < 150 && broker.connect_count() == 0; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(20));
REQUIRE(broker.connect_count() >= 1);
CHECK(agent.send_message_to_printer("dev-1", R"({"print":{"command":"pause","sequence_id":"20007"}})", 0, 0)
== BAMBU_NETWORK_SUCCESS);
// on_connected also publishes 4 requests; poll until "pause" specifically shows up.
bool saw_pause = false;
for (int i = 0; i < 150 && !saw_pause; ++i) {
for (const auto& r : broker.received_requests())
if (r.find("pause") != std::string::npos) { saw_pause = true; break; }
std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
CHECK(saw_pause);
agent.disconnect_printer();
}
TEST_CASE("destroying an agent mid-connect does not hang or crash", "[OrcaPrinterAgent]") {
for (int i = 0; i < 20; ++i) {
auto agent = std::make_unique<OrcaPrinterAgent>("/tmp");
agent->connect_printer(Slic3r::PrinterConnectionParams{"dev-1", "127.0.0.1:1", "orcasonar", "code", "", false, ""}); // nothing listening: instant ECONNREFUSED
agent.reset(); // ~OrcaPrinterAgent must stop the conn, join the thread, and not hang/crash
}
SUCCEED();
}
+1 -147
View File
@@ -1,9 +1,8 @@
#include <catch2/catch_all.hpp>
#include <memory>
#include "slic3r/Utils/ICloudServiceAgent.hpp"
#include "slic3r/Utils/IPrinterAgent.hpp"
#include <slic3r/Utils/BBLPrinterAgent.hpp>
#include <slic3r/Utils/MoonrakerPrinterAgent.hpp>
#include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
@@ -15,156 +14,11 @@
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <atomic>
#include <chrono>
#include <future>
#include <memory>
#include <string>
#include <thread>
using namespace Slic3r;
namespace py = pybind11;
class MoonrakerParserProbe : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::parse_nozzle_diameter;
explicit MoonrakerParserProbe(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
};
TEST_CASE("Moonraker parses nozzle diameter from configfile settings", "[unit][moonraker]")
{
const auto response = nlohmann::json::parse(R"({
"result": {
"status": {
"configfile": {
"settings": {
"extruder": {
"nozzle_diameter": 0.6
}
}
}
}
}
})");
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(response) == Catch::Approx(0.6f));
}
TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missing data", "[unit][moonraker]")
{
const auto raw_config_response = nlohmann::json::parse(R"({
"result": {
"status": {
"configfile": {
"config": {
"extruder": {
"nozzle_diameter": "0.8"
}
}
}
}
}
})");
const auto missing_response = nlohmann::json::object();
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(raw_config_response) == Catch::Approx(0.8f));
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(missing_response) == 0.0f);
}
// why: an agent without a Bambu-dialect translation must refuse these commands before any network or wx path.
TEST_CASE("unit: default AMS commands report not supported", "[unit][moonraker]")
{
MoonrakerPrinterAgent agent("");
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);
}
TEST_CASE("unit: Moonraker light name matching", "[unit][moonraker]")
{
CHECK(moonraker_is_light_name("caselight"));
CHECK(moonraker_is_light_name("LED_STRIP"));
CHECK_FALSE(moonraker_is_light_name("beeper"));
CHECK(moonraker_is_light_name("FLASHLIGHT_SWITCH"));
CHECK(moonraker_is_light_name("MODLELIGHT_SWITCH"));
}
// ===========================================================================
// UNIT - handle_request's not-supported default.
// The agent is the only thing that knows what it can translate, so an untranslated
// command has to say so instead of returning success and letting the UI believe the
// control worked. Guards the inverse too: the pushing namespace is genuinely
// satisfied by the websocket status stream, and it re-fires from the keepalive timer
// roughly once a second, so it must stay a success or it would raise a dialog on a
// timer. Only branches that touch neither the network nor wx are exercised.
// ===========================================================================
TEST_CASE("unit: Moonraker reports untranslated commands as not supported", "[unit][moonraker]")
{
MoonrakerPrinterAgent agent("");
CHECK(agent.send_message("dev", R"({"print":{"command":"ams_change_filament"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"system":{"command":"set_door_stat"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"xcam":{"command":"xcam_control_set"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"pushing":{"command":"pushall"}})", 0, 0) == BAMBU_NETWORK_SUCCESS);
CHECK(agent.send_message("dev", R"({"pushing":{"command":"start"}})", 0, 0) == BAMBU_NETWORK_SUCCESS);
// why: malformed input is a different failure than an untranslated command, and the
// default must not swallow it into a misleading not-supported verdict.
CHECK(agent.send_message("dev", "{not json", 0, 0) == BAMBU_NETWORK_ERR_INVALID_RESULT);
}
// why: IPrinterAgent::fetch_filament_info is the single virtual hook derived agents override
// (MoonrakerPrinterAgent's own override is synchronous, but QidiPrinterAgent's override is
// fire-and-forget: it spawns a detached thread and returns immediately). QidiPrinterAgent is
// `final`, so this probes the same contract with a controllable double instead.
TEST_CASE("unit: a fire-and-forget override of fetch_filament_info is not waited on by the caller",
"[unit][moonraker]")
{
class RecordingAgent : public Slic3r::MoonrakerPrinterAgent
{
public:
explicit RecordingAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
std::atomic<bool> invoked{false};
std::promise<void> release_gate;
std::promise<void> done_promise;
bool fetch_filament_info(std::string /*dev_id*/, FilamentSyncMode /*sync_mode*/ = FilamentSyncMode::pull) override
{
std::thread([this]() {
invoked.store(true);
// Block here until the test explicitly releases us, proving the caller
// (fetch_filament_info) does not wait for this to run.
release_gate.get_future().wait();
done_promise.set_value();
}).detach();
return true;
}
};
auto agent = std::make_shared<RecordingAgent>(std::string{});
auto done_future = agent->done_promise.get_future();
bool immediate_result = agent->fetch_filament_info("test-dev");
// fetch_filament_info must return before its background work completes — prove
// it by confirming the background call is still blocked on the gate right now.
REQUIRE(immediate_result == true);
REQUIRE(done_future.wait_for(std::chrono::milliseconds(100)) == std::future_status::timeout);
// Now let the background call finish and confirm it actually ran (polymorphic dispatch).
agent->release_gate.set_value();
REQUIRE(done_future.wait_for(std::chrono::seconds(2)) == std::future_status::ready);
REQUIRE(agent->invoked.load() == true);
}
// ===========================================================================
// UNIT - printer-agent registry duplicate handling.
// Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in
@@ -1,131 +0,0 @@
#include <catch2/catch_all.hpp>
#include <nlohmann/json.hpp>
#include <slic3r/Utils/QidiPrinterAgent.hpp>
#include <string>
using namespace Slic3r;
TEST_CASE("Qidi slot response rejects null variables without throwing", "[QidiPrinterAgent]")
{
const std::string response = R"({
"result": {
"status": {
"save_variables": {
"variables": null
}
}
}
})";
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = true;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
CHECK_FALSE(parsed);
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("variables"));
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("object"));
}
TEST_CASE("Qidi slot response rejects missing and non-object fields without throwing", "[QidiPrinterAgent]")
{
std::string response;
SECTION("missing result")
{
response = R"({})";
}
SECTION("non-object result")
{
response = R"({"result":null})";
}
SECTION("missing status")
{
response = R"({"result":{}})";
}
SECTION("non-object status")
{
response = R"({"result":{"status":null}})";
}
SECTION("missing save_variables")
{
response = R"({"result":{"status":{}}})";
}
SECTION("non-object save_variables")
{
response = R"({"result":{"status":{"save_variables":null}}})";
}
SECTION("missing variables")
{
response = R"({"result":{"status":{"save_variables":{}}}})";
}
SECTION("scalar")
{
response = R"({"result":{"status":{"save_variables":{"variables":42}}}})";
}
SECTION("array")
{
response = R"({"result":{"status":{"save_variables":{"variables":[]}}}})";
}
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = true;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
CHECK_FALSE(parsed);
}
TEST_CASE("Qidi slot response exposes valid status and variables", "[QidiPrinterAgent]")
{
const std::string response = R"({
"result": {
"status": {
"save_variables": {
"variables": {
"box_count": 2,
"color_slot0": 3
}
},
"box_stepper slot0": {
"runout_button": 0
}
}
}
})";
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = false;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
REQUIRE(parsed);
CHECK(status.is_object());
CHECK(variables.is_object());
CHECK(variables.at("box_count") == 2);
CHECK(status.contains("box_stepper slot0"));
}
TEST_CASE("Qidi slot response rejects invalid JSON", "[QidiPrinterAgent]")
{
nlohmann::json status;
nlohmann::json variables;
std::string error;
bool parsed = true;
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response("{not json", status, variables, error));
CHECK_FALSE(parsed);
CHECK(error == "Invalid JSON response");
}