Compare commits

..
Author SHA1 Message Date
Lam Wei Lun c71e456b95 Scale to Fit Print Volume: fit the area every nozzle reaches
Each nozzle reaches only part of the bed on a multi-nozzle printer (H2D:
left 0-325, right 25-350 in X), but the command fitted the whole 350 mm
bed. A model that filled the bed in X therefore landed outside both
nozzles' printable range, and right after the user asked to fit it the
plater warned "The position or size of the model exceeds the right
nozzle's printable range".

Fit the volume every nozzle shares instead -- the same region Arrange
already places items in -- and centre on it rather than on the bed.
2026-10-20 19:09:01 +08:00
Lam Wei Lun f048ede640 Lay on Face: flip a face pointing straight up about X, not an arbitrary axis
Laying a face that already points up down needs a half turn, and every
horizontal axis does the job. setFromTwoVectors() resolved the choice through an
SVD whose null space is the whole XY plane, so the axis followed rounding noise:
the object landed at an arbitrary angle in the plate plane, and a part that fit
the plate no longer did. Turn such a face over about X, which keeps the
footprint, and share the rotation between the gizmo and the --ground-* options
instead of duplicating it.
2026-10-20 18:24:23 +08:00
Lam Wei Lun 07fa713f0c Scale to Fit Print Volume: centre on the active plate
The command centred the selection on the bed at the origin, so scaling a model
that sat on plate 2 moved it back onto plate 1. Every plate shares the bed size,
so only the centre differs: pass the active plate's centre and, for a circular
bed, shift the print circle by it.
2026-10-20 18:24:20 +08:00
Lam Wei Lun 4c85d0e80a Arrange: place items that fill the printable area, and leave plates alone on failure
An item whose outline fits the printable area could still never be placed: the
arrange bed is inset by bed_shrink (1 mm per side) and the item is inflated by
its brim width, and the clamp meant to relax that only kicked in with more than
5 mm to spare. An item scaled to fit the print volume was therefore rejected
every time. Give the bed margin back where the largest selected item needs it,
never below the skirt distance, and cap the inflation at the printable area.

When an item comes back UNARRANGED, the GUI parked it on a virtual bed that was
never created, moving it a whole plate stride off the plate. Keep the instance
where it is instead, and when nothing could be placed at all, skip the plate
rebuild so a too large item cannot be re-homed to another plate.

Arrange no longer recycles plates: it must not delete a plate the user created,
even when the arrange leaves it empty.
2026-10-20 18:23:29 +08:00
31 changed files with 618 additions and 2922 deletions

No files matched your search

