Compare commits

..
Author SHA1 Message Date
Ian Chua 76a24a3fa2 Merge branch 'main' into feat/printer-agent-impl 2026-10-20 18:05:37 +08:00
SoftFever 069bd402d9 Forward CMake arguments after -- in the macOS build script (#16683)
Unknown options and missing option values now stop the script instead of being ignored.
2026-10-20 16:06:33 +08:00
SoftFever 6626377abc Stop re-asking for plugin permissions granted in an earlier session (#16658) 2026-10-20 13:38:44 +08:00
HanifKoh 74d4392dae Keep a Disabled Filament Override Off After a Restart (Key List Variant) (#16650)
Keep a Disabled Filament Override Off After a Restart

A child preset stores "nil" both for an override the user switched off and
for a per-variant column it inherits from its parent, and the loader filled
every nil from the parent. Whenever the parent preset sets Retract Before
Wipe or another override, switching it off lasted only until the next
restart, project open or newer cloud copy.

Treat the keys in is_filament_extruder_override_key, now including the
ironing overrides, as "nil means off": Preset::save and the project-embedded
preset diff write such an option with all its columns, and the loader keeps
its nil columns while still mapping them onto the parent's variants by
name. Every other nullable option keeps nil as "inherit this column". The
cloud diff only force-emits a disabled override whose default is a real
value.
2026-10-20 13:38:20 +08:00
HanifKoh 0d5b3a79d7 Keep a Scalar Config Option's Value When Deserializing Fails (#16641)
A failed stream extraction writes 0 into its target, so Float, Int, Percent and FloatOrPercent options were overwritten before deserialize reported the error, and Point/Point3 kept a partially scanned value. Parse into a temporary and assign only on success; FloatOrPercent sets its percent flag only then too.
2026-10-20 13:02:15 +08:00
Ian Chua f3156b22ac fix: regression removed announc_printhost_device 2026-10-19 15:01:51 +08:00
Ian Chua 93a5fe22cf Merge branch 'main' into feat/printer-agent-impl 2026-10-19 12:57:43 +08:00
Ian Chua ae36951f99 fix: clang-tidy 2026-10-17 15:31:46 +08:00
Ian Chua b32856317a Merge branch 'main' into feat/printer-agent-impl 2026-10-17 15:30:43 +08:00
Ian Chua 476a7b1302 fix: handle missing API keys correctly 2026-10-09 22:12:32 +08:00
Ian Chua 6070436860 Merge branch 'main' into feat/printer-agent-impl 2026-10-08 19:56:02 +08:00
Ian Chua f075cdcb98 fix: harden printer-agent filament sync and teardown 2026-10-08 19:55:39 +08:00
Ian Chua e6db086729 Merge branch 'main' into feat/printer-agent-impl 2026-10-07 19:16:58 +08:00
Ian Chua f8a718b338 fix: remove dead code 2026-10-07 19:16:50 +08:00
Ian Chua a0f2ea0c29 fix: clang-tidy 2026-10-07 18:59:15 +08:00
Ian Chua 3225bfea9d fix: batch box mapping, de-dup fetches and reject unsupported starts 2026-10-07 18:58:02 +08:00
Ian Chua 5f78baafe2 fix: name Moonraker default ports and warn when a custom CA is ignored 2026-10-07 18:28:56 +08:00
Ian Chua 376ad61a5e fix: fetch webcam info in ws loop 2026-10-07 18:11:44 +08:00
Ian Chua 5b81536be2 fix: snapshot Moonraker connection state and make agent teardown responsive 2026-10-07 16:58:13 +08:00
Ian Chua 5e3a526b72 fix: pick the direction-correct lamp macro and report control-command failures 2026-10-07 16:42:50 +08:00
Ian Chua 5f0534ffd4 fix: verify TLS hostname on Moonraker wss and apply TLS config to Snapmaker fetch 2026-10-07 16:40:25 +08:00
Ian Chua 33894bb46b fix: harden printer-agent async callbacks and detached fetch threads 2026-10-07 16:08:17 +08:00
Ian Chua 17b0eda7f8 fix: clang-tidy 2026-10-06 21:06:31 +08:00
Ian Chua ec22321a4b fix: move connect_mutex to protected 2026-10-06 21:02:27 +08:00
Ian Chua 5e861b31f9 fix: use-after-free in MoonrakerPrinterAgent teardown 2026-10-06 20:54:47 +08:00
Ian Chua 334ba8f84d chore: reconcile with PR 15710 squash base 2026-10-06 20:05:39 +08:00
Ian Chua 418623e2c5 chore: remove orcasonar test changes
(cherry picked from commit c6d3e755ec)
2026-10-06 20:05:38 +08:00
Ian Chua cf476637d4 refactor: move orcasonar changes to dedicated branch
(cherry picked from commit 797c2c1b33)
2026-10-06 20:05:38 +08:00
Ian Chua ceadec4e2d chore: port add_platform_root_certificates from 16027
(cherry picked from commit 6194c4f8bb)
2026-10-06 20:05:37 +08:00
peachismomo 92b3068cf6 refactor: separate out networking infra
(cherry picked from commit 1cac04eae8)
2026-10-06 20:05:37 +08:00
peachismomo e45f5846b2 fix: printer agent and camera shutdown races
(cherry picked from commit a6cad8a6e5)
2026-10-06 20:05:36 +08:00
Ian Chua f0be15df9e fix: remove redundant earlier expires_never call
(cherry picked from commit 50c5ec2862)
2026-10-06 20:05:36 +08:00
Ian Chua 0ddb86bec7 fix: orca mqtt session timeout after connack
(cherry picked from commit b36a72e16a)
2026-10-06 20:05:35 +08:00
Ian Chua fe30bfe7c7 fix: serialize MQTT ws I/O and dewcouple keepalive
(cherry picked from commit 87eb8de001)
2026-10-06 20:05:35 +08:00
Ian Chua 44675778e2 fix: cancel inflight cloud signaling requests
(cherry picked from commit 220508534a)
2026-10-06 20:05:34 +08:00
Ian Chua b870835b01 fix: clarify OrcaCloud signaling endpoint contract
(cherry picked from commit 5a0810365b)
2026-10-06 20:05:34 +08:00
Ian Chua ea464401be feat: add ssl and ca_cert for OrcaPrinterAgent
(cherry picked from commit fa98ec5afe)
2026-10-06 20:05:34 +08:00
Ian Chua 4540a98c58 feat: add https compatibility for moonraker based printer agents
(cherry picked from commit 936b0bbc17)
2026-10-06 20:05:33 +08:00
Ian Chua 76cf64d6c9 fix: command_ams_refresh_rfid function signature in test_printer_agent.cpp
(cherry picked from commit 5222a37eff)
2026-10-06 20:05:33 +08:00
peachismomo 8d7df2684d fix: compilation after merge
(cherry picked from commit 6da938b660)
2026-10-06 20:05:32 +08:00
Ian Chua 62bd8c3743 fix: refresh token before configuring printer MQTT
(cherry picked from commit 2dcdfe2d32)
2026-10-06 20:05:32 +08:00
Ian Chua 936daee0e6 feat: add ssl compatibility with moonrakerprinteragent
(cherry picked from commit 6c353d9596)
2026-10-06 20:05:31 +08:00
Ian Chua 9a161692b5 fix: snapmakerprinteragent use moonraker's lifecycle-owned command worker
(cherry picked from commit ce0c759ded)
2026-10-06 20:05:31 +08:00
Ian Chua eb071f30f8 fix: re-implement missing commands on BBLPrinterAgent
(cherry picked from commit 9a7e925790)
2026-10-06 20:05:30 +08:00
Ian Chua 931b07364a fix: camera signaling API
(cherry picked from commit d9f1f8b207)
2026-10-06 20:05:30 +08:00
peachismomo 34c2c926d1 fix: load windows roots for cloud MQTT and camera signaling
(cherry picked from commit b91f48141d)
2026-10-06 20:05:29 +08:00
Ian Chua fc56ad002a fix: swtich start print command from http to mqtt command
(cherry picked from commit 5287fd0160)
2026-10-06 20:05:29 +08:00
Ian Chua 7b75fe02c8 fix: remove unimplemented pure virtual functions
(cherry picked from commit a7ab01b815)
2026-10-06 20:05:28 +08:00
Ian Chua 2f6de2ed86 fix: updated stale docs
(cherry picked from commit dcddb9283c)
2026-10-06 20:05:28 +08:00
Ian Chua 172f492b74 Revert "fix: latent ams in use bug"
This reverts commit 910dbcd2e6.

(cherry picked from commit ba9cf6e897)
2026-10-06 20:05:28 +08:00
Ian Chua b74fac92a5 feat: restore printer agent implementations
(cherry picked from commit d1f2ecb903)
2026-10-06 20:05:27 +08:00
35 changed files with 4316 additions and 924 deletions

No files matched your search

+25 -3
View File
@@ -47,7 +47,7 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
u ) u )
export BUILD_TARGET="universal" export BUILD_TARGET="universal"
;; ;;
h ) echo "Usage: ./build_release_macos.sh [-d]" h ) echo "Usage: ./build_release_macos.sh [-d] [-- CMAKE_ARGS...]"
echo " -d: Build deps only" echo " -d: Build deps only"
echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)" echo " -a: Set ARCHITECTURE (arm64 or x86_64 or universal)"
echo " -s: Build slicer only" echo " -s: Build slicer only"
@@ -60,13 +60,33 @@ 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 " -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 " -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 " -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 exit 0
;; ;;
: )
echo "Option -${OPTARG} requires an argument, aborting."
exit 1
;;
* ) * )
echo "Unknown option -${OPTARG}, aborting. CMake arguments must follow '--'."
exit 1
;; ;;
esac esac
done 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 # Set defaults
if [ -z "$ARCH" ]; then if [ -z "$ARCH" ]; then
@@ -211,7 +231,8 @@ function build_deps() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \ "${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} ${CMAKE_POLICY_COMPAT} \
"${EXTRA_CMAKE_ARGS[@]}"
fi fi
cmake --build . --config "$BUILD_CONFIG" --target deps cmake --build . --config "$BUILD_CONFIG" --target deps
) )
@@ -314,7 +335,8 @@ function build_slicer() {
-DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \ -DCMAKE_OSX_DEPLOYMENT_TARGET="${OSX_DEPLOYMENT_TARGET}" \
-DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \ -DCMAKE_IGNORE_PREFIX_PATH="${CMAKE_IGNORE_PREFIX_PATH}" \
"${CMAKE_CCACHE_ARGS[@]}" \ "${CMAKE_CCACHE_ARGS[@]}" \
${CMAKE_POLICY_COMPAT} ${CMAKE_POLICY_COMPAT} \
"${EXTRA_CMAKE_ARGS[@]}"
fi fi
cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET" cmake --build . --config "$BUILD_CONFIG" --target "$SLICER_BUILD_TARGET"
) )
+41 -16
View File
@@ -377,7 +377,8 @@ public:
virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0; virtual void set(const ConfigOption* rhs, size_t start, size_t len) = 0;
virtual void set_with_restore(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int stride) = 0; virtual void set_with_restore(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int stride) = 0;
virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int start, int len, bool skip_error = false) = 0; virtual void set_with_restore_2(const ConfigOptionVectorBase* rhs, std::vector<int>& restore_index, int start, int len, bool skip_error = false) = 0;
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) = 0; // keep_nil: copy a nil column from rhs as well, for options whose nil means "disabled" rather than "inherit".
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil = false) = 0;
virtual void set_to_index(const ConfigOptionVectorBase* rhs, std::vector<int>& dest_index, int stride) = 0; virtual void set_to_index(const ConfigOptionVectorBase* rhs, std::vector<int>& dest_index, int stride) = 0;
virtual void set_with_nil(const ConfigOptionVectorBase* rhs, const ConfigOptionVectorBase* inherits, int stride) = 0; virtual void set_with_nil(const ConfigOptionVectorBase* rhs, const ConfigOptionVectorBase* inherits, int stride) = 0;
// Resize the vector of values, copy the newly added values from opt_default if provided. // Resize the vector of values, copy the newly added values from opt_default if provided.
@@ -580,7 +581,7 @@ public:
//set a item related with extruder variants when loading user config, only set the different value of some extruder //set a item related with extruder variants when loading user config, only set the different value of some extruder
//rhs: item from user config //rhs: item from user config
//diff_index: which index in this vector need to be set //diff_index: which index in this vector need to be set
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) override virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride, bool keep_nil) override
{ {
if (rhs->type() == this->type()) { if (rhs->type() == this->type()) {
// Assign the first value of the rhs vector. // Assign the first value of the rhs vector.
@@ -592,7 +593,7 @@ public:
for (size_t i = 0; i < diff_index.size(); i++) { for (size_t i = 0; i < diff_index.size(); i++) {
if (diff_index[i] != -1) { if (diff_index[i] != -1) {
for (size_t j = 0; j < stride; j++) { for (size_t j = 0; j < stride; j++) {
if (!other->is_nil(diff_index[i] * stride)) if (keep_nil || !other->is_nil(diff_index[i] * stride))
this->values[i * stride + j] = other->values[diff_index[i] * stride + j]; this->values[i * stride + j] = other->values[diff_index[i] * stride + j];
} }
} }
@@ -841,8 +842,12 @@ public:
{ {
UNUSED(append); UNUSED(append);
std::istringstream iss(str); std::istringstream iss(str);
iss >> this->value; double value;
return !iss.fail(); iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
} }
bool operator==(const ConfigOption &rhs) const override bool operator==(const ConfigOption &rhs) const override
@@ -1033,8 +1038,12 @@ public:
{ {
UNUSED(append); UNUSED(append);
std::istringstream iss(str); std::istringstream iss(str);
iss >> this->value; int value;
return !iss.fail(); iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
} }
ConfigOptionInt& operator=(const ConfigOption *opt) ConfigOptionInt& operator=(const ConfigOption *opt)
@@ -1252,8 +1261,12 @@ public:
UNUSED(append); UNUSED(append);
// don't try to parse the trailing % since it's optional // don't try to parse the trailing % since it's optional
std::istringstream iss(str); std::istringstream iss(str);
iss >> this->value; double value;
return !iss.fail(); iss >> value;
if (iss.fail())
return false;
this->value = value;
return true;
} }
private: private:
@@ -1365,10 +1378,14 @@ public:
bool deserialize(const std::string &str, bool append = false) override bool deserialize(const std::string &str, bool append = false) override
{ {
UNUSED(append); UNUSED(append);
this->percent = str.find_first_of("%") != std::string::npos;
std::istringstream iss(str); std::istringstream iss(str);
iss >> this->value; double value;
return !iss.fail(); iss >> value;
if (iss.fail())
return false;
this->value = value;
this->percent = str.find_first_of("%") != std::string::npos;
return true;
} }
private: private:
@@ -1538,8 +1555,12 @@ public:
{ {
UNUSED(append); UNUSED(append);
char dummy; char dummy;
return sscanf(str.data(), " %lf , %lf %c", &this->value(0), &this->value(1), &dummy) == 2 || Vec2d value;
sscanf(str.data(), " %lf x %lf %c", &this->value(0), &this->value(1), &dummy) == 2; if (sscanf(str.data(), " %lf , %lf %c", &value(0), &value(1), &dummy) != 2 &&
sscanf(str.data(), " %lf x %lf %c", &value(0), &value(1), &dummy) != 2)
return false;
this->value = value;
return true;
} }
private: private:
@@ -1658,8 +1679,12 @@ public:
{ {
UNUSED(append); UNUSED(append);
char dummy; char dummy;
return sscanf(str.data(), " %lf , %lf , %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3 || Vec3d value;
sscanf(str.data(), " %lf x %lf x %lf %c", &this->value(0), &this->value(1), &this->value(2), &dummy) == 3; if (sscanf(str.data(), " %lf , %lf , %lf %c", &value(0), &value(1), &value(2), &dummy) != 3 &&
sscanf(str.data(), " %lf x %lf x %lf %c", &value(0), &value(1), &value(2), &dummy) != 3)
return false;
this->value = value;
return true;
} }
private: private:
+44 -94
View File
@@ -4,10 +4,8 @@
#include "OrderingStrategies.hpp" #include "OrderingStrategies.hpp"
#include "../Geometry.hpp" #include "../Geometry.hpp"
#include "../ShortestPath.hpp" #include "../ShortestPath.hpp"
#include "libslic3r/KDTreeIndirect.hpp"
#include "libslic3r/Point.hpp" #include "libslic3r/Point.hpp"
#include "libslic3r/PrintConfig.hpp" #include "libslic3r/PrintConfig.hpp"
#include "libslic3r/libslic3r.h"
#include <algorithm> #include <algorithm>
#include <cassert> #include <cassert>
@@ -55,78 +53,47 @@ bool tsp_2opt_improve(std::vector<size_t>& path, const Points& centers, int max_
}; };
std::vector<double> elsq = recompute_edges_sq(); std::vector<double> elsq = recompute_edges_sq();
// Reversing path[i+1..j] replaces edges (a, b) = (path[i], path[i+1]) and (c, d) = (path[j], path[j+1]) with
// (a, c) and (b, d). That can only shorten the cycle if a new edge is shorter than the old one it pairs with:
// |ac| < |ab| or |bd| < |cd|. Rather than test every pair, each pass finds those pairs with radius queries
// around a and d, which keeps a pass near linear on tours of thousands of nodes. It still applies the move a
// full scan would: the largest gain, and the lowest (i, j) among equal gains.
auto coordinate_fn = [&centers](size_t idx, size_t dimension) -> double { return double(centers[idx](dimension)); };
KDTreeIndirect<2, double, decltype(coordinate_fn)> kdtree(coordinate_fn, path);
// Position of each node in the path.
std::vector<size_t> pos(centers.size());
for (size_t k = 0; k < pn; ++k)
pos[path[k]] = k;
// Calls fn(node) for the nodes of the tour within sqrt(radius_sq) of center. The search radius is padded so that
// rounding cannot leave out a node the gain test accepts; that test makes the exact cut.
auto visit_within = [&](const Vec2d& center, double radius_sq, auto&& fn) {
const double search_sq = radius_sq * (1. + 1e-9) + 1.;
auto visitor = [&](size_t idx, size_t dimension) {
if ((centers[idx].cast<double>() - center).squaredNorm() < search_sq)
fn(idx);
return kdtree.descent_mask(center[dimension], search_sq, idx, dimension);
};
kdtree.visit(visitor);
};
size_t best_i = pn, best_j = pn;
double best_gain = 0;
auto consider = [&](size_t i, size_t j) {
// Skip the swap that would reverse the entire cycle (removes both
// edges (0,1) and (pn-1,0), equivalent to traversing the cycle backwards).
if (j < i + 2 || (i == 0 && j == pn - 1)) return;
const Vec2d& pi = centers[path[i]].cast<double>();
const Vec2d& p_in = centers[path[i + 1]].cast<double>();
const Vec2d& pj = centers[path[j]].cast<double>();
const Vec2d& p_jn = centers[path[(j + 1) % pn]].cast<double>();
// Early rejection using squared distances (avoids 2 sqrt calls).
double new_a_sq = (pj - pi).squaredNorm();
double new_b_sq = (p_jn - p_in).squaredNorm();
if (new_a_sq >= elsq[i] && new_b_sq >= elsq[j]) return;
double new_a = std::sqrt(new_a_sq);
double new_b = std::sqrt(new_b_sq);
double gain = el[i] + el[j] - new_a - new_b;
if (gain > best_gain || (gain == best_gain && best_i != pn && std::make_pair(i, j) < std::make_pair(best_i, best_j))) {
best_gain = gain;
best_i = i; best_j = j;
}
};
bool improved = false; bool improved = false;
for (int pass = 0; max_passes <= 0 || pass < max_passes; ++pass) { for (int pass = 0; max_passes <= 0 || pass < max_passes; ++pass) {
best_i = pn; best_j = pn; size_t best_i = pn, best_j = pn;
best_gain = 0; double best_gain = 0;
// |ac| < |ab|: c lies within the length of the edge leaving a. for (size_t i = 0; i < pn; ++i) {
for (size_t i = 0; i + 2 < pn; ++i) const Vec2d& pi = centers[path[i]].cast<double>();
visit_within(centers[path[i]].cast<double>(), elsq[i], [&](size_t c) { consider(i, pos[c]); }); const Vec2d& p_in = centers[path[(i + 1) % pn]].cast<double>();
// |bd| < |cd|: b lies within the length of the edge entering d. double d_i = el[i];
for (size_t j = 2; j < pn; ++j) double d_i_sq = elsq[i];
visit_within(centers[path[(j + 1) % pn]].cast<double>(), elsq[j], [&](size_t b) {
if (pos[b] > 0) for (size_t j = i + 2; j < pn; ++j) {
consider(pos[b] - 1, j); size_t j_next = (j + 1) % pn;
}); // Skip the swap that would reverse the entire cycle (removes both
// edges (0,1) and (pn-1,0), equivalent to traversing the cycle backwards).
if (i == 0 && j_next == 0) continue;
const Vec2d& pj = centers[path[j]].cast<double>();
const Vec2d& p_jn = centers[path[j_next]].cast<double>();
double d_j = el[j];
// Early rejection using squared distances (avoids 2 sqrt calls).
double new_a_sq = (pj - pi).squaredNorm();
double new_b_sq = (p_jn - p_in).squaredNorm();
if (new_a_sq >= d_i_sq && new_b_sq >= elsq[j]) continue;
double new_a = std::sqrt(new_a_sq);
double new_b = std::sqrt(new_b_sq);
double gain = d_i + d_j - new_a - new_b;
if (gain > best_gain) {
best_gain = gain;
best_i = i; best_j = j;
}
}
}
if (best_i == pn) break; if (best_i == pn) break;
improved = true; improved = true;
// Reverse the best swap segment // Reverse the best swap segment
std::reverse(path.begin() + best_i + 1, path.begin() + best_j + 1); std::reverse(path.begin() + best_i + 1, path.begin() + best_j + 1);
for (size_t k = best_i + 1; k <= best_j; ++k)
pos[path[k]] = k;
// Recompute edge lengths after reversal // Recompute edge lengths after reversal
el = recompute_edges(); el = recompute_edges();
@@ -153,9 +120,9 @@ bool tsp_remove_crossings(std::vector<size_t>& path, const Points& centers)
// TSP helpers (2-opt, closing-edge rotation) that operate on the full cycle. // TSP helpers (2-opt, closing-edge rotation) that operate on the full cycle.
size_t n_edges = pn; size_t n_edges = pn;
// Scan from edge `from` for the first pair of intersecting edges worth reversing; returns {i, j} or {npos, npos} if none. // Scan for first crossing; returns {i, j} or {npos, npos} if none.
auto find_crossing = [&](size_t from) -> std::pair<size_t, size_t> { auto find_crossing = [&]() -> std::pair<size_t, size_t> {
for (size_t i = from; i < n_edges; ++i) { for (size_t i = 0; i < n_edges; ++i) {
const Point& ai = centers[path[i]]; const Point& ai = centers[path[i]];
const Point& bi = centers[path[(i + 1) % pn]]; const Point& bi = centers[path[(i + 1) % pn]];
@@ -166,40 +133,23 @@ bool tsp_remove_crossings(std::vector<size_t>& path, const Points& centers)
const Point& aj = centers[path[j]]; const Point& aj = centers[path[j]];
const Point& bj = centers[path[(j + 1) % pn]]; const Point& bj = centers[path[(j + 1) % pn]];
if (!bboxes_overlap(ai, bi, aj, bj) || !Geometry::segments_intersect(ai, bi, aj, bj)) if (!bboxes_overlap(ai, bi, aj, bj)) continue;
continue; if (Geometry::segments_intersect(ai, bi, aj, bj))
// The reversal replaces edges (ai, bi) and (aj, bj) with (ai, aj) and (bi, bj). Edges that only touch
// or overlap collinearly, as at points sharing a position, may gain nothing from it, and reversing
// them would find the same pair again on every scan. A gain within SCALED_EPSILON, which nearly
// collinear crossings can have too, is not worth a reversal either.
if (ai.distance_to(bi) + aj.distance_to(bj) - ai.distance_to(aj) - bi.distance_to(bj) > SCALED_EPSILON)
return {i, j}; return {i, j};
} }
} }
return {std::numeric_limits<size_t>::max(), std::numeric_limits<size_t>::max()}; return {std::numeric_limits<size_t>::max(), std::numeric_limits<size_t>::max()};
}; };
// Process crossings one at a time. Reversing path[ci+1..cj] replaces only edges ci and cj; the edges between // Process crossings one at a time: find first, reverse it, restart scan.
// them are the same segments traversed backwards. The scan therefore resumes at edge ci rather than at edge 0, // Cap iterations to prevent infinite loops on collinear/overlapping segments.
// so a pass over the tour costs one quadratic scan however many reversals it makes. Pairs before ci that int max_iters = static_cast<int>(pn * pn);
// involve a replaced edge are new too, so a scan that reaches the end starts over from edge 0, and the loop
// ends when a scan from edge 0 finds nothing.
// Every reversal shortens the cycle, so no tour repeats and the loop ends; the cap is a safety net.
size_t max_reversals = pn * pn;
bool improved = false; bool improved = false;
for (size_t from = 0;;) { while (max_iters-- > 0) {
auto [ci, cj] = find_crossing(from); auto [ci, cj] = find_crossing();
if (ci == std::numeric_limits<size_t>::max()) { if (ci == std::numeric_limits<size_t>::max()) break;
if (from == 0)
break;
from = 0;
continue;
}
improved = true; improved = true;
std::reverse(path.begin() + ci + 1, path.begin() + cj + 1); std::reverse(path.begin() + ci + 1, path.begin() + cj + 1);
if (--max_reversals == 0)
break;
from = ci;
} }
return improved; return improved;
} }
+4 -4
View File
@@ -21,12 +21,12 @@ namespace Slic3r {
// --- Path improvement (operate on index vectors into `centers`) --- // --- Path improvement (operate on index vectors into `centers`) ---
// 2-opt improvement: reverses segments that reduce total cycle path length. Each of up to max_passes passes // 2-opt improvement: reverses segments that reduce total cycle path length.
// (unlimited if max_passes <= 0) applies the move that shortens the cycle most. Returns true if any improvement was made. // Returns true if any improvement was made.
bool tsp_2opt_improve(std::vector<size_t>& path, const Points& centers, int max_passes = 10); bool tsp_2opt_improve(std::vector<size_t>& path, const Points& centers, int max_passes = 10);
// Crossing removal: reverse each pair of intersecting edges whose reversal shortens the cycle by more // Crossing removal: reverse any segment pair whose edges geometrically cross.
// than SCALED_EPSILON. Returns true if any pair was reversed. // Returns true if any crossing was removed.
bool tsp_remove_crossings(std::vector<size_t>& path, const Points& centers); bool tsp_remove_crossings(std::vector<size_t>& path, const Points& centers);
// The shortest cycle through the points, starting at point 0, by dynamic programming over subsets; for a few points only. // The shortest cycle through the points, starting at point 0, by dynamic programming over subsets; for a few points only.
+8 -5
View File
@@ -797,7 +797,7 @@ bool Preset::save(DynamicPrintConfig* parent_config)
{ {
ConfigOption *opt_src = config.option(option); ConfigOption *opt_src = config.option(option);
ConfigOption *opt_dst = temp_config.option(option, true); ConfigOption *opt_dst = temp_config.option(option, true);
if (opt_dst->is_scalar() || !(opt_dst->nullable())) if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
opt_dst->set(opt_src); opt_dst->set(opt_src);
else { else {
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src); ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
@@ -2132,7 +2132,7 @@ Preset* PresetCollection::get_preset_differed_for_save(Preset& preset)
{ {
ConfigOption *opt_src = preset.config.option(option); ConfigOption *opt_src = preset.config.option(option);
ConfigOption *opt_dst = temp_config.option(option, true); ConfigOption *opt_dst = temp_config.option(option, true);
if (opt_dst->is_scalar() || !(opt_dst->nullable())) if (opt_dst->is_scalar() || !(opt_dst->nullable()) || is_filament_extruder_override_key(option))
opt_dst->set(opt_src); opt_dst->set(opt_src);
else { else {
ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src); ConfigOptionVectorBase* opt_vec_src = static_cast<ConfigOptionVectorBase*>(opt_src);
@@ -2199,9 +2199,12 @@ int PresetCollection::get_differed_values_to_update(Preset& preset, std::map<std
// option's non-nil default (e.g. filament_retract_before_wipe -> 100%), silently // option's non-nil default (e.g. filament_retract_before_wipe -> 100%), silently
// resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe. // resurrecting an override the user turned off. See GitHub issue on Retract Before Wipe.
if (m_type == Preset::TYPE_FILAMENT) { if (m_type == Preset::TYPE_FILAMENT) {
for (const std::string& opt_key : filament_extruder_override_keys) { for (const std::string& opt_key : preset.config.keys()) {
if (key_values.count(opt_key)) if (!is_filament_extruder_override_key(opt_key) || key_values.count(opt_key))
continue; // already carried by the diff continue; // not an override, or already carried by the diff
const ConfigOptionDef *def = print_config_def.get(opt_key);
if (def == nullptr || !def->default_value || def->default_value->is_nil())
continue; // the option's default is nil too, so an absent key cannot come back as a value
const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key)); const auto* opt_vec = dynamic_cast<const ConfigOptionVectorBase*>(preset.config.option(opt_key));
if (opt_vec == nullptr) if (opt_vec == nullptr)
continue; continue;
+11 -4
View File
@@ -119,12 +119,18 @@ const std::vector<std::string> filament_extruder_override_keys = {
}; };
// Some filament override parameters are generated from filament_extruder_override_keys, // Some filament override parameters are generated from filament_extruder_override_keys,
// while filament_retract_length_nc is defined separately. Keep the generator list // while filament_retract_length_nc and the ironing overrides are defined separately.
// unchanged and use this helper for behavior checks that need the full override set. // Keep the generator list unchanged and use this helper for behavior checks that need
// the full override set. A nil in one of these keys means the override is disabled, so
// child presets store and load it verbatim instead of treating nil as "inherit".
bool is_filament_extruder_override_key(const std::string &opt_key) bool is_filament_extruder_override_key(const std::string &opt_key)
{ {
return std::find(filament_extruder_override_keys.begin(), filament_extruder_override_keys.end(), opt_key) != filament_extruder_override_keys.end() || return std::find(filament_extruder_override_keys.begin(), filament_extruder_override_keys.end(), opt_key) != filament_extruder_override_keys.end() ||
opt_key == "filament_retract_length_nc"; opt_key == "filament_retract_length_nc" ||
opt_key == "filament_ironing_flow" ||
opt_key == "filament_ironing_spacing" ||
opt_key == "filament_ironing_inset" ||
opt_key == "filament_ironing_speed";
} }
size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id) size_t get_extruder_index(const GCodeConfig& config, unsigned int filament_id)
@@ -12272,7 +12278,8 @@ void DynamicPrintConfig::update_diff_values_to_child_config(DynamicPrintConfig&
opt_src->set(opt_target); opt_src->set(opt_target);
} }
else else
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride); // An override's nil means "disabled" and is the child's value; every other nil column inherits.
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride, is_filament_extruder_override_key(opt));
} }
} }
} }
+5 -2
View File
@@ -15,6 +15,7 @@
#include <string> #include <string>
#include "slic3r/GUI/wxExtensions.hpp" #include "slic3r/GUI/wxExtensions.hpp"
#include "libslic3r/AppConfig.hpp" #include "libslic3r/AppConfig.hpp"
#include "IPrinterAgent.hpp"
#include "DeviceCore/DevManager.h" #include "DeviceCore/DevManager.h"
@@ -170,14 +171,16 @@ void ConnectPrinterDialog::on_input_enter(wxCommandEvent& evt)
void ConnectPrinterDialog::on_button_confirm(wxCommandEvent &event) void ConnectPrinterDialog::on_button_confirm(wxCommandEvent &event)
{ {
wxString code = m_textCtrl_code->GetTextCtrl()->GetValue(); wxString code = m_textCtrl_code->GetTextCtrl()->GetValue();
if (code.empty() && (!m_obj || m_obj->printer_agent_id != MOONRAKER_PRINTER_AGENT_ID)) // Validate only what the user typed; the no-key sentinel is applied afterwards (it contains
code = "88888888"; // non-alphanumeric characters, so it must not go through this check).
for (char c : code) { for (char c : code) {
if (!(('0' <= c && c <= '9') || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'))) { if (!(('0' <= c && c <= '9') || ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'))) {
show_error(this, _L("Invalid input")); show_error(this, _L("Invalid input"));
return; return;
} }
} }
if (code.empty())
code = NO_API_KEY_SENTINEL;
if (m_obj) { if (m_obj) {
m_obj->set_access_code(code.ToStdString()); m_obj->set_access_code(code.ToStdString());
} }
+2 -1
View File
@@ -2685,7 +2685,8 @@ int MachineObject::connect()
{ {
if (m_access_revoked || get_dev_ip().empty()) return -1; if (m_access_revoked || get_dev_ip().empty()) return -1;
std::string username = m_agent ? m_agent->default_lan_username() : std::string(); std::string username = m_agent ? m_agent->default_lan_username() : std::string();
std::string password = get_access_code(); // 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 port; std::string port;
std::string input = get_dev_ip(); std::string input = get_dev_ip();
+5
View File
@@ -4354,6 +4354,11 @@ void GUI_App::select_machine(const std::string& agent_id)
machine.dev_ip = dev_id; machine.dev_ip = dev_id;
machine.dev_name = dev_id; machine.dev_name = dev_id;
machine.printer_type = preset.config.opt_string("printer_model"); 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( existing = m_device_manager->insert_local_device(
machine, "lan", "free", "", access_code); machine, "lan", "free", "", access_code);
+3 -2
View File
@@ -2,6 +2,7 @@
#include "I18N.hpp" #include "I18N.hpp"
#include "bambu_networking.hpp" #include "bambu_networking.hpp"
#include "IPrinterAgent.hpp"
#include "libslic3r/Utils.hpp" #include "libslic3r/Utils.hpp"
#include "GUI.hpp" #include "GUI.hpp"
#include "GUI_App.hpp" #include "GUI_App.hpp"
@@ -1903,7 +1904,7 @@ void InputIpAddressDialog::on_ok(wxMouseEvent& evt)
std::string str_ip = m_input_ip->GetTextCtrl()->GetValue().ToStdString(); std::string str_ip = m_input_ip->GetTextCtrl()->GetValue().ToStdString();
std::string str_access_code = m_input_access_code->GetTextCtrl()->GetValue().ToStdString(); std::string str_access_code = m_input_access_code->GetTextCtrl()->GetValue().ToStdString();
if (str_access_code.empty()) if (str_access_code.empty())
str_access_code = "88888888"; str_access_code = NO_API_KEY_SENTINEL;
std::string str_name = m_input_printer_name->GetTextCtrl()->GetValue().Strip(wxString::both).ToStdString(); 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 // 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. // if user entered the wrong serial number, so we call `Upper()` here.
@@ -1950,7 +1951,7 @@ void InputIpAddressDialog::on_send_retry()
wxString ip = m_input_ip->GetTextCtrl()->GetValue(); wxString ip = m_input_ip->GetTextCtrl()->GetValue();
wxString str_access_code = m_input_access_code->GetTextCtrl()->GetValue(); wxString str_access_code = m_input_access_code->GetTextCtrl()->GetValue();
if (str_access_code.IsEmpty()) if (str_access_code.IsEmpty())
str_access_code = "88888888"; str_access_code = NO_API_KEY_SENTINEL;
// check support function // check support function
if (!m_obj) return; if (!m_obj) return;
+22 -6
View File
@@ -211,8 +211,9 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
int dir = input_val > 0 ? 1 : -1; int dir = input_val > 0 ? 1 : -1;
// i3-arch printers move the bed for Y/Z, so the on-screen direction is // i3-arch printers move the bed for Y/Z, so the on-screen direction is
// reversed -- same negation the g-code fallback below applies. // 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; dir = -dir;
}
j["print"]["command"] = "xyz_ctrl"; j["print"]["command"] = "xyz_ctrl";
j["print"]["axis"] = axis; j["print"]["axis"] = axis;
@@ -222,8 +223,9 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
} }
double value = input_val; double value = input_val;
if (!is_core_xy && (axis == "Y" || axis == "Z")) if (!is_core_xy && (axis == "Y" || axis == "Z")) {
value = -input_val; value = -1.0 * input_val;
}
std::string value_str = (boost::format("%.1f") % (value * unit)).str(); std::string value_str = (boost::format("%.1f") % (value * unit)).str();
std::string gcode; std::string gcode;
@@ -244,10 +246,11 @@ 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) 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); 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; BOOST_LOG_TRIVIAL(info) << "publish_json: " << j.dump() << " code: " << rtn;
else } else {
BOOST_LOG_TRIVIAL(error) << "publish_json: " << j.dump() << " code: " << rtn; BOOST_LOG_TRIVIAL(error) << "publish_json: " << j.dump() << " code: " << rtn;
}
return rtn; return rtn;
} }
@@ -593,8 +596,21 @@ int BBLPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateS
int BBLPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) int BBLPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{ {
return dispatch_start<func_start_send_gcode_to_sdcard_legacy, func_start_send_gcode_to_sdcard_0203>( // 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>(
BBLNetworkPlugin::instance().get_start_send_gcode_to_sdcard(), params, update_fn, cancel_fn, wait_fn); 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) int BBLPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
+1
View File
@@ -110,6 +110,7 @@ public:
static std::string from_orca_payload(std::string json_text); static std::string from_orca_payload(std::string json_text);
private: 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); int publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode);
}; };
+14 -9
View File
@@ -288,22 +288,27 @@ bool CrealityPrintAgent::parse_cfs_response(const std::string& response,
return true; 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 (device_info.dev_ip.empty()) { if (sync_mode != get_filament_sync_mode())
return false;
const MoonrakerDeviceInfo info = snapshot_device_info();
if (info.dev_ip.empty()) {
BOOST_LOG_TRIVIAL(warning) BOOST_LOG_TRIVIAL(warning)
<< "CrealityPrintAgent::fetch_filament_info: no device IP, falling back to base agent"; << "CrealityPrintAgent::fetch_filament_info: no device IP, falling back to base agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id)); return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
} }
// Build a CrealityPrint helper so we can use its model detection + WS helpers // Build a CrealityPrint helper so we can use its model detection + WS helpers
// (added in upstream PR #13291). // (added in upstream PR #13291).
DynamicPrintConfig cfg; DynamicPrintConfig cfg;
cfg.set_key_value("print_host", new ConfigOptionString("http://" + device_info.dev_ip)); cfg.set_key_value("print_host", new ConfigOptionString("http://" + info.dev_ip));
cfg.set_key_value("print_host_webui", new ConfigOptionString("")); cfg.set_key_value("print_host_webui", new ConfigOptionString(""));
cfg.set_key_value("printhost_cafile", new ConfigOptionString("")); cfg.set_key_value("printhost_cafile", new ConfigOptionString(""));
cfg.set_key_value("printhost_port", new ConfigOptionString("")); cfg.set_key_value("printhost_port", new ConfigOptionString(""));
cfg.set_key_value("printhost_apikey", new ConfigOptionString(device_info.api_key)); cfg.set_key_value("printhost_apikey", new ConfigOptionString(info.api_key));
cfg.set_key_value("printhost_ssl_ignore_revoke", new ConfigOptionBool(false)); cfg.set_key_value("printhost_ssl_ignore_revoke", new ConfigOptionBool(false));
CrealityPrint host(&cfg); CrealityPrint host(&cfg);
@@ -313,7 +318,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
BOOST_LOG_TRIVIAL(info) BOOST_LOG_TRIVIAL(info)
<< "CrealityPrintAgent: " << host.model_name() << "CrealityPrintAgent: " << host.model_name()
<< " is not CFS-capable, deferring to base Moonraker agent"; << " is not CFS-capable, deferring to base Moonraker agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id)); return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
} }
BOOST_LOG_TRIVIAL(info) BOOST_LOG_TRIVIAL(info)
@@ -328,7 +333,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
BOOST_LOG_TRIVIAL(warning) BOOST_LOG_TRIVIAL(warning)
<< "CrealityPrintAgent: CFS query failed (" << parse_err << "), " << "CrealityPrintAgent: CFS query failed (" << parse_err << "), "
<< "falling back to base agent"; << "falling back to base agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id)); return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
} }
if (box_count == 0) { if (box_count == 0) {
@@ -337,7 +342,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
// Moonraker exposes. // Moonraker exposes.
BOOST_LOG_TRIVIAL(info) BOOST_LOG_TRIVIAL(info)
<< "CrealityPrintAgent: no active CFS boxes, deferring to base agent"; << "CrealityPrintAgent: no active CFS boxes, deferring to base agent";
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id)); return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id), sync_mode);
} }
BOOST_LOG_TRIVIAL(info) BOOST_LOG_TRIVIAL(info)
@@ -382,7 +387,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
} }
} }
build_ams_payload(box_count, max_slots - 1, trays); build_ams_payload(box_count, max_slots - 1, trays, sync_mode == FilamentSyncMode::pull);
return true; return true;
} }
+2 -1
View File
@@ -37,12 +37,13 @@ public:
}; };
explicit CrealityPrintAgent(std::string log_dir); explicit CrealityPrintAgent(std::string log_dir);
~CrealityPrintAgent() override = default; ~CrealityPrintAgent() override { shutdown(); }
static AgentInfo get_agent_info_static(); static AgentInfo get_agent_info_static();
AgentInfo get_agent_info() override { return 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; 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 // 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. // a flat list of loaded slots, plus the count of CFS boxes the printer reports.
+21 -3
View File
@@ -45,6 +45,17 @@ struct PrinterConnectionParams
std::string ca_file; 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. * FilamentSyncMode - Modes for filament data synchronization.
* *
@@ -455,9 +466,16 @@ public:
virtual CameraStreamMode get_camera_stream_mode() const { return CameraStreamMode::none; } virtual CameraStreamMode get_camera_stream_mode() const { return CameraStreamMode::none; }
/** /**
* Refresh filament info from the printer synchronously. * Refresh filament info from the printer.
* Should only be called when get_filament_sync_mode() returns FilamentSyncMode::pull. *
* Populates the MachineObject's DevFilaSystem with fetched filament data. * 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.
*/ */
virtual bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) { return false; } 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
+177 -28
View File
@@ -13,6 +13,8 @@
#include <set> #include <set>
#include <string> #include <string>
#include <thread> #include <thread>
#include <condition_variable>
#include <deque>
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <vector> #include <vector>
@@ -21,6 +23,56 @@ namespace Slic3r { class ICloudServiceAgent; }
namespace Slic3r { 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 class MoonrakerPrinterAgent : public IPrinterAgent
{ {
public: public:
@@ -66,12 +118,26 @@ public:
int set_on_local_connect_fn(OnLocalConnectedFn fn) override; int set_on_local_connect_fn(OnLocalConnectedFn fn) override;
int set_on_local_message_fn(OnMessageFn fn) override; int set_on_local_message_fn(OnMessageFn fn) override;
int set_queue_on_main_fn(QueueOnMainFn 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) // Pull-mode agent (on-demand filament sync)
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; } FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; }
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override; CameraStreamMode get_camera_stream_mode() const override;
std::string get_camera_url() const override;
// 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;
protected: 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 struct MoonrakerDeviceInfo
{ {
std::string dev_id; std::string dev_id;
@@ -83,7 +149,9 @@ protected:
std::string dev_name; std::string dev_name;
std::string version; std::string version;
std::string klippy_state; std::string klippy_state;
float nozzle_diameter = 0.0f;
bool use_ssl = false; bool use_ssl = false;
std::string ca_file;
} device_info; } device_info;
// Copied when the stream starts, so changes apply to the next start_status_stream() // Copied when the stream starts, so changes apply to the next start_status_stream()
@@ -107,18 +175,47 @@ protected:
int nozzle_temp = 0; // Optional int nozzle_temp = 0; // Optional
}; };
// Build ams JSON and call parser // Build ams JSON and call parser.
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays); // 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 = {});
// Methods that derived classes may need to override or access // Methods that derived classes may need to override or access
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port); virtual bool init_device_info(const PrinterConnectionParams& params);
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const; virtual bool fetch_device_info(const ConnectionSettings& connection, 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); 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);
void stop_status_stream(); void stop_status_stream();
// State access for derived classes // State access for derived classes
mutable std::recursive_mutex state_mutex; 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 // Helpers
bool is_numeric(const std::string& value); bool is_numeric(const std::string& value);
std::string normalize_base_url(bool use_ssl, const std::string& host, const std::string& port); std::string normalize_base_url(bool use_ssl, const std::string& host, const std::string& port);
@@ -131,13 +228,30 @@ protected:
// Map filament type to OrcaFilamentLibrary preset ID for AMS sync compatibility // 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); 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: private:
int handle_request(const std::string& dev_id, const std::string& json_str); int handle_request(const std::string& dev_id, const std::string& json_str);
int send_version_info(const std::string& dev_id); int send_version_info(const std::string& dev_id);
int send_access_code(const std::string& dev_id); int send_access_code(const std::string& dev_id);
bool fetch_object_list(const std::string& base_url, const std::string& api_key, std::set<std::string>& objects, std::string& error) const; bool fetch_object_list(const ConnectionSettings& connection, 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 query_printer_status(const ConnectionSettings& connection, nlohmann::json& status, std::string& error) const;
bool send_gcode_sync(const std::string& dev_id, const std::string& gcode) const; 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, void send_gcode_async(const std::string& dev_id, const std::string& gcode,
std::function<void(bool)> on_result = {}) const; std::function<void(bool)> on_result = {}) const;
@@ -145,38 +259,46 @@ private:
void announce_printhost_device(); void announce_printhost_device();
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg); void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
void dispatch_printer_connected(const std::string& dev_id); void dispatch_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); 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 // 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 join_stopped_status_stream();
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings); void run_status_stream(std::string dev_id, ConnectionSettings connection, StatusStreamTimings timings);
void handle_ws_message(const std::string& dev_id, const std::string& payload); void handle_ws_message(std::string dev_id, std::string payload, ConnectionSettings connection);
void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval); 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); void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const; 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 // File upload
bool upload_gcode(const std::string& local_path, const std::string& filename, bool upload_gcode(const std::string& local_path, const std::string& filename,
const std::string& base_url, const std::string& api_key, const ConnectionSettings& connection,
OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn); 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 // Connection thread management
void perform_connection_async(const std::string& dev_id, void perform_connection_async(const std::string& dev_id,
const std::string& base_url, ConnectionSettings connection,
const std::string& api_key,
uint64_t generation); uint64_t generation);
void refresh_webcam_info() const;
bool fetch_webcam_info(const ConnectionSettings& connection, uint64_t generation) const;
// System-specific filament fetch methods // System-specific filament fetch methods
bool fetch_hh_filament_info(std::vector<AmsTrayData>& trays, int& max_lane_index); bool fetch_hh_filament_info(const ConnectionSettings& connection, std::vector<AmsTrayData>& trays, int& max_lane_index);
bool fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index); bool fetch_moonraker_filament_data(const ConnectionSettings& connection, std::vector<AmsTrayData>& trays, int& max_lane_index);
// JSON helper methods // JSON helper methods
static std::string safe_json_string(const nlohmann::json& obj, const char* key); static std::string safe_json_string(const nlohmann::json& obj, const char* key);
@@ -202,16 +324,37 @@ private:
mutable std::recursive_mutex payload_mutex; mutable std::recursive_mutex payload_mutex;
nlohmann::json status_cache; 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::atomic<int> next_jsonrpc_id{1};
std::set<std::string> available_objects; // Track for feature detection 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_stop{false};
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
std::atomic<uint64_t> ws_last_emit_ms{0}; std::atomic<uint64_t> ws_last_emit_ms{0};
std::thread ws_thread; std::thread ws_thread;
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 // 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};
// Throttling configuration for WebSocket updates // Throttling configuration for WebSocket updates
// Critical changes (state transitions) dispatch immediately; telemetry is throttled // Critical changes (state transitions) dispatch immediately; telemetry is throttled
@@ -222,7 +365,13 @@ private:
// Connection thread management // Connection thread management
std::atomic<uint64_t> connect_generation{0}; std::atomic<uint64_t> connect_generation{0};
std::thread connect_thread; std::thread connect_thread;
std::recursive_mutex connect_mutex;
void run_command_worker();
std::thread cmd_thread;
std::deque<std::function<void()>> cmd_queue;
std::mutex cmd_mutex;
std::condition_variable cmd_cv;
bool cmd_stop = false;
}; };
} // namespace Slic3r } // namespace Slic3r
+244 -66
View File
@@ -2,18 +2,25 @@
#include "Http.hpp" #include "Http.hpp"
#include "MoonrakerPrinterAgent.hpp" #include "MoonrakerPrinterAgent.hpp"
#include "IPrinterAgent.hpp" #include "IPrinterAgent.hpp"
#include "bambu_networking.hpp"
#include "libslic3r/PresetBundle.hpp" #include "libslic3r/PresetBundle.hpp"
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
#include "nlohmann/json.hpp" #include "nlohmann/json.hpp"
#include <atomic>
#include <boost/algorithm/string.hpp> #include <boost/algorithm/string.hpp>
#include <boost/algorithm/string/trim.hpp> #include <boost/algorithm/string/trim.hpp>
#include <boost/algorithm/string/case_conv.hpp> #include <boost/algorithm/string/case_conv.hpp>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <cctype> #include <cctype>
#include <cmath>
#include "libslic3r/Preset.hpp" #include "libslic3r/Preset.hpp"
#include <cstddef>
#include <exception>
#include <map> #include <map>
#include <mutex>
#include <sstream> #include <sstream>
#include <thread>
#include <string> #include <string>
#include <utility> #include <utility>
#include <vector> #include <vector>
@@ -34,6 +41,40 @@ bool has_visible_base_preset(const PresetCollection& filaments, const std::strin
return false; 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 } // anonymous namespace
const std::string QidiPrinterAgent_VERSION = "0.0.1"; const std::string QidiPrinterAgent_VERSION = "0.0.1";
@@ -47,51 +88,179 @@ AgentInfo QidiPrinterAgent::get_agent_info_static()
return AgentInfo{"qidi", "Qidi", QidiPrinterAgent_VERSION, "Qidi printer agent"}; return AgentInfo{"qidi", "Qidi", QidiPrinterAgent_VERSION, "Qidi printer agent"};
} }
bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/) FilamentSyncMode QidiPrinterAgent::get_filament_sync_mode() const
{ {
std::string error; if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
return FilamentSyncMode::subscription;
return FilamentSyncMode::pull;
}
// 1. Fetch device info and infer series_id bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
std::string series_id; {
{ if (sync_mode != get_filament_sync_mode())
MoonrakerDeviceInfo info;
if (fetch_device_info(device_info.base_url, device_info.api_key, info, error)) {
series_id = infer_series_id(info.model_id, info.dev_name);
}
}
if (series_id.empty()) {
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
series_id = infer_series_id(device_info.model_id, device_info.model_name);
}
// 2. Fetch filament dictionary
QidiFilamentDict dict;
if (!fetch_filament_dict(device_info.base_url, device_info.api_key, dict, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch filament dict: " << error;
}
// 3. Fetch slot info and build AmsTrayData directly
std::vector<AmsTrayData> trays;
int box_count = 0;
if (!fetch_slot_info(device_info.base_url, device_info.api_key, dict, series_id, trays, box_count, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
return false; 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;
{
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
model_id = device_info.model_id;
model_name = device_info.model_name;
} }
// 4. Build the AMS payload // One implementation for both modes; only where it runs differs. pull is the documented
build_ams_payload(box_count, box_count * 4 - 1, trays); // 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;
}
};
if (sync_mode == FilamentSyncMode::pull) {
return work(/*apply_inline=*/true);
}
// 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();
return true; return true;
} }
bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url, bool QidiPrinterAgent::apply_box_mapping(const PrintParams& params) const
const std::string& api_key, {
// 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,
const QidiFilamentDict& dict, const QidiFilamentDict& dict,
const std::string& series_id, const std::string& series_id,
std::vector<AmsTrayData>& trays, std::vector<AmsTrayData>& trays,
int& box_count, int& box_count,
std::string& error) std::string& error)
{ {
std::string url = join_url(base_url, "/printer/objects/query?save_variables=variables"); std::string url = join_url(connection.base_url, "/printer/objects/query?save_variables=variables");
for (int i = 0; i < 16; ++i) { for (int i = 0; i < 16; ++i) {
url += "&box_stepper%20slot" + std::to_string(i) + "=runout_button"; url += "&box_stepper%20slot" + std::to_string(i) + "=runout_button";
} }
@@ -101,8 +270,9 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
std::string http_error; std::string http_error;
auto http = Http::get(url); auto http = Http::get(url);
if (!api_key.empty()) { configure_http(http, connection);
http.header("X-Api-Key", api_key); if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
} }
http.timeout_connect(5) http.timeout_connect(5)
.timeout_max(10) .timeout_max(10)
@@ -127,22 +297,12 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
return false; return false;
} }
auto json = nlohmann::json::parse(response_body, nullptr, false, true); nlohmann::json status;
if (json.is_discarded()) { nlohmann::json variables;
error = "Invalid JSON response"; if (!parse_slot_response(response_body, status, variables, error))
return false; return false;
}
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("save_variables") || box_count = read_int_or(variables, "box_count", 1);
!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) { if (box_count < 0) {
box_count = 0; box_count = 0;
} }
@@ -165,9 +325,9 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
tray.slot_index = i; tray.slot_index = i;
// Read slot variables // Read slot variables
const int color_index = variables.value("color_slot" + std::to_string(i), 1); const int color_index = read_int_or(variables, "color_slot" + std::to_string(i), 1);
const int filament_type = variables.value("filament_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 = variables.value("vendor_slot" + std::to_string(i), 0); const int vendor_type = read_int_or(variables, "vendor_slot" + std::to_string(i), 0);
// Check filament presence via runout sensor // Check filament presence via runout sensor
std::string box_stepper_key = "box_stepper slot" + std::to_string(i); std::string box_stepper_key = "box_stepper slot" + std::to_string(i);
@@ -175,7 +335,7 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
if (status.contains(box_stepper_key)) { if (status.contains(box_stepper_key)) {
auto& box_stepper = status[box_stepper_key]; auto& box_stepper = status[box_stepper_key];
if (box_stepper.contains("runout_button") && !box_stepper["runout_button"].is_null()) { if (box_stepper.contains("runout_button") && !box_stepper["runout_button"].is_null()) {
int runout_button = box_stepper["runout_button"].template get<int>(); const int runout_button = read_int_or(box_stepper, "runout_button", 0);
tray.has_filament = (runout_button == 0); tray.has_filament = (runout_button == 0);
} }
} }
@@ -189,16 +349,9 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
} }
tray.tray_type = normalize_filament_type(filament_name); tray.tray_type = normalize_filament_type(filament_name);
// Try Qidi-specific setting ID first; fall back to visible preset by type // Qidi-specific setting id derived from device data only; the preset-dependent
std::string setting_id = build_setting_id(filament_type, vendor_type, tray.tray_type); // fallback runs on the main thread in the resolve hook passed to build_ams_payload.
auto* bundle = GUI::wxGetApp().preset_bundle; tray.tray_info_idx = build_setting_id(filament_type, vendor_type, tray.tray_type);
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 // Look up color from dictionary
auto color_it = dict.colors.find(color_index); auto color_it = dict.colors.find(color_index);
@@ -215,20 +368,45 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
return true; return true;
} }
bool QidiPrinterAgent::fetch_filament_dict(const std::string& base_url, bool QidiPrinterAgent::parse_slot_response(const std::string& response_body,
const std::string& api_key, 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,
QidiFilamentDict& dict, QidiFilamentDict& dict,
std::string& error) const std::string& error) const
{ {
std::string url = join_url(base_url, "/server/files/config/officiall_filas_list.cfg"); std::string url = join_url(connection.base_url, "/server/files/config/officiall_filas_list.cfg");
std::string response_body; std::string response_body;
bool success = false; bool success = false;
std::string http_error; std::string http_error;
auto http = Http::get(url); auto http = Http::get(url);
if (!api_key.empty()) { configure_http(http, connection);
http.header("X-Api-Key", api_key); if (!connection.api_key.empty()) {
http.header("X-Api-Key", connection.api_key);
} }
http.timeout_connect(5) http.timeout_connect(5)
.timeout_max(10) .timeout_max(10)
+20 -4
View File
@@ -3,6 +3,8 @@
#include "IPrinterAgent.hpp" #include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp" #include "MoonrakerPrinterAgent.hpp"
#include "bambu_networking.hpp"
#include "nlohmann/json_fwd.hpp"
#include <map> #include <map>
#include <string> #include <string>
@@ -14,7 +16,7 @@ class QidiPrinterAgent final : public MoonrakerPrinterAgent
{ {
public: public:
explicit QidiPrinterAgent(std::string log_dir); explicit QidiPrinterAgent(std::string log_dir);
~QidiPrinterAgent() override = default; ~QidiPrinterAgent() override { shutdown(); }
static AgentInfo get_agent_info_static(); static AgentInfo get_agent_info_static();
AgentInfo get_agent_info() override { return get_agent_info_static(); } AgentInfo get_agent_info() override { return get_agent_info_static(); }
@@ -22,7 +24,22 @@ public:
// Override filament sync (Qidi-specific implementation) // Override filament sync (Qidi-specific implementation)
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override; 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: 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 struct QidiFilamentDict
{ {
std::map<int, std::string> colors; std::map<int, std::string> colors;
@@ -30,14 +47,13 @@ private:
}; };
// Qidi-specific methods // Qidi-specific methods
bool fetch_slot_info(const std::string& base_url, bool fetch_slot_info(const ConnectionSettings& connection,
const std::string& api_key,
const QidiFilamentDict& dict, const QidiFilamentDict& dict,
const std::string& series_id, const std::string& series_id,
std::vector<AmsTrayData>& trays, std::vector<AmsTrayData>& trays,
int& box_count, int& box_count,
std::string& error); std::string& error);
bool fetch_filament_dict(const std::string& base_url, const std::string& api_key, QidiFilamentDict& dict, std::string& error) const; bool fetch_filament_dict(const ConnectionSettings& connection, QidiFilamentDict& dict, std::string& error) const;
std::string normalize_filament_type(const std::string& filament_type); std::string 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 infer_series_id(const std::string& model_id, const std::string& dev_name);
std::string normalize_model_key(std::string value); std::string normalize_model_key(std::string value);
+285 -99
View File
@@ -2,11 +2,20 @@
#include "Http.hpp" #include "Http.hpp"
#include "MoonrakerPrinterAgent.hpp" #include "MoonrakerPrinterAgent.hpp"
#include "IPrinterAgent.hpp" #include "IPrinterAgent.hpp"
#include "bambu_networking.hpp"
#include "libslic3r/PresetBundle.hpp" #include "libslic3r/PresetBundle.hpp"
#include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/GUI_App.hpp"
#include "nlohmann/json.hpp" #include "nlohmann/json.hpp"
#include <atomic>
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <exception>
#include <mutex>
#include <sstream>
#include <thread>
#include <vector> #include <vector>
#include <string> #include <string>
#include "libslic3r/Preset.hpp" #include "libslic3r/Preset.hpp"
@@ -18,6 +27,91 @@ namespace Slic3r {
namespace { namespace {
constexpr const char* SNAPMAKER_AGENT_VERSION = "0.0.1"; 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. // Safely access a parallel array by index, returning a fallback if out of bounds.
template<typename T> template<typename T>
@@ -40,14 +134,16 @@ 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 && filaments.get_preset_base(p) == &p && p.config.opt_string("filament_vendor", 0u) == vendor_name &&
p.config.opt_string("filament_type", 0u) == filament_type) { p.config.opt_string("filament_type", 0u) == filament_type) {
// The printer returns RGBA in the format RRGGBBAA, but profiles store color as #RRGGBB, // 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 // so we must remove # and ignore alpha channel for distance calculation. Firmware
unsigned int target_color_value = std::stoul(color_rgba.substr(0, color_rgba.length() - 2), nullptr, 16); // 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));
std::string p_color = p.config.opt_string("default_filament_colour", 0u); std::string p_color = p.config.opt_string("default_filament_colour", 0u);
unsigned int p_color_value; unsigned int p_color_value = 0;
if (!p_color.empty()) { if (!p_color.empty()) {
size_t hash_pos = p_color.find("#"); 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); p_color_value = parse_hex_color(p_color.substr(hash_pos != std::string::npos ? hash_pos + 1 : 0));
} else { } else {
// Default to black if no color specified in profile. Assume other profiles might be a closer color match. // 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 // Could be a problem if the target color is also black and there exist a specific profile for that type, vendor and color
@@ -74,6 +170,31 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection&
SnapmakerPrinterAgent::SnapmakerPrinterAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {} 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() AgentInfo SnapmakerPrinterAgent::get_agent_info_static()
{ {
return AgentInfo{"snapmaker", "Snapmaker", SNAPMAKER_AGENT_VERSION, "Snapmaker printer agent"}; return AgentInfo{"snapmaker", "Snapmaker", SNAPMAKER_AGENT_VERSION, "Snapmaker printer agent"};
@@ -109,127 +230,192 @@ std::string SnapmakerPrinterAgent::combine_filament_type(const std::string& type
return base; 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)
{ {
std::string url = join_url(device_info.base_url, "/printer/objects/query?print_task_config&filament_detect"); (void) dev_id;
if (sync_mode != get_filament_sync_mode())
return false;
std::string response_body; // Snapshot everything the fetch needs (URL, api key, TLS/CA): a reconnect can rewrite
bool success = false; // device_info meanwhile.
std::string http_error; const ConnectionSettings connection = get_connection_settings();
auto http = Http::get(url); // One implementation for both modes; only where it runs differs. pull is the documented
if (!device_info.api_key.empty()) { // blocking contract (the GUI thread reads DevFilaSystem right after the call), so it runs
http.header("X-Api-Key", device_info.api_key); // 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.
http.timeout_connect(5) auto work = [this, connection](bool apply_inline) -> bool {
.timeout_max(10) try {
.on_complete([&](std::string body, unsigned status) { const std::string url = join_url(connection.base_url, "/printer/objects/query?print_task_config&filament_detect");
if (status == 200) {
response_body = body; std::string response_body;
success = true; bool success = false;
} else { std::string http_error;
http_error = "HTTP error: " + std::to_string(status);
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)
.on_error([&](std::string body, std::string err, unsigned status) { .timeout_max(10)
http_error = err; .on_complete([&](std::string body, unsigned status) {
if (status > 0) { if (status == 200) {
http_error += " (HTTP " + std::to_string(status) + ")"; 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;
} }
})
.perform_sync();
if (!success) { auto json = nlohmann::json::parse(response_body, nullptr, false, true);
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error; if (json.is_discarded()) {
return false; BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response";
} return false;
}
auto json = nlohmann::json::parse(response_body, nullptr, false, true); // Navigate to result.status.print_task_config
if (json.is_discarded()) { if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("print_task_config")) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response"; BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response";
return false; return false;
} }
// Navigate to result.status.print_task_config auto& ptc = json["result"]["status"]["print_task_config"];
if (!json.contains("result") || !json["result"].contains("status") ||
!json["result"]["status"].contains("print_task_config")) {
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response";
return false;
}
auto& ptc = json["result"]["status"]["print_task_config"]; // Read parallel arrays from print_task_config
auto filament_exist = 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");
// Read parallel arrays from print_task_config const int slot_count = static_cast<int>(filament_exist.size());
auto filament_exist = ptc.value("filament_exist", std::vector<bool>{}); if (slot_count == 0) {
auto filament_type = ptc.value("filament_type", std::vector<std::string>{}); BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported";
auto filament_sub_type = ptc.value("filament_sub_type", std::vector<std::string>{}); return false;
auto filament_color = ptc.value("filament_color_rgba", std::vector<std::string>{}); }
auto filament_vendor = ptc.value("filament_vendor", std::vector<std::string>{});
const int slot_count = static_cast<int>(filament_exist.size()); // Read NFC filament_detect data for temperature info (optional)
if (slot_count == 0) { nlohmann::json nfc_info;
BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported"; if (json["result"]["status"].contains("filament_detect") && json["result"]["status"]["filament_detect"].contains("info")) {
return false; nfc_info = json["result"]["status"]["filament_detect"]["info"];
} }
// Read NFC filament_detect data for temperature info (optional) static const std::string empty_str;
nlohmann::json nfc_info; static const std::string default_color = "FFFFFFFF";
if (json["result"]["status"].contains("filament_detect") &&
json["result"]["status"]["filament_detect"].contains("info")) {
nfc_info = json["result"]["status"]["filament_detect"]["info"];
}
static const std::string empty_str; // Per-tray vendor names, kept so the preset lookup can run on the main thread.
static const std::string default_color = "FFFFFFFF"; std::vector<std::string> vendors(slot_count);
std::vector<AmsTrayData> trays; std::vector<AmsTrayData> trays;
trays.reserve(slot_count); trays.reserve(slot_count);
for (int i = 0; i < slot_count; ++i) { for (int i = 0; i < slot_count; ++i) {
AmsTrayData tray; AmsTrayData tray;
tray.slot_index = i; tray.slot_index = i;
tray.has_filament = filament_exist[i]; tray.has_filament = filament_exist[i];
if (tray.has_filament) { if (tray.has_filament) {
tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str), tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str), safe_at(filament_sub_type, i, empty_str));
safe_at(filament_sub_type, i, empty_str)); tray.tray_color = safe_at(filament_color, i, default_color);
tray.tray_color = safe_at(filament_color, i, default_color); vendors[i] = safe_at(filament_vendor, i, empty_str);
auto* bundle = GUI::wxGetApp().preset_bundle; // Generic id derived from device data only; the preset-dependent lookup runs
// Try to find a matching preset for this filament based on vendor, type and color. // on the main thread in the resolve hook passed to build_ams_payload.
// If not found, default to traditional search by type only or generic type mapping. tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
if (bundle) {
std::string vendor = safe_at(filament_vendor, i, empty_str);
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type,
tray.tray_color);
if (!filament_id.empty()) { // Extract NFC temperature data if available
tray.tray_info_idx = filament_id; if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
BOOST_LOG_TRIVIAL(warning) << "Filament sync: Found manufacturer-specific profile for slot " << i << ": " auto& nfc_slot = nfc_info[i];
<< filament_id; std::string vendor = "NONE";
} else { if (auto vendor_it = nfc_slot.find("VENDOR"); vendor_it != nfc_slot.end() && vendor_it->is_string()) {
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type); 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);
}
}
} }
} else {
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type); trays.emplace_back(std::move(tray));
} }
// Extract NFC temperature data if available // Resolve against GUI-owned presets on the main thread (inside build_ams_payload).
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) { auto resolve_ids = [vendors](std::vector<AmsTrayData>& resolved_trays) {
auto& nfc_slot = nfc_info[i]; auto* bundle = GUI::wxGetApp().preset_bundle;
std::string vendor = nfc_slot.value("VENDOR", "NONE"); if (!bundle) {
if (vendor != "NONE" && !vendor.empty()) { return; // keep the generic ids computed from device data
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;
} }
};
trays.emplace_back(std::move(tray)); if (sync_mode == FilamentSyncMode::pull) {
return work(/*apply_inline=*/true);
} }
build_ams_payload(1, slot_count - 1, trays); // 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();
return true; 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 } // namespace Slic3r
+12 -1
View File
@@ -3,6 +3,8 @@
#include "IPrinterAgent.hpp" #include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp" #include "MoonrakerPrinterAgent.hpp"
#include <atomic>
#include <cstdint>
#include <string> #include <string>
namespace Slic3r { namespace Slic3r {
@@ -11,16 +13,25 @@ class SnapmakerPrinterAgent final : public MoonrakerPrinterAgent
{ {
public: public:
explicit SnapmakerPrinterAgent(std::string log_dir); explicit SnapmakerPrinterAgent(std::string log_dir);
~SnapmakerPrinterAgent() override = default; ~SnapmakerPrinterAgent() override { shutdown(); }
static AgentInfo get_agent_info_static(); static AgentInfo get_agent_info_static();
AgentInfo get_agent_info() override { return 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; 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: private:
// Combine filament_type + filament_sub_type into a unified type string // 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); 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 } // namespace Slic3r
+340 -133
View File
@@ -11,6 +11,7 @@
#include <boost/log/trivial.hpp> #include <boost/log/trivial.hpp>
#include <algorithm> #include <algorithm>
#include <cassert>
#include <cctype> #include <cctype>
#include <cstddef> #include <cstddef>
#include <cstdlib> #include <cstdlib>
@@ -130,6 +131,20 @@ static AuditEventCategory event_category(const std::string& event_name)
return it == audit_event_categories.end() ? AuditEventCategory::None : it->second; return it == audit_event_categories.end() ? AuditEventCategory::None : it->second;
} }
// Events the 3.12 stdlib raises with sys.audit() inside the function that performs the whole
// operation, so the raising frame bounds everything the operation does. Every other mapped event
// is a primitive raised from C, whose current frame is whatever Python code called it -- possibly
// a library loop over many targets -- so it never marks an operation. glob.glob and glob.glob/2
// are raised in iglob(), which returns its lazy generator before any directory is listed.
static const std::unordered_set<std::string> operation_root_events{
"urllib.Request", "http.client.connect", "os.walk", "os.fwalk",
"glob.glob", "glob.glob/2", "pathlib.Path.glob", "pathlib.Path.rglob",
"subprocess.Popen", "pty.spawn", "tempfile.mkstemp", "tempfile.mkdtemp",
"shutil.copyfile", "shutil.copymode", "shutil.copystat", "shutil.copytree",
"shutil.chown", "shutil.move", "shutil.rmtree", "shutil.make_archive",
"shutil.unpack_archive",
};
// True for the categories whose targets are filesystem paths, as opposed to a network // True for the categories whose targets are filesystem paths, as opposed to a network
// address or a process command line -- the deny-path and allowed-root checks only make sense // address or a process command line -- the deny-path and allowed-root checks only make sense
// against a path. // against a path.
@@ -146,6 +161,24 @@ static bool is_fs_category(AuditEventCategory category)
} }
} }
// The category's name in a saved event grant, e.g. "FsRead" in "FsRead:open".
static const char* category_name(AuditEventCategory category)
{
switch (category) {
case AuditEventCategory::FsRead: return "FsRead";
case AuditEventCategory::FsReadWrite: return "FsReadWrite";
case AuditEventCategory::FsCreate: return "FsCreate";
case AuditEventCategory::FsDelete: return "FsDelete";
case AuditEventCategory::Http: return "Http";
case AuditEventCategory::Socket: return "Socket";
case AuditEventCategory::ProcessCreate: return "ProcessCreate";
case AuditEventCategory::ProcessReplace: return "ProcessReplace";
case AuditEventCategory::Threading: return "Threading";
case AuditEventCategory::None: break;
}
return "None";
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Path safety // Path safety
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -215,26 +248,50 @@ bool is_inside_allowed_root(const boost::filesystem::path& candidate, const boos
thread_local std::string PluginAuditManager::m_current_plugin_key = ""; thread_local std::string PluginAuditManager::m_current_plugin_key = "";
thread_local std::string PluginAuditManager::m_current_capability_name = ""; thread_local std::string PluginAuditManager::m_current_capability_name = "";
thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots; thread_local std::vector<AllowedRoot> PluginAuditManager::m_scoped_allowed_roots;
thread_local std::vector<PyObject*> PluginAuditManager::m_root_frames;
thread_local int PluginAuditManager::m_releasing_frames = 0;
thread_local bool PluginAuditManager::m_audit_denial_pending = false; thread_local bool PluginAuditManager::m_audit_denial_pending = false;
thread_local bool PluginAuditManager::m_has_last_violation = false; thread_local bool PluginAuditManager::m_has_last_violation = false;
thread_local AuditViolation PluginAuditManager::m_last_violation; thread_local AuditViolation PluginAuditManager::m_last_violation;
ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key, ScopedPluginAuditContext::ScopedPluginAuditContext(const std::string& plugin_key,
const std::string& capability_name) const std::string& capability_name)
: m_previous_id(PluginAuditManager::instance().current_plugin()) : m_plugin_key(plugin_key)
, m_capability(capability_name)
, m_previous_id(PluginAuditManager::instance().current_plugin())
, m_previous_capability(PluginAuditManager::instance().current_capability()) , m_previous_capability(PluginAuditManager::instance().current_capability())
, m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots) , m_previous_scoped_roots(PluginAuditManager::m_scoped_allowed_roots)
, m_previous_root_frames(std::move(PluginAuditManager::m_root_frames))
{ {
PluginAuditManager::instance().set_current_plugin(plugin_key); PluginAuditManager::instance().set_current_plugin(plugin_key);
PluginAuditManager::instance().set_current_capability(capability_name); PluginAuditManager::instance().set_current_capability(capability_name);
PluginAuditManager::m_scoped_allowed_roots.clear(); PluginAuditManager::m_scoped_allowed_roots.clear();
PluginAuditManager::m_root_frames.clear();
} }
ScopedPluginAuditContext::~ScopedPluginAuditContext() ScopedPluginAuditContext::~ScopedPluginAuditContext()
{ {
PluginAuditManager::instance().set_current_plugin(m_previous_id); PluginAuditManager& mgr = PluginAuditManager::instance();
PluginAuditManager::instance().set_current_capability(m_previous_capability);
// Let this scope's root frames go while it is still the current context, and as its own
// plugin: the trampoline's denial path clears the key before the scope ends, and a finalizer
// must never be judged as the outer plugin, or bypass the audit when there is none. A
// finalizer may record a new root frame, so repeat until none is held.
if (!PluginAuditManager::m_root_frames.empty()) {
assert(PyGILState_Check());
mgr.set_current_plugin(m_plugin_key);
mgr.set_current_capability(m_capability);
while (!PluginAuditManager::m_root_frames.empty()) {
std::vector<PyObject*> frames = std::move(PluginAuditManager::m_root_frames);
PluginAuditManager::m_root_frames.clear();
mgr.release_frames(frames);
}
}
mgr.set_current_plugin(m_previous_id);
mgr.set_current_capability(m_previous_capability);
PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots); PluginAuditManager::m_scoped_allowed_roots = std::move(m_previous_scoped_roots);
PluginAuditManager::m_root_frames = std::move(m_previous_root_frames);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -575,34 +632,6 @@ bool PluginAuditManager::last_violation(AuditViolation& violation) const
return true; return true;
} }
bool PluginAuditManager::has_approved_ancestor(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids) const
{
if (plugin_key.empty() || call_site_ids.empty())
return false;
std::lock_guard<std::mutex> lock(m_mutex);
auto it = m_approved_call_sites.find(plugin_key);
if (it == m_approved_call_sites.end())
return false;
for (const auto& id : call_site_ids)
if (it->second.count(id))
return true;
return false;
}
void PluginAuditManager::record_approved_call_sites(const std::string& plugin_key,
const std::vector<std::string>& call_site_ids)
{
if (plugin_key.empty() || call_site_ids.empty())
return;
std::lock_guard<std::mutex> lock(m_mutex);
auto& approved = m_approved_call_sites[plugin_key];
approved.insert(call_site_ids.begin(), call_site_ids.end());
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The C-level audit hook // The C-level audit hook
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -663,40 +692,88 @@ std::string python_str(PyObject* object)
return result; return result;
} }
std::vector<std::string> call_site_identities(const std::string& plugin_root) // The interpreter's stdlib folders, and the site-packages folders inside them that hold
{ // third-party code, both resolved. Set by install_hook(); like the verdict cache below, only
std::vector<std::string> ids; // touched while the GIL is held.
if (plugin_root.empty()) static std::vector<boost::filesystem::path> s_stdlib_dirs;
return ids; static std::vector<boost::filesystem::path> s_site_package_dirs;
static std::unordered_map<std::string, bool> s_stdlib_file_verdicts; // co_filename -> is stdlib
void collect_stdlib_dirs()
{
s_stdlib_dirs.clear();
s_site_package_dirs.clear();
s_stdlib_file_verdicts.clear();
PyObject* sysconfig = PyImport_ImportModule("sysconfig");
PyObject* paths = sysconfig ? PyObject_CallMethod(sysconfig, "get_paths", nullptr) : nullptr;
if (paths && PyDict_Check(paths)) {
const auto collect = [paths](const char* key, std::vector<boost::filesystem::path>& out) {
const std::string dir = python_unicode(PyDict_GetItemString(paths, key)); // borrowed
if (dir.empty())
return;
boost::system::error_code ec;
const boost::filesystem::path resolved = boost::filesystem::weakly_canonical(dir, ec);
out.push_back(ec ? boost::filesystem::path(dir) : resolved);
};
collect("stdlib", s_stdlib_dirs);
collect("platstdlib", s_stdlib_dirs);
collect("purelib", s_site_package_dirs);
collect("platlib", s_site_package_dirs);
}
Py_XDECREF(paths);
Py_XDECREF(sysconfig);
if (PyErr_Occurred()) {
PyErr_Clear();
BOOST_LOG_TRIVIAL(error) << "[AUDIT] Failed to read the stdlib folders from sysconfig";
}
}
bool is_stdlib_frame(PyObject* frame)
{
PyCodeObject* code = PyFrame_GetCode(reinterpret_cast<PyFrameObject*>(frame)); // new reference
const std::string filename = python_unicode(code->co_filename);
Py_DECREF(code);
return PluginAuditManager::is_stdlib_file(filename);
}
// The frames on this thread's Python stack, innermost first. The pointers stay valid while the
// caller runs, because a running activation keeps its frame object alive.
std::vector<PyObject*> stack_frames()
{
std::vector<PyObject*> frames;
PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference PyFrameObject* frame = PyEval_GetFrame(); // borrowed reference
Py_XINCREF(frame); // normalize to an owned reference for the loop below Py_XINCREF(frame); // normalize to an owned reference for the loop below
while (frame) { while (frame) {
PyCodeObject* code = PyFrame_GetCode(frame); // new reference frames.push_back(reinterpret_cast<PyObject*>(frame));
const std::string filename = python_unicode(reinterpret_cast<PyObject*>(code->co_filename));
const bool is_plugin_frame = !filename.empty() && boost::algorithm::starts_with(filename, plugin_root);
std::string id;
if (!is_plugin_frame && !filename.empty()) {
const std::string funcname = python_unicode(reinterpret_cast<PyObject*>(code->co_name));
id = filename + ":" + funcname + ":" + std::to_string(code->co_firstlineno);
}
Py_DECREF(code);
PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack PyFrameObject* back = PyFrame_GetBack(frame); // new reference, or nullptr at the top of the stack
Py_DECREF(frame); Py_DECREF(frame);
frame = back; frame = back;
if (is_plugin_frame)
break;
if (!id.empty())
ids.push_back(std::move(id));
} }
return frames;
}
Py_XDECREF(frame); // only holds a reference here if the loop exited via break // Moves the held root frames whose operation has returned from `held` to `dropped`. A suspended
// generator's frame (os.walk between two items) is off the stack, but its operation resumes, so
// it stays held until the scope ends.
void take_returned_frames(std::vector<PyObject*>& held, std::vector<PyObject*>& dropped)
{
if (held.empty())
return;
return ids; const std::vector<PyObject*> stack = stack_frames();
const auto running = [&stack](PyObject* frame) {
if (std::find(stack.begin(), stack.end(), frame) != stack.end())
return true;
// NULL once the generator has finished or been deleted: its frame then owns its own data.
PyObject* generator = PyFrame_GetGenerator(reinterpret_cast<PyFrameObject*>(frame)); // new reference
const bool suspended = generator != nullptr;
Py_XDECREF(generator);
return suspended;
};
const auto returned = std::stable_partition(held.begin(), held.end(), running);
dropped.insert(dropped.end(), returned, held.end());
held.erase(returned, held.end());
} }
static const std::unordered_set<std::string> two_path_fs_events{ static const std::unordered_set<std::string> two_path_fs_events{
@@ -717,12 +794,15 @@ static const std::unordered_map<std::string, std::vector<Py_ssize_t>> audit_targ
{"socket.getnameinfo", {0}}, {"socket.getnameinfo", {0}},
{"socket.getservbyname", {0}}, {"socket.getservbyname", {0}},
{"socket.getservbyport", {0}}, {"socket.getservbyport", {0}},
{"socket.sendto", {1}},
{"socket.sendmsg", {1}},
{"os.system", {0}}, {"os.system", {0}},
{"subprocess.Popen", {1, 0}}, {"subprocess.Popen", {1, 0}},
{"os.posix_spawn", {1, 0}}, {"os.posix_spawn", {1, 0}},
{"os.spawn", {2, 1}}, {"os.spawn", {2, 1}},
{"os.startfile", {0}}, {"os.startfile", {0}},
{"os.startfile/2", {0}},
{"pty.spawn", {0}}, {"pty.spawn", {0}},
{"_winapi.CreateProcess", {1, 0}}, {"_winapi.CreateProcess", {1, 0}},
{"_posixsubprocess.fork_exec", {0}}, {"_posixsubprocess.fork_exec", {0}},
@@ -767,7 +847,11 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
const auto it = audit_target_arg_indices.find(event_name); const auto it = audit_target_arg_indices.find(event_name);
if (it != audit_target_arg_indices.end()) { if (it != audit_target_arg_indices.end()) {
for (const Py_ssize_t index : it->second) { for (const Py_ssize_t index : it->second) {
std::string value = python_str(tuple_item(args, index)); // None is an argument left out (sendmsg without an address), not a target named "None".
PyObject* const item = tuple_item(args, index);
if (item == Py_None)
continue;
std::string value = python_str(item);
if (!value.empty()) { if (!value.empty()) {
targets.push_back(std::move(value)); targets.push_back(std::move(value));
break; break;
@@ -778,8 +862,14 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
return targets; return targets;
} }
std::vector<std::string>* permission_list_for(AuditEventCategory category, PluginPermissions& permissions) // The saved list that grants an event: its category's target list, or the event-grant list for
// an event with no target. Kept apart, so that no grant doubles as a target of the same name (an
// os.fork grant is not an os.exec of a command named "os.fork").
std::vector<std::string>* permission_list_for(AuditEventCategory category, bool by_event, PluginPermissions& permissions)
{ {
if (by_event)
return &permissions.events;
switch (category) { switch (category) {
case AuditEventCategory::FsRead: return &permissions.fs_read; case AuditEventCategory::FsRead: return &permissions.fs_read;
case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite; case AuditEventCategory::FsReadWrite: return &permissions.fs_readwrite;
@@ -792,6 +882,13 @@ std::vector<std::string>* permission_list_for(AuditEventCategory category, Plugi
} }
} }
// An event with no target is granted by its effective category and name, e.g. "FsRead:open"
// for a read of a file descriptor, which does not grant a write of one ("FsReadWrite:open").
std::string event_grant_key(AuditEventCategory category, const std::string& event_name)
{
return std::string(category_name(category)) + ":" + event_name;
}
bool has_permission(const std::vector<std::string>& granted, const std::string& target) bool has_permission(const std::vector<std::string>& granted, const std::string& target)
{ {
return std::find(granted.begin(), granted.end(), target) != granted.end(); return std::find(granted.begin(), granted.end(), target) != granted.end();
@@ -902,22 +999,25 @@ bool prompt_for_targets(AuditEventCategory category, const std::string& plugin_n
// Records a grant in the plugin's sidecar so it is not asked again. Reads the install state // Records a grant in the plugin's sidecar so it is not asked again. Reads the install state
// freshly because the async prompt outlives the caller's stack copy of it. // freshly because the async prompt outlives the caller's stack copy of it.
void persist_grant(const std::string& plugin_key, AuditEventCategory category, const std::vector<std::string>& targets) void persist_grant(const std::string& plugin_key, AuditEventCategory category, bool by_event, const std::vector<std::string>& keys)
{ {
PluginInstallState state; PluginInstallState state;
if (!PluginManager::instance().get_install_state(plugin_key, state)) if (!PluginManager::instance().get_install_state(plugin_key, state))
return; return;
std::vector<std::string>* permission_list = permission_list_for(category, state.permissions); std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
if (!permission_list) if (!permission_list)
return; return;
for (const auto& target : targets) for (const auto& key : keys)
persist_permission(plugin_key, state, *permission_list, target); persist_permission(plugin_key, state, *permission_list, key);
} }
// Requests permission for an audited event, returning true when it is already granted or the user static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
// approves an inline prompt.
// Asks the user to grant an audited event, returning true when they approve an inline prompt.
// `keys` are what a Yes saves: the unresolved targets, or the event-grant key when the event has
// no target (by_event), which the prompt does not show as a target.
// //
// An audited event can fire on a thread the UI thread may itself be blocked waiting on: the // An audited event can fire on a thread the UI thread may itself be blocked waiting on: the
// SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and // SlicingPipeline hook runs on the slicing worker thread (see PluginHooks.cpp), and
@@ -929,56 +1029,205 @@ void persist_grant(const std::string& plugin_key, AuditEventCategory category, c
// prompt); the grant is persisted once the user accepts, so a later attempt succeeds without // prompt); the grant is persisted once the user accepts, so a later attempt succeeds without
// re-prompting. // re-prompting.
bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name, bool request_permission(AuditEventCategory category, const std::string& plugin_key, const std::string& plugin_name,
const std::string& event_name, const std::vector<std::string>& unresolved) const std::string& event_name, bool by_event, const std::vector<std::string>& keys)
{ {
const std::vector<std::string> shown = by_event ? std::vector<std::string>{} : keys;
if (s_permission_prompt_for_testing)
return s_permission_prompt_for_testing(event_name, shown);
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return false; return false;
if (wxIsMainThread()) if (wxIsMainThread())
return prompt_for_targets(category, plugin_name, event_name, unresolved); return prompt_for_targets(category, plugin_name, event_name, shown);
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, unresolved]() { GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() {
if (wxTheApp == nullptr || GUI::wxGetApp().is_closing()) if (wxTheApp == nullptr || GUI::wxGetApp().is_closing())
return; return;
if (prompt_for_targets(category, plugin_name, event_name, unresolved)) if (prompt_for_targets(category, plugin_name, event_name, shown))
persist_grant(plugin_key, category, unresolved); persist_grant(plugin_key, category, by_event, keys);
}); });
return false; return false;
} }
// Decides an event that no allowed root or running operation covers: a saved grant allows it,
// otherwise the user is asked and a Yes is saved.
int decide_audited_event(PluginAuditManager& mgr, int decide_audited_event(PluginAuditManager& mgr,
PluginInstallState& state, PluginInstallState& state,
const std::string& plugin_key, const std::string& plugin_key,
const std::string& plugin_name, const std::string& plugin_name,
const std::string& event_name, const std::string& event_name,
AuditEventCategory category, AuditEventCategory category,
const std::vector<std::string>& targets, const std::vector<std::string>& targets)
std::vector<std::string>* permission_list,
const std::vector<std::string>& call_site_ids)
{ {
std::vector<std::string> unresolved = targets; const bool by_event = targets.empty();
const std::vector<std::string> keys = by_event ? std::vector<std::string>{event_grant_key(category, event_name)} : targets;
std::vector<std::string>* permission_list = permission_list_for(category, by_event, state.permissions);
std::vector<std::string> unresolved = keys;
if (permission_list) { if (permission_list) {
unresolved.clear(); unresolved.clear();
for (const auto& target : targets) for (const auto& key : keys)
if (!has_permission(*permission_list, target)) if (!has_permission(*permission_list, key))
unresolved.push_back(target); unresolved.push_back(key);
if (!targets.empty() && unresolved.empty()) if (unresolved.empty())
return 0; return 0;
} }
if (!request_permission(category, plugin_key, plugin_name, event_name, unresolved)) // Letting a root frame go runs finalizers inside a scope destructor or another event's hook,
// where no dialog may open: what they do must already be granted.
if (mgr.releasing_frames())
return report_denied(mgr, event_name, {false, "audit permission required"});
if (!request_permission(category, plugin_key, plugin_name, event_name, by_event, unresolved))
return report_denied(mgr, event_name, {false, "audit permission required"}); return report_denied(mgr, event_name, {false, "audit permission required"});
if (permission_list) if (permission_list)
for (const auto& target : unresolved) for (const auto& key : unresolved)
persist_permission(plugin_key, state, *permission_list, target); persist_permission(plugin_key, state, *permission_list, key);
mgr.record_approved_call_sites(plugin_key, call_site_ids);
return 0; return 0;
} }
// Judges an audited event raised while a plugin context is active.
int audit_plugin_event(PluginAuditManager& mgr, const std::string& event_name, AuditEventCategory event_type, PyObject* args)
{
const bool fs_category = is_fs_category(event_type);
const std::vector<std::string> targets = audit_targets(event_name, event_type, args);
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
// unconditional block: checked before the running-operation check below, so no granted
// operation can launder access to one, and before the allowed-root shortcut further down,
// so an allowed root (e.g. the read-only resources folder) cannot make a denied path
// underneath it reachable.
if (fs_category) {
for (const auto& target : targets) {
if (mgr.is_denied_path(boost::filesystem::path(target)))
return report_denied(mgr, event_name, {false, "denied path"}, target);
}
}
// A nested event of an operation already granted, e.g. socket.connect inside an approved
// urlopen(), is not asked again. urllib.Request is always judged on its own URL, so a
// redirect or a nested urlopen() inside a running request is not covered by it.
if (event_name != "urllib.Request" && mgr.covered_by_root_frame())
return 0;
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
// installed/system profile cache), the read-only bundled resources folder, or a per-call
// scoped root such as the current G-code folder -- is part of the plugin system's normal
// workflow and does not need a prompt.
if (fs_category && !targets.empty()) {
const bool is_write = event_type != AuditEventCategory::FsRead;
const bool all_inside_allowed_root =
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
return mgr.check_path_access(boost::filesystem::path(target), is_write).allowed;
});
if (all_inside_allowed_root)
return 0;
}
PluginInstallState state;
const bool have_install_state = PluginManager::instance().get_install_state(mgr.current_plugin(), state);
if (!have_install_state) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr.current_plugin();
}
const std::string plugin_name = state.plugin_name.empty() ? mgr.current_plugin() : state.plugin_name;
const int result = decide_audited_event(mgr, state, mgr.current_plugin(), plugin_name, event_name, event_type, targets);
// A granted operation covers its nested events while it runs. A targetless grant names no
// operation, so it covers nothing else.
if (result == 0 && !targets.empty() && operation_root_events.count(event_name))
mgr.record_root_frame();
return result;
}
} // namespace PluginAuditDetail } // namespace PluginAuditDetail
void PluginAuditManager::record_root_frame()
{
PyObject* frame = reinterpret_cast<PyObject*>(PyEval_GetFrame()); // borrowed reference
if (!frame || !PluginAuditDetail::is_stdlib_frame(frame))
return;
if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end())
return; // glob.glob and glob.glob/2 share a frame
Py_INCREF(frame);
m_root_frames.push_back(frame);
}
bool PluginAuditManager::covered_by_root_frame() const
{
if (m_root_frames.empty())
return false;
// Only stdlib frames may sit between the event and the operation: plugin or library code
// the operation calls back into (an os.walk onerror, a urllib handler) acts on its own.
for (PyObject* frame : PluginAuditDetail::stack_frames()) {
if (std::find(m_root_frames.begin(), m_root_frames.end(), frame) != m_root_frames.end())
return true;
if (!PluginAuditDetail::is_stdlib_frame(frame))
return false;
}
return false;
}
bool PluginAuditManager::releasing_frames() const { return m_releasing_frames > 0; }
bool PluginAuditManager::is_stdlib_file(const std::string& filename)
{
// Frozen stdlib modules (os, posixpath, codecs, ...) carry a pseudo filename.
if (boost::algorithm::starts_with(filename, "<frozen "))
return true;
if (filename.empty() || filename.front() == '<')
return false;
// Both sides are resolved: co_filename follows the Python home as it was given, which may
// be a symlink.
const auto [it, inserted] = PluginAuditDetail::s_stdlib_file_verdicts.try_emplace(filename, false);
if (inserted) {
const boost::filesystem::path file(filename);
const auto inside_any = [&file](const std::vector<boost::filesystem::path>& dirs) {
return std::any_of(dirs.begin(), dirs.end(),
[&file](const boost::filesystem::path& dir) { return is_inside_allowed_root(file, dir); });
};
it->second = inside_any(PluginAuditDetail::s_stdlib_dirs) && !inside_any(PluginAuditDetail::s_site_package_dirs);
}
return it->second;
}
void PluginAuditManager::release_frames(std::vector<PyObject*>& frames)
{
if (frames.empty())
return;
// A finalizer's own denial must neither replace the PermissionError of the event being
// decided nor leave a stale denial behind for the next call.
PyObject* const error = PyErr_GetRaisedException();
const bool denial_pending = m_audit_denial_pending;
const bool has_violation = m_has_last_violation;
const AuditViolation violation = m_last_violation;
std::vector<PyObject*> released;
released.swap(frames);
++m_releasing_frames;
for (PyObject* frame : released)
Py_DECREF(frame);
--m_releasing_frames;
m_audit_denial_pending = denial_pending;
m_has_last_violation = has_violation;
m_last_violation = violation;
PyErr_SetRaisedException(error);
}
void PluginAuditManager::set_permission_prompt_for_testing(PermissionPrompt prompt)
{
PluginAuditDetail::s_permission_prompt_for_testing = std::move(prompt);
}
int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data) int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user_data)
{ {
auto* mgr = static_cast<PluginAuditManager*>(user_data); auto* mgr = static_cast<PluginAuditManager*>(user_data);
@@ -1006,62 +1255,20 @@ int PluginAuditManager::audit_hook(const char* event, PyObject* args, void* user
if (event_type == AuditEventCategory::None) if (event_type == AuditEventCategory::None)
return 0; return 0;
const bool fs_category = is_fs_category(event_type); // Root frames whose operation has returned are let go once this event is decided, so that a
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args); // finalizer they run re-enters a hook whose held frames are already consistent.
std::vector<PyObject*> returned_frames;
// A denied path (secrets, certificates, config files -- see is_denied_path) is an PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames);
// unconditional block: checked before the ancestor-cascade check below, so a cascade const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args);
// approval recorded for an unrelated action can never launder access to one, and before mgr->release_frames(returned_frames);
// the allowed-root shortcut further down, so an allowed root (e.g. the read-only resources return result;
// folder) cannot make a denied path underneath it reachable.
if (fs_category) {
for (const auto& target : targets) {
if (mgr->is_denied_path(boost::filesystem::path(target)))
return PluginAuditDetail::report_denied(*mgr, event_name, {false, "denied path"}, target);
}
}
PluginDescriptor plugin_descriptor;
PluginManager::instance().try_get_plugin_descriptor(mgr->current_plugin(), plugin_descriptor);
// Some plugins call other audited events, e.g. urlib.request will call socket.connect. So this is so that if urlib.request
// was already approved, socket.connect won't trigger another permission dialog.
const std::vector<std::string> call_site_ids =
PluginAuditDetail::call_site_identities(plugin_descriptor.plugin_root);
if (mgr->has_approved_ancestor(mgr->current_plugin(), call_site_ids))
return 0;
// A filesystem target that resolves entirely inside a pre-determined allowed root -- the
// plugin system's own data_dir() tree (which holds each plugin's storage folder and the
// installed/system profile cache), the read-only bundled resources folder, or a per-call
// scoped root such as the current G-code folder -- is part of the plugin system's normal
// workflow and does not need a prompt.
if (fs_category && !targets.empty()) {
const bool is_write = event_type != AuditEventCategory::FsRead;
const bool all_inside_allowed_root =
std::all_of(targets.begin(), targets.end(), [&](const std::string& target) {
return mgr->check_path_access(boost::filesystem::path(target), is_write).allowed;
});
if (all_inside_allowed_root)
return 0;
}
PluginInstallState state;
const bool have_install_state = PluginManager::instance().get_install_state(mgr->current_plugin(), state);
if (!have_install_state) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " Failed to get install state for " << mgr->current_plugin();
}
const std::string plugin_name = state.plugin_name.empty() ? mgr->current_plugin() : state.plugin_name;
std::vector<std::string>* permission_list = PluginAuditDetail::permission_list_for(event_type, state.permissions);
return PluginAuditDetail::decide_audited_event(*mgr, state, mgr->current_plugin(), plugin_name, event_name,
event_type, targets, permission_list, call_site_ids);
} }
void PluginAuditManager::install_hook() void PluginAuditManager::install_hook()
{ {
// Tells the stdlib's own frames apart, for the operation-root frames (see record_root_frame).
PluginAuditDetail::collect_stdlib_dirs();
// data_dir() is the primary globally-allowed root during plugin execution: read+write. It // data_dir() is the primary globally-allowed root during plugin execution: read+write. It
// covers the plugin system's own workflow needs -- each plugin's storage folder // covers the plugin system's own workflow needs -- each plugin's storage folder
// (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) -- // (data_dir()/orca_plugins) and the installed/system profile cache (data_dir()/system) --
+34 -13
View File
@@ -4,10 +4,9 @@
// Via pybind11 so this file requests the same python3xx.lib as everything else. // Via pybind11 so this file requests the same python3xx.lib as everything else.
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
#include <pybind11/conduit/wrap_include_python_h.h> #include <pybind11/conduit/wrap_include_python_h.h>
#include <functional>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector> #include <vector>
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
@@ -144,15 +143,26 @@ public:
void clear_last_violation(); void clear_last_violation();
bool last_violation(AuditViolation& violation) const; bool last_violation(AuditViolation& violation) const;
// --- call-site cascade cache --- // --- operation-root frames ---
// A single plugin action often fires several nested CPython audit events as it passes // One stdlib operation fires several nested audit events as it passes through lower layers:
// through stdlib layers (urllib.request calling http.client calling socket, for example). // urlopen() raises urllib.Request, then http.client and socket events. When an operation's
// Once the user approves one event, every stdlib frame still on the stack for that call // own root event is granted (by a saved grant or the user), the stdlib frame that raised it
// is recorded here by (filename, function, first line) identity. A later event whose own // is held, and a later event whose stack reaches that frame through stdlib frames only is
// ancestor chain still contains one of those frames is the same logical action seen from // part of the same operation, so it is allowed without a prompt of its own. Plugin or
// a deeper layer, so it is auto-approved instead of prompting again. // library code the operation calls back into is judged on its own. A frame is let go once it
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const; // returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids); // run finalizers of objects its locals held; they are judged as the plugin and never prompt.
void record_root_frame();
bool covered_by_root_frame() const;
bool releasing_frames() const;
// True when code from `filename` (a code object's co_filename) is the interpreter's own
// stdlib: the only code a root frame's coverage passes through. Valid after install_hook().
static bool is_stdlib_file(const std::string& filename);
// Answers permission prompts in place of the dialog, for tests. Production never sets it.
using PermissionPrompt = std::function<bool(const std::string& event_name, const std::vector<std::string>& targets)>;
static void set_permission_prompt_for_testing(PermissionPrompt prompt);
bool verbose_events = true; bool verbose_events = true;
@@ -163,24 +173,32 @@ private:
static int audit_hook(const char* event, PyObject* args, void* user_data); static int audit_hook(const char* event, PyObject* args, void* user_data);
// Drops the references in `frames`, keeping the caller's error and denial state intact.
void release_frames(std::vector<PyObject*>& frames);
static thread_local std::string m_current_plugin_key; static thread_local std::string m_current_plugin_key;
static thread_local std::string m_current_capability_name; static thread_local std::string m_current_capability_name;
static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots; static thread_local std::vector<AllowedRoot> m_scoped_allowed_roots;
static thread_local std::vector<PyObject*> m_root_frames; // strong references
static thread_local int m_releasing_frames;
static thread_local bool m_audit_denial_pending; static thread_local bool m_audit_denial_pending;
static thread_local bool m_has_last_violation; static thread_local bool m_has_last_violation;
static thread_local AuditViolation m_last_violation; static thread_local AuditViolation m_last_violation;
// mutable: is_denied_filename() and has_approved_ancestor() are const queries that must lock. // mutable: is_denied_filename() is a const query that must lock.
mutable std::mutex m_mutex; mutable std::mutex m_mutex;
std::vector<AllowedRoot> m_global_allowed_roots; std::vector<AllowedRoot> m_global_allowed_roots;
std::vector<std::string> m_denied_filenames; std::vector<std::string> m_denied_filenames;
std::vector<std::string> m_denied_path_keywords; std::vector<std::string> m_denied_path_keywords;
std::unordered_map<std::string, std::unordered_set<std::string>> m_approved_call_sites; // plugin_key -> call-site ids
}; };
// RAII guard that sets the current plugin key and capability name, restoring the previous // RAII guard that sets the current plugin key and capability name, restoring the previous
// pair on scope exit. `capability_name` may be empty for calls that are not scoped to a // pair on scope exit. `capability_name` may be empty for calls that are not scoped to a
// single capability. // single capability.
//
// Each scope owns the root frames recorded inside it and lets them go, as its own plugin, before
// restoring the outer context. That needs the GIL whenever a frame is still held, so construct
// the scope after the GIL guard, to be destroyed before it.
class ScopedPluginAuditContext class ScopedPluginAuditContext
{ {
public: public:
@@ -194,9 +212,12 @@ public:
ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete; ScopedPluginAuditContext& operator=(const ScopedPluginAuditContext&) = delete;
private: private:
std::string m_plugin_key;
std::string m_capability;
std::string m_previous_id; std::string m_previous_id;
std::string m_previous_capability; std::string m_previous_capability;
std::vector<AllowedRoot> m_previous_scoped_roots; std::vector<AllowedRoot> m_previous_scoped_roots;
std::vector<PyObject*> m_previous_root_frames;
}; };
} // namespace Slic3r } // namespace Slic3r
+2
View File
@@ -808,6 +808,7 @@ bool read_install_state(const boost::filesystem::path& plugin_dir, PluginInstall
read_string_list("network_socket", parsed.permissions.network_socket); read_string_list("network_socket", parsed.permissions.network_socket);
read_string_list("process", parsed.permissions.process); read_string_list("process", parsed.permissions.process);
read_string_list("threading", parsed.permissions.threading); read_string_list("threading", parsed.permissions.threading);
read_string_list("events", parsed.permissions.events);
} }
if (state.contains("enabled") && state["enabled"].is_boolean()) if (state.contains("enabled") && state["enabled"].is_boolean())
@@ -852,6 +853,7 @@ bool write_install_state(const boost::filesystem::path& plugin_dir, const Plugin
{"network_socket", state.permissions.network_socket}, {"network_socket", state.permissions.network_socket},
{"process", state.permissions.process}, {"process", state.permissions.process},
{"threading", state.permissions.threading}, {"threading", state.permissions.threading},
{"events", state.permissions.events},
}; };
nlohmann::json capabilities = nlohmann::json::array(); nlohmann::json capabilities = nlohmann::json::array();
+2
View File
@@ -104,6 +104,8 @@ struct PluginPermissions
std::vector<std::string> network_socket; std::vector<std::string> network_socket;
std::vector<std::string> process; std::vector<std::string> process;
std::vector<std::string> threading; std::vector<std::string> threading;
// Grants for audit events that expose no target, keyed "<category>:<event>" (e.g. "Socket:socket.__new__").
std::vector<std::string> events;
}; };
struct PluginInstallState { struct PluginInstallState {
+1 -1
View File
@@ -102,9 +102,9 @@ public:
// mistake), both fall back to the base's empty object rather than writing `"cap_config": null`. // mistake), both fall back to the base's empty object rather than writing `"cap_config": null`.
nlohmann::json get_default_config() const override nlohmann::json get_default_config() const override
{ {
pybind11::gil_scoped_acquire gil;
ORCA_PY_AUDIT_SCOPE(); ORCA_PY_AUDIT_SCOPE();
try { try {
pybind11::gil_scoped_acquire gil;
pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config"); pybind11::function override = pybind11::get_override(static_cast<const Base*>(this), "get_default_config");
if (!override) if (!override)
return Base::get_default_config(); return Base::get_default_config();
@@ -282,11 +282,11 @@ public:
int request_bind_ticket(std::string* ticket) override int request_bind_ticket(std::string* ticket) override
{ {
try { try {
ORCA_PY_AUDIT_SCOPE();
::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this); ::Slic3r::PluginCapabilityInterface::RefCounter _orca_ref_counter(*this);
::Slic3r::PythonGILState gil; ::Slic3r::PythonGILState gil;
if (!gil) if (!gil)
throw std::runtime_error("Python interpreter is shutting down"); throw std::runtime_error("Python interpreter is shutting down");
ORCA_PY_AUDIT_SCOPE();
pybind11::function override = pybind11::function override =
pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket"); pybind11::get_override(static_cast<const PrinterAgentPluginCapability*>(this), "request_bind_ticket");
if (!override) if (!override)
+121
View File
@@ -1263,6 +1263,55 @@ TEST_CASE("read_cli rejects an invalid scalar numeric value", "[Config]") {
REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys)); REQUIRE_FALSE(config.read_cli(3, argv, &extra, &keys));
} }
TEST_CASE("A scalar option keeps its value when deserializing fails", "[Config]") {
using Catch::Matchers::WithinAbs;
SECTION("float") {
ConfigOptionFloat opt(7.5);
REQUIRE_FALSE(opt.deserialize("abc"));
CHECK_THAT(opt.value, WithinAbs(7.5, 1e-9));
}
SECTION("int") {
ConfigOptionInt opt(3);
REQUIRE_FALSE(opt.deserialize("abc"));
CHECK(opt.value == 3);
}
SECTION("percent") {
ConfigOptionPercent opt(40.);
REQUIRE_FALSE(opt.deserialize("abc%"));
CHECK_THAT(opt.value, WithinAbs(40., 1e-9));
}
SECTION("float or percent") {
ConfigOptionFloatOrPercent opt(0.4, false);
REQUIRE_FALSE(opt.deserialize("abc%"));
CHECK_THAT(opt.value, WithinAbs(0.4, 1e-9));
CHECK_FALSE(opt.percent);
}
SECTION("point") {
ConfigOptionPoint opt(Vec2d(1., 2.));
REQUIRE_FALSE(opt.deserialize("5,abc"));
CHECK(opt.value == Vec2d(1., 2.));
}
SECTION("point3") {
ConfigOptionPoint3 opt(Vec3d(1., 2., 3.));
REQUIRE_FALSE(opt.deserialize("5,6,abc"));
CHECK(opt.value == Vec3d(1., 2., 3.));
}
}
TEST_CASE("A scalar option still takes a valid value", "[Config]") {
using Catch::Matchers::WithinAbs;
ConfigOptionFloatOrPercent opt(0.4, false);
REQUIRE(opt.deserialize("50%"));
CHECK_THAT(opt.value, WithinAbs(50., 1e-9));
CHECK(opt.percent);
ConfigOptionPoint point;
REQUIRE(point.deserialize("3x4"));
CHECK(point.value == Vec2d(3., 4.));
}
TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") { TEST_CASE("read_cli appends values when a vector option is repeated", "[Config]") {
Slic3r::DynamicPrintConfig config; Slic3r::DynamicPrintConfig config;
t_config_option_keys extra, keys; t_config_option_keys extra, keys;
@@ -1678,3 +1727,75 @@ TEST_CASE("Default options of enum lists get their definition's keys map", "[Con
} }
CHECK(enum_lists > 0); CHECK(enum_lists > 0);
} }
TEST_CASE("A disabled filament override survives the merge with a parent that sets it", "[Config][Variant]")
{
std::set<std::string> no_keys;
Slic3r::DynamicPrintConfig parent;
parent.set_deserialize_strict("filament_retract_before_wipe", "100%");
parent.set_deserialize_strict("filament_ironing_flow", "15%");
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
child.set_deserialize_strict("filament_ironing_flow", "nil");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil");
CHECK(parent.opt_serialize("filament_ironing_flow") == "nil");
}
SCENARIO("A two-variant child's disabled override column is matched to the parent's variant by name", "[Config][Variant]")
{
std::set<std::string> no_keys;
GIVEN("A two-variant filament parent") {
Slic3r::DynamicPrintConfig parent;
parent.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
parent.set_deserialize_strict("filament_retract_before_wipe", "100%,100%");
parent.set_deserialize_strict("filament_flow_ratio", "1,1");
WHEN("the child disables the override on one variant and leaves a plain option nil on it") {
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow");
child.set_deserialize_strict("filament_retract_before_wipe", "nil,100%");
child.set_deserialize_strict("filament_flow_ratio", "nil,1.1");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("the override column is disabled while the plain option's nil column inherits") {
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,100%");
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
}
}
WHEN("the child lists the same variants in the other order") {
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive High Flow;Direct Drive Standard");
child.set_deserialize_strict("filament_retract_before_wipe", "70%,nil");
child.set_deserialize_strict("filament_flow_ratio", "1.1,nil");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("columns are matched by variant name, the disabled one included") {
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,70%");
CHECK(parent.opt_serialize("filament_flow_ratio") == "1,1.1");
}
}
WHEN("the child was saved with a single variant") {
Slic3r::DynamicPrintConfig child;
child.set_deserialize_strict("filament_extruder_variant", "Direct Drive Standard");
child.set_deserialize_strict("filament_retract_before_wipe", "nil");
parent.update_diff_values_to_child_config(child, "", "filament_extruder_variant",
Slic3r::filament_options_with_variant, no_keys);
THEN("its one value applies to every variant of that extruder, as slicing does") {
CHECK(parent.opt_serialize("filament_retract_before_wipe") == "nil,nil");
}
}
}
}
TEST_CASE("Every listed filament override is nullable", "[Config]")
{
std::set<std::string> overrides(Slic3r::filament_extruder_override_keys.begin(), Slic3r::filament_extruder_override_keys.end());
overrides.insert({ "filament_retract_length_nc", "filament_ironing_flow", "filament_ironing_spacing", "filament_ironing_inset", "filament_ironing_speed" });
for (const auto &[key, def] : Slic3r::print_config_def.options) {
INFO(key);
CHECK(Slic3r::is_filament_extruder_override_key(key) == (overrides.count(key) == 1));
if (overrides.count(key) == 1)
CHECK(def.nullable);
}
}
@@ -1,9 +1,6 @@
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp> #include <catch2/generators/catch_generators.hpp>
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <vector> #include <vector>
#include <cstddef> #include <cstddef>
#include <math.h> #include <math.h>
@@ -18,11 +15,8 @@
#include "libslic3r/GCode/OrderingStrategies.hpp" #include "libslic3r/GCode/OrderingStrategies.hpp"
#include "libslic3r/ShortestPath.hpp" #include "libslic3r/ShortestPath.hpp"
#include "libslic3r/Geometry.hpp" #include "libslic3r/Geometry.hpp"
#include "libslic3r/ShortestPath.hpp"
#include <algorithm> #include <algorithm>
#include <numeric>
#include <random>
#include <unordered_set> #include <unordered_set>
#include <numeric> #include <numeric>
#include <random> #include <random>
@@ -57,73 +51,6 @@ static bool has_crossings(const std::vector<size_t>& path, const Points& centers
return false; return false;
} }
// Intersecting edge pairs whose reversal would shorten the cycle by more than SCALED_EPSILON, which
// tsp_remove_crossings must leave none of.
static size_t count_shortening_crossings(const std::vector<size_t>& path, const Points& centers)
{
size_t pn = path.size();
size_t count = 0;
for (size_t i = 0; i < pn; ++i) {
for (size_t j = i + 2; j < pn; ++j) {
if (i == 0 && j == pn - 1) continue;
const Point& ai = centers[path[i]];
const Point& bi = centers[path[(i + 1) % pn]];
const Point& aj = centers[path[j]];
const Point& bj = centers[path[(j + 1) % pn]];
if (Geometry::segments_intersect(ai, bi, aj, bj) &&
ai.distance_to(bi) + aj.distance_to(bj) - ai.distance_to(aj) - bi.distance_to(bj) > SCALED_EPSILON)
++count;
}
}
return count;
}
// Exhaustive best-improvement 2-opt: each pass tests every pair of edges and applies the move with the largest
// gain, the first (i, j) on a tie. tsp_2opt_improve must make the same moves.
static bool exhaustive_2opt(std::vector<size_t>& path, const Points& centers, int max_passes)
{
size_t pn = path.size();
if (pn <= 2) return false;
std::vector<double> el(pn), elsq(pn);
auto recompute = [&]() {
for (size_t i = 0; i < pn; ++i) {
el[i] = (centers[path[i]].cast<double>() - centers[path[(i + 1) % pn]].cast<double>()).norm();
elsq[i] = (centers[path[i]].cast<double>() - centers[path[(i + 1) % pn]].cast<double>()).squaredNorm();
}
};
recompute();
bool improved = false;
for (int pass = 0; max_passes <= 0 || pass < max_passes; ++pass) {
size_t best_i = pn, best_j = pn;
double best_gain = 0;
for (size_t i = 0; i < pn; ++i) {
const Vec2d pi = centers[path[i]].cast<double>();
const Vec2d p_in = centers[path[(i + 1) % pn]].cast<double>();
for (size_t j = i + 2; j < pn; ++j) {
size_t j_next = (j + 1) % pn;
if (i == 0 && j_next == 0) continue;
const Vec2d pj = centers[path[j]].cast<double>();
const Vec2d p_jn = centers[path[j_next]].cast<double>();
double new_a_sq = (pj - pi).squaredNorm();
double new_b_sq = (p_jn - p_in).squaredNorm();
if (new_a_sq >= elsq[i] && new_b_sq >= elsq[j]) continue;
double new_a = std::sqrt(new_a_sq);
double new_b = std::sqrt(new_b_sq);
double gain = el[i] + el[j] - new_a - new_b;
if (gain > best_gain) {
best_gain = gain;
best_i = i; best_j = j;
}
}
}
if (best_i == pn) break;
improved = true;
std::reverse(path.begin() + best_i + 1, path.begin() + best_j + 1);
recompute();
}
return improved;
}
static bool is_permutation(const std::vector<size_t>& path, size_t n) static bool is_permutation(const std::vector<size_t>& path, size_t n)
{ {
if (path.size() != n) return false; if (path.size() != n) return false;
@@ -195,42 +122,6 @@ TEST_CASE("tsp_2opt_improve reduces path length", "[TSPPostProcessing]") {
CHECK(after <= before); CHECK(after <= before);
} }
TEST_CASE("tsp_2opt_improve makes the same moves as an exhaustive best-improvement scan", "[TSPPostProcessing]") {
// 300 points in a 170000-unit square: random on a 1000-unit grid like island centroids snapped to 1 mm (many
// share a position or a line), random at full resolution, or a 20 x 15 lattice with a 1000-unit pitch, whose
// congruent cells give many moves exactly the same gain. The coordinates are small enough that every squared
// distance is an exact double, so both implementations compute bit-identical gains even where the compiler
// fuses multiply-adds. The tour is either the nearest-neighbour tour the island ordering starts from, or the
// input order, whose long edges make every radius query large. Both the default pass cap and an uncapped run
// to a 2-opt local optimum are compared.
enum class Layout { Snapped, Fine, Lattice };
const Layout layout = GENERATE(Layout::Snapped, Layout::Fine, Layout::Lattice);
const bool nn_tour = GENERATE(true, false);
const int max_passes = GENERATE(10, 0);
std::mt19937 rng(GENERATE(1u, 2u));
Points centers;
for (int i = 0; i < 300; ++i) {
if (layout == Layout::Lattice) {
centers.emplace_back(coord_t(i % 20) * 1000, coord_t(i / 20) * 1000);
} else {
const coord_t x = layout == Layout::Snapped ? coord_t(rng() % 171) * 1000 : coord_t(rng() % 170001);
const coord_t y = layout == Layout::Snapped ? coord_t(rng() % 171) * 1000 : coord_t(rng() % 170001);
centers.emplace_back(x, y);
}
}
std::vector<size_t> path(centers.size());
if (nn_tour)
path = chain_points(centers);
else
std::iota(path.begin(), path.end(), size_t(0));
std::vector<size_t> expected = path;
REQUIRE(exhaustive_2opt(expected, centers, max_passes));
CHECK(tsp_2opt_improve(path, centers, max_passes));
CHECK(path == expected);
}
TEST_CASE("tsp_remove_crossings eliminates crossings", "[TSPPostProcessing]") { TEST_CASE("tsp_remove_crossings eliminates crossings", "[TSPPostProcessing]") {
Points centers = make_random_16(); Points centers = make_random_16();
std::vector<size_t> path(centers.size()); std::vector<size_t> path(centers.size());
@@ -245,76 +136,6 @@ TEST_CASE("tsp_remove_crossings eliminates crossings", "[TSPPostProcessing]") {
REQUIRE(is_permutation(path, centers.size())); REQUIRE(is_permutation(path, centers.size()));
} }
TEST_CASE("tsp_remove_crossings leaves a tour alone when no reversal shortens it", "[TSPPostProcessing]") {
// Three points on a diagonal, the middle one doubled as island centroids snapped to the same 1 mm cell are,
// and a corner closing the tour. Edges 0-1 and 2-3 touch at the doubled point, and reversing between them
// only swaps the two copies.
Points centers;
centers.emplace_back(0, 0);
centers.emplace_back(3000000, 3000000);
centers.emplace_back(3000000, 3000000);
centers.emplace_back(6000000, 6000000);
centers.emplace_back(6000000, 0);
std::vector<size_t> path = {0, 1, 2, 3, 4};
REQUIRE(Geometry::segments_intersect(centers[0], centers[1], centers[2], centers[3]));
CHECK(!tsp_remove_crossings(path, centers));
CHECK(path == std::vector<size_t>{0, 1, 2, 3, 4});
}
TEST_CASE("tsp_remove_crossings keeps a crossing whose reversal gains less than SCALED_EPSILON", "[TSPPostProcessing]") {
// Two 100 mm edges crossing at a 10 nm offset: uncrossing them saves about 1e-6 scaled units of travel.
Points centers;
centers.emplace_back(0, 0);
centers.emplace_back(100000000, 10);
centers.emplace_back(100000000, 0);
centers.emplace_back(0, 10);
std::vector<size_t> path = {0, 1, 2, 3};
REQUIRE(Geometry::segments_intersect(centers[0], centers[1], centers[2], centers[3]));
CHECK(!tsp_remove_crossings(path, centers));
CHECK(path == std::vector<size_t>{0, 1, 2, 3});
}
TEST_CASE("tsp_remove_crossings reverses overlapping collinear edges when that shortens the tour", "[TSPPostProcessing]") {
// Points at x = 0, 3, 1 and 2 mm on one line. Tour 0-3-1-2 is 8 mm long, and its edges [0,3] and [1,2] overlap.
// The shortest tour through points on a line is twice their 3 mm span.
Points centers;
centers.emplace_back(0, 0);
centers.emplace_back(3000000, 0);
centers.emplace_back(1000000, 0);
centers.emplace_back(2000000, 0);
std::vector<size_t> path = {0, 1, 2, 3};
REQUIRE_THAT(tsp_cycle_path_length(path, centers), Catch::Matchers::WithinAbs(8000000., 1.));
CHECK(tsp_remove_crossings(path, centers));
REQUIRE(is_permutation(path, centers.size()));
CHECK_THAT(tsp_cycle_path_length(path, centers), Catch::Matchers::WithinAbs(6000000., 1.));
}
TEST_CASE("tsp_remove_crossings leaves no shortening crossing in a tour of thousands of grid-snapped points", "[TSPPostProcessing]") {
// 4000 island centroids on a 170 mm plate, snapped to 1 mm as GCode::process_layer does, so many of them share
// a position or a line. The nearest-neighbour tour the island ordering starts from has crossings to remove.
std::mt19937 rng(GENERATE(1u, 2u, 3u));
Points centers;
for (int i = 0; i < 4000; ++i) {
const double x = double(rng() % 171);
const double y = double(rng() % 171);
centers.push_back(Point::new_scale(x, y));
}
std::vector<size_t> path = chain_points(centers);
const size_t first = path.front();
const double length_before = tsp_cycle_path_length(path, centers);
REQUIRE(count_shortening_crossings(path, centers) > 0);
CHECK(tsp_remove_crossings(path, centers));
REQUIRE(is_permutation(path, centers.size()));
CHECK(count_shortening_crossings(path, centers) == 0);
CHECK(tsp_cycle_path_length(path, centers) < length_before);
// Callers that start the tour near a given point rely on the first node staying in place.
CHECK(path.front() == first);
}
TEST_CASE("tsp_rotate_minimize_closing shortens closing edge", "[TSPPostProcessing]") { TEST_CASE("tsp_rotate_minimize_closing shortens closing edge", "[TSPPostProcessing]") {
@@ -8417,3 +8417,58 @@ TEST_CASE("An empty choice list in a G-code's settings keeps the value it had be
CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow"); CHECK(config.opt_serialize("nozzle_volume_type") == "High Flow");
CHECK(config.option<ConfigOptionStrings>("filament_multi_colour")->values.empty()); CHECK(config.option<ConfigOptionStrings>("filament_multi_colour")->values.empty());
} }
TEST_CASE("A disabled filament override is saved and reloaded column by column", "[Preset][Bundle]")
{
ScopedPresetDataDir data_dir;
PresetBundle bundle;
Preset &parent = add_inmemory_preset(bundle.filaments, "Parent PLA @System");
parent.config.option<ConfigOptionStrings>("filament_extruder_variant", true)->values = { "Direct Drive Standard", "Direct Drive High Flow" };
parent.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe", true)->values = { 100., 100. };
parent.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio", true)->values = { 1., 1. };
// The child turns the override off on the first variant and changes a plain option on the second.
const fs::path file = data_dir.tmp.path() / PRESET_FILAMENT_NAME / "Child PLA.json";
Preset child(Preset::TYPE_FILAMENT, "Child PLA");
child.file = file.string();
child.version = *Semver::parse("1.0.0");
child.config = parent.config;
child.config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = parent.name;
child.config.option<ConfigOptionPercentsNullable>("filament_retract_before_wipe")->set_at_to_nil(0);
child.config.option<ConfigOptionFloatsNullable>("filament_flow_ratio")->values = { 1., 1.1 };
REQUIRE(child.save(&parent.config));
// On disk the override keeps every column, so its nil can only mean "off"; a plain
// option still stores nil for the columns that match the parent.
DynamicPrintConfig saved;
std::map<std::string, std::string> key_values;
std::string reason;
saved.load_from_json(file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, key_values, reason);
REQUIRE(reason.empty());
CHECK(saved.opt_serialize("filament_retract_before_wipe") == "nil,100%");
CHECK(saved.opt_serialize("filament_flow_ratio") == "nil,1.1");
PresetsConfigSubstitutions substitutions;
bundle.filaments.load_presets(data_dir.tmp.path().string(), PRESET_FILAMENT_NAME, substitutions,
ForwardCompatibilitySubstitutionRule::EnableSilent);
const Preset *loaded = bundle.filaments.find_preset("Child PLA");
REQUIRE(loaded != nullptr);
CHECK(loaded->config.opt_serialize("filament_retract_before_wipe") == "nil,100%");
CHECK(loaded->config.opt_serialize("filament_flow_ratio") == "1,1.1");
}
TEST_CASE("The cloud diff force-emits a disabled override only where the cloud would fill a value in", "[Preset][Bundle]")
{
PresetBundle bundle;
add_inmemory_preset(bundle.filaments, "Parent PLA @System");
Preset &child = add_inmemory_preset(bundle.filaments, "Child PLA", "Parent PLA @System");
// Every override is nil in both parent and child, so none is in the plain diff.
std::map<std::string, std::string> key_values;
REQUIRE(bundle.filaments.get_differed_values_to_update(child, key_values) >= 0);
// Retract Before Wipe defaults to 100%: an absent key would come back as 100%, so nil is sent.
CHECK(key_values.count("filament_retract_before_wipe") == 1);
CHECK(key_values["filament_retract_before_wipe"] == "nil");
// An ironing override defaults to nil: an absent key stays nil, so it is not sent.
CHECK(key_values.count("filament_ironing_flow") == 0);
}
+1
View File
@@ -30,6 +30,7 @@ add_executable(${_TEST_NAME}_tests
test_plugin_capabilities_in_use.cpp test_plugin_capabilities_in_use.cpp
test_plugin_status.cpp test_plugin_status.cpp
test_printer_agent.cpp test_printer_agent.cpp
test_qidi_printer_agent.cpp
test_plugin_install.cpp test_plugin_install.cpp
test_plugin_lifecycle.cpp test_plugin_lifecycle.cpp
test_plugin_resource_texture.cpp test_plugin_resource_texture.cpp
@@ -53,7 +53,7 @@ public:
mutable unsigned fetch_count = 0; mutable unsigned fetch_count = 0;
protected: protected:
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override bool fetch_device_info(const ConnectionSettings&, MoonrakerDeviceInfo& info, std::string&) const override
{ {
++fetch_count; ++fetch_count;
info.dev_name = "Test printer"; info.dev_name = "Test printer";
@@ -65,21 +65,32 @@ class StreamAgent : public MoonrakerPrinterAgent
{ {
public: public:
using MoonrakerPrinterAgent::MoonrakerPrinterAgent; using MoonrakerPrinterAgent::MoonrakerPrinterAgent;
using MoonrakerPrinterAgent::start_status_stream;
using MoonrakerPrinterAgent::stop_status_stream;
using MoonrakerPrinterAgent::ws_timings; 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: protected:
bool init_device_info( bool init_device_info(const PrinterConnectionParams& params) override
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 = MoonrakerDeviceInfo{};
device_info.dev_id = dev_id; device_info.dev_id = params.dev_id;
device_info.base_url = "http://" + dev_ip + ":" + port; device_info.dev_ip = params.host;
device_info.base_url = std::string(params.use_ssl ? "https://" : "http://") + params.host + ":" + params.port;
return true; return true;
} }
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override bool fetch_device_info(const ConnectionSettings&, MoonrakerDeviceInfo& info, std::string&) const override
{ {
info.dev_name = "Test printer"; info.dev_name = "Test printer";
return true; return true;
@@ -97,6 +108,7 @@ enum class Behavior {
IgnoresClose, // Reports Klippy disconnected, then never answers the close IgnoresClose, // Reports Klippy disconnected, then never answers the close
StallsObjectList, // Never answers the stream's object list request StallsObjectList, // Never answers the stream's object list request
StallsStatusQuery, // Never answers HTTP requests until released, then hangs up on them 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 class FakePrinter
@@ -150,6 +162,19 @@ public:
return m_close_frames; 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. // 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) bool wait_for_close_frames(int count)
{ {
@@ -189,6 +214,20 @@ private:
return; 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; beast::flat_buffer buffer;
http::request<http::string_body> request; http::request<http::string_body> request;
beast::error_code ec; beast::error_code ec;
@@ -343,6 +382,7 @@ private:
int m_http_requests = 0; int m_http_requests = 0;
int m_http_hung_up = 0; int m_http_hung_up = 0;
int m_close_frames = 0; int m_close_frames = 0;
int m_first_byte = -1;
bool m_released = false; bool m_released = false;
}; };
@@ -385,12 +425,15 @@ struct StreamFixture
// Runs before the members are destroyed, so the agent's destructor never joins a stream the printer still holds open. // Runs before the members are destroyed, so the agent's destructor never joins a stream the printer still holds open.
~StreamFixture() { printer.release(); } ~StreamFixture() { printer.release(); }
void start() { agent.start_status_stream("printer", printer.base_url(), ""); } 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); }
// Stops the stream, giving it `limit` to return before the printer hangs up on it. // Stops the stream, giving it `limit` to return before the printer hangs up on it.
bool stops_within(std::chrono::milliseconds limit) bool stops_within(std::chrono::milliseconds limit)
{ {
auto stopped = std::async(std::launch::async, [this] { agent.stop_status_stream(); }); auto stopped = std::async(std::launch::async, [this] { agent.stop_stream(); });
const bool in_time = stopped.wait_for(limit) == std::future_status::ready; const bool in_time = stopped.wait_for(limit) == std::future_status::ready;
printer.release(); printer.release();
stopped.wait(); stopped.wait();
@@ -447,6 +490,16 @@ TEST_CASE("Stopping the status stream gives a silent printer no more than its ti
CHECK(f.stops_within(limit)); 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]") 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. // A stop while the stream reads, and one while it waits on the object list request.
@@ -524,7 +577,7 @@ TEST_CASE("A callback on the stream thread can stop the status stream", "[Moonra
StreamFixture f(Behavior::AnswersPings); StreamFixture f(Behavior::AnswersPings);
f.agent.set_on_local_message_fn([&](std::string, std::string) { f.agent.set_on_local_message_fn([&](std::string, std::string) {
std::call_once(stopped_once, [&] { std::call_once(stopped_once, [&] {
f.agent.stop_status_stream(); f.agent.stop_stream();
stopped.set_value(); stopped.set_value();
}); });
}); });
@@ -582,9 +635,12 @@ TEST_CASE("A disconnect during the connection queries keeps the status stream fr
f.agent.connect_printer(params); f.agent.connect_printer(params);
REQUIRE(f.printer.wait_for_http_requests(1)); REQUIRE(f.printer.wait_for_http_requests(1));
f.agent.disconnect_printer(); // 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.printer.release(); f.printer.release();
// The query has returned once the printer hangs up on it, and a stream would connect right after. // 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)); REQUIRE(f.printer.wait_for_http_hang_ups(1));
CHECK_FALSE(f.printer.wait_for_ready_connections(1, 500ms)); CHECK_FALSE(f.printer.wait_for_ready_connections(1, 500ms));
disconnecting.wait();
} }
+770
View File
@@ -1,22 +1,48 @@
#include <boost/filesystem/path.hpp> #include <boost/filesystem/path.hpp>
#include <boost/filesystem/operations.hpp> #include <boost/filesystem/operations.hpp>
#include <boost/system/error_code.hpp>
#include <catch2/catch_all.hpp> #include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp> #include <libslic3r/Utils.hpp>
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h) #include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
#include <slic3r/plugin/PluginAuditManager.hpp> #include <slic3r/plugin/PluginAuditManager.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/Utils/IPrinterAgent.hpp>
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME #include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
#include <catch2/catch_message.hpp>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include "libslic3r_version.h" #include "libslic3r_version.h"
#include "plugin_test_utils.hpp" #include "plugin_test_utils.hpp"
#include "test_utils.hpp"
#include <boost/filesystem.hpp> #include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <pybind11/cast.h>
#include <pybind11/embed.h>
#include <pybind11/eval.h>
#include <pybind11/gil.h>
#include <pybind11/pybind11.h>
#include <pybind11/pytypes.h>
#include <pybind11/stl.h>
#include <fstream>
#include <functional>
#include <ios>
#include <memory>
#include <string> #include <string>
#include <utility>
#include <vector>
using namespace Slic3r; using namespace Slic3r;
namespace fs = boost::filesystem; namespace fs = boost::filesystem;
namespace py = pybind11;
namespace { namespace {
@@ -349,3 +375,747 @@ TEST_CASE("Plugin audit a scoped root can also be registered read-only", "[audit
CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed); CHECK_FALSE(mgr.check_open((readonly_dir / "vendor.json").string(), "w").allowed);
} }
} }
// ---------------------------------------------------------------------------
// The audit hook, run in the embedded interpreter
// ---------------------------------------------------------------------------
//
// Each TEST_CASE runs in its own process (catch_discover_tests), so each brings the plugin system
// up with the audit hook installed, once, and shuts it down at the end. The tests loop over their
// cases instead of using SECTIONs, which would restart the interpreter for every section.
namespace {
// Discovered, never loaded: the hook only needs the plugin's root and its sidecar.
const char* const AUDIT_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Audit Plugin"
# description = "Plugin audit hook fixture"
# author = "OrcaSlicer"
# version = "1.0"
# type = "script"
# ///
)PY";
// A third-party library, installed outside the stdlib and outside the plugin.
const char* const LIBRARY_SOURCE = R"PY(
import socket
import urllib.request
def read_all(paths):
for path in paths:
with open(path) as f:
f.read()
def connect_from_host(port):
socket.gethostname()
socket.create_connection(("127.0.0.1", port)).close()
def fetch(url):
with urllib.request.urlopen(url) as response:
return response.read()
def fetch_each(urls):
for url in urls:
yield fetch(url)
class ReadingHandler(urllib.request.BaseHandler):
"""Reads a file while each response is processed."""
def __init__(self, path):
self.path = path
self.outcome = None
def http_response(self, request, response):
try:
open(self.path).close()
self.outcome = "ok"
except PermissionError:
self.outcome = "denied"
return response
)PY";
// Test-side Python, run outside any plugin context: files outside every allowed root, a local
// HTTP server, the library, and every module the tests use imported up front, so that no import
// happens inside a plugin context. Code from exec() runs as "<string>", which is not stdlib, so
// attempt() and fetch() stand in for plugin code.
const char* const AUDIT_SETUP = R"PY(
import http.client, http.server, os, pathlib, shutil, socket, subprocess, sys, tempfile, threading
import urllib.request, zipfile
if os.name != "nt":
import pty
APPROVED = os.path.join(OUTSIDE, "approved.txt")
PRIVATE = os.path.join(OUTSIDE, "private.txt")
MISSING = os.path.join(OUTSIDE, "missing")
TREE = os.path.join(OUTSIDE, "tree")
TRUE_COMMAND = ["cmd", "/c", "exit 0"] if os.name == "nt" else ["true"]
for path in (APPROVED, PRIVATE):
with open(path, "w") as f:
f.write("x")
for sub in ("one", "two"):
os.makedirs(os.path.join(TREE, sub))
with open(os.path.join(TREE, sub, "f.txt"), "w") as f:
f.write("x")
LIBRARY = os.path.join(OUTSIDE, "library")
os.makedirs(LIBRARY)
with open(os.path.join(LIBRARY, "netlib.py"), "w") as f:
f.write(LIBRARY_SOURCE)
sys.path.insert(0, LIBRARY)
import netlib
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/redirect":
self.send_response(302)
self.send_header("Location", URL_B)
body = b""
else:
self.send_response(200)
body = b"ok"
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
PORT = server.server_address[1]
BASE = "http://127.0.0.1:%d" % PORT
URL_A, URL_B, URL_REDIRECT = BASE + "/a", BASE + "/b", BASE + "/redirect"
urllib.request.install_opener(urllib.request.build_opener(urllib.request.ProxyHandler({})))
def fetch(url):
with urllib.request.urlopen(url) as response:
return response.read()
fetch(URL_A) # urllib imports some modules on its first request
def attempt(action):
try:
action()
return "ok"
except PermissionError:
return "denied"
def spawn_checked(argv):
parent = os.getpid()
try:
return pty.spawn(argv)
finally:
if os.getpid() != parent:
os._exit(1) # a child whose exec was refused must not go on running the tests
finalizer_outcomes = []
class TrackedRequest(urllib.request.Request):
def __del__(self):
finalizer_outcomes.append(attempt(lambda: open(PRIVATE).close()))
)PY";
struct Prompt
{
std::string plugin;
std::string event;
std::vector<std::string> targets;
};
// One line per prompt, "event: target ...", so a failed check shows every prompt.
std::string describe(const std::vector<Prompt>& prompts)
{
std::string text;
for (const Prompt& prompt : prompts) {
text += prompt.event + ":";
for (const std::string& target : prompt.targets)
text += " " + target;
text += "\n";
}
return text;
}
// The plugin system with the audit hook installed, and the permission dialog replaced by a
// recorder that answers with `approve`. Declare it first in a test, so it is destroyed last.
struct AuditHarness
{
// Before initialize(): the interpreter creates {data_dir}/python/packages and {data_dir}/log.
// data_dir() is a global allowed root from then on, so the test files live in `outside`.
ScopedDataDir data{"plugin-audit-hook"};
bool initialized = PluginManager::instance().initialize();
ScopedTemporaryDir outside{"orca-plugin-audit-outside"};
std::vector<Prompt> prompts;
std::function<bool(const Prompt&)> approve = [](const Prompt&) { return false; };
AuditHarness()
{
PluginAuditManager::set_permission_prompt_for_testing(
[this](const std::string& event, const std::vector<std::string>& targets) {
prompts.push_back({PluginAuditManager::instance().current_plugin(), event, targets});
return approve(prompts.back());
});
}
~AuditHarness()
{
PluginAuditManager::set_permission_prompt_for_testing(nullptr);
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
// Call before taking the GIL: discovery is not Python work.
void add_plugin(const std::string& key)
{
const fs::path root = data.plugins_dir() / key;
fs::create_directories(root);
std::ofstream((root / (key + ".py")).string(), std::ios::binary) << AUDIT_PLUGIN_SOURCE;
save_permissions(key, {});
PluginManager::instance().discover_plugins(/*async=*/false, /*clear=*/true);
}
// Grants as if saved in an earlier session.
void save_permissions(const std::string& key, const PluginPermissions& permissions)
{
PluginInstallState state;
state.installed_from = "local";
state.installed_version = "1.0";
state.plugin_name = key;
state.permissions = permissions;
REQUIRE(write_install_state(data.plugins_dir() / key, state));
}
PluginPermissions saved_permissions(const std::string& key) const
{
PluginInstallState state;
read_install_state(data.plugins_dir() / key, state);
return state.permissions;
}
};
// The test-side Python globals. Construct and destroy it with the GIL held; it stops the HTTP
// server before the interpreter shuts down.
struct PythonSide
{
py::dict globals;
explicit PythonSide(const AuditHarness& harness)
{
globals["OUTSIDE"] = harness.outside.path().string();
globals["LIBRARY_SOURCE"] = LIBRARY_SOURCE;
exec(AUDIT_SETUP);
}
// A Python error fails the test here, as text: an error_already_set left to propagate would
// outlive the interpreter the harness shuts down.
void exec(const char* code)
{
std::string error;
try {
py::exec(code, globals);
} catch (py::error_already_set& raised) {
error = raised.what();
}
if (!error.empty())
FAIL(error);
}
~PythonSide()
{
try {
globals["server"].attr("shutdown")();
globals["server"].attr("server_close")();
} catch (...) {
}
}
std::string str(const char* name) const { return py::str(globals[name]).cast<std::string>(); }
std::string eval(const char* expression) const { return py::str(py::eval(expression, globals)).cast<std::string>(); }
std::vector<std::string> strings(const char* name) const { return globals[name].cast<std::vector<std::string>>(); }
// Runs `code` as `plugin_key`, as a trampoline call does: inside the caller's GIL, in an
// audit scope of its own.
void run_as(const std::string& plugin_key, const char* code)
{
ScopedPluginAuditContext audit(plugin_key);
exec(code);
}
};
PluginPermissions http_grants(std::vector<std::string> urls)
{
PluginPermissions permissions;
permissions.network_http = std::move(urls);
return permissions;
}
const char* const PLUGIN = "AuditPlugin";
} // namespace
TEST_CASE("A saved request grant covers the request's own connection in a later session", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A")}));
python.run_as(PLUGIN, "outcome = attempt(lambda: fetch(URL_A))");
CHECK(python.str("outcome") == "ok");
CHECK(describe(harness.prompts).empty());
}
TEST_CASE("A saved request grant does not cover another URL or a redirect", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
harness.save_permissions(PLUGIN, http_grants({python.str("URL_A"), python.str("URL_REDIRECT")}));
python.run_as(PLUGIN, R"PY(
outcomes = [attempt(lambda: fetch(url)) for url in (URL_A, URL_B, URL_REDIRECT)]
outcomes += [attempt(lambda: netlib.fetch(url)) for url in (URL_A, URL_B)]
outcomes.append(attempt(lambda: list(netlib.fetch_each([URL_A, URL_B]))))
)PY");
// The redirect target is asked for inside the approved redirecting request, and a library's
// wrapper or generator asks for the second URL as plain code does.
CHECK(python.strings("outcomes") == std::vector<std::string>{"ok", "denied", "denied", "ok", "denied", "denied"});
const std::string url_b = "urllib.Request: " + python.str("URL_B") + "\n";
CHECK(describe(harness.prompts) == url_b + url_b + url_b + url_b);
}
TEST_CASE("Code an approved operation calls back into is judged on its own", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
python.exec(R"PY(
CALLBACK_COPY = os.path.join(OUTSIDE, "callback-copy")
handler = netlib.ReadingHandler(PRIVATE)
handler_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), handler)
walk_outcomes = []
copy_outcomes = []
def on_walk_error(error):
walk_outcomes.append(attempt(lambda: open(PRIVATE).close()))
def copy_file(source, destination):
copy_outcomes.append(attempt(lambda: open(PRIVATE).close()))
)PY");
PluginPermissions permissions = http_grants({python.str("URL_A")});
permissions.fs_read = {python.str("APPROVED"), python.str("MISSING")};
permissions.fs_readwrite = {python.str("TREE"), python.str("CALLBACK_COPY")};
harness.save_permissions(PLUGIN, permissions);
// A library handler that urllib calls while the approved request runs, the plugin's own
// os.walk error callback and copytree copy function (called once per file), and a library
// loop over a saved file and an unsaved one.
python.run_as(PLUGIN, R"PY(
request_outcome = attempt(lambda: handler_opener.open(URL_A).read())
for _ in os.walk(MISSING, onerror=on_walk_error):
pass
shutil.copytree(TREE, CALLBACK_COPY, copy_function=copy_file)
loop_outcome = attempt(lambda: netlib.read_all([APPROVED, PRIVATE]))
)PY");
CHECK(python.str("request_outcome") == "ok");
CHECK(python.eval("handler.outcome") == "denied");
CHECK(python.strings("walk_outcomes") == std::vector<std::string>{"denied"});
CHECK(python.strings("copy_outcomes") == std::vector<std::string>{"denied", "denied"});
CHECK(python.str("loop_outcome") == "denied");
const std::string private_file = "open: " + python.str("PRIVATE") + "\n";
CHECK(describe(harness.prompts) == private_file + private_file + private_file + private_file + private_file);
}
TEST_CASE("A saved directory walk covers each directory it lists", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
PluginPermissions permissions;
permissions.fs_read = {python.str("TREE"), python.str("APPROVED")};
harness.save_permissions(PLUGIN, permissions);
// The walk lists the tree's two subdirectories on later resumes, after the plugin's own
// audited reads in between. Abandoned walks leave frames whose generator is gone, which the
// next audited event lets go.
python.run_as(PLUGIN, R"PY(
walked = []
for root, dirs, files in os.walk(TREE):
walked.append(root)
open(APPROVED).close()
globbed = list(pathlib.Path(TREE).glob("**/*"))
abandoned = os.walk(TREE)
next(abandoned)
del abandoned
open(APPROVED).close()
for _ in pathlib.Path(TREE).glob("*"):
break
open(APPROVED).close()
)PY");
CHECK(py::len(python.globals["walked"]) == 3);
CHECK(py::len(python.globals["globbed"]) == 4);
CHECK(describe(harness.prompts).empty());
}
TEST_CASE("A freshly approved operation covers its nested events", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
struct Case
{
const char* prompts;
const char* code;
};
// Each operation names targets no earlier one saved, so each is asked once. FsCreate and
// FsDelete grants are never saved, so those operations are asked every time. Only the
// expected prompts are approved, so a nested event left uncovered fails the operation.
std::vector<Case> cases = {
{"shutil.copytree", "shutil.copytree(TREE, os.path.join(OUTSIDE, 'copy'))"},
{"shutil.copyfile", "shutil.copyfile(APPROVED, os.path.join(OUTSIDE, 'copied.txt'))"},
{"shutil.move", "shutil.move(os.path.join(OUTSIDE, 'copied.txt'), os.path.join(OUTSIDE, 'moved.txt'))"},
{"shutil.copystat", "shutil.copystat(os.path.join(TREE, 'one', 'f.txt'), PRIVATE)"},
{"shutil.make_archive", "shutil.make_archive(os.path.join(OUTSIDE, 'archive'), 'zip', root_dir=TREE)"},
{"shutil.unpack_archive", "shutil.unpack_archive(os.path.join(OUTSIDE, 'archive.zip'), os.path.join(OUTSIDE, 'unpacked'))"},
{"pathlib.Path.glob", "list(pathlib.Path(TREE).glob('**/*'))"},
{"subprocess.Popen", "subprocess.run(TRUE_COMMAND, check=True)"},
{"tempfile.mkdtemp", "tempfile.mkdtemp(dir=OUTSIDE)"},
{"tempfile.mkstemp", "os.close(tempfile.mkstemp(dir=OUTSIDE)[0])"},
{"shutil.rmtree", "shutil.rmtree(os.path.join(OUTSIDE, 'copy'))"},
// send() runs after connect() has returned, so its own grant is asked once.
{"http.client.connect\nhttp.client.send",
"connection = http.client.HTTPConnection('127.0.0.1', PORT)\n"
"connection.request('GET', '/a')\n"
"connection.getresponse().read()\n"
"connection.close()"},
};
#ifndef _WIN32
cases.push_back({"os.fwalk", "list(os.fwalk(os.path.join(OUTSIDE, 'unpacked')))"});
// The forked child's exec is covered by the spawn too: had it prompted, the child's own
// denial would end it with status 1.
cases.push_back({"pty.spawn", "spawn_status = spawn_checked(TRUE_COMMAND + ['spawned'])"});
#endif
for (const Case& operation : cases) {
INFO(operation.code);
const std::string expected = std::string(operation.prompts) + "\n";
harness.approve = [&expected](const Prompt& prompt) {
return expected.find(prompt.event + "\n") != std::string::npos;
};
harness.prompts.clear();
python.run_as(PLUGIN, operation.code);
std::string asked;
for (const Prompt& prompt : harness.prompts)
asked += (asked.empty() ? "" : "\n") + prompt.event;
CHECK(asked == operation.prompts);
}
#ifndef _WIN32
CHECK(python.eval("spawn_status") == "0");
#endif
}
TEST_CASE("An event with no target is granted by name and remembered", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
harness.approve = [](const Prompt& prompt) { return prompt.event != "socket.sendto"; };
py::gil_scoped_acquire gil;
PythonSide python(harness);
python.run_as(PLUGIN, R"PY(
socket.socket().close()
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sendto_outcome = attempt(lambda: udp.sendto(b"x", ("127.0.0.1", 9)))
udp.close()
)PY");
// The socket is asked for once, without a target to show; the datagram's address is a target.
CHECK(python.str("sendto_outcome") == "denied");
CHECK(describe(harness.prompts) == "socket.__new__:\nsocket.sendto: ('127.0.0.1', 9)\n");
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"Socket:socket.__new__"});
CHECK(harness.saved_permissions(PLUGIN).network_socket.empty());
harness.prompts.clear();
python.run_as(PLUGIN, "socket.socket().close()");
CHECK(describe(harness.prompts).empty());
// A grant for an event with no target covers nothing else: a library's approved
// gethostname() does not carry the connection it makes next.
harness.approve = [](const Prompt& prompt) { return prompt.event == "socket.gethostname"; };
python.run_as(PLUGIN, "connect_outcome = attempt(lambda: netlib.connect_from_host(PORT))");
CHECK(python.str("connect_outcome") == "denied");
CHECK(describe(harness.prompts) == "socket.gethostname:\nsocket.getaddrinfo: 127.0.0.1\n");
harness.prompts.clear();
harness.approve = [](const Prompt&) { return true; };
#ifndef _WIN32
// sendmsg() without an address names no target either.
python.run_as(PLUGIN, R"PY(
left, right = socket.socketpair()
left.sendmsg([b"x"])
left.close()
right.close()
)PY");
CHECK(describe(harness.prompts) == "socket.sendmsg:\n");
CHECK(harness.saved_permissions(PLUGIN).events ==
std::vector<std::string>{"Socket:socket.__new__", "Socket:socket.gethostname", "Socket:socket.sendmsg"});
#endif
}
TEST_CASE("An event grant does not stand in for another access or a target of the same name", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
harness.approve = [asked = 0](const Prompt&) mutable { return asked++ == 0; };
py::gil_scoped_acquire gil;
PythonSide python(harness);
python.exec(R"PY(
READ_FD = os.open(APPROVED, os.O_RDONLY)
WRITE_FD = os.open(os.path.join(OUTSIDE, "scratch.txt"), os.O_WRONLY | os.O_CREAT)
)PY");
// A file descriptor has no path to show or save: a granted read of one is not a write.
python.run_as(PLUGIN, R"PY(
fd_outcomes = [attempt(lambda: open(READ_FD, "r", closefd=False).close()),
attempt(lambda: open(WRITE_FD, "w", closefd=False).close())]
)PY");
CHECK(python.strings("fd_outcomes") == std::vector<std::string>{"ok", "denied"});
CHECK(describe(harness.prompts) == "open:\nopen:\n");
CHECK(harness.saved_permissions(PLUGIN).events == std::vector<std::string>{"FsRead:open"});
// A saved os.fork event grant is not an os.system command named "os.fork".
PluginPermissions permissions;
permissions.events = {"FsRead:open", "ProcessCreate:os.fork"};
harness.save_permissions(PLUGIN, permissions);
harness.prompts.clear();
python.run_as(PLUGIN, R"PY(
reread_outcome = attempt(lambda: open(READ_FD, "r", closefd=False).close())
system_outcome = attempt(lambda: os.system("os.fork"))
os.close(READ_FD)
os.close(WRITE_FD)
)PY");
CHECK(python.str("reread_outcome") == "ok");
CHECK(python.str("system_outcome") == "denied");
REQUIRE(harness.prompts.size() == 1);
CHECK(harness.prompts.front().event == "os.system");
CHECK(harness.saved_permissions(PLUGIN).process.empty());
}
TEST_CASE("A finalizer run when a root frame is let go is judged as the plugin without a prompt", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin(PLUGIN);
py::gil_scoped_acquire gil;
PythonSide python(harness);
PluginPermissions permissions = http_grants({python.str("URL_A")});
permissions.fs_read = {python.str("APPROVED")};
harness.save_permissions(PLUGIN, permissions);
PluginAuditManager& mgr = PluginAuditManager::instance();
// Once the request returns, only its held frame keeps the request object alive, and the
// request's finalizer reads a file the plugin was never granted.
python.run_as(PLUGIN, "fetch(TrackedRequest(URL_A))");
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"}); // at scope end
CHECK(describe(harness.prompts).empty());
CHECK_FALSE(mgr.audit_denial_pending());
python.exec("finalizer_outcomes.clear()");
python.run_as(PLUGIN, R"PY(
fetch(TrackedRequest(URL_A))
open(APPROVED).close() # the next event lets the returned request's frame go
outcomes_before_scope_end = list(finalizer_outcomes)
)PY");
CHECK(python.strings("outcomes_before_scope_end") == std::vector<std::string>{"denied"});
CHECK(describe(harness.prompts).empty());
// The finalizer's denial during another event's decision leaves that event's own denial intact.
python.exec("finalizer_outcomes.clear()");
python.run_as(PLUGIN, R"PY(
fetch(TrackedRequest(URL_A))
try:
os.listdir(OUTSIDE)
outer_error = ""
except PermissionError as error:
outer_error = str(error)
)PY");
CHECK(python.strings("finalizer_outcomes") == std::vector<std::string>{"denied"});
CHECK_THAT(python.str("outer_error"), Catch::Matchers::ContainsSubstring("os.listdir"));
CHECK(describe(harness.prompts) == "os.listdir: " + python.str("OUTSIDE") + "\n");
AuditViolation violation;
REQUIRE(mgr.last_violation(violation));
CHECK(violation.event_name == "os.listdir");
CHECK(mgr.audit_denial_pending());
}
TEST_CASE("A nested audit scope does not see the outer scope's running operations", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.add_plugin("PluginA");
harness.add_plugin("PluginB");
py::gil_scoped_acquire gil;
PythonSide python(harness);
PluginPermissions permissions;
permissions.fs_read = {python.str("TREE")};
harness.save_permissions("PluginA", permissions);
{
// Two walks plugin A started and suspended after listing the tree itself.
ScopedPluginAuditContext plugin_a("PluginA");
python.exec("first = os.walk(TREE)\nnext(first)\nsecond = os.walk(TREE)\nnext(second)");
{
// Plugin B resuming one of them is asked for each subdirectory, as plugin B.
ScopedPluginAuditContext plugin_b("PluginB");
python.exec("listed_by_b = len(list(first))");
}
// Back in plugin A's scope, its walks are covered again.
python.exec("listed_by_a = len(list(second))");
}
CHECK(python.globals["listed_by_b"].cast<int>() == 0);
CHECK(python.globals["listed_by_a"].cast<int>() == 2);
REQUIRE(harness.prompts.size() == 2);
for (const Prompt& prompt : harness.prompts) {
CHECK(prompt.plugin == "PluginB");
CHECK(prompt.event == "os.scandir");
}
}
TEST_CASE("Root frames held when a capability call ends are let go with the GIL, as the plugin", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
harness.approve = [](const Prompt& prompt) { return prompt.event == "os.walk"; };
py::gil_scoped_acquire gil;
PythonSide python(harness);
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
python.globals["orca"] = py::module_::import("orca");
// walk_and_drop() leaves a returned walk's frame held by the audit scope alone, with a marker
// in its locals; the frame is let go when the capability call ends, which runs the marker's
// finalizer.
python.exec(R"PY(
class Marker:
def __init__(self, action=None):
self.action = action
def __del__(self):
finalized.append(self.action() if self.action else "ran")
finalized = []
def walk_and_drop(marker):
walker = os.walk(TREE, onerror=marker)
next(walker)
del walker
class ConfigCapability(orca.PythonPluginBase):
def get_name(self):
return "config_cap"
def get_default_config(self):
walk_and_drop(Marker())
return {"finalized_during_call": len(finalized)}
def has_config_ui(self):
walker = os.walk(TREE, onerror=Marker(lambda: attempt(lambda: open(PRIVATE).close())))
next(walker)
attempt(lambda: os.listdir(OUTSIDE)) # denied: the trampoline clears the plugin key
del walker # the walk's frame is now held by the audit scope alone
raise RuntimeError("the capability fails after a denial")
class TicketAgent(orca.printer_agent.PrinterAgentBase):
def get_name(self):
return "ticket_agent"
def request_bind_ticket(self):
walk_and_drop(Marker())
return (0, "ticket")
)PY");
py::object config_instance = python.globals["ConfigCapability"]();
auto config_cap = config_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
config_cap->set_audit_plugin_key("GilPlugin");
config_cap->set_resolved_identity("config_cap", PluginCapabilityType::Script);
py::object agent_instance = python.globals["TicketAgent"]();
auto agent_cap = agent_instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
agent_cap->set_audit_plugin_key("GilPlugin");
agent_cap->set_resolved_identity("ticket_agent", PluginCapabilityType::PrinterConnection);
auto agent = std::dynamic_pointer_cast<IPrinterAgent>(agent_cap);
REQUIRE(agent);
// A host call arrives without the GIL; both calls take it before their audit scope.
nlohmann::json config;
int ticket_result = -1;
std::string ticket;
{
py::gil_scoped_release released;
config = config_cap->get_default_config();
ticket_result = agent->request_bind_ticket(&ticket);
}
CHECK(config == nlohmann::json{{"finalized_during_call", 0}});
CHECK(ticket_result == 0);
CHECK(ticket == "ticket");
CHECK(python.strings("finalized") == std::vector<std::string>{"ran", "ran"});
// On the trampoline's denial path the frame is let go as the plugin, though the ambient key
// was cleared: the finalizer's read is audited and denied without a prompt.
python.exec("finalized.clear()");
harness.prompts.clear();
CHECK_THROWS_AS(config_cap->has_config_ui(), py::error_already_set);
CHECK(python.strings("finalized") == std::vector<std::string>{"denied"});
CHECK(describe(harness.prompts) == "os.walk: " + python.str("TREE") + "\nos.listdir: " + python.str("OUTSIDE") + "\n");
}
TEST_CASE("Stdlib code is recognized through a symlinked Python home", "[audit][Python]")
{
AuditHarness harness;
if (!harness.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil;
const py::dict paths = py::module_::import("sysconfig").attr("get_paths")();
const fs::path stdlib = py::str(paths["stdlib"]).cast<std::string>();
const fs::path purelib = py::str(paths["purelib"]).cast<std::string>();
const fs::path link = harness.outside.path() / "linked-stdlib";
boost::system::error_code ec;
fs::create_directory_symlink(stdlib, link, ec);
if (ec)
SKIP("Cannot create a directory symlink here: " + ec.message());
CHECK(PluginAuditManager::is_stdlib_file((stdlib / "os.py").string()));
CHECK(PluginAuditManager::is_stdlib_file((link / "urllib" / "request.py").string()));
CHECK(PluginAuditManager::is_stdlib_file("<frozen os>"));
CHECK_FALSE(PluginAuditManager::is_stdlib_file((purelib / "requests" / "api.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file((link / "site-packages" / "requests" / "api.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file((harness.outside.path() / "plugin.py").string()));
CHECK_FALSE(PluginAuditManager::is_stdlib_file("<string>"));
}
+393 -3
View File
@@ -1,18 +1,30 @@
#include <catch2/catch_all.hpp> #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 <memory>
#include <slic3r/Utils/NetworkAgentFactory.hpp> #include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <catch2/catch_test_macros.hpp> #include <catch2/catch_test_macros.hpp>
#include <pybind11/pytypes.h> #include <pybind11/pytypes.h>
#include <catch2/catch_message.hpp> #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 "python_test_support.hpp"
#include <pybind11/embed.h> #include <pybind11/embed.h>
#include <pybind11/pybind11.h> #include <pybind11/pybind11.h>
#include <atomic>
#include <chrono>
#include <future>
#include <slic3r/Utils/bambu_networking.hpp>
#include <string> #include <string>
#include <thread>
#include <pybind11/cast.h> #include <pybind11/cast.h>
#include <utility>
namespace Slic3r { class ICloudServiceAgent; } namespace Slic3r { class ICloudServiceAgent; }
namespace Slic3r { class IPrinterAgent; } namespace Slic3r { class IPrinterAgent; }
@@ -20,12 +32,390 @@ namespace Slic3r { class IPrinterAgent; }
using namespace Slic3r; using namespace Slic3r;
namespace py = pybind11; 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. // UNIT - printer-agent registry duplicate handling.
// Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in // Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in
// or previously registered agent. // or previously registered agent.
// =========================================================================== // ===========================================================================
TEST_CASE("unit: printer-agent registry register / lookup / duplicate-reject", "[registry][unit]") TEST_CASE("printer-agent registry register / lookup / duplicate-reject", "[PrinterAgent]")
{ {
// why: the registry is process-global state shared by the test binary, and // 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 // Catch2 may run cases in any order. Use an id that cannot collide with
@@ -69,7 +459,7 @@ TEST_CASE("unit: printer-agent registry register / lookup / duplicate-reject", "
// fails to load the codecs needed for the filesystem encoding. The shared helper // 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. // points PyConfig.home at the python/ runtime staged next to the test executable.
TEST_CASE("integration: orca.printer_agent binding surface", "[integration][Python]") TEST_CASE("orca.printer_agent binding surface", "[integration][Python]")
{ {
py::module_ orca = import_orca_module(); py::module_ orca = import_orca_module();
@@ -120,7 +510,7 @@ TEST_CASE("integration: orca.printer_agent binding surface", "[integration][Pyth
// them in the lightweight embedded-interpreter test catches binding breakage // them in the lightweight embedded-interpreter test catches binding breakage
// before the plugin-loader test needs to run. // before the plugin-loader test needs to run.
// =========================================================================== // ===========================================================================
TEST_CASE("integration: orca plugin-registration API surface + discovery-context guards", "[integration][Python]") TEST_CASE("orca plugin-registration API surface + discovery-context guards", "[integration][Python]")
{ {
py::module_ orca = import_orca_module(); py::module_ orca = import_orca_module();
@@ -0,0 +1,134 @@
#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");
}