mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-20 22:18:30 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93939971e0 | ||
|
|
a93848b1e2 | ||
|
|
beae8aa516 |
No files matched your search
+3
-25
@@ -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] [-- CMAKE_ARGS...]"
|
h ) echo "Usage: ./build_release_macos.sh [-d]"
|
||||||
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,33 +60,13 @@ while getopts ":dpa:snt:xbc:i:j:Tuh" opt; do
|
|||||||
echo " -i: Add a prefix to ignore during CMake dependency discovery (repeatable), defaults to /opt/local:/usr/local:/opt/homebrew"
|
echo " -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
|
||||||
@@ -231,8 +211,7 @@ 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
|
||||||
)
|
)
|
||||||
@@ -335,8 +314,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|||||||
+16
-41
@@ -377,8 +377,7 @@ 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;
|
||||||
// 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) = 0;
|
||||||
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.
|
||||||
@@ -581,7 +580,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, bool keep_nil) override
|
virtual void set_only_diff(const ConfigOptionVectorBase* rhs, std::vector<int>& diff_index, int stride) 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.
|
||||||
@@ -593,7 +592,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 (keep_nil || !other->is_nil(diff_index[i] * stride))
|
if (!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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -842,12 +841,8 @@ public:
|
|||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
std::istringstream iss(str);
|
std::istringstream iss(str);
|
||||||
double value;
|
iss >> this->value;
|
||||||
iss >> value;
|
return !iss.fail();
|
||||||
if (iss.fail())
|
|
||||||
return false;
|
|
||||||
this->value = value;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool operator==(const ConfigOption &rhs) const override
|
bool operator==(const ConfigOption &rhs) const override
|
||||||
@@ -1038,12 +1033,8 @@ public:
|
|||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
std::istringstream iss(str);
|
std::istringstream iss(str);
|
||||||
int value;
|
iss >> this->value;
|
||||||
iss >> value;
|
return !iss.fail();
|
||||||
if (iss.fail())
|
|
||||||
return false;
|
|
||||||
this->value = value;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfigOptionInt& operator=(const ConfigOption *opt)
|
ConfigOptionInt& operator=(const ConfigOption *opt)
|
||||||
@@ -1261,12 +1252,8 @@ 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);
|
||||||
double value;
|
iss >> this->value;
|
||||||
iss >> value;
|
return !iss.fail();
|
||||||
if (iss.fail())
|
|
||||||
return false;
|
|
||||||
this->value = value;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -1378,14 +1365,10 @@ 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);
|
||||||
std::istringstream iss(str);
|
|
||||||
double value;
|
|
||||||
iss >> value;
|
|
||||||
if (iss.fail())
|
|
||||||
return false;
|
|
||||||
this->value = value;
|
|
||||||
this->percent = str.find_first_of("%") != std::string::npos;
|
this->percent = str.find_first_of("%") != std::string::npos;
|
||||||
return true;
|
std::istringstream iss(str);
|
||||||
|
iss >> this->value;
|
||||||
|
return !iss.fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -1555,12 +1538,8 @@ public:
|
|||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
char dummy;
|
char dummy;
|
||||||
Vec2d value;
|
return sscanf(str.data(), " %lf , %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", &this->value(0), &this->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:
|
||||||
@@ -1679,12 +1658,8 @@ public:
|
|||||||
{
|
{
|
||||||
UNUSED(append);
|
UNUSED(append);
|
||||||
char dummy;
|
char dummy;
|
||||||
Vec3d value;
|
return sscanf(str.data(), " %lf , %lf , %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", &this->value(0), &this->value(1), &this->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:
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
#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>
|
||||||
@@ -53,47 +55,78 @@ 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 = [¢ers](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) {
|
||||||
size_t best_i = pn, best_j = pn;
|
best_i = pn; best_j = pn;
|
||||||
double best_gain = 0;
|
best_gain = 0;
|
||||||
|
|
||||||
for (size_t i = 0; i < pn; ++i) {
|
// |ac| < |ab|: c lies within the length of the edge leaving a.
|
||||||
const Vec2d& pi = centers[path[i]].cast<double>();
|
for (size_t i = 0; i + 2 < pn; ++i)
|
||||||
const Vec2d& p_in = centers[path[(i + 1) % pn]].cast<double>();
|
visit_within(centers[path[i]].cast<double>(), elsq[i], [&](size_t c) { consider(i, pos[c]); });
|
||||||
double d_i = el[i];
|
// |bd| < |cd|: b lies within the length of the edge entering d.
|
||||||
double d_i_sq = elsq[i];
|
for (size_t j = 2; j < pn; ++j)
|
||||||
|
visit_within(centers[path[(j + 1) % pn]].cast<double>(), elsq[j], [&](size_t b) {
|
||||||
for (size_t j = i + 2; j < pn; ++j) {
|
if (pos[b] > 0)
|
||||||
size_t j_next = (j + 1) % pn;
|
consider(pos[b] - 1, 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 (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();
|
||||||
@@ -120,9 +153,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 for first crossing; returns {i, j} or {npos, npos} if none.
|
// Scan from edge `from` for the first pair of intersecting edges worth reversing; returns {i, j} or {npos, npos} if none.
|
||||||
auto find_crossing = [&]() -> std::pair<size_t, size_t> {
|
auto find_crossing = [&](size_t from) -> std::pair<size_t, size_t> {
|
||||||
for (size_t i = 0; i < n_edges; ++i) {
|
for (size_t i = from; 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]];
|
||||||
|
|
||||||
@@ -133,23 +166,40 @@ 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)) continue;
|
if (!bboxes_overlap(ai, bi, aj, bj) || !Geometry::segments_intersect(ai, bi, aj, bj))
|
||||||
if (Geometry::segments_intersect(ai, bi, aj, bj))
|
continue;
|
||||||
|
// 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: find first, reverse it, restart scan.
|
// Process crossings one at a time. Reversing path[ci+1..cj] replaces only edges ci and cj; the edges between
|
||||||
// Cap iterations to prevent infinite loops on collinear/overlapping segments.
|
// them are the same segments traversed backwards. The scan therefore resumes at edge ci rather than at edge 0,
|
||||||
int max_iters = static_cast<int>(pn * pn);
|
// so a pass over the tour costs one quadratic scan however many reversals it makes. Pairs before ci that
|
||||||
|
// 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;
|
||||||
while (max_iters-- > 0) {
|
for (size_t from = 0;;) {
|
||||||
auto [ci, cj] = find_crossing();
|
auto [ci, cj] = find_crossing(from);
|
||||||
if (ci == std::numeric_limits<size_t>::max()) break;
|
if (ci == std::numeric_limits<size_t>::max()) {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// 2-opt improvement: reverses segments that reduce total cycle path length. Each of up to max_passes passes
|
||||||
// Returns true if any improvement was made.
|
// (unlimited if max_passes <= 0) applies the move that shortens the cycle most. 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 any segment pair whose edges geometrically cross.
|
// Crossing removal: reverse each pair of intersecting edges whose reversal shortens the cycle by more
|
||||||
// Returns true if any crossing was removed.
|
// than SCALED_EPSILON. Returns true if any pair was reversed.
|
||||||
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.
|
||||||
|
|||||||
@@ -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()) || is_filament_extruder_override_key(option))
|
if (opt_dst->is_scalar() || !(opt_dst->nullable()))
|
||||||
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()) || is_filament_extruder_override_key(option))
|
if (opt_dst->is_scalar() || !(opt_dst->nullable()))
|
||||||
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,12 +2199,9 @@ 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 : preset.config.keys()) {
|
for (const std::string& opt_key : filament_extruder_override_keys) {
|
||||||
if (!is_filament_extruder_override_key(opt_key) || key_values.count(opt_key))
|
if (key_values.count(opt_key))
|
||||||
continue; // not an override, or already carried by the diff
|
continue; // 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;
|
||||||
|
|||||||
@@ -119,18 +119,12 @@ 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 and the ironing overrides are defined separately.
|
// while filament_retract_length_nc is defined separately. Keep the generator list
|
||||||
// Keep the generator list unchanged and use this helper for behavior checks that need
|
// unchanged and use this helper for behavior checks that need the full override set.
|
||||||
// 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)
|
||||||
@@ -12278,8 +12272,7 @@ void DynamicPrintConfig::update_diff_values_to_child_config(DynamicPrintConfig&
|
|||||||
opt_src->set(opt_target);
|
opt_src->set(opt_target);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
// 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);
|
||||||
opt_vec_src->set_only_diff(opt_vec_dest, variant_index, stride, is_filament_extruder_override_key(opt));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
#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"
|
||||||
|
|
||||||
@@ -171,16 +170,14 @@ 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();
|
||||||
// Validate only what the user typed; the no-key sentinel is applied afterwards (it contains
|
if (code.empty() && (!m_obj || m_obj->printer_agent_id != MOONRAKER_PRINTER_AGENT_ID))
|
||||||
// non-alphanumeric characters, so it must not go through this check).
|
code = "88888888";
|
||||||
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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2685,8 +2685,7 @@ 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();
|
||||||
// The stored access code may be the "no API key" sentinel; agents expect an empty credential.
|
std::string password = get_access_code();
|
||||||
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();
|
||||||
|
|||||||
@@ -4354,11 +4354,6 @@ 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);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
#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"
|
||||||
@@ -1904,7 +1903,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 = NO_API_KEY_SENTINEL;
|
str_access_code = "88888888";
|
||||||
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.
|
||||||
@@ -1951,7 +1950,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 = NO_API_KEY_SENTINEL;
|
str_access_code = "88888888";
|
||||||
|
|
||||||
// check support function
|
// check support function
|
||||||
if (!m_obj) return;
|
if (!m_obj) return;
|
||||||
|
|||||||
@@ -211,9 +211,8 @@ 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;
|
||||||
@@ -223,9 +222,8 @@ 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 = -1.0 * input_val;
|
value = -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;
|
||||||
@@ -246,11 +244,10 @@ int BBLPrinterAgent::command_axis_control(std::string dev_id, std::string axis,
|
|||||||
int BBLPrinterAgent::publish(const std::string& dev_id, const nlohmann::json& j, bool lan_mode)
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,21 +593,8 @@ int BBLPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateS
|
|||||||
|
|
||||||
int BBLPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
|
int BBLPrinterAgent::start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
|
||||||
{
|
{
|
||||||
// dispatch_start() moves out of `params`, so snapshot the diagnostic fields first;
|
return dispatch_start<func_start_send_gcode_to_sdcard_legacy, func_start_send_gcode_to_sdcard_0203>(
|
||||||
// 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)
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -288,27 +288,22 @@ 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 (sync_mode != get_filament_sync_mode())
|
if (device_info.dev_ip.empty()) {
|
||||||
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), sync_mode);
|
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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://" + info.dev_ip));
|
cfg.set_key_value("print_host", new ConfigOptionString("http://" + device_info.dev_ip));
|
||||||
cfg.set_key_value("print_host_webui", new ConfigOptionString(""));
|
cfg.set_key_value("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(info.api_key));
|
cfg.set_key_value("printhost_apikey", new ConfigOptionString(device_info.api_key));
|
||||||
cfg.set_key_value("printhost_ssl_ignore_revoke", new ConfigOptionBool(false));
|
cfg.set_key_value("printhost_ssl_ignore_revoke", new ConfigOptionBool(false));
|
||||||
|
|
||||||
CrealityPrint host(&cfg);
|
CrealityPrint host(&cfg);
|
||||||
@@ -318,7 +313,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), sync_mode);
|
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info)
|
BOOST_LOG_TRIVIAL(info)
|
||||||
@@ -333,7 +328,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), sync_mode);
|
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (box_count == 0) {
|
if (box_count == 0) {
|
||||||
@@ -342,7 +337,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), sync_mode);
|
return MoonrakerPrinterAgent::fetch_filament_info(std::move(dev_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOST_LOG_TRIVIAL(info)
|
BOOST_LOG_TRIVIAL(info)
|
||||||
@@ -387,7 +382,7 @@ bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
build_ams_payload(box_count, max_slots - 1, trays, sync_mode == FilamentSyncMode::pull);
|
build_ams_payload(box_count, max_slots - 1, trays);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,13 +37,12 @@ public:
|
|||||||
};
|
};
|
||||||
|
|
||||||
explicit CrealityPrintAgent(std::string log_dir);
|
explicit CrealityPrintAgent(std::string log_dir);
|
||||||
~CrealityPrintAgent() override { shutdown(); }
|
~CrealityPrintAgent() override = default;
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -45,17 +45,6 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -466,16 +455,9 @@ 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.
|
* Refresh filament info from the printer synchronously.
|
||||||
*
|
* Should only be called when get_filament_sync_mode() returns FilamentSyncMode::pull.
|
||||||
* When called with FilamentSyncMode::pull (which requires get_filament_sync_mode() to
|
* Populates the MachineObject's DevFilaSystem with fetched filament data.
|
||||||
* 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
@@ -13,8 +13,6 @@
|
|||||||
#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>
|
||||||
@@ -23,56 +21,6 @@ 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:
|
||||||
@@ -118,26 +66,12 @@ 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; }
|
||||||
CameraStreamMode get_camera_stream_mode() const override;
|
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) 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;
|
||||||
@@ -149,9 +83,7 @@ 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()
|
||||||
@@ -175,47 +107,18 @@ protected:
|
|||||||
int nozzle_temp = 0; // Optional
|
int nozzle_temp = 0; // Optional
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build ams JSON and call parser.
|
// Build ams JSON and call parser
|
||||||
// apply_inline: when true the MachineObject mutation runs on the calling thread, which
|
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays);
|
||||||
// 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 PrinterConnectionParams& params);
|
virtual bool init_device_info(const std::string& dev_id, const std::string& dev_ip, const std::string& username, const std::string& password, bool use_ssl, const std::string& port);
|
||||||
virtual bool fetch_device_info(const ConnectionSettings& connection, MoonrakerDeviceInfo& info, std::string& error) const;
|
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
|
||||||
ConnectionSettings get_connection_settings() const;
|
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
|
||||||
// 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);
|
||||||
@@ -228,30 +131,13 @@ 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 ConnectionSettings& connection, std::set<std::string>& objects, std::string& error) const;
|
bool fetch_object_list(const std::string& base_url, const std::string& api_key, std::set<std::string>& objects, std::string& error) const;
|
||||||
bool query_printer_status(const ConnectionSettings& connection, nlohmann::json& status, std::string& error) const;
|
bool query_printer_status(const std::string& base_url, const std::string& api_key, nlohmann::json& status, std::string& error) const;
|
||||||
bool send_gcode_sync(const std::string& dev_id, const std::string& gcode) const;
|
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;
|
||||||
@@ -259,46 +145,38 @@ 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, ConnectionSettings connection, StatusStreamTimings timings);
|
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key, StatusStreamTimings timings);
|
||||||
void handle_ws_message(std::string dev_id, std::string payload, ConnectionSettings connection);
|
void handle_ws_message(const std::string& dev_id, const std::string& payload);
|
||||||
void send_heartbeat_if_due(const std::string& dev_id, std::chrono::milliseconds interval);
|
void 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 ConnectionSettings& connection,
|
const std::string& base_url, const std::string& api_key,
|
||||||
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,
|
||||||
ConnectionSettings connection,
|
const std::string& base_url,
|
||||||
|
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(const ConnectionSettings& connection, std::vector<AmsTrayData>& trays, int& max_lane_index);
|
bool fetch_hh_filament_info(std::vector<AmsTrayData>& trays, int& max_lane_index);
|
||||||
bool fetch_moonraker_filament_data(const ConnectionSettings& connection, std::vector<AmsTrayData>& trays, int& max_lane_index);
|
bool fetch_moonraker_filament_data(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);
|
||||||
@@ -324,37 +202,16 @@ 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
|
||||||
// Guards ws_thread and ws_abort. ws_abort posts to the stream's io_context, cancelling the
|
std::function<void()> ws_abort; // Interrupts whatever the stream thread is waiting on, from any thread
|
||||||
// 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
|
||||||
@@ -365,13 +222,7 @@ 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
|
||||||
|
|||||||
@@ -2,25 +2,18 @@
|
|||||||
#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>
|
||||||
@@ -41,40 +34,6 @@ 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";
|
||||||
@@ -88,179 +47,51 @@ AgentInfo QidiPrinterAgent::get_agent_info_static()
|
|||||||
return AgentInfo{"qidi", "Qidi", QidiPrinterAgent_VERSION, "Qidi printer agent"};
|
return AgentInfo{"qidi", "Qidi", QidiPrinterAgent_VERSION, "Qidi printer agent"};
|
||||||
}
|
}
|
||||||
|
|
||||||
FilamentSyncMode QidiPrinterAgent::get_filament_sync_mode() const
|
bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode /*sync_mode*/)
|
||||||
{
|
{
|
||||||
if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
|
std::string error;
|
||||||
return FilamentSyncMode::subscription;
|
|
||||||
return FilamentSyncMode::pull;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
|
// 1. Fetch device info and infer series_id
|
||||||
{
|
std::string series_id;
|
||||||
if (sync_mode != get_filament_sync_mode())
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Snapshot what the fetch needs; a reconnect can rewrite device_info meanwhile.
|
|
||||||
ConnectionSettings connection = get_connection_settings();
|
|
||||||
std::string model_id;
|
|
||||||
std::string model_name;
|
|
||||||
{
|
{
|
||||||
std::lock_guard<std::recursive_mutex> lock(connect_mutex);
|
MoonrakerDeviceInfo info;
|
||||||
model_id = device_info.model_id;
|
if (fetch_device_info(device_info.base_url, device_info.api_key, info, error)) {
|
||||||
model_name = device_info.model_name;
|
series_id = infer_series_id(info.model_id, info.dev_name);
|
||||||
}
|
|
||||||
|
|
||||||
// One implementation for both modes; only where it runs differs. pull is the documented
|
|
||||||
// blocking contract (the GUI thread reads DevFilaSystem right after the call), so it runs
|
|
||||||
// on the caller with the payload applied inline. subscription runs on a background thread
|
|
||||||
// so the status loop is not stalled, and the payload is marshalled back to the main thread.
|
|
||||||
auto work = [this, connection = std::move(connection), model_id, model_name](bool apply_inline) -> bool {
|
|
||||||
try {
|
|
||||||
std::string error;
|
|
||||||
|
|
||||||
// 1. Fetch device info and infer series_id
|
|
||||||
std::string series_id;
|
|
||||||
{
|
|
||||||
MoonrakerDeviceInfo info;
|
|
||||||
if (fetch_device_info(connection, info, error)) {
|
|
||||||
series_id = infer_series_id(info.model_id, info.dev_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (series_id.empty()) {
|
|
||||||
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
|
|
||||||
series_id = infer_series_id(model_id, model_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fetch filament dictionary
|
|
||||||
QidiFilamentDict dict;
|
|
||||||
if (!fetch_filament_dict(connection, dict, error)) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch filament dict: " << error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fetch slot info and build AmsTrayData directly
|
|
||||||
std::vector<AmsTrayData> trays;
|
|
||||||
int box_count = 0;
|
|
||||||
if (!fetch_slot_info(connection, dict, series_id, trays, box_count, error)) {
|
|
||||||
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Build the AMS payload, resolving preset-dependent ids on the main thread.
|
|
||||||
auto resolve_ids = [](std::vector<AmsTrayData>& resolved_trays) {
|
|
||||||
auto* bundle = GUI::wxGetApp().preset_bundle;
|
|
||||||
if (!bundle) {
|
|
||||||
return; // keep the Qidi-specific ids computed from device data
|
|
||||||
}
|
|
||||||
for (auto& tray : resolved_trays) {
|
|
||||||
if (!tray.has_filament) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!tray.tray_info_idx.empty() && has_visible_base_preset(bundle->filaments, tray.tray_info_idx)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
build_ams_payload(box_count, box_count * 4 - 1, trays, apply_inline, resolve_ids);
|
|
||||||
return true;
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
// why: an exception escaping a detached thread is std::terminate, and firmware
|
|
||||||
// JSON is untrusted; mirror run_command_worker and swallow it here.
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::fetch_filament_info: unhandled exception: " << e.what();
|
|
||||||
return false;
|
|
||||||
} catch (...) {
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::fetch_filament_info: unhandled exception";
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
if (series_id.empty()) {
|
||||||
if (sync_mode == FilamentSyncMode::pull) {
|
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
|
||||||
return work(/*apply_inline=*/true);
|
series_id = infer_series_id(device_info.model_id, device_info.model_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscription: fire-and-forget. A `true` return means the refresh was scheduled (or is
|
// 2. Fetch filament dictionary
|
||||||
// already running), not that DevFilaSystem has been updated. Reserve under the same mutex
|
QidiFilamentDict dict;
|
||||||
// shutdown() uses, so the flag and the count can't race.
|
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;
|
||||||
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};
|
// 3. Fetch slot info and build AmsTrayData directly
|
||||||
std::thread([work = std::move(work), guard = std::move(guard)]() mutable {
|
std::vector<AmsTrayData> trays;
|
||||||
work(/*apply_inline=*/false);
|
int box_count = 0;
|
||||||
}).detach();
|
if (!fetch_slot_info(device_info.base_url, device_info.api_key, dict, series_id, trays, box_count, error)) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build the AMS payload
|
||||||
|
build_ams_payload(box_count, box_count * 4 - 1, trays);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool QidiPrinterAgent::apply_box_mapping(const PrintParams& params) const
|
bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
|
||||||
{
|
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(connection.base_url, "/printer/objects/query?save_variables=variables");
|
std::string url = join_url(base_url, "/printer/objects/query?save_variables=variables");
|
||||||
for (int i = 0; i < 16; ++i) {
|
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";
|
||||||
}
|
}
|
||||||
@@ -270,9 +101,8 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
|
|||||||
std::string http_error;
|
std::string http_error;
|
||||||
|
|
||||||
auto http = Http::get(url);
|
auto http = Http::get(url);
|
||||||
configure_http(http, connection);
|
if (!api_key.empty()) {
|
||||||
if (!connection.api_key.empty()) {
|
http.header("X-Api-Key", api_key);
|
||||||
http.header("X-Api-Key", connection.api_key);
|
|
||||||
}
|
}
|
||||||
http.timeout_connect(5)
|
http.timeout_connect(5)
|
||||||
.timeout_max(10)
|
.timeout_max(10)
|
||||||
@@ -297,12 +127,22 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
nlohmann::json status;
|
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
|
||||||
nlohmann::json variables;
|
if (json.is_discarded()) {
|
||||||
if (!parse_slot_response(response_body, status, variables, error))
|
error = "Invalid JSON response";
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
box_count = read_int_or(variables, "box_count", 1);
|
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("save_variables") ||
|
||||||
|
!json["result"]["status"]["save_variables"].contains("variables")) {
|
||||||
|
error = "Unexpected JSON structure";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto& variables = json["result"]["status"]["save_variables"]["variables"];
|
||||||
|
auto& status = json["result"]["status"];
|
||||||
|
|
||||||
|
box_count = variables.value("box_count", 1);
|
||||||
if (box_count < 0) {
|
if (box_count < 0) {
|
||||||
box_count = 0;
|
box_count = 0;
|
||||||
}
|
}
|
||||||
@@ -325,9 +165,9 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
|
|||||||
tray.slot_index = i;
|
tray.slot_index = i;
|
||||||
|
|
||||||
// Read slot variables
|
// Read slot variables
|
||||||
const int color_index = read_int_or(variables, "color_slot" + std::to_string(i), 1);
|
const int color_index = variables.value("color_slot" + std::to_string(i), 1);
|
||||||
const int filament_type = read_int_or(variables, "filament_slot" + std::to_string(i), 1);
|
const int filament_type = variables.value("filament_slot" + std::to_string(i), 1);
|
||||||
const int vendor_type = read_int_or(variables, "vendor_slot" + std::to_string(i), 0);
|
const int vendor_type = variables.value("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);
|
||||||
@@ -335,7 +175,7 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
|
|||||||
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()) {
|
||||||
const int runout_button = read_int_or(box_stepper, "runout_button", 0);
|
int runout_button = box_stepper["runout_button"].template get<int>();
|
||||||
tray.has_filament = (runout_button == 0);
|
tray.has_filament = (runout_button == 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,9 +189,16 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
|
|||||||
}
|
}
|
||||||
tray.tray_type = normalize_filament_type(filament_name);
|
tray.tray_type = normalize_filament_type(filament_name);
|
||||||
|
|
||||||
// Qidi-specific setting id derived from device data only; the preset-dependent
|
// Try Qidi-specific setting ID first; fall back to visible preset by type
|
||||||
// fallback runs on the main thread in the resolve hook passed to build_ams_payload.
|
std::string setting_id = build_setting_id(filament_type, vendor_type, tray.tray_type);
|
||||||
tray.tray_info_idx = build_setting_id(filament_type, vendor_type, tray.tray_type);
|
auto* bundle = GUI::wxGetApp().preset_bundle;
|
||||||
|
if (!bundle) {
|
||||||
|
tray.tray_info_idx = setting_id;
|
||||||
|
} else if (!setting_id.empty() && has_visible_base_preset(bundle->filaments, setting_id)) {
|
||||||
|
tray.tray_info_idx = setting_id;
|
||||||
|
} else {
|
||||||
|
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
|
||||||
|
}
|
||||||
|
|
||||||
// Look up color from dictionary
|
// Look up color from dictionary
|
||||||
auto color_it = dict.colors.find(color_index);
|
auto color_it = dict.colors.find(color_index);
|
||||||
@@ -368,45 +215,20 @@ bool QidiPrinterAgent::fetch_slot_info(const ConnectionSettings& connection,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool QidiPrinterAgent::parse_slot_response(const std::string& response_body,
|
bool QidiPrinterAgent::fetch_filament_dict(const std::string& base_url,
|
||||||
nlohmann::json& status,
|
const std::string& api_key,
|
||||||
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(connection.base_url, "/server/files/config/officiall_filas_list.cfg");
|
std::string url = join_url(base_url, "/server/files/config/officiall_filas_list.cfg");
|
||||||
|
|
||||||
std::string response_body;
|
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);
|
||||||
configure_http(http, connection);
|
if (!api_key.empty()) {
|
||||||
if (!connection.api_key.empty()) {
|
http.header("X-Api-Key", api_key);
|
||||||
http.header("X-Api-Key", connection.api_key);
|
|
||||||
}
|
}
|
||||||
http.timeout_connect(5)
|
http.timeout_connect(5)
|
||||||
.timeout_max(10)
|
.timeout_max(10)
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
|
|
||||||
#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>
|
||||||
@@ -16,7 +14,7 @@ class QidiPrinterAgent final : public MoonrakerPrinterAgent
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit QidiPrinterAgent(std::string log_dir);
|
explicit QidiPrinterAgent(std::string log_dir);
|
||||||
~QidiPrinterAgent() override { shutdown(); }
|
~QidiPrinterAgent() override = default;
|
||||||
|
|
||||||
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(); }
|
||||||
@@ -24,22 +22,7 @@ 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;
|
||||||
@@ -47,13 +30,14 @@ private:
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Qidi-specific methods
|
// Qidi-specific methods
|
||||||
bool fetch_slot_info(const ConnectionSettings& connection,
|
bool fetch_slot_info(const std::string& base_url,
|
||||||
|
const std::string& api_key,
|
||||||
const QidiFilamentDict& dict,
|
const 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 ConnectionSettings& connection, QidiFilamentDict& dict, std::string& error) const;
|
bool fetch_filament_dict(const std::string& base_url, const std::string& api_key, QidiFilamentDict& dict, std::string& error) const;
|
||||||
std::string normalize_filament_type(const std::string& filament_type);
|
std::string 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);
|
||||||
|
|||||||
@@ -2,20 +2,11 @@
|
|||||||
#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"
|
||||||
@@ -27,91 +18,6 @@ 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>
|
||||||
@@ -134,16 +40,14 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection&
|
|||||||
filaments.get_preset_base(p) == &p && p.config.opt_string("filament_vendor", 0u) == vendor_name &&
|
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. Firmware
|
// so we must remove # and ignore alpha channel for distance calculation
|
||||||
// colours are untrusted; parse_hex_color tolerates empty/non-hex instead of throwing.
|
unsigned int target_color_value = std::stoul(color_rgba.substr(0, color_rgba.length() - 2), nullptr, 16);
|
||||||
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 = 0;
|
unsigned int p_color_value;
|
||||||
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 = parse_hex_color(p_color.substr(hash_pos != std::string::npos ? hash_pos + 1 : 0));
|
p_color_value = std::stoul(p_color.substr(hash_pos != std::string::npos ? hash_pos + 1 : 0), nullptr, 16);
|
||||||
} 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
|
||||||
@@ -170,31 +74,6 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection&
|
|||||||
|
|
||||||
SnapmakerPrinterAgent::SnapmakerPrinterAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
|
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"};
|
||||||
@@ -230,192 +109,127 @@ 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*/)
|
||||||
{
|
{
|
||||||
(void) dev_id;
|
std::string url = join_url(device_info.base_url, "/printer/objects/query?print_task_config&filament_detect");
|
||||||
if (sync_mode != get_filament_sync_mode())
|
|
||||||
|
std::string response_body;
|
||||||
|
bool success = false;
|
||||||
|
std::string http_error;
|
||||||
|
|
||||||
|
auto http = Http::get(url);
|
||||||
|
if (!device_info.api_key.empty()) {
|
||||||
|
http.header("X-Api-Key", device_info.api_key);
|
||||||
|
}
|
||||||
|
http.timeout_connect(5)
|
||||||
|
.timeout_max(10)
|
||||||
|
.on_complete([&](std::string body, unsigned status) {
|
||||||
|
if (status == 200) {
|
||||||
|
response_body = body;
|
||||||
|
success = true;
|
||||||
|
} else {
|
||||||
|
http_error = "HTTP error: " + std::to_string(status);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_error([&](std::string body, std::string err, unsigned status) {
|
||||||
|
http_error = err;
|
||||||
|
if (status > 0) {
|
||||||
|
http_error += " (HTTP " + std::to_string(status) + ")";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.perform_sync();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error;
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot everything the fetch needs (URL, api key, TLS/CA): a reconnect can rewrite
|
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
|
||||||
// device_info meanwhile.
|
if (json.is_discarded()) {
|
||||||
const ConnectionSettings connection = get_connection_settings();
|
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// One implementation for both modes; only where it runs differs. pull is the documented
|
// Navigate to result.status.print_task_config
|
||||||
// blocking contract (the GUI thread reads DevFilaSystem right after the call), so it runs
|
if (!json.contains("result") || !json["result"].contains("status") ||
|
||||||
// on the caller with the payload applied inline. subscription runs on a background thread
|
!json["result"]["status"].contains("print_task_config")) {
|
||||||
// so the status loop is not stalled, and the payload is marshalled back to the main thread.
|
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response";
|
||||||
auto work = [this, connection](bool apply_inline) -> bool {
|
return false;
|
||||||
try {
|
}
|
||||||
const std::string url = join_url(connection.base_url, "/printer/objects/query?print_task_config&filament_detect");
|
|
||||||
|
|
||||||
std::string response_body;
|
auto& ptc = json["result"]["status"]["print_task_config"];
|
||||||
bool success = false;
|
|
||||||
std::string http_error;
|
|
||||||
|
|
||||||
auto http = Http::get(url);
|
// Read parallel arrays from print_task_config
|
||||||
configure_http(http, connection);
|
auto filament_exist = ptc.value("filament_exist", std::vector<bool>{});
|
||||||
if (!connection.api_key.empty()) {
|
auto filament_type = ptc.value("filament_type", std::vector<std::string>{});
|
||||||
http.header("X-Api-Key", connection.api_key);
|
auto filament_sub_type = ptc.value("filament_sub_type", std::vector<std::string>{});
|
||||||
}
|
auto filament_color = ptc.value("filament_color_rgba", std::vector<std::string>{});
|
||||||
http.timeout_connect(5)
|
auto filament_vendor = ptc.value("filament_vendor", std::vector<std::string>{});
|
||||||
.timeout_max(10)
|
|
||||||
.on_complete([&](std::string body, unsigned status) {
|
|
||||||
if (status == 200) {
|
|
||||||
response_body = body;
|
|
||||||
success = true;
|
|
||||||
} else {
|
|
||||||
http_error = "HTTP error: " + std::to_string(status);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on_error([&](std::string body, std::string err, unsigned status) {
|
|
||||||
http_error = err;
|
|
||||||
if (status > 0) {
|
|
||||||
http_error += " (HTTP " + std::to_string(status) + ")";
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.perform_sync();
|
|
||||||
|
|
||||||
if (!success) {
|
const int slot_count = static_cast<int>(filament_exist.size());
|
||||||
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: HTTP request failed: " << http_error;
|
if (slot_count == 0) {
|
||||||
return false;
|
BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported";
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
|
// Read NFC filament_detect data for temperature info (optional)
|
||||||
if (json.is_discarded()) {
|
nlohmann::json nfc_info;
|
||||||
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Invalid JSON response";
|
if (json["result"]["status"].contains("filament_detect") &&
|
||||||
return false;
|
json["result"]["status"]["filament_detect"].contains("info")) {
|
||||||
}
|
nfc_info = json["result"]["status"]["filament_detect"]["info"];
|
||||||
|
}
|
||||||
|
|
||||||
// Navigate to result.status.print_task_config
|
static const std::string empty_str;
|
||||||
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("print_task_config")) {
|
static const std::string default_color = "FFFFFFFF";
|
||||||
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent::fetch_filament_info: Missing print_task_config in response";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto& ptc = json["result"]["status"]["print_task_config"];
|
std::vector<AmsTrayData> trays;
|
||||||
|
trays.reserve(slot_count);
|
||||||
|
|
||||||
// Read parallel arrays from print_task_config
|
for (int i = 0; i < slot_count; ++i) {
|
||||||
auto filament_exist = read_bool_array_or(ptc, "filament_exist");
|
AmsTrayData tray;
|
||||||
auto filament_type = read_string_array_or(ptc, "filament_type");
|
tray.slot_index = i;
|
||||||
auto filament_sub_type = read_string_array_or(ptc, "filament_sub_type");
|
tray.has_filament = filament_exist[i];
|
||||||
auto filament_color = read_string_array_or(ptc, "filament_color_rgba");
|
|
||||||
auto filament_vendor = read_string_array_or(ptc, "filament_vendor");
|
|
||||||
|
|
||||||
const int slot_count = static_cast<int>(filament_exist.size());
|
if (tray.has_filament) {
|
||||||
if (slot_count == 0) {
|
tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str),
|
||||||
BOOST_LOG_TRIVIAL(info) << "SnapmakerPrinterAgent::fetch_filament_info: No filament slots reported";
|
safe_at(filament_sub_type, i, empty_str));
|
||||||
return false;
|
tray.tray_color = safe_at(filament_color, i, default_color);
|
||||||
}
|
|
||||||
|
|
||||||
// Read NFC filament_detect data for temperature info (optional)
|
auto* bundle = GUI::wxGetApp().preset_bundle;
|
||||||
nlohmann::json nfc_info;
|
// Try to find a matching preset for this filament based on vendor, type and color.
|
||||||
if (json["result"]["status"].contains("filament_detect") && json["result"]["status"]["filament_detect"].contains("info")) {
|
// If not found, default to traditional search by type only or generic type mapping.
|
||||||
nfc_info = json["result"]["status"]["filament_detect"]["info"];
|
if (bundle) {
|
||||||
}
|
std::string vendor = safe_at(filament_vendor, i, empty_str);
|
||||||
|
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type,
|
||||||
|
tray.tray_color);
|
||||||
|
|
||||||
static const std::string empty_str;
|
if (!filament_id.empty()) {
|
||||||
static const std::string default_color = "FFFFFFFF";
|
tray.tray_info_idx = filament_id;
|
||||||
|
BOOST_LOG_TRIVIAL(warning) << "Filament sync: Found manufacturer-specific profile for slot " << i << ": "
|
||||||
// Per-tray vendor names, kept so the preset lookup can run on the main thread.
|
<< filament_id;
|
||||||
std::vector<std::string> vendors(slot_count);
|
} else {
|
||||||
|
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
|
||||||
std::vector<AmsTrayData> trays;
|
|
||||||
trays.reserve(slot_count);
|
|
||||||
|
|
||||||
for (int i = 0; i < slot_count; ++i) {
|
|
||||||
AmsTrayData tray;
|
|
||||||
tray.slot_index = i;
|
|
||||||
tray.has_filament = filament_exist[i];
|
|
||||||
|
|
||||||
if (tray.has_filament) {
|
|
||||||
tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str), safe_at(filament_sub_type, i, empty_str));
|
|
||||||
tray.tray_color = safe_at(filament_color, i, default_color);
|
|
||||||
vendors[i] = safe_at(filament_vendor, i, empty_str);
|
|
||||||
|
|
||||||
// Generic id derived from device data only; the preset-dependent lookup runs
|
|
||||||
// on the main thread in the resolve hook passed to build_ams_payload.
|
|
||||||
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
|
|
||||||
|
|
||||||
// Extract NFC temperature data if available
|
|
||||||
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
|
|
||||||
auto& nfc_slot = nfc_info[i];
|
|
||||||
std::string vendor = "NONE";
|
|
||||||
if (auto vendor_it = nfc_slot.find("VENDOR"); vendor_it != nfc_slot.end() && vendor_it->is_string()) {
|
|
||||||
vendor = vendor_it->get<std::string>();
|
|
||||||
}
|
|
||||||
if (vendor != "NONE" && !vendor.empty()) {
|
|
||||||
tray.bed_temp = read_int_or(nfc_slot, "BED_TEMP", 0);
|
|
||||||
tray.nozzle_temp = read_int_or(nfc_slot, "FIRST_LAYER_TEMP", 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
trays.emplace_back(std::move(tray));
|
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve against GUI-owned presets on the main thread (inside build_ams_payload).
|
// Extract NFC temperature data if available
|
||||||
auto resolve_ids = [vendors](std::vector<AmsTrayData>& resolved_trays) {
|
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
|
||||||
auto* bundle = GUI::wxGetApp().preset_bundle;
|
auto& nfc_slot = nfc_info[i];
|
||||||
if (!bundle) {
|
std::string vendor = nfc_slot.value("VENDOR", "NONE");
|
||||||
return; // keep the generic ids computed from device data
|
if (vendor != "NONE" && !vendor.empty()) {
|
||||||
|
tray.bed_temp = nfc_slot.value("BED_TEMP", 0);
|
||||||
|
tray.nozzle_temp = nfc_slot.value("FIRST_LAYER_TEMP", 0);
|
||||||
}
|
}
|
||||||
for (size_t i = 0; i < resolved_trays.size() && i < vendors.size(); ++i) {
|
}
|
||||||
AmsTrayData& tray = resolved_trays[i];
|
|
||||||
if (!tray.has_filament) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Prefer a manufacturer/colour-specific preset, else fall back to type.
|
|
||||||
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendors[i], tray.tray_type,
|
|
||||||
tray.tray_color);
|
|
||||||
tray.tray_info_idx = filament_id.empty() ? bundle->filaments.filament_id_by_type(tray.tray_type) : filament_id;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
build_ams_payload(1, slot_count - 1, trays, apply_inline, resolve_ids);
|
|
||||||
return true;
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
// why: an exception escaping a detached thread is std::terminate, and firmware
|
|
||||||
// JSON is untrusted; mirror run_command_worker and swallow it here.
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "SnapmakerPrinterAgent::fetch_filament_info: unhandled exception: " << e.what();
|
|
||||||
return false;
|
|
||||||
} catch (...) {
|
|
||||||
BOOST_LOG_TRIVIAL(error) << "SnapmakerPrinterAgent::fetch_filament_info: unhandled exception";
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (sync_mode == FilamentSyncMode::pull) {
|
trays.emplace_back(std::move(tray));
|
||||||
return work(/*apply_inline=*/true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscription: fire-and-forget. A `true` return means the refresh was scheduled (or is
|
build_ams_payload(1, slot_count - 1, trays);
|
||||||
// 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
|
||||||
@@ -3,8 +3,6 @@
|
|||||||
#include "IPrinterAgent.hpp"
|
#include "IPrinterAgent.hpp"
|
||||||
#include "MoonrakerPrinterAgent.hpp"
|
#include "MoonrakerPrinterAgent.hpp"
|
||||||
|
|
||||||
#include <atomic>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
@@ -13,25 +11,16 @@ class SnapmakerPrinterAgent final : public MoonrakerPrinterAgent
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit SnapmakerPrinterAgent(std::string log_dir);
|
explicit SnapmakerPrinterAgent(std::string log_dir);
|
||||||
~SnapmakerPrinterAgent() override { shutdown(); }
|
~SnapmakerPrinterAgent() override = default;
|
||||||
|
|
||||||
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
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
#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>
|
||||||
@@ -131,20 +130,6 @@ 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.
|
||||||
@@ -161,24 +146,6 @@ 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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -248,50 +215,26 @@ 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_plugin_key(plugin_key)
|
: m_previous_id(PluginAuditManager::instance().current_plugin())
|
||||||
, 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& mgr = PluginAuditManager::instance();
|
PluginAuditManager::instance().set_current_plugin(m_previous_id);
|
||||||
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -632,6 +575,34 @@ 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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -692,88 +663,40 @@ std::string python_str(PyObject* object)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The interpreter's stdlib folders, and the site-packages folders inside them that hold
|
std::vector<std::string> call_site_identities(const std::string& plugin_root)
|
||||||
// third-party code, both resolved. Set by install_hook(); like the verdict cache below, only
|
|
||||||
// touched while the GIL is held.
|
|
||||||
static std::vector<boost::filesystem::path> s_stdlib_dirs;
|
|
||||||
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();
|
std::vector<std::string> ids;
|
||||||
s_site_package_dirs.clear();
|
if (plugin_root.empty())
|
||||||
s_stdlib_file_verdicts.clear();
|
return ids;
|
||||||
|
|
||||||
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) {
|
||||||
frames.push_back(reinterpret_cast<PyObject*>(frame));
|
PyCodeObject* code = PyFrame_GetCode(frame); // new reference
|
||||||
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Moves the held root frames whose operation has returned from `held` to `dropped`. A suspended
|
Py_XDECREF(frame); // only holds a reference here if the loop exited via break
|
||||||
// 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;
|
|
||||||
|
|
||||||
const std::vector<PyObject*> stack = stack_frames();
|
return ids;
|
||||||
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{
|
||||||
@@ -794,15 +717,12 @@ 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}},
|
||||||
@@ -847,11 +767,7 @@ 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) {
|
||||||
// None is an argument left out (sendmsg without an address), not a target named "None".
|
std::string value = python_str(tuple_item(args, index));
|
||||||
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;
|
||||||
@@ -862,14 +778,8 @@ std::vector<std::string> audit_targets(const std::string& event_name, AuditEvent
|
|||||||
return targets;
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The saved list that grants an event: its category's target list, or the event-grant list for
|
std::vector<std::string>* permission_list_for(AuditEventCategory category, PluginPermissions& permissions)
|
||||||
// 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;
|
||||||
@@ -882,13 +792,6 @@ std::vector<std::string>* permission_list_for(AuditEventCategory category, bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -999,25 +902,22 @@ 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, bool by_event, const std::vector<std::string>& keys)
|
void persist_grant(const std::string& plugin_key, AuditEventCategory category, const std::vector<std::string>& targets)
|
||||||
{
|
{
|
||||||
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, by_event, state.permissions);
|
std::vector<std::string>* permission_list = permission_list_for(category, state.permissions);
|
||||||
if (!permission_list)
|
if (!permission_list)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
for (const auto& key : keys)
|
for (const auto& target : targets)
|
||||||
persist_permission(plugin_key, state, *permission_list, key);
|
persist_permission(plugin_key, state, *permission_list, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
|
// Requests permission for an audited event, returning true when it is already granted or the user
|
||||||
|
// 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
|
||||||
@@ -1029,205 +929,56 @@ static PluginAuditManager::PermissionPrompt s_permission_prompt_for_testing;
|
|||||||
// 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, bool by_event, const std::vector<std::string>& keys)
|
const std::string& event_name, const std::vector<std::string>& unresolved)
|
||||||
{
|
{
|
||||||
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, shown);
|
return prompt_for_targets(category, plugin_name, event_name, unresolved);
|
||||||
|
|
||||||
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, by_event, keys, shown]() {
|
GUI::wxGetApp().CallAfter([category, plugin_key, plugin_name, event_name, unresolved]() {
|
||||||
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, shown))
|
if (prompt_for_targets(category, plugin_name, event_name, unresolved))
|
||||||
persist_grant(plugin_key, category, by_event, keys);
|
persist_grant(plugin_key, category, unresolved);
|
||||||
});
|
});
|
||||||
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)
|
||||||
{
|
{
|
||||||
const bool by_event = targets.empty();
|
std::vector<std::string> unresolved = targets;
|
||||||
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& key : keys)
|
for (const auto& target : targets)
|
||||||
if (!has_permission(*permission_list, key))
|
if (!has_permission(*permission_list, target))
|
||||||
unresolved.push_back(key);
|
unresolved.push_back(target);
|
||||||
if (unresolved.empty())
|
if (!targets.empty() && unresolved.empty())
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Letting a root frame go runs finalizers inside a scope destructor or another event's hook,
|
if (!request_permission(category, plugin_key, plugin_name, event_name, unresolved))
|
||||||
// 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& key : unresolved)
|
for (const auto& target : unresolved)
|
||||||
persist_permission(plugin_key, state, *permission_list, key);
|
persist_permission(plugin_key, state, *permission_list, target);
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -1255,20 +1006,62 @@ 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;
|
||||||
|
|
||||||
// Root frames whose operation has returned are let go once this event is decided, so that a
|
const bool fs_category = is_fs_category(event_type);
|
||||||
// finalizer they run re-enters a hook whose held frames are already consistent.
|
const std::vector<std::string> targets = PluginAuditDetail::audit_targets(event_name, event_type, args);
|
||||||
std::vector<PyObject*> returned_frames;
|
|
||||||
PluginAuditDetail::take_returned_frames(m_root_frames, returned_frames);
|
// A denied path (secrets, certificates, config files -- see is_denied_path) is an
|
||||||
const int result = PluginAuditDetail::audit_plugin_event(*mgr, event_name, event_type, args);
|
// unconditional block: checked before the ancestor-cascade check below, so a cascade
|
||||||
mgr->release_frames(returned_frames);
|
// approval recorded for an unrelated action can never launder access to one, and before
|
||||||
return result;
|
// 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 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) --
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
// 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>
|
||||||
@@ -143,26 +144,15 @@ public:
|
|||||||
void clear_last_violation();
|
void clear_last_violation();
|
||||||
bool last_violation(AuditViolation& violation) const;
|
bool last_violation(AuditViolation& violation) const;
|
||||||
|
|
||||||
// --- operation-root frames ---
|
// --- call-site cascade cache ---
|
||||||
// One stdlib operation fires several nested audit events as it passes through lower layers:
|
// A single plugin action often fires several nested CPython audit events as it passes
|
||||||
// urlopen() raises urllib.Request, then http.client and socket events. When an operation's
|
// through stdlib layers (urllib.request calling http.client calling socket, for example).
|
||||||
// own root event is granted (by a saved grant or the user), the stdlib frame that raised it
|
// Once the user approves one event, every stdlib frame still on the stack for that call
|
||||||
// is held, and a later event whose stack reaches that frame through stdlib frames only is
|
// is recorded here by (filename, function, first line) identity. A later event whose own
|
||||||
// part of the same operation, so it is allowed without a prompt of its own. Plugin or
|
// ancestor chain still contains one of those frames is the same logical action seen from
|
||||||
// library code the operation calls back into is judged on its own. A frame is let go once it
|
// a deeper layer, so it is auto-approved instead of prompting again.
|
||||||
// returns, a suspended generator's (os.walk) when the audit scope ends. Letting a frame go may
|
bool has_approved_ancestor(const std::string& plugin_key, const std::vector<std::string>& call_site_ids) const;
|
||||||
// run finalizers of objects its locals held; they are judged as the plugin and never prompt.
|
void record_approved_call_sites(const std::string& plugin_key, const std::vector<std::string>& call_site_ids);
|
||||||
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;
|
||||||
|
|
||||||
@@ -173,32 +163,24 @@ 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() is a const query that must lock.
|
// mutable: is_denied_filename() and has_approved_ancestor() are const queries 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:
|
||||||
@@ -212,12 +194,9 @@ 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
|
||||||
|
|||||||
@@ -808,7 +808,6 @@ 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())
|
||||||
@@ -853,7 +852,6 @@ 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();
|
||||||
|
|||||||
@@ -104,8 +104,6 @@ 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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+1
-1
@@ -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)
|
||||||
|
|||||||
@@ -1263,55 +1263,6 @@ 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;
|
||||||
@@ -1727,75 +1678,3 @@ 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,6 +1,9 @@
|
|||||||
#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>
|
||||||
@@ -15,8 +18,11 @@
|
|||||||
#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>
|
||||||
@@ -51,6 +57,73 @@ 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;
|
||||||
@@ -122,6 +195,42 @@ 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());
|
||||||
@@ -136,6 +245,76 @@ 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,58 +8417,3 @@ 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);
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,6 @@ 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 ConnectionSettings&, MoonrakerDeviceInfo& info, std::string&) const override
|
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
|
||||||
{
|
{
|
||||||
++fetch_count;
|
++fetch_count;
|
||||||
info.dev_name = "Test printer";
|
info.dev_name = "Test printer";
|
||||||
@@ -65,32 +65,21 @@ 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(const PrinterConnectionParams& params) override
|
bool init_device_info(
|
||||||
|
const std::string& dev_id, const std::string& dev_ip, const std::string&, const std::string&, bool, const std::string& port) override
|
||||||
{
|
{
|
||||||
device_info = MoonrakerDeviceInfo{};
|
device_info = MoonrakerDeviceInfo{};
|
||||||
device_info.dev_id = params.dev_id;
|
device_info.dev_id = dev_id;
|
||||||
device_info.dev_ip = params.host;
|
device_info.base_url = "http://" + dev_ip + ":" + port;
|
||||||
device_info.base_url = std::string(params.use_ssl ? "https://" : "http://") + params.host + ":" + params.port;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool fetch_device_info(const ConnectionSettings&, MoonrakerDeviceInfo& info, std::string&) const override
|
bool fetch_device_info(const std::string&, const std::string&, MoonrakerDeviceInfo& info, std::string&) const override
|
||||||
{
|
{
|
||||||
info.dev_name = "Test printer";
|
info.dev_name = "Test printer";
|
||||||
return true;
|
return true;
|
||||||
@@ -108,7 +97,6 @@ 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
|
||||||
@@ -162,19 +150,6 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -214,20 +189,6 @@ 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;
|
||||||
@@ -382,7 +343,6 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -425,15 +385,12 @@ struct StreamFixture
|
|||||||
// Runs before the members are destroyed, so the agent's destructor never joins a stream the printer still holds open.
|
// 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_stream(printer.base_url()); }
|
void start() { agent.start_status_stream("printer", 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_stream(); });
|
auto stopped = std::async(std::launch::async, [this] { agent.stop_status_stream(); });
|
||||||
const bool in_time = stopped.wait_for(limit) == std::future_status::ready;
|
const bool in_time = stopped.wait_for(limit) == std::future_status::ready;
|
||||||
printer.release();
|
printer.release();
|
||||||
stopped.wait();
|
stopped.wait();
|
||||||
@@ -490,16 +447,6 @@ TEST_CASE("Stopping the status stream gives a silent printer no more than its ti
|
|||||||
CHECK(f.stops_within(limit));
|
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.
|
||||||
@@ -577,7 +524,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_stream();
|
f.agent.stop_status_stream();
|
||||||
stopped.set_value();
|
stopped.set_value();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -635,12 +582,9 @@ TEST_CASE("A disconnect during the connection queries keeps the status stream fr
|
|||||||
f.agent.connect_printer(params);
|
f.agent.connect_printer(params);
|
||||||
REQUIRE(f.printer.wait_for_http_requests(1));
|
REQUIRE(f.printer.wait_for_http_requests(1));
|
||||||
|
|
||||||
// disconnect_printer joins the worker running the stalled query, so it is called aside and the
|
f.agent.disconnect_printer();
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
@@ -1,48 +1,22 @@
|
|||||||
#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 {
|
||||||
|
|
||||||
@@ -375,747 +349,3 @@ 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>"));
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,18 @@
|
|||||||
#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; }
|
||||||
@@ -32,390 +20,12 @@ 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("printer-agent registry register / lookup / duplicate-reject", "[PrinterAgent]")
|
TEST_CASE("unit: printer-agent registry register / lookup / duplicate-reject", "[registry][unit]")
|
||||||
{
|
{
|
||||||
// why: the registry is process-global state shared by the test binary, and
|
// 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
|
||||||
@@ -459,7 +69,7 @@ TEST_CASE("printer-agent registry register / lookup / duplicate-reject", "[Print
|
|||||||
// fails to load the codecs needed for the filesystem encoding. The shared helper
|
// 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("orca.printer_agent binding surface", "[integration][Python]")
|
TEST_CASE("integration: orca.printer_agent binding surface", "[integration][Python]")
|
||||||
{
|
{
|
||||||
py::module_ orca = import_orca_module();
|
py::module_ orca = import_orca_module();
|
||||||
|
|
||||||
@@ -510,7 +120,7 @@ TEST_CASE("orca.printer_agent binding surface", "[integration][Python]")
|
|||||||
// them in the lightweight embedded-interpreter test catches binding breakage
|
// 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("orca plugin-registration API surface + discovery-context guards", "[integration][Python]")
|
TEST_CASE("integration: orca plugin-registration API surface + discovery-context guards", "[integration][Python]")
|
||||||
{
|
{
|
||||||
py::module_ orca = import_orca_module();
|
py::module_ orca = import_orca_module();
|
||||||
|
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
#include "catch2/catch_test_macros.hpp"
|
|
||||||
#include "catch2/matchers/catch_matchers.hpp"
|
|
||||||
#include "catch2/matchers/catch_matchers_string.hpp"
|
|
||||||
#include <catch2/catch_all.hpp>
|
|
||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
|
||||||
|
|
||||||
#include <slic3r/Utils/QidiPrinterAgent.hpp>
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
using namespace Slic3r;
|
|
||||||
|
|
||||||
TEST_CASE("Qidi slot response rejects null variables without throwing", "[QidiPrinterAgent]")
|
|
||||||
{
|
|
||||||
const std::string response = R"({
|
|
||||||
"result": {
|
|
||||||
"status": {
|
|
||||||
"save_variables": {
|
|
||||||
"variables": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})";
|
|
||||||
nlohmann::json status;
|
|
||||||
nlohmann::json variables;
|
|
||||||
std::string error;
|
|
||||||
bool parsed = true;
|
|
||||||
|
|
||||||
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
|
|
||||||
CHECK_FALSE(parsed);
|
|
||||||
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("variables"));
|
|
||||||
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("object"));
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("Qidi slot response rejects missing and non-object fields without throwing", "[QidiPrinterAgent]")
|
|
||||||
{
|
|
||||||
std::string response;
|
|
||||||
|
|
||||||
SECTION("missing result")
|
|
||||||
{
|
|
||||||
response = R"({})";
|
|
||||||
}
|
|
||||||
|
|
||||||
SECTION("non-object result")
|
|
||||||
{
|
|
||||||
response = R"({"result":null})";
|
|
||||||
}
|
|
||||||
|
|
||||||
SECTION("missing status")
|
|
||||||
{
|
|
||||||
response = R"({"result":{}})";
|
|
||||||
}
|
|
||||||
|
|
||||||
SECTION("non-object status")
|
|
||||||
{
|
|
||||||
response = R"({"result":{"status":null}})";
|
|
||||||
}
|
|
||||||
|
|
||||||
SECTION("missing save_variables")
|
|
||||||
{
|
|
||||||
response = R"({"result":{"status":{}}})";
|
|
||||||
}
|
|
||||||
|
|
||||||
SECTION("non-object save_variables")
|
|
||||||
{
|
|
||||||
response = R"({"result":{"status":{"save_variables":null}}})";
|
|
||||||
}
|
|
||||||
|
|
||||||
SECTION("missing variables")
|
|
||||||
{
|
|
||||||
response = R"({"result":{"status":{"save_variables":{}}}})";
|
|
||||||
}
|
|
||||||
|
|
||||||
SECTION("scalar")
|
|
||||||
{
|
|
||||||
response = R"({"result":{"status":{"save_variables":{"variables":42}}}})";
|
|
||||||
}
|
|
||||||
|
|
||||||
SECTION("array")
|
|
||||||
{
|
|
||||||
response = R"({"result":{"status":{"save_variables":{"variables":[]}}}})";
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json status;
|
|
||||||
nlohmann::json variables;
|
|
||||||
std::string error;
|
|
||||||
bool parsed = true;
|
|
||||||
|
|
||||||
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
|
|
||||||
CHECK_FALSE(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("Qidi slot response exposes valid status and variables", "[QidiPrinterAgent]")
|
|
||||||
{
|
|
||||||
const std::string response = R"({
|
|
||||||
"result": {
|
|
||||||
"status": {
|
|
||||||
"save_variables": {
|
|
||||||
"variables": {
|
|
||||||
"box_count": 2,
|
|
||||||
"color_slot0": 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"box_stepper slot0": {
|
|
||||||
"runout_button": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})";
|
|
||||||
nlohmann::json status;
|
|
||||||
nlohmann::json variables;
|
|
||||||
std::string error;
|
|
||||||
bool parsed = false;
|
|
||||||
|
|
||||||
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response(response, status, variables, error));
|
|
||||||
REQUIRE(parsed);
|
|
||||||
CHECK(status.is_object());
|
|
||||||
CHECK(variables.is_object());
|
|
||||||
CHECK(variables.at("box_count") == 2);
|
|
||||||
CHECK(status.contains("box_stepper slot0"));
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_CASE("Qidi slot response rejects invalid JSON", "[QidiPrinterAgent]")
|
|
||||||
{
|
|
||||||
nlohmann::json status;
|
|
||||||
nlohmann::json variables;
|
|
||||||
std::string error;
|
|
||||||
bool parsed = true;
|
|
||||||
|
|
||||||
REQUIRE_NOTHROW(parsed = QidiPrinterAgent::parse_slot_response("{not json", status, variables, error));
|
|
||||||
CHECK_FALSE(parsed);
|
|
||||||
CHECK(error == "Invalid JSON response");
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user