+3 -25
View File
@@ -47,7 +47,7 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
u )
export BUILD_TARGET="universal"
;;
h ) echo "Usage: ./build_release_macos.sh [-d] [-- CMAKE_ARGS...]"
h ) echo "Usage: ./build_release_macos.sh [-d]"
echo " -d: Build deps only"
echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)"
echo " -s: Build slicer only"
@@ -60,33 +60,13 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew"
echo " -j: Set the number of parallel build jobs (CMAKE_BUILD_PARALLEL_LEVEL)"
echo " -T: Build and run tests (set ORCA_TESTS_BUILD_ONLY=1 to build without running)"
echo " -- CMAKE_ARGS...: pass everything after '--' to the cmake configure step of the deps (-d) and slicer (-s) builds,"
echo " e.g. -DDESTDIR=<dir> for the deps, -DCMAKE_PREFIX_PATH=<dir>/usr/local -DSLIC3R_PCH=OFF for the slicer, --fresh."
echo " They come last on the cmake command line. Use absolute paths: cmake runs in the build directory."
exit 0
;;
: )
echo "Option -${OPTARG} requires an argument, aborting."
exit 1
;;
* )
echo "Unknown option -${OPTARG}, aborting. CMake arguments must follow '--'."
exit 1
;;
esac
done
# getopts stops at '--'; whatever follows is handed to cmake unchanged.
if [[ ${OPTIND} -le $# ]] ; then
separator_index=$((OPTIND-1))
if [[ "${!separator_index}" != "--" ]] ; then
echo "Unexpected argument '${!OPTIND}': cmake arguments must follow '--', aborting."
exit 1
fi
fi
shift $((OPTIND-1))
EXTRA_CMAKE_ARGS=("$@")
# Set defaults
if [ -z "$ARCH" ]; then
@@ -231,8 +211,7 @@ function build_deps() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} \
"${EXTRA_CMAKE_ARGS[@]}"
${CMAKE_POLICY_COMPAT}
fi
cmake --build . --config "$BUILD_CONFIG" --target deps
)
@@ -335,8 +314,7 @@ function build_slicer() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} \
"${EXTRA_CMAKE_ARGS[@]}"
${CMAKE_POLICY_COMPAT}
fi
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
)
+25 -9
View File
@@ -122,8 +122,8 @@ void update_arrange_params(ArrangeParams& params, const DynamicPrintConfig* prin
void update_selected_items_inflation(ArrangePolygons& selected, const DynamicPrintConfig* print_cfg, ArrangeParams& params) {
// do not inflate brim_width. Objects are allowed to have overlapped brim.
Points bedpts = get_shrink_bedpts(print_cfg, params);
BoundingBox bedbb = Polygon(bedpts).bounding_box();
// A brim reaching the printable area's edge is fine, so measure against the full bed.
BoundingBox bedbb = Polygon(get_bed_shape(*print_cfg)).bounding_box();
// set obj distance for auto seq_print
if (params.is_seq_print) {
bool all_objects_are_short = std::all_of(selected.begin(), selected.end(), [&](ArrangePolygon& ap) { return ap.height < params.nozzle_height; });
@@ -144,14 +144,30 @@ void update_selected_items_inflation(ArrangePolygons& selected, const DynamicPri
// 3. otherwise, use each object's own brim width
ap.inflation = params.min_obj_distance != 0 ? params.min_obj_distance / 2 :
plate_has_tree_support ? scaled(brim_max / 2) : scaled(ap.brim_width);
BoundingBox apbb = ap.poly.contour.bounding_box();
auto diffx = bedbb.size().x() - apbb.size().x() - 5;
auto diffy = bedbb.size().y() - apbb.size().y() - 5;
if (diffx > 0 && diffy > 0) {
auto min_diff = std::min(diffx, diffy);
ap.inflation = std::min(min_diff / 2, ap.inflation);
}
if (params.min_obj_distance != 0)
return; // an explicit object distance is the user's call, not ours to shrink
// An item cannot be larger than the printable area, inflation included. Inflating
// an almost bed filling item would leave it too big to ever be placed.
BoundingBox apbb = ap.poly.contour.bounding_box();
auto diffx = bedbb.size().x() - apbb.size().x();
auto diffy = bedbb.size().y() - apbb.size().y();
ap.inflation = std::max<coord_t>(0, std::min({ ap.inflation, diffx / 2, diffy / 2 }));
});
// Give the bed margin, which the arrange bed keeps from the printable area, back
// where the largest item needs it. The skirt keeps its room: pushing a skirt off
// the bed is worse than saying the item does not fit.
if (!selected.empty()) {
coord_t max_width = 0, max_height = 0;
for (const ArrangePolygon &ap : selected) {
BoundingBox apbb = ap.poly.contour.bounding_box();
max_width = std::max(max_width, apbb.size().x() + 2 * ap.inflation);
max_height = std::max(max_height, apbb.size().y() + 2 * ap.inflation);
}
auto margin = [](coord_t bed, coord_t item) { return std::max(0., unscale<double>(bed - item) / 2.); };
params.bed_shrink_x = std::min<double>(params.bed_shrink_x, std::max<double>(params.brim_skirt_distance, margin(bedbb.size().x(), max_width)));
params.bed_shrink_y = std::min<double>(params.bed_shrink_y, std::max<double>(params.brim_skirt_distance, margin(bedbb.size().y(), max_height)));
}
}
void update_unselected_items_inflation(ArrangePolygons& unselected, const DynamicPrintConfig* print_cfg, const ArrangeParams& params)
+19 -3
View File
@@ -213,13 +213,29 @@ int find_plane_at_point(const std::vector<LayOnFacePlane> &planes, const Transfo
return -1;
}
Transform3d lay_on_face_rotation(const Vec3d &normal, const Transform3d &instance_matrix_no_offset)
{
// Normal in instance coordinates: a normal transforms by the inverse transpose.
const Vec3d tnormal = (instance_matrix_no_offset.matrix().block(0, 0, 3, 3).inverse().transpose() * normal).normalized();
const Vec3d down = -Vec3d::UnitZ();
// A face already pointing straight up needs a half turn, and every horizontal axis does
// that equally well. setFromTwoVectors() resolves the choice through an SVD whose null
// space is the whole XY plane, so the axis follows rounding noise and the object lands at
// an arbitrary angle in the plate plane. Turn it over about X instead, which keeps the
// footprint the object already had.
if (tnormal.dot(down) < -1. + 1e-9)
return Transform3d(Eigen::AngleAxisd(PI, Vec3d::UnitX()));
return Transform3d(Eigen::Quaterniond().setFromTwoVectors(tnormal, down));
}
void lay_on_face(ModelObject &object, size_t instance_idx, const Vec3d &normal)
{
ModelInstance &instance = *object.instances[instance_idx];
const Geometry::Transformation &trafo = instance.get_transformation();
// Same rotation as Selection::flattening_rotate(): turn the transformed normal to point down.
const Vec3d tnormal = trafo.get_matrix().matrix().block(0, 0, 3, 3).inverse().transpose() * normal;
const Transform3d rotation = Transform3d(Eigen::Quaterniond().setFromTwoVectors(tnormal, -Vec3d::UnitZ()));
// Same rotation as Selection::flattening_rotate(), which the gizmo uses.
const Transform3d rotation = lay_on_face_rotation(normal, trafo.get_matrix_no_offset());
instance.set_transformation(Geometry::Transformation(trafo.get_offset_matrix() * rotation * trafo.get_matrix_no_offset()));
// Drop this instance only: ensure_on_bed() skips instances without auto_drop and measures the first instance.
object.translate_instance(instance_idx, -object.instance_bounding_box(instance_idx).min.z() * Vec3d::UnitZ());
+6
View File
@@ -46,4 +46,10 @@ int find_plane_at_point(const std::vector<LayOnFacePlane> &planes, const Transfo
// the gizmo applies, then drops the instance so its lowest point is at z = 0.
void lay_on_face(ModelObject &object, size_t instance_idx, const Vec3d &normal);
// The rotation that turns `normal` (object coordinates) to point down, given the instance's
// rotation, scale and mirror. A face that already points straight up is turned over about X:
// the axis setFromTwoVectors() picks there is arbitrary, and it lands the object at a random
// angle in the plate plane.
Transform3d lay_on_face_rotation(const Vec3d &normal, const Transform3d &instance_matrix_no_offset);
} // namespace Slic3r
+2 -5
View File
@@ -15,7 +15,6 @@
#include <string>
#include "slic3r/GUI/wxExtensions.hpp"
#include "libslic3r/AppConfig.hpp"
#include "IPrinterAgent.hpp"
#include "DeviceCore/DevManager.h"
@@ -171,16 +170,14 @@ void ConnectPrinterDialog::on_input_enter(wxCommandEvent& evt)
void ConnectPrinterDialog::on_button_confirm(wxCommandEvent &event)
{
wxString code = m_textCtrl_code->GetTextCtrl()->GetValue();
// Validate only what the user typed; the no-key sentinel is applied afterwards (it contains
// non-alphanumeric characters, so it must not go through this check).
if (code.empty() && (!m_obj || m_obj->printer_agent_id != MOONRAKER_PRINTER_AGENT_ID))
code = "88888888";
for (char c : code) {
if (!(('0' <= c && c <= '9') || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'))) {
show_error(this, _L("Invalid input"));
return;
}
}
if (code.empty())
code = NO_API_KEY_SENTINEL;
if (m_obj) {
m_obj->set_access_code(code.ToStdString());
}
+1 -2
View File
@@ -2685,8 +2685,7 @@ int MachineObject::connect()
{
if (m_access_revoked || get_dev_ip().empty()) return -1;
std::string username = m_agent ? m_agent->default_lan_username() : std::string();
// The stored access code may be the "no API key" sentinel; agents expect an empty credential.
std::string password = normalize_access_code(get_access_code());
std::string password = get_access_code();
std::string port;
std::string input = get_dev_ip();
-5
View File
@@ -4354,11 +4354,6 @@ void GUI_App::select_machine(const std::string& agent_id)
machine.dev_ip = dev_id;
machine.dev_name = dev_id;
machine.printer_type = preset.config.opt_string("printer_model");
auto access_code = preset.config.opt_string("printhost_apikey");
// Orca expect non empty access code
if (access_code.empty()) {
access_code = NO_API_KEY_SENTINEL;
}
existing = m_device_manager->insert_local_device(
machine, "lan", "free", "", access_code);
+15 -3
View File
@@ -771,6 +771,17 @@ void ArrangeJob::finalize(bool canceled, std::exception_ptr &eptr) {
if (canceled || eptr)
return;
// Nothing could be placed: leave the plates and the instances exactly as they were.
// Rebuilding the plates would only re-home the instances by their bounds, which moves
// a too large item to whichever plate it happens to reach over.
const bool nothing_arranged = !m_selected.empty() &&
std::all_of(m_selected.begin(), m_selected.end(), [](const ArrangePolygon &ap) { return !ap.is_arranged(); });
if (nothing_arranged) {
m_plater->get_notification_manager()->close_notification_of_type(NotificationType::ArrangeOngoing);
m_plater->m_arrange_running.store(false);
return;
}
// Unprintable items go to the last virtual bed
int beds = 0;
@@ -870,12 +881,13 @@ void ArrangeJob::finalize(bool canceled, std::exception_ptr &eptr) {
}
m_plater->get_notification_manager()->close_notification_of_type(NotificationType::ArrangeOngoing);
//BBS: reload all objects due to arrange
//BBS: reload all objects due to arrange. Plates are never recycled here: arranging
// must not remove a plate the user created, even when the arrange left it empty.
if (only_on_partplate) {
plate_list.rebuild_plates_after_arrangement(!only_on_partplate, true, current_plate_index);
plate_list.rebuild_plates_after_arrangement(false, true, current_plate_index);
}
else {
plate_list.rebuild_plates_after_arrangement(!only_on_partplate, true);
plate_list.rebuild_plates_after_arrangement(false, true);
}
// unlock the plates we just locked
+2 -1
View File
@@ -248,7 +248,6 @@ void FillBedJob::process(Ctl &ctl)
if (m_object_idx == -1 || m_selected.empty()) return;
update_arrange_params(params, m_plater->config(), m_selected);
m_bedpts = get_shrink_bedpts(m_plater->config(), params);
auto &partplate_list = m_plater->get_partplate_list();
auto &print = wxGetApp().plater()->get_partplate_list().get_current_fff_print();
@@ -260,6 +259,8 @@ void FillBedJob::process(Ctl &ctl)
update_selected_items_inflation(m_selected, m_plater->config(), params);
update_unselected_items_inflation(m_unselected, m_plater->config(), params);
// After the inflation, which may give back part of the bed margin.
m_bedpts = get_shrink_bedpts(m_plater->config(), params);
bool do_stop = false;
params.stopcondition = [&ctl, &do_stop]() {
+6 -11
View File
@@ -7048,19 +7048,14 @@ void PartPlateList::postprocess_arrange_polygon(arrangement::ArrangePolygon& arr
{
BOOST_LOG_TRIVIAL(debug) << __FUNCTION__ << boost::format(": bed_idx %1%, selected %2%, translation(x) %3%, (y) %4%") % arrange_polygon.bed_idx % selected % unscale<double>(arrange_polygon.translation(X)) % unscale<double>(arrange_polygon.translation(Y));
// UNARRANGED means the item could not be placed, so it has no bed to map onto and
// must be left where the user had it. Moving it to a virtual bed here would shift
// it off the plate, because no such plate is created for it afterwards.
if (arrange_polygon.bed_idx == arrangement::UNARRANGED)
return;
if ((selected) || (arrange_polygon.bed_idx != PartPlateList::MAX_PLATES_COUNT))
{
if (arrange_polygon.bed_idx == -1)
{
// outarea for large object
arrange_polygon.bed_idx = m_plate_list.size();
BoundingBox apbox = get_extents(arrange_polygon.transformed_poly()); // the item may have been rotated
auto apbox_size = apbox.size();
arrange_polygon.translation(X) = 0.5 * apbox_size[0];
arrange_polygon.translation(Y) = scaled<double>(static_cast<double>(m_plate_depth)) - 0.5 * apbox_size[1];
}
arrange_polygon.row = arrange_polygon.bed_idx / m_plate_cols;
arrange_polygon.col = arrange_polygon.bed_idx % m_plate_cols;
arrange_polygon.translation(X) += scaled<double>(plate_stride_x() * arrange_polygon.col);
+11 -1
View File
@@ -11034,7 +11034,17 @@ void Plater::priv::split_volume()
void Plater::priv::scale_selection_to_fit_print_volume()
{
#if ENABLE_ENHANCED_PRINT_VOLUME_FIT
this->view3D->get_canvas3d()->get_selection().scale_to_fit_print_volume(this->bed.build_volume());
const BuildVolume volume = this->bed.build_volume();
// Every plate shares the bed size, but each sits at its own origin: fit onto the
// plate the selection is on instead of always onto the first one.
PartPlate* plate = this->partplate_list.get_curr_plate();
const Vec3d bed_center = plate != nullptr ? plate->get_center_origin() :
Vec3d(volume.bed_center().x(), volume.bed_center().y(), 0.);
// On a multi-nozzle printer each nozzle reaches only part of the bed, so a model fitted
// to the bed can still exceed a nozzle's printable range. Fit into the volume every
// nozzle shares, which is also the area Arrange places items in.
const BoundingBoxf3 print_volume = plate != nullptr ? plate->get_build_volume(true) : volume.bounding_volume();
this->view3D->get_canvas3d()->get_selection().scale_to_fit_print_volume(volume, Vec2d(bed_center.x(), bed_center.y()), print_volume);
#else
this->view3D->get_canvas3d()->get_selection().scale_to_fit_print_volume(*config);
#endif // ENABLE_ENHANCED_PRINT_VOLUME_FIT
+2 -3
View File
@@ -2,7 +2,6 @@
#include "I18N.hpp"
#include "bambu_networking.hpp"
#include "IPrinterAgent.hpp"
#include "libslic3r/Utils.hpp"
#include "GUI.hpp"
#include "GUI_App.hpp"
@@ -1904,7 +1903,7 @@ void InputIpAddressDialog::on_ok(wxMouseEvent& evt)
std::string str_ip = m_input_ip->GetTextCtrl()->GetValue().ToStdString();
std::string str_access_code = m_input_access_code->GetTextCtrl()->GetValue().ToStdString();
if (str_access_code.empty())
str_access_code = NO_API_KEY_SENTINEL;
str_access_code = "88888888";
std::string str_name = m_input_printer_name->GetTextCtrl()->GetValue().Strip(wxString::both).ToStdString();
// Serial number should not contain lower case letters, and bambu_network plugin crashes
// if user entered the wrong serial number, so we call `Upper()` here.
@@ -1951,7 +1950,7 @@ void InputIpAddressDialog::on_send_retry()
wxString ip = m_input_ip->GetTextCtrl()->GetValue();
wxString str_access_code = m_input_access_code->GetTextCtrl()->GetValue();
if (str_access_code.IsEmpty())
str_access_code = NO_API_KEY_SENTINEL;
str_access_code = "88888888";
// check support function
if (!m_obj) return;
+10 -10
View File
@@ -17,6 +17,7 @@
#include "libslic3r/Color.hpp"
#include "libslic3r/Point.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/LayOnFace.hpp"
#include "slic3r/GUI/GLModel.hpp"
#include <string>
#include <vector>
@@ -1472,11 +1473,9 @@ void Selection::flattening_rotate(const Vec3d& normal)
for (unsigned int i : m_list) {
GLVolume& v = *(*m_volumes)[i];
// Normal transformed from the object coordinate space to the world coordinate space.
// Additional rotation to align the face normal with the down vector in the world coordinate space.
const Geometry::Transformation& old_inst_trafo = v.get_instance_transformation();
const Vec3d tnormal = old_inst_trafo.get_matrix().matrix().block(0, 0, 3, 3).inverse().transpose() * normal;
// Additional rotation to align tnormal with the down vector in the world coordinate space.
const Transform3d rotation_matrix = Transform3d(Eigen::Quaterniond().setFromTwoVectors(tnormal, -Vec3d::UnitZ()));
const Transform3d rotation_matrix = lay_on_face_rotation(normal, old_inst_trafo.get_matrix_no_offset());
v.set_instance_transformation(old_inst_trafo.get_offset_matrix() * rotation_matrix * old_inst_trafo.get_matrix_no_offset());
}
@@ -1496,7 +1495,7 @@ void Selection::scale(const Vec3d& scale, TransformationType transformation_type
}
#if ENABLE_ENHANCED_PRINT_VOLUME_FIT
void Selection::scale_to_fit_print_volume(const BuildVolume& volume)
void Selection::scale_to_fit_print_volume(const BuildVolume& volume, const Vec2d& bed_center, const BoundingBoxf3& print_volume)
{
auto fit = [this](double s, Vec3d offset) {
if (s <= 0.0 || s == 1.0)
@@ -1526,8 +1525,7 @@ void Selection::scale_to_fit_print_volume(const BuildVolume& volume)
//wxGetApp().obj_manipul()->set_dirty();
};
auto fit_rectangle = [this, fit](const BuildVolume& volume) {
const BoundingBoxf3 print_volume = volume.bounding_volume();
auto fit_rectangle = [this, fit, &print_volume]() {
const Vec3d print_volume_size = print_volume.size();
// adds 1/100th of a mm on all sides to avoid false out of print volume detections due to floating-point roundings
@@ -1541,7 +1539,7 @@ void Selection::scale_to_fit_print_volume(const BuildVolume& volume)
fit(std::min(sx, std::min(sy, sz)), print_volume.center() - get_bounding_box().center());
};
auto fit_circle = [this, fit](const BuildVolume& volume) {
auto fit_circle = [this, fit, &bed_center](const BuildVolume& volume) {
const Geometry::Circled& print_circle = volume.circle();
double print_circle_radius = unscale<double>(print_circle.radius);
@@ -1572,7 +1570,9 @@ void Selection::scale_to_fit_print_volume(const BuildVolume& volume)
const double s = std::min(print_circle_radius / circle_radius, volume.printable_height() / max_z);
const Vec3d sel_center = get_bounding_box().center();
const Vec3d offset = s * (Vec3d(unscale<double>(circle.center.x()), unscale<double>(circle.center.y()), 0.5 * max_z) - sel_center);
const Vec3d print_center = { unscale<double>(print_circle.center.x()), unscale<double>(print_circle.center.y()), 0.5 * volume.printable_height() };
// The active plate's bed is the same circle, moved by its centre.
const Vec3d plate_shift = Vec3d(bed_center.x() - volume.bed_center().x(), bed_center.y() - volume.bed_center().y(), 0.);
const Vec3d print_center = Vec3d(unscale<double>(print_circle.center.x()), unscale<double>(print_circle.center.y()), 0.5 * volume.printable_height()) + plate_shift;
fit(s, print_center - (sel_center + offset));
};
@@ -1581,7 +1581,7 @@ void Selection::scale_to_fit_print_volume(const BuildVolume& volume)
switch (volume.type())
{
case BuildVolume_Type::Rectangle: { fit_rectangle(volume); break; }
case BuildVolume_Type::Rectangle: { fit_rectangle(); break; }
case BuildVolume_Type::Circle: { fit_circle(volume); break; }
default: { break; }
}
+4 -1
View File
@@ -354,7 +354,10 @@ public:
void flattening_rotate(const Vec3d& normal);
void scale(const Vec3d& scale, TransformationType transformation_type);
#if ENABLE_ENHANCED_PRINT_VOLUME_FIT
void scale_to_fit_print_volume(const BuildVolume& volume);
// bed_center is the centre of the plate the selection is on: every plate shares the
// bed size, but each sits at its own origin. print_volume is the box the selection must
// fit in, which is the area every nozzle reaches rather than the whole bed.
void scale_to_fit_print_volume(const BuildVolume& volume, const Vec2d& bed_center, const BoundingBoxf3& print_volume);
#else
void scale_to_fit_print_volume(const DynamicPrintConfig& config);
#endif // ENABLE_ENHANCED_PRINT_VOLUME_FIT
+6 -22
View File
@@ -211,9 +211,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;
@@ -223,9 +222,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;
@@ -246,11 +244,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;
}
@@ -596,21 +593,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)
{
// dispatch_start() moves out of `params`, so snapshot the diagnostic fields first;
// logging them after the call would print empty strings.
const bool try_emmc_print = params.try_emmc_print;
const std::string dev_ip = params.dev_ip;
const std::string dev_id = params.dev_id;
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=" << try_emmc_print
<< ", legacy_mode=" << BBLNetworkPlugin::instance().use_legacy_network()
<< ", dev_ip=" << dev_ip << ", dev_id=" << dev_id;
}
return result;
}
int BBLPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
-1
View File
@@ -110,7 +110,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);
};
+9 -14
View File
@@ -288,27 +288,22 @@ 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;
const MoonrakerDeviceInfo info = snapshot_device_info();
if (info.dev_ip.empty()) {
if (device_info.dev_ip.empty()) {
BOOST_LOG_TRIVIAL(warning)
<< "CrealityPrintAgent::fetch_filament_info: no device IP, falling back to base agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
}
// Build a CrealityPrint helper so we can use its model detection + WS helpers
// (added in upstream PR #13291).
DynamicPrintConfig cfg;
cfg.set_key_value("print_host", new ConfigOptionString("http://" + info.dev_ip));
cfg.set_key_value("print_host", new ConfigOptionString("http://" + device_info.dev_ip));
cfg.set_key_value("print_host_webui", new ConfigOptionString(""));
cfg.set_key_value("printhost_cafile", new ConfigOptionString(""));
cfg.set_key_value("printhost_port", new ConfigOptionString(""));
cfg.set_key_value("printhost_apikey", new ConfigOptionString(info.api_key));
cfg.set_key_value("printhost_apikey", new ConfigOptionString(device_info.api_key));
cfg.set_key_value("printhost_ssl_ignore_revoke", new ConfigOptionBool(false));
CrealityPrint host(&cfg);
@@ -318,7 +313,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
BOOST_LOG_TRIVIAL(info)
<< "CrealityPrintAgent: " << host.model_name()
<< " is not CFS-capable, deferring to base Moonraker agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
}
BOOST_LOG_TRIVIAL(info)
@@ -333,7 +328,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
BOOST_LOG_TRIVIAL(warning)
<< "CrealityPrintAgent: CFS query failed (" << parse_err << "), "
<< "falling back to base agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
}
if (box_count == 0) {
@@ -342,7 +337,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
// Moonraker exposes.
BOOST_LOG_TRIVIAL(info)
<< "CrealityPrintAgent: no active CFS boxes, deferring to base agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
}
BOOST_LOG_TRIVIAL(info)
@@ -387,7 +382,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
}
}
build_ams_payload(box_count, max_slots - 1, trays, sync_mode == FilamentSyncMode::pull);
build_ams_payload(box_count, max_slots - 1, trays);
return true;
}
+1 -2
View File
@@ -37,13 +37,12 @@ public:
};
explicit CrealityPrintAgent(std::string log_dir);
~CrealityPrintAgent() override { shutdown(); }
~CrealityPrintAgent() override = default;
static AgentInfo get_agent_info_static();
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 { return FilamentSyncMode::pull; }
// Parse the boxsInfo JSON returned by CrealityPrint::query_boxes_info() into
// a flat list of loaded slots, plus the count of CFS boxes the printer reports.
+3 -21
View File
@@ -45,17 +45,6 @@ struct PrinterConnectionParams
std::string ca_file;
};
// Sentinel stored as a LAN device's access code when the user has not supplied one, so the device
// stays registered (has_access_right() is !access_code.empty()). It is not a real credential: every
// printer agent must translate it back to an empty string before using/sending it on the wire.
inline constexpr const char* NO_API_KEY_SENTINEL = "*_{NO_API_KEY}_*";
// Returns an empty string when `code` is the "no API key" sentinel, otherwise `code` unchanged.
inline std::string normalize_access_code(const std::string& code)
{
return code == NO_API_KEY_SENTINEL ? std::string() : code;
}
/**
* FilamentSyncMode - Modes for filament data synchronization.
*
@@ -466,16 +455,9 @@ public:
virtual CameraStreamMode get_camera_stream_mode() const { return CameraStreamMode::none; }
/**
* Refresh filament info from the printer.
*
* When called with FilamentSyncMode::pull (which requires get_filament_sync_mode() to
* return pull) this is a blocking, synchronous call: when it returns true the MachineObject's
* DevFilaSystem has already been populated and the caller may read it immediately.
*
* When called with FilamentSyncMode::subscription — by an implementation's own status loop —
* the refresh may be performed asynchronously: a true return then means the refresh was
* scheduled (or is already in flight), and DevFilaSystem is updated later on the main thread.
* Callers must not assume the data is ready on return.
* Refresh filament info from the printer synchronously.
* Should only be called when get_filament_sync_mode() returns FilamentSyncMode::pull.
* Populates the MachineObject's DevFilaSystem with fetched filament data.
*/
virtual bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) { return false; }
File diff suppressed because it is too large. Load diff
+28 -177
View File
@@ -13,8 +13,6 @@
#include <set>
#include <string>
#include <thread>
#include <condition_variable>
#include <deque>
#include <nlohmann/json.hpp>
#include <vector>
@@ -23,56 +21,6 @@ namespace Slic3r { class ICloudServiceAgent; }
namespace Slic3r {
class Http;
bool moonraker_is_light_name(const std::string& name);
// Direction encoded by a light name: +1 turns on, -1 turns off, 0 is an ambiguous toggle.
// e.g. LIGHT_ON -> +1, LIGHT_OFF -> -1, LIGHT -> 0.
int moonraker_light_name_direction(const std::string& name);
struct MoonrakerWebcamSelection
{
std::string url;
CameraStreamMode mode = CameraStreamMode::none;
std::string name;
std::string error; // set when no selectable webcam was found
};
// Selects a webcam from a parsed /server/webcams/list response (the top-level JSON or its
// "result" value). Returns true and fills url/mode/name on success; on failure returns
// false and sets error.
bool moonraker_parse_webcam_list(const nlohmann::json& response, const std::string& base_url,
MoonrakerWebcamSelection& out);
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:
@@ -118,26 +66,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;
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
// Pull-mode agent (on-demand filament sync)
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; }
CameraStreamMode get_camera_stream_mode() const override;
std::string get_camera_url() const override;
// Called by the fetch-thread RAII guard when a background filament fetch finishes.
// Serialized with the reservation so shutdown()'s wait cannot miss the transition to 0.
void release_fetch_slot() noexcept;
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) 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;
@@ -149,9 +83,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;
// Copied when the stream starts, so changes apply to the next start_status_stream()
@@ -175,47 +107,18 @@ protected:
int nozzle_temp = 0; // Optional
};
// Build ams JSON and call parser.
// apply_inline: when true the MachineObject mutation runs on the calling thread, which
// must therefore be the main thread; when false it is deferred through queue_on_main_fn.
// Pull-mode fetches run synchronously on the GUI thread and are read back immediately, so
// they pass true to avoid the deferred mutation landing after the caller's read.
//
// resolve_tray_ids: optional hook that runs as part of the commit, i.e. on the main thread.
// Agents that would otherwise resolve tray_info_idx against GUI-owned preset state on a
// background fetch thread must do it here to avoid racing the GUI. It must not capture
// `this` (the commit may outlive the agent).
using ResolveAmsTrayIdsFn = std::function<void(std::vector<AmsTrayData>&)>;
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays, bool apply_inline = false,
ResolveAmsTrayIdsFn resolve_tray_ids = {});
// Build ams JSON and call parser
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;
// Copy of the mutable connection state, taken under connect_mutex. Background threads
// must use this instead of reading device_info directly.
MoonrakerDeviceInfo snapshot_device_info() const;
void configure_http(Http& http, const ConnectionSettings& connection) const;
static float parse_nozzle_diameter(const nlohmann::json& response);
// The connection is copied when the stream starts, so changes apply to the next start.
void start_status_stream(const std::string& dev_id, ConnectionSettings connection);
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;
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
void stop_status_stream();
// State access for derived classes
mutable std::recursive_mutex state_mutex;
// Detached fetch threads hold a raw `this`; shutdown() waits for this to reach 0.
std::atomic<int> filament_fetch_in_flight{0};
// Idempotent teardown; must be called from the most-derived destructor.
void shutdown();
std::atomic<bool> shutting_down{false};
// Serializes the shutting_down check with the in-flight reservation.
std::mutex fetch_lifecycle_mutex;
std::condition_variable fetch_done_cv; // notified when filament_fetch_in_flight reaches 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);
@@ -228,30 +131,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);
mutable std::recursive_mutex connect_mutex;
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;
@@ -259,46 +145,38 @@ private:
void announce_printhost_device();
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
void dispatch_printer_connected(const std::string& dev_id);
// Self-contained snapshot of the message callbacks. Async completions capture this
// by value so they never dereference `this` after the agent may have been destroyed.
struct MessageRouter
{
OnMessageFn local_fn;
OnMessageFn cloud_fn;
QueueOnMainFn queue_fn;
std::string dev_id;
void deliver(std::string payload) const;
};
MessageRouter make_message_router(const std::string& dev_id) const;
void dispatch_message(const std::string& dev_id, const std::string& payload);
// Joins a stream stopped from its own callback, so a new stream can start without waiting on it under connect_mutex
void join_stopped_status_stream();
void run_status_stream(std::string dev_id, ConnectionSettings connection, StatusStreamTimings timings);
void handle_ws_message(std::string dev_id, std::string payload, ConnectionSettings connection);
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings);
void handle_ws_message(const std::string& dev_id, const std::string& payload);
void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
void refresh_thumbnail_url(const ConnectionSettings& connection);
void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const;
// Print control helpers
int pause_print(const std::string& dev_id);
int resume_print(const std::string& dev_id);
int cancel_print(const std::string& dev_id);
// 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);
// 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);
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);
@@ -324,37 +202,16 @@ 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;
// Next time the status loop may look the webcam up again (steady_clock ms).
mutable std::atomic<uint64_t> webcam_info_next_attempt_ms{0};
unsigned thumbnail_lookup_attempts = 0;
static constexpr uint64_t WEBCAM_INFO_REFRESH_INTERVAL_MS = 30000;
static constexpr uint64_t WEBCAM_INFO_FAILURE_BACKOFF_MS = 60000;
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;
// Guards ws_thread and ws_abort. ws_abort posts to the stream's io_context, cancelling the
// reconnect wait and interrupting whatever connection attempt is running, so stop is prompt
// even when the stream thread is blocked on connect, TLS, or a read.
std::mutex ws_abort_mutex;
std::function<void()> ws_abort; // 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};
std::mutex ws_abort_mutex; // Guards ws_thread and ws_abort
std::function<void()> ws_abort; // Interrupts whatever the stream thread is waiting on, from any thread
// Throttling configuration for WebSocket updates
// Critical changes (state transitions) dispatch immediately; telemetry is throttled
@@ -365,13 +222,7 @@ private:
// Connection thread management
std::atomic<uint64_t> connect_generation{0};
std::thread connect_thread;
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
+63 -241
View File
@@ -2,25 +2,18 @@
#include "Http.hpp"
#include "MoonrakerPrinterAgent.hpp"
#include "IPrinterAgent.hpp"
#include "bambu_networking.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "nlohmann/json.hpp"
#include <atomic>
#include <boost/algorithm/string.hpp>
#include <boost/algorithm/string/trim.hpp>
#include <boost/algorithm/string/case_conv.hpp>
#include <boost/log/trivial.hpp>
#include <cctype>
#include <cmath>
#include "libslic3r/Preset.hpp"
#include <cstddef>
#include <exception>
#include <map>
#include <mutex>
#include <sstream>
#include <thread>
#include <string>
#include <utility>
#include <vector>
@@ -41,40 +34,6 @@ bool has_visible_base_preset(const PresetCollection& filaments, const std::strin
return false;
}
// RAII release of the in-flight fetch slot; movable so a failed thread start still releases it.
struct InFlightGuard
{
MoonrakerPrinterAgent* owner;
explicit InFlightGuard(MoonrakerPrinterAgent& o) noexcept : owner(&o) {}
InFlightGuard(InFlightGuard&& other) noexcept : owner(other.owner) { other.owner = nullptr; }
InFlightGuard(const InFlightGuard&) = delete;
InFlightGuard& operator=(const InFlightGuard&) = delete;
InFlightGuard& operator=(InFlightGuard&&) = delete;
~InFlightGuard() { if (owner) owner->release_fetch_slot(); }
};
// nlohmann::json::value() returns the default only when the key is absent; a present but
// null/wrong-typed value throws. Firmware JSON is untrusted, so read defensively.
int read_int_or(const nlohmann::json& obj, const std::string& key, int fallback)
{
auto it = obj.find(key);
if (it == obj.end()) {
return fallback;
}
if (it->is_number_integer()) {
return it->get<int>();
}
// Firmware may report an integral field as a JSON float (e.g. 2.0). Accept it only when it
// has no fractional part, so a genuinely fractional value falls back instead of truncating.
if (it->is_number_float()) {
const double value = it->get<double>();
if (value == std::floor(value)) {
return static_cast<int>(value);
}
}
return fallback;
}
} // anonymous namespace
const std::string QidiPrinterAgent_VERSION = "0.0.1";
@@ -88,179 +47,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())
return false;
// Snapshot what the fetch needs; a reconnect can rewrite device_info meanwhile.
ConnectionSettings connection = get_connection_settings();
std::string model_id;
std::string model_name;
// 1. Fetch device info and infer series_id
std::string series_id;
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
model_id = device_info.model_id;
model_name = device_info.model_name;
}
// One implementation for both modes; only where it runs differs. pull is the documented
// blocking contract (the GUI thread reads DevFilaSystem right after the call), so it runs
// on the caller with the payload applied inline. subscription runs on a background thread
// so the status loop is not stalled, and the payload is marshalled back to the main thread.
auto work = [this, connection = std::move(connection), model_id, model_name](bool apply_inline) -> bool {
try {
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 false;
}
// 4. Build the AMS payload, resolving preset-dependent ids on the main thread.
auto resolve_ids = [](std::vector<AmsTrayData>& resolved_trays) {
auto* bundle = GUI::wxGetApp().preset_bundle;
if (!bundle) {
return; // keep the Qidi-specific ids computed from device data
}
for (auto& tray : resolved_trays) {
if (!tray.has_filament) {
continue;
}
if (!tray.tray_info_idx.empty() && has_visible_base_preset(bundle->filaments, tray.tray_info_idx)) {
continue;
}
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
}
};
build_ams_payload(box_count, box_count * 4 - 1, trays, apply_inline, resolve_ids);
return true;
} catch (const std::exception& e) {
// why: an exception escaping a detached thread is std::terminate, and firmware
// JSON is untrusted; mirror run_command_worker and swallow it here.
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::fetch_filament_info: unhandled exception: " << e.what();
return false;
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::fetch_filament_info: unhandled exception";
return false;
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 (sync_mode == FilamentSyncMode::pull) {
return work(/*apply_inline=*/true);
}
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);
}
// Subscription: fire-and-forget. A `true` return means the refresh was scheduled (or is
// already running), not that DevFilaSystem has been updated. Reserve under the same mutex
// shutdown() uses, so the flag and the count can't race.
{
std::lock_guard<std::mutex> lock(fetch_lifecycle_mutex);
if (shutting_down.load())
return false;
if (filament_fetch_in_flight.load() > 0)
return true; // a fetch is already running; don't pile on
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
// 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;
}
InFlightGuard guard{*this};
std::thread([work = std::move(work), guard = std::move(guard)]() mutable {
work(/*apply_inline=*/false);
}).detach();
// 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;
}
// 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;
const std::string dev_id = get_connection_settings().dev_id;
// Build one gcode/script request instead of N blocking HTTP calls: apply_box_mapping
// runs on the caller's (GUI) thread before the print starts, so per-tool round trips
// would freeze the UI.
std::string script = "SAVE_VARIABLE VARIABLE=enable_box VALUE=" + std::to_string(enable);
// 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) {
if (params.ams_mapping.empty()) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::apply_box_mapping: enable_box set but ams_mapping is empty";
} else {
// ams_mapping (v0) is a JSON array indexed by filament/tool; each value is the
// physical box slot (-1 = unmapped). Mirror it onto value_t<tool>.
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
script += "\nSAVE_VARIABLE VARIABLE=value_t" + std::to_string(tool) +
" VALUE=\"'slot" + std::to_string(slot) + "'\"";
}
}
}
if (!send_gcode(dev_id, script)) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to send box mapping";
return false;
}
return true;
}
int QidiPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
{
// Only the LAN print path is implemented by the Moonraker base; the box config is emitted
// here, before the print starts. The cloud/record/sdcard variants inherited from the base
// deliberately return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED without side effects.
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);
}
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";
}
@@ -270,9 +101,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)
@@ -297,12 +127,22 @@ 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;
}
box_count = read_int_or(variables, "box_count", 1);
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) {
box_count = 0;
}
@@ -325,9 +165,9 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
tray.slot_index = i;
// Read slot variables
const int color_index = read_int_or(variables, "color_slot" + std::to_string(i), 1);
const int filament_type = read_int_or(variables, "filament_slot" + std::to_string(i), 1);
const int vendor_type = read_int_or(variables, "vendor_slot" + std::to_string(i), 0);
const int color_index = variables.value("color_slot" + std::to_string(i), 1);
const int filament_type = variables.value("filament_slot" + std::to_string(i), 1);
const int vendor_type = variables.value("vendor_slot" + std::to_string(i), 0);
// Check filament presence via runout sensor
std::string box_stepper_key = "box_stepper slot" + std::to_string(i);
@@ -335,7 +175,7 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
if (status.contains(box_stepper_key)) {
auto& box_stepper = status[box_stepper_key];
if (box_stepper.contains("runout_button") && !box_stepper["runout_button"].is_null()) {
const int runout_button = read_int_or(box_stepper, "runout_button", 0);
int runout_button = box_stepper["runout_button"].template get<int>();
tray.has_filament = (runout_button == 0);
}
}
@@ -349,9 +189,16 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
}
tray.tray_type = normalize_filament_type(filament_name);
// Qidi-specific setting id derived from device data only; the preset-dependent
// fallback runs on the main thread in the resolve hook passed to build_ams_payload.
tray.tray_info_idx = build_setting_id(filament_type, vendor_type, tray.tray_type);
// Try Qidi-specific setting ID first; fall back to visible preset by type
std::string setting_id = build_setting_id(filament_type, vendor_type, tray.tray_type);
auto* bundle = GUI::wxGetApp().preset_bundle;
if (!bundle) {
tray.tray_info_idx = setting_id;
} else if (!setting_id.empty() && has_visible_base_preset(bundle->filaments, setting_id)) {
tray.tray_info_idx = setting_id;
} else {
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
}
// Look up color from dictionary
auto color_it = dict.colors.find(color_index);
@@ -368,45 +215,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)
+4 -20
View File
@@ -3,8 +3,6 @@
#include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp"
#include "bambu_networking.hpp"
#include "nlohmann/json_fwd.hpp"
#include <map>
#include <string>
@@ -16,7 +14,7 @@ class QidiPrinterAgent final : public MoonrakerPrinterAgent
{
public:
explicit QidiPrinterAgent(std::string log_dir);
~QidiPrinterAgent() override { shutdown(); }
~QidiPrinterAgent() override = default;
static AgentInfo get_agent_info_static();
AgentInfo get_agent_info() override { return get_agent_info_static(); }
@@ -24,22 +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.
// Only the LAN print path is supported by the base; the cloud/record/sdcard variants
// are inherited and return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED.
int start_local_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);
+103 -289
View File
@@ -2,20 +2,11 @@
#include "Http.hpp"
#include "MoonrakerPrinterAgent.hpp"
#include "IPrinterAgent.hpp"
#include "bambu_networking.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "nlohmann/json.hpp"
#include <atomic>
#include <boost/log/trivial.hpp>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <exception>
#include <mutex>
#include <sstream>
#include <thread>
#include <vector>
#include <string>
#include "libslic3r/Preset.hpp"
@@ -27,91 +18,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();
}
// RAII release of the in-flight fetch slot; movable so a failed thread start still releases it.
struct InFlightGuard
{
MoonrakerPrinterAgent* owner;
explicit InFlightGuard(MoonrakerPrinterAgent& o) noexcept : owner(&o) {}
InFlightGuard(InFlightGuard&& other) noexcept : owner(other.owner) { other.owner = nullptr; }
InFlightGuard(const InFlightGuard&) = delete;
InFlightGuard& operator=(const InFlightGuard&) = delete;
InFlightGuard& operator=(InFlightGuard&&) = delete;
~InFlightGuard() { if (owner) owner->release_fetch_slot(); }
};
// nlohmann::json::value() returns the default only when the key is absent; a present but
// null/wrong-typed value throws. Firmware JSON is untrusted, so read defensively.
int read_int_or(const nlohmann::json& obj, const char* key, int fallback)
{
auto it = obj.find(key);
if (it == obj.end()) {
return fallback;
}
if (it->is_number_integer()) {
return it->get<int>();
}
// Firmware may report an integral field as a JSON float (e.g. 2.0). Accept it only when it
// has no fractional part, so a genuinely fractional value falls back instead of truncating.
if (it->is_number_float()) {
const double value = it->get<double>();
if (value == std::floor(value)) {
return static_cast<int>(value);
}
}
return fallback;
}
std::vector<std::string> read_string_array_or(const nlohmann::json& obj, const char* key)
{
auto it = obj.find(key);
if (it == obj.end() || !it->is_array())
return {};
std::vector<std::string> out;
out.reserve(it->size());
for (const auto& v : *it)
out.push_back(v.is_string() ? v.get<std::string>() : std::string{});
return out;
}
std::vector<bool> read_bool_array_or(const nlohmann::json& obj, const char* key)
{
auto it = obj.find(key);
if (it == obj.end() || !it->is_array())
return {};
std::vector<bool> out;
out.reserve(it->size());
for (const auto& v : *it)
out.push_back(v.is_boolean() ? v.get<bool>() : false);
return out;
}
// Parse a hex colour, stopping at the first non-hex character. std::stoul throws on
// empty/non-hex input, which must not escape the detached fetch thread.
unsigned int parse_hex_color(const std::string& hex)
{
unsigned int value = 0;
for (char c : hex) {
int digit;
if (c >= '0' && c <= '9')
digit = c - '0';
else if (c >= 'a' && c <= 'f')
digit = c - 'a' + 10;
else if (c >= 'A' && c <= 'F')
digit = c - 'A' + 10;
else
break;
value = (value << 4) | static_cast<unsigned int>(digit);
}
return value;
}
// Safely access a parallel array by index, returning a fallback if out of bounds.
template<typename T>
@@ -134,16 +40,14 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection&
filaments.get_preset_base(p) == &p && p.config.opt_string("filament_vendor", 0u) == vendor_name &&
p.config.opt_string("filament_type", 0u) == filament_type) {
// The printer returns RGBA in the format RRGGBBAA, but profiles store color as #RRGGBB,
// so we must remove # and ignore alpha channel for distance calculation. Firmware
// colours are untrusted; parse_hex_color tolerates empty/non-hex instead of throwing.
unsigned int target_color_value =
parse_hex_color(color_rgba.substr(0, color_rgba.size() >= 2 ? color_rgba.size() - 2 : 0));
// so we must remove # and ignore alpha channel for distance calculation
unsigned int target_color_value = std::stoul(color_rgba.substr(0, color_rgba.length() - 2), nullptr, 16);
std::string p_color = p.config.opt_string("default_filament_colour", 0u);
unsigned int p_color_value = 0;
unsigned int p_color_value;
if (!p_color.empty()) {
size_t hash_pos = p_color.find("#");
p_color_value = parse_hex_color(p_color.substr(hash_pos != std::string::npos ? hash_pos + 1 : 0));
size_t hash_pos = p_color.find("#");
p_color_value = std::stoul(p_color.substr(hash_pos != std::string::npos ? hash_pos + 1 : 0), nullptr, 16);
} else {
// Default to black if no color specified in profile. Assume other profiles might be a closer color match.
// Could be a problem if the target color is also black and there exist a specific profile for that type, vendor and color
@@ -170,31 +74,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"};
@@ -230,192 +109,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;
}
// Snapshot everything the fetch needs (URL, api key, TLS/CA): a reconnect can rewrite
// device_info meanwhile.
const ConnectionSettings connection = get_connection_settings();
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;
}
// One implementation for both modes; only where it runs differs. pull is the documented
// blocking contract (the GUI thread reads DevFilaSystem right after the call), so it runs
// on the caller with the payload applied inline. subscription runs on a background thread
// so the status loop is not stalled, and the payload is marshalled back to the main thread.
auto work = [this, connection](bool apply_inline) -> bool {
try {
const std::string url = join_url(connection.base_url, "/printer/objects/query?print_task_config&filament_detect");
// 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::string response_body;
bool success = false;
std::string http_error;
auto& ptc = json["result"]["status"]["print_task_config"];
auto http = Http::get(url);
configure_http(http, connection);
if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.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();
// 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>{});
if (!success) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error;
return false;
}
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 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;
}
// 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"];
}
// 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;
}
static const std::string empty_str;
static const std::string default_color = "FFFFFFFF";
auto& ptc = json["result"]["status"]["print_task_config"];
std::vector<AmsTrayData> trays;
trays.reserve(slot_count);
// Read parallel arrays from print_task_config
auto filament_exist = read_bool_array_or(ptc, "filament_exist");
auto filament_type = read_string_array_or(ptc, "filament_type");
auto filament_sub_type = read_string_array_or(ptc, "filament_sub_type");
auto filament_color = read_string_array_or(ptc, "filament_color_rgba");
auto filament_vendor = read_string_array_or(ptc, "filament_vendor");
for (int i = 0; i < slot_count; ++i) {
AmsTrayData tray;
tray.slot_index = i;
tray.has_filament = filament_exist[i];
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;
}
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);
// 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"];
}
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);
static const std::string empty_str;
static const std::string default_color = "FFFFFFFF";
// Per-tray vendor names, kept so the preset lookup can run on the main thread.
std::vector<std::string> vendors(slot_count);
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);
vendors[i] = safe_at(filament_vendor, i, empty_str);
// Generic id derived from device data only; the preset-dependent lookup runs
// on the main thread in the resolve hook passed to build_ams_payload.
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 = "NONE";
if (auto vendor_it = nfc_slot.find("VENDOR"); vendor_it != nfc_slot.end() && vendor_it->is_string()) {
vendor = vendor_it->get<std::string>();
}
if (vendor != "NONE" && !vendor.empty()) {
tray.bed_temp = read_int_or(nfc_slot, "BED_TEMP", 0);
tray.nozzle_temp = read_int_or(nfc_slot, "FIRST_LAYER_TEMP", 0);
}
}
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);
}
trays.emplace_back(std::move(tray));
} else {
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
}
// Resolve against GUI-owned presets on the main thread (inside build_ams_payload).
auto resolve_ids = [vendors](std::vector<AmsTrayData>& resolved_trays) {
auto* bundle = GUI::wxGetApp().preset_bundle;
if (!bundle) {
return; // keep the generic ids computed from device data
// 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);
}
for (size_t i = 0; i < resolved_trays.size() && i < vendors.size(); ++i) {
AmsTrayData& tray = resolved_trays[i];
if (!tray.has_filament) {
continue;
}
// Prefer a manufacturer/colour-specific preset, else fall back to type.
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendors[i], tray.tray_type,
tray.tray_color);
tray.tray_info_idx = filament_id.empty() ? bundle->filaments.filament_id_by_type(tray.tray_type) : filament_id;
}
};
build_ams_payload(1, slot_count - 1, trays, apply_inline, resolve_ids);
return true;
} catch (const std::exception& e) {
// why: an exception escaping a detached thread is std::terminate, and firmware
// JSON is untrusted; mirror run_command_worker and swallow it here.
BOOST_LOG_TRIVIAL(error) << "SnapmakerPrinterAgent::fetch_filament_info: unhandled exception: " << e.what();
return false;
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "SnapmakerPrinterAgent::fetch_filament_info: unhandled exception";
return false;
}
}
};
if (sync_mode == FilamentSyncMode::pull) {
return work(/*apply_inline=*/true);
trays.emplace_back(std::move(tray));
}
// Subscription: fire-and-forget. A `true` return means the refresh was scheduled (or is
// already running), not that DevFilaSystem has been updated. Reserve under the same mutex
// shutdown() uses, so the flag and the count can't race.
{
std::lock_guard<std::mutex> lock(fetch_lifecycle_mutex);
if (shutting_down.load())
return false;
if (filament_fetch_in_flight.load() > 0)
return true; // a fetch is already running; don't pile on
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
}
InFlightGuard guard{*this};
std::thread([work = std::move(work), guard = std::move(guard)]() mutable {
work(/*apply_inline=*/false);
}).detach();
build_ams_payload(1, slot_count - 1, trays);
return true;
}
std::string SnapmakerPrinterAgent::get_camera_url() const
{
return join_url(get_connection_settings().base_url, "/server/files/camera/monitor.jpg");
}
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
+1 -12
View File
@@ -3,8 +3,6 @@
#include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp"
#include <atomic>
#include <cstdint>
#include <string>
namespace Slic3r {
@@ -13,25 +11,16 @@ class SnapmakerPrinterAgent final : public MoonrakerPrinterAgent
{
public:
explicit SnapmakerPrinterAgent(std::string log_dir);
~SnapmakerPrinterAgent() override { shutdown(); }
~SnapmakerPrinterAgent() override = default;
static AgentInfo get_agent_info_static();
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;
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
+28
View File
@@ -220,11 +220,39 @@ TEST_CASE("An item larger than the bed cannot be placed", "[Arrange]")
ArrangePolygons items;
items.emplace_back(make_square(scaled(20.)));
items.emplace_back(make_square(scaled(400.))); // far bigger than the bed
// An item that cannot be placed keeps the position it came in with, so the
// caller can leave it where the user had it.
items[1].translation = {scaled(10.), scaled(20.)};
arrange(items, bed(200, 200), quiet_params(scaled(1.)));
REQUIRE(items[0].bed_idx == 0);
REQUIRE(items[1].bed_idx == UNARRANGED);
REQUIRE(items[1].translation.x() == scaled(10.));
REQUIRE(items[1].translation.y() == scaled(20.));
}
TEST_CASE("An item filling the printable area is arranged", "[Arrange]")
{
// The arrange bed keeps a margin from the printable area and the item is widened by
// its brim. Neither may keep an item that fits the printable area from being placed.
DynamicPrintConfig cfg = bed_config(); // 200 x 200
ArrangeParams p = quiet_params();
ArrangePolygons items = squares(1, 197.);
items.front().brim_width = 1.;
update_arrange_params(p, &cfg, items);
update_selected_items_inflation(items, &cfg, p);
arrange(items, get_shrink_bedpts(&cfg, p), p);
REQUIRE(items.front().bed_idx == 0);
// Larger than the printable area is still refused.
ArrangeParams p_large = quiet_params();
ArrangePolygons large = squares(1, 201.);
update_arrange_params(p_large, &cfg, large);
update_selected_items_inflation(large, &cfg, p_large);
arrange(large, get_shrink_bedpts(&cfg, p_large), p_large);
REQUIRE(large.front().bed_idx == UNARRANGED);
}
TEST_CASE("Items overflowing one bed spill onto virtual beds", "[Arrange]")
+17
View File
@@ -187,6 +187,23 @@ TEST_CASE("A part rests on its largest hull face even when parallel inner faces
check_on_bed(plate);
}
TEST_CASE("A box laid on its top face is turned over about X", "[LayOnFace]")
{
// A face pointing straight up leaves the half turn's axis open to every horizontal
// direction. It must be the fixed X flip both times, keep the footprint and not follow
// rounding noise to an arbitrary angle.
Model model;
ModelObject &box = add_box_object(model, { 40, 20, 10 });
lay_on_face(box, 0, { 0, 0, 1 });
check_size(box, { 40, 20, 10 });
check_on_bed(box);
const Matrix3d rotation = box.instances.front()->get_matrix_no_offset().linear();
CHECK_THAT(rotation(0, 0), WithinAbs(1., 1e-9));
CHECK_THAT(rotation(1, 1), WithinAbs(-1., 1e-9));
CHECK_THAT(rotation(2, 2), WithinAbs(-1., 1e-9));
}
TEST_CASE("Faces are selected in object coordinates whatever the instance rotation", "[LayOnFace]")
{
Model model;
-1
View File
@@ -30,7 +30,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_plugin_install.cpp
test_plugin_lifecycle.cpp
test_plugin_resource_texture.cpp
@@ -53,7 +53,7 @@ public:
mutable unsigned fetch_count = 0;
protected:
bool fetch_device_info(const ConnectionSettings&, MoonrakerDeviceInfo& info, std::string&) const override
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
{
++fetch_count;
info.dev_name = "Test printer";
@@ -65,32 +65,21 @@ class StreamAgent : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
using MoonrakerPrinterAgent::start_status_stream;
using MoonrakerPrinterAgent::stop_status_stream;
using MoonrakerPrinterAgent::ws_timings;
// Starts the stream the way a connection does, from a snapshot of the connection settings.
void start_stream(const std::string& base_url, bool use_ssl = false)
{
const std::string dev_id = "printer";
ConnectionSettings connection;
connection.dev_id = dev_id;
connection.base_url = base_url;
connection.use_ssl = use_ssl;
start_status_stream(dev_id, std::move(connection));
}
void stop_stream() { stop_status_stream(); }
protected:
bool init_device_info(const PrinterConnectionParams& params) override
bool init_device_info(
const std::string& dev_id, const std::string& dev_ip, const std::string&, const std::string&, bool, const std::string& port) override
{
device_info = MoonrakerDeviceInfo{};
device_info.dev_id = params.dev_id;
device_info.dev_ip = params.host;
device_info.base_url = std::string(params.use_ssl ? "https://" : "http://") + params.host + ":" + params.port;
device_info.dev_id = dev_id;
device_info.base_url = "http://" + dev_ip + ":" + port;
return true;
}
bool fetch_device_info(const ConnectionSettings&, MoonrakerDeviceInfo& info, std::string&) const override
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
{
info.dev_name = "Test printer";
return true;
@@ -108,7 +97,6 @@ enum class Behavior {
IgnoresClose, // Reports Klippy disconnected, then never answers the close
StallsObjectList, // Never answers the stream's object list request
StallsStatusQuery, // Never answers HTTP requests until released, then hangs up on them
RejectsTls, // Records the first byte of the stream, which a TLS client sends as a handshake
};
class FakePrinter
@@ -162,19 +150,6 @@ public:
return m_close_frames;
}
// First byte the stream sent, or -1 until it has sent one.
bool wait_for_first_byte()
{
std::unique_lock<std::mutex> lock(m_mutex);
return m_changed.wait_for(lock, 10s, [&] { return m_first_byte >= 0; });
}
int first_byte()
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_first_byte;
}
// The printer answers a close frame before its own read completes, so the agent's stop can return first.
bool wait_for_close_frames(int count)
{
@@ -214,20 +189,6 @@ private:
return;
}
if (m_behavior == Behavior::RejectsTls) {
char first = 0;
beast::error_code ec;
socket.read_some(net::buffer(&first, 1), ec);
if (!ec) {
std::lock_guard<std::mutex> lock(m_mutex);
m_first_byte = static_cast<unsigned char>(first);
m_changed.notify_all();
}
mark_ready();
wait_for_release();
return;
}
beast::flat_buffer buffer;
http::request<http::string_body> request;
beast::error_code ec;
@@ -382,7 +343,6 @@ private:
int m_http_requests = 0;
int m_http_hung_up = 0;
int m_close_frames = 0;
int m_first_byte = -1;
bool m_released = false;
};
@@ -425,15 +385,12 @@ struct StreamFixture
// Runs before the members are destroyed, so the agent's destructor never joins a stream the printer still holds open.
~StreamFixture() { printer.release(); }
void start() { agent.start_stream(printer.base_url()); }
// Runs the stream as a wss client; the printer must be served on the same port.
void start_secure() { agent.start_stream("https://" + printer.host() + ":" + printer.port(), true); }
void start() { agent.start_status_stream("printer", printer.base_url(), ""); }
// Stops the stream, giving it `limit` to return before the printer hangs up on it.
bool stops_within(std::chrono::milliseconds limit)
{
auto stopped = std::async(std::launch::async, [this] { agent.stop_stream(); });
auto stopped = std::async(std::launch::async, [this] { agent.stop_status_stream(); });
const bool in_time = stopped.wait_for(limit) == std::future_status::ready;
printer.release();
stopped.wait();
@@ -490,16 +447,6 @@ TEST_CASE("Stopping the status stream gives a silent printer no more than its ti
CHECK(f.stops_within(limit));
}
TEST_CASE("A secure status stream opens with a TLS handshake", "[MoonrakerPrinterAgent][StatusStream]")
{
StreamFixture f(Behavior::RejectsTls);
f.start_secure();
REQUIRE(f.printer.wait_for_first_byte());
// 0x16 is a TLS handshake record; a plaintext client would send "GET".
CHECK(f.printer.first_byte() == 0x16);
CHECK(f.stops_within(3s));
}
TEST_CASE("Stopping the status stream sends the printer a close frame", "[MoonrakerPrinterAgent][StatusStream]")
{
// A stop while the stream reads, and one while it waits on the object list request.
@@ -577,7 +524,7 @@ TEST_CASE("A callback on the stream thread can stop the status stream", "[Moonra
StreamFixture f(Behavior::AnswersPings);
f.agent.set_on_local_message_fn([&](std::string, std::string) {
std::call_once(stopped_once, [&] {
f.agent.stop_stream();
f.agent.stop_status_stream();
stopped.set_value();
});
});
@@ -635,12 +582,9 @@ TEST_CASE("A disconnect during the connection queries keeps the status stream fr
f.agent.connect_printer(params);
REQUIRE(f.printer.wait_for_http_requests(1));
// disconnect_printer joins the worker running the stalled query, so it is called aside and the
// printer is released to let that query return.
auto disconnecting = std::async(std::launch::async, [&] { f.agent.disconnect_printer(); });
f.agent.disconnect_printer();
f.printer.release();
// The query has returned once the printer hangs up on it, and a stream would connect right after.
REQUIRE(f.printer.wait_for_http_hang_ups(1));
CHECK_FALSE(f.printer.wait_for_ready_connections(1, 500ms));
disconnecting.wait();
}
+3 -393
View File
@@ -1,30 +1,18 @@
#include <catch2/catch_all.hpp>
#include <functional>
#include <slic3r/Utils/BBLPrinterAgent.hpp>
#include <slic3r/Utils/IPrinterAgent.hpp>
#include <slic3r/Utils/MoonrakerPrinterAgent.hpp>
#include <memory>
#include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <catch2/catch_test_macros.hpp>
#include <pybind11/pytypes.h>
#include <catch2/catch_message.hpp>
#include "catch2/matchers/catch_matchers.hpp"
#include "catch2/matchers/catch_matchers_floating_point.hpp"
#include "python_test_support.hpp"
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <atomic>
#include <chrono>
#include <future>
#include <slic3r/Utils/bambu_networking.hpp>
#include <string>
#include <thread>
#include <pybind11/cast.h>
#include <utility>
namespace Slic3r { class ICloudServiceAgent; }
namespace Slic3r { class IPrinterAgent; }
@@ -32,390 +20,12 @@ namespace Slic3r { class IPrinterAgent; }
using namespace Slic3r;
namespace py = pybind11;
namespace {
// Releases a promise on scope exit, so a throwing REQUIRE cannot leave a parked detached
// thread (and any destructor that joins it) blocked forever.
class ScopedPromiseRelease
{
public:
explicit ScopedPromiseRelease(std::shared_ptr<std::promise<void>> p) : m_p(std::move(p)) {}
~ScopedPromiseRelease()
{
if (m_p) {
try {
m_p->set_value();
} catch (...) {
// promise already satisfied
}
}
}
ScopedPromiseRelease(const ScopedPromiseRelease&) = delete;
ScopedPromiseRelease& operator=(const ScopedPromiseRelease&) = delete;
private:
std::shared_ptr<std::promise<void>> m_p;
};
} // namespace
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", "[MoonrakerPrinterAgent]")
{
const auto response = nlohmann::json::parse(R"({
"result": {
"status": {
"configfile": {
"settings": {
"extruder": {
"nozzle_diameter": 0.6
}
}
}
}
}
})");
CHECK_THAT(MoonrakerParserProbe::parse_nozzle_diameter(response), Catch::Matchers::WithinAbs(0.6f, 1e-4f));
}
TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missing data", "[MoonrakerPrinterAgent]")
{
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_THAT(MoonrakerParserProbe::parse_nozzle_diameter(raw_config_response), Catch::Matchers::WithinAbs(0.8f, 1e-4f));
CHECK_THAT(MoonrakerParserProbe::parse_nozzle_diameter(missing_response), Catch::Matchers::WithinAbs(0.0f, 1e-6f));
}
// why: an agent without a Bambu-dialect translation must refuse these commands before any network or wx path.
TEST_CASE("default AMS commands report not supported", "[MoonrakerPrinterAgent]")
{
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("Moonraker light name matching", "[MoonrakerPrinterAgent]")
{
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"));
}
TEST_CASE("Moonraker webcam selection skips disabled webcams and prefers the first enabled one",
"[MoonrakerPrinterAgent]")
{
const auto response = nlohmann::json::parse(R"({
"result": { "webcams": [
{ "name": "disabled", "enabled": false, "stream_url": "http://192.168.1.9:8080/stream" },
{ "name": "enabled", "enabled": true, "stream_url": "http://192.168.1.9:8080/stream" }
]}
})");
MoonrakerWebcamSelection selection;
REQUIRE(moonraker_parse_webcam_list(response, "http://192.168.1.9:7125", selection));
CHECK(selection.name == "enabled");
CHECK(selection.url == "http://192.168.1.9:8080/stream");
CHECK(selection.mode == CameraStreamMode::http);
CHECK(selection.error.empty());
}
TEST_CASE("Moonraker webcam selection resolves relative URLs, maps rtsp, and rejects other schemes",
"[MoonrakerPrinterAgent]")
{
const auto relative = nlohmann::json::parse(R"({
"result": { "webcams": [ { "name": "cam", "snapshot_url": "/webcam/?action=snapshot" } ] }
})");
MoonrakerWebcamSelection rel;
REQUIRE(moonraker_parse_webcam_list(relative, "http://192.168.1.9:7125", rel));
// Relative URLs use the printer web root, without the Moonraker API port.
CHECK(rel.url == "http://192.168.1.9/webcam/?action=snapshot");
CHECK(rel.mode == CameraStreamMode::http_snapshot);
const auto rtsp = nlohmann::json::parse(R"({
"result": { "webcams": [ { "name": "cam", "stream_url": "rtsp://192.168.1.9:554/live" } ] }
})");
MoonrakerWebcamSelection rt;
REQUIRE(moonraker_parse_webcam_list(rtsp, "http://192.168.1.9:7125", rt));
CHECK(rt.mode == CameraStreamMode::rtsp);
const auto unsupported = nlohmann::json::parse(R"({
"result": { "webcams": [ { "name": "cam", "stream_url": "weird://host/x" } ] }
})");
MoonrakerWebcamSelection bad;
CHECK_FALSE(moonraker_parse_webcam_list(unsupported, "http://192.168.1.9:7125", bad));
CHECK(bad.error == "Unsupported webcam URL");
}
TEST_CASE("Moonraker webcam selection reports no webcam and malformed structure", "[MoonrakerPrinterAgent]")
{
const auto empty = nlohmann::json::parse(R"({ "result": { "webcams": [] } })");
MoonrakerWebcamSelection none;
CHECK_FALSE(moonraker_parse_webcam_list(empty, "http://host:7125", none));
CHECK(none.error == "No enabled webcam");
const auto disabled_only = nlohmann::json::parse(R"({
"result": { "webcams": [ { "name": "disabled", "enabled": false, "stream_url": "http://host/stream" } ] }
})");
MoonrakerWebcamSelection off;
CHECK_FALSE(moonraker_parse_webcam_list(disabled_only, "http://host:7125", off));
const auto malformed = nlohmann::json::parse(R"({ "result": { "nope": 1 } })");
MoonrakerWebcamSelection shape;
CHECK_FALSE(moonraker_parse_webcam_list(malformed, "http://host:7125", shape));
CHECK(shape.error == "Unexpected JSON structure");
}
// ===========================================================================
// 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("Moonraker reports untranslated commands as not supported", "[MoonrakerPrinterAgent]")
{
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.
// Pull-mode overrides must be synchronous (the caller reads DevFilaSystem as soon as it
// returns), but subscription overrides may be fire-and-forget: QidiPrinterAgent/Snapmaker
// spawn a detached thread when asked for subscription updates. QidiPrinterAgent is `final`,
// so this probes the subscription contract with a controllable double instead.
TEST_CASE("a fire-and-forget subscription fetch is not waited on by the caller",
"[MoonrakerPrinterAgent]")
{
class RecordingAgent : public Slic3r::MoonrakerPrinterAgent
{
public:
explicit RecordingAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
// Shared so the detached proxy fetch never touches `this`: a throwing REQUIRE
// then cannot leave it dereferencing a destroyed agent.
std::shared_ptr<std::atomic<bool>> invoked{std::make_shared<std::atomic<bool>>(false)};
std::shared_ptr<std::promise<void>> release_gate{std::make_shared<std::promise<void>>()};
std::shared_ptr<std::promise<void>> done_promise{std::make_shared<std::promise<void>>()};
bool fetch_filament_info(std::string /*dev_id*/, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override
{
// Only the subscription path is allowed to be fire-and-forget.
if (sync_mode != FilamentSyncMode::subscription)
return true;
auto invoked_p = invoked;
auto release_gate_p = release_gate;
auto done_promise_p = done_promise;
std::thread([invoked_p, release_gate_p, done_promise_p]() {
invoked_p->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_p->get_future().wait();
done_promise_p->set_value();
}).detach();
return true;
}
};
auto agent = std::make_shared<RecordingAgent>(std::string{});
auto done_future = agent->done_promise->get_future();
ScopedPromiseRelease release_gate_guard{agent->release_gate};
bool immediate_result = agent->fetch_filament_info("test-dev", FilamentSyncMode::subscription);
// 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);
}
namespace {
// Globals so a parked proxy fetch thread never dereferences a freed agent.
std::atomic<int> g_deferred_fetch_running{0};
std::atomic<bool> g_deferred_destroy_returned{false};
// Releases the given gates, then joins on scope exit: so a throwing REQUIRE cannot leave
// the thread blocked (deadlocking the join) or let it std::terminate.
class ScopedJoiner
{
public:
ScopedJoiner(std::thread& t, std::shared_ptr<std::promise<void>> gate1, std::shared_ptr<std::promise<void>> gate2)
: m_thread(t), m_gates{std::move(gate1), std::move(gate2)}
{}
~ScopedJoiner()
{
for (auto& gate : m_gates) {
if (gate) {
try {
gate->set_value();
} catch (...) {
// promise already satisfied
}
}
}
if (m_thread.joinable()) m_thread.join();
}
ScopedJoiner(const ScopedJoiner&) = delete;
ScopedJoiner& operator=(const ScopedJoiner&) = delete;
private:
std::thread& m_thread;
std::shared_ptr<std::promise<void>> m_gates[2];
};
// A fetch that parks before touching the in-flight counter, so teardown's wait can
// observe zero first.
class DeferredFetchAgent : public MoonrakerPrinterAgent
{
public:
explicit DeferredFetchAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
// Shared so a parked proxy fetch can never outlive the stack that owns it.
std::shared_ptr<std::promise<void>> entered{std::make_shared<std::promise<void>>()};
std::shared_ptr<std::promise<void>> allow_fetch{std::make_shared<std::promise<void>>()};
std::shared_ptr<std::promise<void>> allow_finish{std::make_shared<std::promise<void>>()};
std::shared_ptr<std::promise<void>> running{std::make_shared<std::promise<void>>()};
// Runs the callable on the command worker, which teardown joins.
void post(std::function<void()> fn) { enqueue_command(std::move(fn)); }
bool fetch_filament_info(std::string /*dev_id*/, FilamentSyncMode /*sync_mode*/ = FilamentSyncMode::pull) override
{
// Resumes after ~DeferredFetchAgent destroyed these members; snapshot up front.
auto entered_p = entered;
auto allow_fetch_p = allow_fetch;
auto allow_finish_p = allow_finish;
auto running_p = running;
entered_p->set_value();
allow_fetch_p->get_future().wait();
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
std::thread([this, finish = std::move(allow_finish_p), running = std::move(running_p)] {
struct InFlightGuard
{
MoonrakerPrinterAgent& owner;
~InFlightGuard() { owner.release_fetch_slot(); }
} guard{*this};
g_deferred_fetch_running.fetch_add(1, std::memory_order_relaxed);
running->set_value();
finish->get_future().wait();
g_deferred_fetch_running.fetch_sub(1, std::memory_order_relaxed);
}).detach();
return true;
}
};
} // namespace
// REGRESSION - teardown must not return while a fetch it started is in flight.
// The command worker parks a fetch before it reserves the in-flight slot, forcing
// the "wait already observed zero" interleaving deterministically.
TEST_CASE("an agent's destruction waits for a fetch started by its worker during teardown",
"[MoonrakerPrinterAgent][Regression]")
{
g_deferred_fetch_running.store(0);
g_deferred_destroy_returned.store(false);
auto agent = std::make_shared<DeferredFetchAgent>(std::string{});
auto entered = agent->entered;
auto allow_fetch = agent->allow_fetch;
auto allow_finish = agent->allow_finish;
auto running = agent->running;
// Safety net for the pre-destroyer failure paths: release both gates before the
// agent is destroyed (declared after it, so destroyed before it).
ScopedPromiseRelease release_finish{allow_finish};
ScopedPromiseRelease release_fetch{allow_fetch};
// Park a fetch inside the command worker while the agent is still complete.
agent->post([ptr = agent.get()] { ptr->fetch_filament_info("dev", FilamentSyncMode::pull); });
REQUIRE(entered->get_future().wait_for(std::chrono::seconds(5)) == std::future_status::ready);
// Destroy on another thread so this one can drive the parked fetch.
std::thread destroyer([owned = std::move(agent)]() mutable {
owned.reset();
g_deferred_destroy_returned.store(true);
});
// Releases both gates before joining, so a failing REQUIRE cannot deadlock the join.
ScopedJoiner join_destroyer{destroyer, allow_fetch, allow_finish};
// Let the worker reserve the in-flight slot and spawn its fetch, then wait until it
// is genuinely parked (no polling).
allow_fetch->set_value();
REQUIRE(running->get_future().wait_for(std::chrono::seconds(5)) == std::future_status::ready);
REQUIRE(g_deferred_fetch_running.load() == 1);
// A correct teardown cannot return while the fetch is parked; give a buggy one time.
for (int i = 0; i < 200 && !g_deferred_destroy_returned.load(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
if (g_deferred_destroy_returned.load()) {
// Bug: teardown returned with a fetch still running. Don't release allow_finish.
CHECK(g_deferred_fetch_running.load() == 0);
return;
}
// Fixed order: destruction is still blocked on the in-flight fetch.
allow_finish->set_value();
destroyer.join();
CHECK(g_deferred_destroy_returned.load());
CHECK(g_deferred_fetch_running.load() == 0);
}
// ===========================================================================
// UNIT - printer-agent registry duplicate handling.
// Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in
// or previously registered agent.
// ===========================================================================
TEST_CASE("printer-agent registry register / lookup / duplicate-reject", "[PrinterAgent]")
TEST_CASE("unit: printer-agent registry register / lookup / duplicate-reject", "[registry][unit]")
{
// why: the registry is process-global state shared by the test binary, and
// Catch2 may run cases in any order. Use an id that cannot collide with
@@ -459,7 +69,7 @@ TEST_CASE("printer-agent registry register / lookup / duplicate-reject", "[Print
// fails to load the codecs needed for the filesystem encoding. The shared helper
// points PyConfig.home at the python/ runtime staged next to the test executable.
TEST_CASE("orca.printer_agent binding surface", "[integration][Python]")
TEST_CASE("integration: orca.printer_agent binding surface", "[integration][Python]")
{
py::module_ orca = import_orca_module();
@@ -510,7 +120,7 @@ TEST_CASE("orca.printer_agent binding surface", "[integration][Python]")
// them in the lightweight embedded-interpreter test catches binding breakage
// before the plugin-loader test needs to run.
// ===========================================================================
TEST_CASE("orca plugin-registration API surface + discovery-context guards", "[integration][Python]")
TEST_CASE("integration: orca plugin-registration API surface + discovery-context guards", "[integration][Python]")
{
py::module_ orca = import_orca_module();
@@ -1,134 +0,0 @@
#include "catch2/catch_test_macros.hpp"
#include "catch2/matchers/catch_matchers.hpp"
#include "catch2/matchers/catch_matchers_string.hpp"
#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");
}