Compare commits

..

39 Commits

Author SHA1 Message Date
Ian Chua
ba7fdb8ceb Merge branch 'main' into feat/printer-agent-impl 2026-08-21 14:17:56 +08:00
Ian Chua
8be9800567 remove irrelevant docs 2026-08-21 14:16:49 +08:00
Ian Chua
c4eabc3c52 fix: extend access code requirements t 0, 8 or more characters. 2026-08-21 14:14:23 +08:00
Ian Chua
0d0d281d0c feat: parse nozzle information for qidi and moonraker printer agents 2026-08-20 20:11:31 +08:00
Ian Chua
c4bbcd322b fix: skip filament sync dialog if filamentSyncMode is none 2026-08-20 19:28:02 +08:00
Ian Chua
7aea1b1235 Merge branch 'main' into feat/printer-agent-impl 2026-08-20 16:39:54 +08:00
Ian Chua
c2a43157d2 Merge branch 'main' into feat/printer-agent-impl 2026-08-19 18:29:36 +08:00
Ian Chua
738b30a743 fix: ams sync info and periodic ams sync via subscription workflow 2026-08-19 18:29:19 +08:00
Ian Chua
96092ef2d3 feat: update qidi to use subscription based filament sync mode 2026-08-18 19:05:17 +08:00
Ian Chua
d9002ad87d fix: ams filament mapping workflow 2026-08-18 18:07:22 +08:00
Ian Chua
4c1ea0a602 Merge branch 'main' into 'feat/printer-agent-impl' 2026-08-17 14:51:04 +08:00
Ian Chua
07c62112b8 Reconcile implementation split with PR tip 2026-08-04 18:12:51 +08:00
Ian Chua
f23e4963bf fix: merge access codes into one 2026-08-04 18:12:43 +08:00
Andrew
95279f7084 docs: document the printer-agent subsystem 2026-08-04 18:12:43 +08:00
Andrew
1014558c91 Keep printer-agent progress in sync
Keep the shared task progress aligned with agent
reports that lack Bambu cloud task identity.

Release the lazily allocated task during reset to
avoid leaks when machine objects reconnect.
2026-08-04 18:12:42 +08:00
Andrew
6515062a3a fix: stop Qidi slot parse throwing on null 2026-08-04 18:12:42 +08:00
Andrew
8153e26b6d fix: start stream when camera URL changes 2026-08-04 18:12:42 +08:00
Andrew
79fd33d49a Show Snapmaker U1 camera in Device tab
The U1 exposes no /server/webcams/list entry;
its camera only captures after an explicit
camera.start_monitor RPC, which the Moonraker
websocket executes unauthenticated but only
answers over MQTT - so the call is fire and
forget.

Start the camera when the camera view is
shown and renew every 300 s: the printer
retires the capture task at ~362 s and
stop_monitor is accepted but ineffective,
so teardown is simply to stop renewing.

Frames land in monitor.jpg as still JPEGs
(~2 fps at interval 0), so the webview loads
a local HTML wrapper that repolls with a
cache buster.
2026-08-04 18:12:41 +08:00
Andrew
cdb662d4b2 Move Moonraker commands off the UI thread
Pause/resume/stop, g-code sends, temps, and
light ran synchronous HTTP on the UI thread,
freezing the app up to 10s per click on slow
or unreachable printers.

Run them on a single agent-owned FIFO worker
so g-code ordering is preserved, while command
translation stays synchronous so unsupported-
command dialogs still work.

Add a pending-disabled state to the pause,
resume, and abort buttons for Moonraker-family
printers: the icon only flips once the
WebSocket reports the real state, which also
rules out double-click races.
2026-08-04 18:12:41 +08:00
Andrew
a27fa7b5df Fix multi-color filament logic
Reuse color decoding across functions to improve
code readability and maintain consistency in
multi-color filament handling.
2026-08-04 18:12:40 +08:00
Andrew
cdeed107c5 Keep Bambu AMS dialect out of the agent waist
M620 is Bambu firmware dialect, not a
neutral command. Composing it in
MachineObject let non-Bambu agents
(Moonraker/Klipper) forward it and
report success on firmware that
cannot run it.

Agents now own the dialect: the
default refusal on IPrinterAgent
returns not-supported so the UI
can say so; BBLPrinterAgent keeps
the byte-identical composition.
2026-08-04 18:12:40 +08:00
Andrew
be92f7e78c Surface Moonraker webcams and gate unrunnable controls 2026-08-04 18:12:39 +08:00
Andrew
76f9de4cfb fix: make Klipper macro lamp control reliable 2026-08-04 18:12:39 +08:00
Andrew
cd6d2cbf5e Stop blocking print on unreported nozzle data 2026-08-04 18:12:39 +08:00
Andrew
deaca0189f Bring Moonraker device panel to feature parity
The monitor panel showed wrong or missing
data for Moonraker printers, and its
controls did nothing.

Push payload now carries layer number and
total layers. Remaining time replaces the
wrong total_duration - print_duration
formula. The chamber light toggle maps to
Klipper SET_PIN / SET_LED, and pause,
resume and stop post to
/printer/print/{action}. Task thumbnails
resolve via /server/files/thumbnails onto
a new MachineObject thumbnail url.

Filament sync switches to pull mode so the
agent is queried on demand.

Not compiled or run.
2026-08-04 18:12:38 +08:00
Andrew
7d54ee286d fix: pin HTTP to prevent connection refusal
Set `use_ssl` to false to ensure Moonraker
connectivity, as the service uses HTTP rather
than HTTPS, preventing connection issues. Initialize
device info early for reliable name resolution.
2026-08-04 18:12:38 +08:00
Andrew
dddfc7a8ad Add printer-agent and plugin status tests
Ports the agent lifecycle, duplicate
agent-id, built-in-id clash and
status-resolution tests. The loader
runs on a detached worker thread, so
the lifecycle tests live in their own
executable. Tests install the
production unload-side registry
wiring themselves (no GUI in the
test binary) and register agents
manually so concurrent loads stay
deterministic.
2026-08-04 18:12:37 +08:00
Andrew
673bfb0dee Track BBLPrinterAgentPlugin.py 2026-08-04 18:12:37 +08:00
Andrew
b9e8c5a38c Gate agent mode behind use_printer_agents toggle
Replace per-printer auto-activation
(is_current_printer_agent_plugin)
with a global experimental AppConfig
toggle, default off: legacy
print-host behavior is unchanged
until the user opts in. The toggle
drives device-tab routing, print
button defaults, connect-button
visibility and sidebar layout, and
dedups machine-select dialog opens.
2026-08-04 18:12:36 +08:00
Andrew
a2a4ca20e4 Reset device selection on agent swap or unload (#124)
set_live_printer_agent centralizes
the swap: deselect the machine,
clear stale sidebar state and the
previous agent's Other Devices, then
install the new agent (or null when
its provider vanished). Plugin
load/unload callbacks refresh the
dropdown and re-run agent selection.
load_last_machine no longer falls
back to the first available machine.
2026-08-04 18:12:36 +08:00
Andrew
12075459d2 Replace fake-enum printer agent dropdown (#121)
A dedicated PrinterAgentChoice field
reads rows straight from the live
agent registry and stores the agent
id string, replacing the fake-coEnum
index mapping. The field moves to
TabPrinter and registers with the
searcher so UnsavedChanges renders
it; the PhysicalPrinterDialog copy
and its update hook are removed
(#125). switch_printer_agent now
resolves ids via
resolve_printer_agent_id.
2026-08-04 18:12:36 +08:00
Andrew
f3770f3106 fix: checkbox should depend on plugin is_loaded status 2026-08-04 18:12:35 +08:00
Andrew
90fbf1770f Resolve duplicate agent ID conflicts
Reject a printer-agent capability
whose agent ID is already owned by
another capability or built-in:
flag the plugin error, disable the
capability, and warn the user
instead of silently ignoring it.
2026-08-04 18:12:35 +08:00
Andrew
bde94ab37f Add support for runtime error status in plugins
Distinguish a loaded plugin whose
capability errored (RuntimeError,
warn-styled, stays checked) from a
load-time Error. Status now derives
via resolve_plugin_status(); enum
ordinal keeps dialog sort priority.
Unloading clears stale errors.
2026-08-04 18:12:34 +08:00
Ian Chua
6d25e1777e Working Moonraker and Qidi printer agent transport (#104)
Folds the Qidi AMS box-mapping print
overrides (apply_box_mapping +
start_* wrappers) that the transport
fix builds on.
2026-08-04 18:12:34 +08:00
Andrew
7ce26ca8e5 Harden send flow and separate upload failure recovery (#111)
* fix(send): harden FT send path + IP pre-flight UX

* Remove early returns
2026-08-04 18:12:33 +08:00
Andrew
367fcce634 Prevent loss of user access code on LAN reselect
Keep user access code intact to maintain access rights even if
device slot is unpopulated, ensuring continuous connection
and status message reception.
2026-08-04 18:12:33 +08:00
Andrew
9b3a44b339 Parse user print info on the UI thread to prevent heap corruption (#119)
get_user_print_info()'s HTTP fetch can run on a worker thread (e.g. BindJob),
but parse_user_print_info() mutates userMachineList (insert/erase/delete
MachineObject). on_machine_alive (SSDP) mutates the same maps on the UI thread
without locking, so parsing off-thread races the map and frees MachineObjects
out from under it -> heap corruption.

Keep all device-list mutation on the UI thread: parse inline when already on
the main thread, otherwise marshal via CallAfter so it stays serialized with
on_machine_alive.
2026-08-04 18:12:33 +08:00
Ian Chua
6384191102 Add developer flag for printer agents 2026-08-04 18:12:32 +08:00
42 changed files with 3025 additions and 449 deletions

View File

@@ -575,7 +575,7 @@ function CapabilityCanRun(plugin, capability) {
}
function IsPluginChecked(plugin) {
return GetStatus(plugin) === "Activated";
return plugin.is_loaded;
}
function HasMixedCapabilityState(plugin) {
@@ -1347,6 +1347,8 @@ function StatusDescription(plugin) {
return "This plugin is still loading.";
case "Error":
return "This plugin is blocked until its error is fixed.";
case "RuntimeError":
return "This plugin is loaded but a capability reported an error.";
case "Inactive":
default:
return "This plugin is inactive. Activate it to install or load it.";

View File

@@ -424,6 +424,11 @@ body.pane-resizing {
font-weight: 600;
}
.status-cell.status-runtimeerror {
color: var(--plugin-status-warn);
font-weight: 600;
}
.status-cell.status-loading {
color: var(--plugin-status-warn);
font-weight: 600;
@@ -680,6 +685,11 @@ body.pane-resizing {
color: var(--plugin-status-danger);
}
.detail-status-chip.status-runtimeerror {
background: var(--plugin-status-warn-bg);
color: var(--plugin-status-warn);
}
.detail-status-chip.status-loading {
background: var(--plugin-status-warn-bg);
color: var(--plugin-status-warn);

View File

@@ -18,6 +18,7 @@
#include <boost/filesystem.hpp>
#include <boost/algorithm/clamp.hpp>
#include <boost/algorithm/string/predicate.hpp>
#include <boost/algorithm/string/join.hpp>
#include <boost/range/adaptor/transformed.hpp>
#include <boost/nowide/cstdio.hpp>
#include <boost/nowide/fstream.hpp>
@@ -3386,6 +3387,27 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
ConfigOptionStrings *filament_color_type = project_config.option<ConfigOptionStrings>("filament_colour_type");
ConfigOptionInts * filament_map = project_config.option<ConfigOptionInts>("filament_map");
ConfigOptionInts * filament_volume_map = project_config.option<ConfigOptionInts>("filament_volume_map");
// why: project filament_multi_colour stores space-joined components per
// filament; decode once so every merge branch seeds from the same view,
// falling back to the main color where the project has no components.
auto decode_project_multi_colors = [this](const std::vector<std::string> &main_colors) {
std::vector<std::vector<std::string>> decoded(main_colors.size());
for (size_t i = 0; i < main_colors.size(); ++i) decoded[i] = {main_colors[i]};
const ConfigOptionStrings *project_multi_color = project_config.option<ConfigOptionStrings>("filament_multi_colour");
if (project_multi_color) {
for (size_t i = 0; i < std::min(decoded.size(), project_multi_color->values.size()); ++i) {
std::vector<std::string> colors = split_string(project_multi_color->values[i], ' ');
// why: a whitespace-only persisted entry splits into empty
// tokens, so keep the main-color fallback rather than
// serializing an empty component.
colors.erase(std::remove_if(colors.begin(), colors.end(),
[](const std::string &c) { return c.empty(); }),
colors.end());
if (!colors.empty()) decoded[i] = colors;
}
}
return decoded;
};
if (color_only) {
auto get_map_index = [&ams_infos](const std::vector<AMSMapInfo> &infos, const AMSMapInfo &temp) {
for (int i = 0; i < infos.size(); i++) {
@@ -3398,20 +3420,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
};
auto exist_colors = filament_color->values;
std::vector<std::vector<std::string>> exist_multi_color_filment(exist_colors.size());
for (size_t i = 0; i < exist_colors.size(); i++) {
exist_multi_color_filment[i] = {exist_colors[i]};
}
ConfigOptionStrings *project_multi_color = project_config.option<ConfigOptionStrings>("filament_multi_colour");
if (project_multi_color) {
for (size_t i = 0; i < std::min(exist_multi_color_filment.size(), project_multi_color->values.size()); i++) {
std::vector<std::string> colors = split_string(project_multi_color->values[i], ' ');
if (!colors.empty()) {
exist_multi_color_filment[i] = colors;
}
}
}
auto exist_multi_color_filment = decode_project_multi_colors(exist_colors);
bool mapped_any = false;
if (use_map && !maps.empty()) {
@@ -3487,11 +3496,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
auto exist_colors = filament_color->values;
auto exist_color_types = filament_color_type->values;
auto exist_filament_presets = this->filament_presets;
std::vector<std::vector<std::string>> exist_multi_color_filment;
exist_multi_color_filment.resize(exist_colors.size());
for (int i = 0; i < exist_colors.size(); i++) {
exist_multi_color_filment[i] = {exist_colors[i]};
}
auto exist_multi_color_filment = decode_project_multi_colors(exist_colors);
for (size_t i = 0; i < exist_colors.size(); i++) {
if (maps.find(i) != maps.end()) {//mapping exist
auto valid_index = get_map_index(ams_array_maps, maps[i]);
@@ -3499,7 +3504,10 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
exist_colors[i] = ams_filament_colors[valid_index];
exist_color_types[i] = ams_filament_color_types[valid_index];
exist_filament_presets[i] = ams_filament_presets[valid_index];
exist_multi_color_filment[i] = ams_multi_color_filment[valid_index];
// why: a single-color agent tray reports no components, so
// fall back to the printer main color rather than keeping
// the replaced project filament's components.
exist_multi_color_filment[i] = ams_multi_color_filment[valid_index].empty() ? std::vector<std::string>{ams_filament_colors[valid_index]} : ams_multi_color_filment[valid_index];
} else {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << "check error: array bound (mapping exist)";
}
@@ -3559,7 +3567,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
exist_filament_presets.push_back(need_append_colors[i].filament_preset);
exist_colors.push_back(need_append_colors[i].filament_color);
exist_color_types.push_back(need_append_colors[i].filament_color_type);
exist_multi_color_filment.push_back(need_append_colors[i].mutli_filament_color);
exist_multi_color_filment.push_back(need_append_colors[i].mutli_filament_color.empty() ? std::vector<std::string>{need_append_colors[i].filament_color} : need_append_colors[i].mutli_filament_color);
}
}
filament_color->values = exist_colors;
@@ -3577,6 +3585,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
auto exist_colors = filament_color->values;
auto exist_color_types = filament_color_type->values;
auto exist_presets = this->filament_presets;
auto existing_multi_colors = decode_project_multi_colors(exist_colors);
size_t tray_count = ams_filament_presets.size();
size_t total = std::max(tray_count, exist_presets.size());
@@ -3586,25 +3595,34 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
std::vector<std::string> result_presets;
std::vector<std::vector<std::string>> result_multi_colors;
for (size_t i = 0; i < total; i++) {
bool is_loaded = (i < ams_infos.size() && ams_infos[i].valid);
for (size_t i = 0; i < total; ++i) {
const bool is_loaded = i < ams_infos.size() && ams_infos[i].valid;
if (is_loaded) {
// Loaded tray: use tray's filament data
// The printer replaces the project filament at this index.
result_colors.push_back(ams_filament_colors[i]);
result_color_types.push_back(ams_filament_color_types[i]);
result_presets.push_back(ams_filament_presets[i]);
result_multi_colors.push_back(
i < ams_multi_color_filment.size() ? ams_multi_color_filment[i]
: std::vector<std::string>{ams_filament_colors[i]});
} else if (i < exist_presets.size()) {
// Empty tray or beyond tray count: keep existing filament
if (i < ams_multi_color_filment.size() && !ams_multi_color_filment[i].empty()) {
result_multi_colors.push_back(ams_multi_color_filment[i]);
} else {
// why: old project components belong to the replaced
// filament, so use the new printer filament's main color.
result_multi_colors.push_back({ams_filament_colors[i]});
}
// why: update_multi_material_filament_presets() can grow
// filament_presets alone to the extruder count, so presets
// may outrun the colour arrays; fall through to a generic
// filament rather than read past them.
} else if (i < exist_presets.size() && i < exist_colors.size() && i < exist_color_types.size()) {
// An empty or absent printer slot retains the project filament.
result_colors.push_back(exist_colors[i]);
result_color_types.push_back(exist_color_types[i]);
result_presets.push_back(exist_presets[i]);
result_multi_colors.push_back({exist_colors[i]});
// note: already carries the project components or its
// main-color fallback.
result_multi_colors.push_back(existing_multi_colors[i]);
} else {
// New slot beyond existing count: prefer a generic filament preset
// Neither source has a filament, so create a generic one.
auto it = std::find_if(filaments.begin(), filaments.end(), [](const Preset &f) {
return f.is_compatible && f.is_system
&& boost::algorithm::starts_with(f.name, "Generic ");
@@ -3650,23 +3668,28 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
void PresetBundle::update_filament_multi_color()
{
std::vector<std::string> exsit_multi_colors;
for (auto &fil_item : ams_multi_color_filment){
if (fil_item.empty()) break;
if (fil_item.size() == 1)
exsit_multi_colors.push_back(fil_item[0]);
else {
std::string colors = "";
for (auto &color : fil_item){
colors += color + " ";
}
colors.erase(colors.size() - 1); // remove last space
exsit_multi_colors.push_back(colors);
const ConfigOptionStrings *filament_color = project_config.option<ConfigOptionStrings>("filament_colour");
ConfigOptionStrings *filament_multi_colour = project_config.option<ConfigOptionStrings>("filament_multi_colour");
// note: cheap defence only. Both keys live in s_project_options and the
// constructor applies them unconditionally, so this never fires today.
if (!filament_color || !filament_multi_colour) return;
// note: this is std::vector::resize(), which safely creates empty inner
// vectors. Not ConfigOptionStrings::resize(), which throws without a
// default value.
ams_multi_color_filment.resize(filament_color->values.size());
for (size_t i = 0; i < filament_color->values.size(); ++i) {
if (ams_multi_color_filment[i].empty()) {
// why: every final main color requires one parallel
// multi-color entry.
ams_multi_color_filment[i] = {filament_color->values[i]};
}
}
ConfigOptionStrings *filament_multi_colour = project_config.option<ConfigOptionStrings>("filament_multi_colour");
filament_multi_colour->resize(exsit_multi_colors.size());
filament_multi_colour->values = exsit_multi_colors;
std::vector<std::string> serialized;
serialized.reserve(ams_multi_color_filment.size());
for (const auto &components : ams_multi_color_filment)
serialized.push_back(boost::algorithm::join(components, " "));
// why: assignment sets both size and contents, so no resize is needed.
filament_multi_colour->values = std::move(serialized);
}
std::vector<int> PresetBundle::get_used_tpu_filaments(const std::vector<int> &used_filaments)

View File

@@ -752,9 +752,9 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS
{
curr_tray->remain = -1;
}
if (tray_it->contains("tray_slot_placeholder")) {
curr_tray->is_slot_placeholder = true;
}
// The tray objects are reused across status updates. Reset this
// state when a previously empty slot receives a filament again.
curr_tray->is_slot_placeholder = tray_it->contains("tray_slot_placeholder");
int ams_id_int = 0;
int tray_id_int = 0;
try
@@ -989,4 +989,4 @@ void DevFilaSystemParser::ParseAgentFilament(const json& data, MachineObject* ob
}
}
}
}

View File

@@ -154,4 +154,4 @@ public:
protected:
virtual void on_timer(wxTimerEvent& event);
};
};
};

View File

@@ -1731,9 +1731,11 @@ int MachineObject::command_ams_user_settings(bool start_read_opt, bool tray_read
int MachineObject::command_ams_calibrate(int ams_id)
{
std::string gcode_cmd = (boost::format("M620 C%1% \n") % ams_id).str();
BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode_cmd;
return this->publish_gcode(gcode_cmd);
if (!m_agent) return -1;
int rtn = m_agent->command_ams_calibrate(get_dev_id(), ams_id, MachineObject::m_sequence_id++, is_lan_mode_printer());
if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE)
show_unsupported_dlg(rtn);
return rtn;
}
int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::string filament_id, std::string setting_id, std::string tray_color, std::string tray_type, int nozzle_temp_min, int nozzle_temp_max)
@@ -1771,9 +1773,11 @@ int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::s
int MachineObject::command_ams_refresh_rfid(std::string tray_id)
{
std::string gcode_cmd = (boost::format("M620 R%1% \n") % tray_id).str();
BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode_cmd;
return this->publish_gcode(gcode_cmd);
if (!m_agent) return -1;
int rtn = m_agent->command_ams_refresh_rfid(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer());
if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE)
show_unsupported_dlg(rtn);
return rtn;
}
int MachineObject::command_ams_refresh_rfid2(int ams_id, int slot_id)
@@ -1786,12 +1790,22 @@ int MachineObject::command_ams_refresh_rfid2(int ams_id, int slot_id)
return this->publish_json(j);
}
int MachineObject::command_start_camera()
{
if (!m_agent) return -1;
// why: this fires from the camera view's renew timer, so a refusal must stay silent -
// show_unsupported_dlg() here would pop a dialog every ~5 min on every other printer.
return m_agent->command_start_camera(get_dev_id());
}
int MachineObject::command_ams_select_tray(std::string tray_id)
{
std::string gcode_cmd = (boost::format("M620 P%1% \n") % tray_id).str();
BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode_cmd;
return this->publish_gcode(gcode_cmd);
if (!m_agent) return -1;
int rtn = m_agent->command_ams_select_tray(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer());
if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE)
show_unsupported_dlg(rtn);
return rtn;
}
int MachineObject::command_ams_control(std::string action)
@@ -2615,7 +2629,12 @@ void MachineObject::reset()
vt_slot.erase(vt_slot.begin() + 1);
}
}
subtask_ = nullptr;
// why: reset reuses MachineObject, so release its lazy subtask
// before dropping the pointer to prevent reconnect leaks.
if (subtask_) {
delete subtask_;
subtask_ = nullptr;
}
has_extra_flow_type = false;
m_partskip_ids.clear();
}
@@ -2625,6 +2644,20 @@ void MachineObject::set_print_state(std::string status)
print_status = status;
}
// why: printer agents can report progress without BBL cloud task identity.
void MachineObject::update_print_progress(const json& value)
{
if (value.is_string())
mc_print_percent = stoi(value.get<std::string>());
else if (value.is_number_integer())
mc_print_percent = value.get<int>();
else
return;
if (BBLSubTask* curr_task = get_subtask())
curr_task->task_progress = mc_print_percent;
}
int MachineObject::connect(bool use_openssl)
{
if (get_dev_ip().empty()) return -1;
@@ -2738,6 +2771,14 @@ int MachineObject::publish_json(const json& json_item, int qos, int flag)
BOOST_LOG_TRIVIAL(error) << "publish_json: " << json_item.dump() << " code: " << rtn;
}
// why: the agent is the only thing that knows what it can translate, so it reports
// not-supported in its return value and this - the single funnel every command_* builder
// passes through - is the one place that turns it into something the user sees. No list of
// unsupported commands is needed anywhere: an agent that has no case for a command says so.
if (rtn == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED || rtn == ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE) {
show_unsupported_dlg(rtn);
}
return rtn;
}
@@ -3294,10 +3335,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
print_type = jj["print_type"].get<std::string>();
}
if (jj.contains("mc_percent")) {
if (jj["mc_percent"].is_string())
mc_print_percent = stoi(j["print"]["mc_percent"].get<std::string>());
else if (jj["mc_percent"].is_number_integer())
mc_print_percent = j["print"]["mc_percent"].get<int>();
update_print_progress(jj["mc_percent"]);
}
if (jj.contains("mc_print_sub_stage")) {
if (jj["mc_print_sub_stage"].is_number_integer())
@@ -3467,6 +3505,9 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
this->task_id_ = jj["task_id"].get<std::string>();
}
if (jj.contains("thumbnail_url") && jj["thumbnail_url"].is_string())
m_agent_thumbnail_url = jj["thumbnail_url"].get<std::string>();
if (jj.contains("job_attr")) {
int jobAttr = jj["job_attr"].get<int>();
jobState_ = get_flag_bits(jobAttr, 4, 4);
@@ -3512,7 +3553,6 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
update_slice_info(jj["project_id"].get<std::string>(), jj["profile_id"].get<std::string>(), jj["subtask_id"].get<std::string>(), plate_index);
BBLSubTask* curr_task = get_subtask();
if (curr_task) {
curr_task->task_progress = mc_print_percent;
curr_task->printing_status = print_status;
curr_task->task_id = jj["subtask_id"].get<std::string>();
}
@@ -3815,6 +3855,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
has_ipcam = true;
} else {
has_ipcam = false;
webcam_stream_url.clear();
}
}
if (ipcam.contains("resolution")) {
@@ -3849,6 +3890,9 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
liveview_local = local_rtsp_url.empty() ? LVL_None : local_rtsp_url == "disable"
? LVL_Disable : boost::algorithm::starts_with(local_rtsp_url, "rtsps") ? LVL_Rtsps : LVL_Rtsp;
}
if (ipcam.contains("stream_url") && ipcam["stream_url"].is_string()) {
webcam_stream_url = ipcam["stream_url"].get<std::string>();
}
if (ipcam.contains("tutk_server")) {
tutk_state = ipcam["tutk_server"].get<std::string>();
}

View File

@@ -547,6 +547,7 @@ public:
bool xcam_first_layer_inspector { false };
time_t xcam_first_layer_hold_start = 0;
std::string local_rtsp_url;
std::string webcam_stream_url;
std::string tutk_state;
enum LiveviewLocal {
LVL_None,
@@ -708,6 +709,8 @@ public:
std::string subtask_id_;
std::string job_id_;
std::string last_subtask_id_;
// note: printer-agent-supplied thumbnail url, empty when the agent supplies none.
std::string m_agent_thumbnail_url;
BBLSliceInfo* slice_info {nullptr};
boost::thread* get_slice_info_thread { nullptr };
boost::thread* get_model_task_thread { nullptr };
@@ -813,6 +816,7 @@ public:
int command_ams_select_tray(std::string tray_id);
int command_ams_refresh_rfid(std::string tray_id);
int command_ams_refresh_rfid2(int ams_id, int slot_id);
int command_start_camera();
int command_ams_control(std::string action);
int command_ams_drying_stop();
int command_start_extrusion_cali(int tray_index, int nozzle_temp, int bed_temp, float max_volumetric_speed, std::string setting_id = "");
@@ -894,6 +898,7 @@ public:
static bool is_in_printing_status(std::string status);
void set_print_state(std::string status);
void update_print_progress(const json& value);
bool is_connected();
bool is_connecting();

View File

@@ -4336,11 +4336,30 @@ void Sidebar::load_ams_list(MachineObject* obj)
filament_ams_list = build_filament_ams_list(obj);
}
bool device_change = false;
const std::string& device = obj ? obj->get_dev_id() : "";
if (p->ams_list_device != device) {
const bool same_device = p->ams_list_device == device;
// Keep sync metadata out of the device payload, but preserve it across a
// subscription refresh when the physical filament in a slot is unchanged.
// Otherwise the refreshed configs differ only by the missing
// filament_changed key, causing combo boxes to rebuild and lose their
// transient post-sync badges.
auto &previous_filament_ams_list = wxGetApp().preset_bundle->filament_ams_list;
for (auto &entry : filament_ams_list) {
auto previous = previous_filament_ams_list.find(entry.first);
const auto *previous_changed = previous == previous_filament_ams_list.end() ? nullptr :
dynamic_cast<const ConfigOptionBool *>(previous->second.option("filament_changed"));
if (!same_device || previous_changed == nullptr ||
previous->second.opt_string("filament_id", 0u) != entry.second.opt_string("filament_id", 0u)) {
continue;
}
entry.second.set_key_value("filament_changed",
new ConfigOptionBool{previous_changed->value});
}
bool device_change = !same_device;
if (device_change) {
p->ams_list_device = device;
device_change = true;
}
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << boost::format(": %1% items") % filament_ams_list.size();
if (wxGetApp().preset_bundle->filament_ams_list == filament_ams_list && !device_change)
@@ -4350,9 +4369,27 @@ void Sidebar::load_ams_list(MachineObject* obj)
wxGetApp().preset_bundle->filament_ams_list = filament_ams_list;
for (auto c : p->combos_filament){
c->set_sync_badge(false);
c->update();
if (device_change) {
c->ShowBadge(false);//change printer,then clear badge
}
if (!device_change) {
size_t combo_index = 0;
for (const auto &entry : filament_ams_list) {
const auto &tray = entry.second;
const bool has_filament = !tray.opt_string("filament_id", 0u).empty();
const bool is_placeholder = tray.has("filament_slot_placeholder") &&
tray.opt_bool("filament_slot_placeholder", 0u);
if (!has_filament && !is_placeholder) {
continue;
}
if (combo_index >= p->combos_filament.size()) {
break;
}
const auto *filament_changed = dynamic_cast<const ConfigOptionBool *>(tray.option("filament_changed"));
p->combos_filament[combo_index]->set_sync_badge(
has_filament && !is_placeholder && filament_changed != nullptr && filament_changed->value);
++combo_index;
}
}
@@ -4526,18 +4563,32 @@ void Sidebar::sync_ams_list(bool is_from_big_sync_btn)
auto tip = sync_color_only ? _L("Only filament color information has been synchronized from printer.") :
_L("Filament type and color information have been synchronized, but slot information is not included.");
c->SetToolTip(tip);
c->ShowBadge(true);
c->set_sync_badge(true);
};
{ // badge ams filament
clear_combos_filament_badge();
if (sync_result.direct_sync) {
// Orca: PresetBundle::sync_ams_list rebuilds combos_filament
// 1:1 from the AMS trays that produce a combo (loaded trays + placeholders; non-placeholder
// empty trays are skipped), so every resulting combo is AMS-sourced and gets a badge. The
// previous per-tray index walked the full filament_ams_list (including the skipped empties),
// so an empty slot before a loaded one dropped the badge for the trailing filaments.
for (auto &c : p->combos_filament) {
badge_combox_filament(c);
// A placeholder contributes a preserved project filament to the
// overwrite result, but it is not AMS-sourced and must not get a
// sync badge. Non-placeholder empty trays are omitted entirely.
size_t combo_index = 0;
for (const auto &entry : wxGetApp().preset_bundle->filament_ams_list) {
const auto &tray = entry.second;
const bool has_filament = !tray.opt_string("filament_id", 0u).empty();
const bool is_placeholder = tray.has("filament_slot_placeholder") &&
tray.opt_bool("filament_slot_placeholder", 0u);
if (!has_filament && !is_placeholder) {
continue;
}
if (combo_index >= p->combos_filament.size()) {
break;
}
if (is_placeholder) {
p->combos_filament[combo_index]->set_sync_badge(false);
} else {
badge_combox_filament(p->combos_filament[combo_index]);
}
++combo_index;
}
}
}
@@ -4804,6 +4855,11 @@ template<typename T> void setup_dialog_position(T& info)
void Sidebar::pop_sync_nozzle_and_ams_dialog() {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " begin pop_sync_nozzle_and_ams_dialog";
auto agent = wxGetApp().getAgent();
if (!agent || agent->get_filament_sync_mode() == FilamentSyncMode::none) {
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << " filament synchronization is not supported; skipping dialog";
return;
}
wxTheApp->CallAfter([this]() {
SyncNozzleAndAmsDialog::InputInfo temp_na_info;
wxPoint big_btn_pt;
@@ -4935,17 +4991,14 @@ void Sidebar::clear_combos_filament_badge()
{
auto &combos_filament = p->combos_filament;
for (auto &c : combos_filament) { // clear flag
c->ShowBadge(false);
c->set_sync_badge(false);
}
}
void Sidebar::udpate_combos_filament_badge() {
auto &combos_filament = p->combos_filament;
for (auto &c : combos_filament) {
auto selection = c->GetSelection();
auto select_flag = c->GetFlag(selection);
auto ok = select_flag == (int) PresetComboBox::FilamentAMSType::FROM_AMS;
c->ShowBadge(ok);
c->update_badge_according_flag();
}
}
@@ -10487,7 +10540,7 @@ void Plater::priv::on_select_preset(wxCommandEvent &evt)
sidebar->auto_calc_flushing_volumes(idx);
}
auto select_flag = combo->GetFlag(selection);
combo->ShowBadge(select_flag == (int)PresetComboBox::FilamentAMSType::FROM_AMS);
combo->set_sync_badge(select_flag == (int)PresetComboBox::FilamentAMSType::FROM_AMS);
q->on_filament_change(idx);
}
bool select_preset = !combo->selection_is_changed_according_to_physical_printers();

View File

@@ -11,6 +11,7 @@ namespace Slic3r
// IMPORTANT: ordinal order is the Plugins dialog Status sort priority.
Activated,
Error,
RuntimeError,
Inactive,
Loading
};
@@ -21,11 +22,28 @@ namespace Slic3r
{
case PluginStatus::Activated: return "Activated";
case PluginStatus::Error: return "Error";
case PluginStatus::RuntimeError: return "RuntimeError";
case PluginStatus::Inactive: return "Inactive";
case PluginStatus::Loading: return "Loading";
}
return "Inactive";
}
// why: a plugin whose module is live but whose catalog carries an error is a
// RUNTIME fault (e.g. a capability rejected at register time) - it stays
// loaded/checked and is only flagged, distinct from a load-time Error where
// the module never came up. Loading wins over both so an in-flight reload
// never flashes an error.
inline PluginStatus resolve_plugin_status(bool loading, bool has_error, bool is_loaded)
{
if (loading)
return PluginStatus::Loading;
if (has_error)
return is_loaded ? PluginStatus::RuntimeError : PluginStatus::Error;
if (is_loaded)
return PluginStatus::Activated;
return PluginStatus::Inactive;
}
}
} // namespace Slic3r::GUI

View File

@@ -236,6 +236,7 @@ nlohmann::json build_plugin_payload_item(const PluginDialogItem& dialog_item)
payload_item["label"] = dialog_item.display_name;
payload_item["source"] = to_string(dialog_item.source);
payload_item["status"] = to_string(dialog_item.status);
payload_item["is_loaded"] = dialog_item.is_loaded;
payload_item["error"] = dialog_item.error_text;
payload_item["update_status"] = to_string(dialog_item.update_status);
payload_item["unauthorized"] = dialog_item.unauthorized;
@@ -380,14 +381,7 @@ PluginDialogItem build_plugin_dialog_item(const PluginDescriptor& descriptor)
item.sharing_token = descriptor.sharing_token;
item.thumbnail_url = descriptor.thumbnail_url;
if (item.loading)
item.status = PluginStatus::Loading;
else if (item.has_error)
item.status = PluginStatus::Error;
else if (item.is_loaded)
item.status = PluginStatus::Activated;
else
item.status = PluginStatus::Inactive;
item.status = resolve_plugin_status(item.loading, item.has_error, item.is_loaded);
item.available_actions = evaluate_action_policy(item);
const bool has_enabled_script = std::any_of(item.capabilities.begin(), item.capabilities.end(),
@@ -663,6 +657,9 @@ void PluginsDialog::toggle_plugin(const std::string& plugin_key, bool enabled)
}
BOOST_LOG_TRIVIAL(info) << "Plugin unloaded from Plugins dialog: " << plugin_key;
// A user-disabled plugin has no meaningful error state.
if (!manager.clear_plugin_error(plugin_key))
BOOST_LOG_TRIVIAL(warning) << "Failed to clear plugin error for " << plugin_key << " (failed to find)";
// A prior activation of this plugin is moot now; drop it so no stale "Activated" arrives later.
if (m_activating_plugin_key == plugin_key)
m_activating_plugin_key.clear();

View File

@@ -994,7 +994,13 @@ void PlaterPresetComboBox::update_badge_according_flag() {
auto selection = GetSelection();
auto select_flag = GetFlag(selection);
auto ok = select_flag == (int) PresetComboBox::FilamentAMSType::FROM_AMS;
ShowBadge(ok);
ShowBadge(m_sync_badge || ok);
}
void PlaterPresetComboBox::set_sync_badge(bool show)
{
m_sync_badge = show;
ShowBadge(show);
}
bool PlaterPresetComboBox::switch_to_tab()

View File

@@ -205,6 +205,7 @@ public:
void msw_rescale() override;
void OnSelect(wxCommandEvent& evt) override;
void update_badge_according_flag();
void set_sync_badge(bool show);
FilamentColor get_cur_color_info();
void show_default_color_picker();
@@ -214,6 +215,7 @@ public:
private:
// BBS
wxColor m_color;
bool m_sync_badge{false};
};

View File

@@ -1736,7 +1736,7 @@ void InputIpAddressDialog::set_machine_obj(MachineObject* obj)
auto str_ip = m_input_ip->GetTextCtrl()->GetValue();
auto str_access_code = m_input_access_code->GetTextCtrl()->GetValue();
// ORCA enabling / disabling buttons with conditions enough to change its style
m_button_ok->Enable(isIp(str_ip.ToStdString()) && str_access_code.Length() == 8);
m_button_ok->Enable(isIp(str_ip.ToStdString()) && str_access_code.Length() >= 8);
Layout();
Fit();
@@ -2056,10 +2056,6 @@ void InputIpAddressDialog::on_text(wxCommandEvent &evt)
auto str_ip = m_input_ip->GetTextCtrl()->GetValue();
auto str_access_code = m_input_access_code->GetTextCtrl()->GetValue();
if (str_access_code.empty()) {
str_access_code = "88888888";
}
auto str_name = m_input_printer_name->GetTextCtrl()->GetValue().Strip(wxString::both);
auto str_sn = m_input_sn->GetTextCtrl()->GetValue().Strip(wxString::both);
bool invalid_access_code = true;
@@ -2072,7 +2068,8 @@ void InputIpAddressDialog::on_text(wxCommandEvent &evt)
}
// ORCA enabling / disabling buttons with conditions enough to change its style
bool enable_btns = isIp(str_ip.ToStdString()) && str_access_code.Length() == 8 && invalid_access_code;
bool valid_access_code_length = str_access_code.Length() >= 8;
bool enable_btns = isIp(str_ip.ToStdString()) && valid_access_code_length && invalid_access_code;
m_button_manual_setup->Enable(enable_btns);
m_button_ok->Enable(enable_btns);

View File

@@ -3695,10 +3695,32 @@ void SelectMachineDialog::on_send_print()
m_print_job->on_success([this]() { finish_mode(); });
m_print_job->on_check_ip_address_fail([this]() {
wxCommandEvent* evt = new wxCommandEvent(EVT_CLEAR_IPADDRESS);
wxQueueEvent(this, evt);
wxGetApp().show_ip_address_enter_dialog();
});
// Invoked from the PrintJob worker thread when the LAN pre-flight (file upload
// verification) fails. Marshal device/UI access to the main thread.
CallAfter([this]()
{
// Reset the dialog out of sending mode so the user can retry.
wxCommandEvent* evt = new wxCommandEvent(EVT_CLEAR_IPADDRESS);
wxQueueEvent(this, evt);
DeviceManager* dev = wxGetApp().getDeviceManager();
MachineObject* obj = dev ? dev->get_selected_machine() : nullptr;
if (obj && obj->is_connected())
{
// Connected: failed on file upload
MessageDialog dlg(this,
_L("Failed to upload the file to the printer's storage. Please try again."),
_L("Send Failed"), wxOK | wxICON_ERROR);
dlg.ShowModal();
}
else
{
// Not connected: reenter ip and access code
wxGetApp().show_ip_address_enter_dialog();
}
});
});
// update ota version
NetworkAgent* agent = wxGetApp().getAgent();
@@ -4528,11 +4550,13 @@ bool SelectMachineDialog::CheckErrorExtruderNozzleWithSlicing(MachineObject* obj
// check nozzle data valid
{
if (installed_ext_nozzle.GetNozzleType() == NozzleType::ntUndefine ||
installed_ext_nozzle.GetNozzleDiameter() <= 0.0f) {
show_status(PrintDialogStatus::PrintStatusNozzleDataInvalid);
return false;
}
// Commented out the following as ntUndefine and 0.0f are default values
// (signifying that the value is not given) that should PASS, not fail
// if (installed_ext_nozzle.GetNozzleType() == NozzleType::ntUndefine ||
// installed_ext_nozzle.GetNozzleDiameter() <= 0.0f) {
// show_status(PrintDialogStatus::PrintStatusNozzleDataInvalid);
// return false;
// }
if (obj_->is_nozzle_flow_type_supported() &&
installed_ext_nozzle.GetNozzleFlowType() == NozzleFlowType::NONE_FLOWTYPE) {
@@ -4561,7 +4585,10 @@ bool SelectMachineDialog::CheckErrorExtruderNozzleWithSlicing(MachineObject* obj
// check nozzle diameter
{
if (slicing_ext.nozzle_diameter != installed_ext_nozzle.GetNozzleDiameter()) {
// 0.0f is default when there is no nozzle diameter is given.
// In nozzle_diameter == 0.0f case, it passes and does not require a comparison
if (installed_ext_nozzle.GetNozzleDiameter() > 0.0f &&
slicing_ext.nozzle_diameter != installed_ext_nozzle.GetNozzleDiameter()) {
std::vector<wxString> msg_params;
if (ext_sys->GetTotalExtderCount() == 2) {
const wxString& mismatch_nozzle_str = _get_nozzle_name(ext_sys->GetTotalExtderCount(), slicing_ext_idx);

View File

@@ -300,7 +300,7 @@ SendToPrinterDialog::SendToPrinterDialog(Plater *plater)
m_storage_panel->Layout();
// try to connect
m_statictext_printer_msg = new wxStaticText(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, wxALIGN_CENTER_HORIZONTAL);
m_statictext_printer_msg = new wxStaticText(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxSize(FromDIP(400), -1), wxALIGN_CENTER_HORIZONTAL);
m_statictext_printer_msg->SetFont(::Label::Body_13);
m_statictext_printer_msg->SetForegroundColour(*wxBLACK);
m_statictext_printer_msg->Hide();
@@ -760,9 +760,25 @@ void SendToPrinterDialog::update_priner_status_msg(wxString msg, bool is_warning
if (str_new != str_old) {
if (m_statictext_printer_msg->GetLabel() != msg) {
m_statictext_printer_msg->SetLabel(msg);
m_statictext_printer_msg->SetMinSize(wxSize(FromDIP(400), -1));
m_statictext_printer_msg->SetMaxSize(wxSize(FromDIP(400), -1));
m_statictext_printer_msg->Wrap(FromDIP(400));
const int wrap_width = FromDIP(400);
m_statictext_printer_msg->Wrap(wrap_width);
int line_count = 1;
const wxString wrapped_label = m_statictext_printer_msg->GetLabel();
for (size_t i = 0; i < wrapped_label.length(); ++i) {
if (wrapped_label[i] == '\n')
++line_count;
}
wxCoord text_width = 0;
wxCoord text_height = 0;
m_statictext_printer_msg->GetTextExtent(msg, &text_width, &text_height);
const int extent_line_count = text_width > 0 ?
std::max(1, (static_cast<int>(text_width) + wrap_width - 1) / wrap_width) : 1;
line_count = std::max(line_count, extent_line_count);
const int line_height = std::max(m_statictext_printer_msg->GetCharHeight(), static_cast<int>(text_height));
const int min_height = std::max(m_statictext_printer_msg->GetBestSize().GetHeight(),
line_count * line_height + FromDIP(2));
m_statictext_printer_msg->SetMinSize(wxSize(wrap_width, min_height));
m_statictext_printer_msg->SetMaxSize(wxDefaultSize);
m_statictext_printer_msg->Show();
Layout();
Fit();
@@ -1488,6 +1504,9 @@ void SendToPrinterDialog::show_status(PrintDialogStatus status, std::vector<wxSt
Enable_Send_Button(false);
Enable_Refresh_Button(true);
} else if (status == PrintDialogStatus::PrintStatusPublicInitFailed) {
wxString msg_text = _L(
"Failed to initialize the printer file transfer. Please check the connection and try again.");
update_print_status_msg(msg_text, true, true);
Enable_Send_Button(false);
Enable_Refresh_Button(true);
} else if (status == PrintDialogStatus::PrintStatusPublicUploadFiled) {
@@ -1665,30 +1684,18 @@ extern void refresh_agora_url(char const *device, char const *dev_ver, char
void SendToPrinterDialog::GetConnection()
{
DeviceManager *dm = GUI::wxGetApp().getDeviceManager();
MachineObject *obj = dm ? dm->get_selected_machine() : nullptr;
MachineObject *obj = dm->get_selected_machine();
if (obj == nullptr) {
if (!obj)
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " : obj is empty";
m_connection_status = ConnectionStatus::NOT_START;
}
int remote_proto = obj->get_file_remote();
if (!remote_proto) {
if (obj && !obj->get_file_remote())
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " : remote_proto is not support";
m_connection_status = ConnectionStatus::NOT_START;
}
if (obj && obj->is_camera_busy_off())
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " : camera is busy";
if (obj->is_camera_busy_off()) {
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << " : camera is busy";
m_connection_status = ConnectionStatus::NOT_START;
}
NetworkAgent* agent = wxGetApp().getAgent();
NetworkAgent *agent = wxGetApp().getAgent();
std::string agent_version = agent ? agent->get_version() : "";
std::string dev_ver = obj->get_ota_version();
std::string dev_id = obj->get_dev_id();
if (m_url_timer && m_url_timer->IsRunning())
if (m_url_timer && m_url_timer->IsRunning())
{
m_url_timer->Stop();
}
@@ -1711,19 +1718,40 @@ void SendToPrinterDialog::GetConnection()
m_url_timer->GetId());
m_url_timer->StartOnce(8000);
if (agent) {
if (obj && agent)
{
std::string dev_ver = obj->get_ota_version();
std::string dev_id = obj->get_dev_id();
if (m_tcp_try_connect) {
std::string devIP = obj->get_dev_ip();
std::string accessCode = obj->get_access_code();
std::string url = "bambu:///local/" + devIP + "?port=6000&user=" + "bblp" + "&passwd=" + accessCode;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Connect method tcp";
m_filetransfer_tunnel = std::make_unique<FileTransferTunnel>(module(), url);
m_filetransfer_tunnel->on_connection([this](bool is_success, int err_code, std::string error_msg) {
CallAfter([this, is_success, err_code, error_msg]() {
OnConnection(is_success, err_code, error_msg);
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Connect method tcp, dev_id=" << dev_id
<< ", dev_ip=" << devIP << ", access_code_len=" << accessCode.size();
try
{
m_filetransfer_tunnel = std::make_unique<FileTransferTunnel>(module(), url);
m_filetransfer_tunnel->on_connection([this](bool is_success, int err_code, std::string error_msg)
{
CallAfter([this, is_success, err_code, error_msg]()
{
OnConnection(is_success, err_code, error_msg);
});
});
});
m_filetransfer_tunnel->start_connect();
m_filetransfer_tunnel->start_connect();
}
catch (const std::exception& e)
{
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": tcp FileTransferTunnel unavailable for dev_id=" <<
dev_id
<< " dev_ip=" << devIP << ": " << e.what();
if (m_url_timer && m_url_timer->IsRunning()) m_url_timer->Stop();
m_filetransfer_tunnel.reset();
m_connection_status = ConnectionStatus::CONNECTION_FAILED;
show_status(PrintDialogStatus::PrintStatusPublicInitFailed);
}
}
else if (m_tutk_try_connect)
{
@@ -1751,11 +1779,28 @@ void SendToPrinterDialog::GetConnection()
if (boost::algorithm::starts_with(url, "bambu:///"))
{
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Connect method tutk";
m_filetransfer_tunnel = std::make_unique<FileTransferTunnel>(module(), url);
m_filetransfer_tunnel->on_connection([this](bool is_success, int err_code, std::string error_msg) {
CallAfter([this, is_success, err_code, error_msg]() { OnConnection(is_success, err_code, error_msg); });
});
m_filetransfer_tunnel->start_connect();
try
{
m_filetransfer_tunnel = std::make_unique<FileTransferTunnel>(module(), url);
m_filetransfer_tunnel->on_connection(
[this](bool is_success, int err_code, std::string error_msg)
{
CallAfter([this, is_success, err_code, error_msg]()
{
OnConnection(is_success, err_code, error_msg);
});
});
m_filetransfer_tunnel->start_connect();
}
catch (const std::exception& e)
{
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": tutk FileTransferTunnel unavailable: " << e.
what();
if (m_url_timer && m_url_timer->IsRunning()) m_url_timer->Stop();
m_filetransfer_tunnel.reset();
m_connection_status = ConnectionStatus::CONNECTION_FAILED;
show_status(PrintDialogStatus::PrintStatusPublicInitFailed);
}
}
else
{
@@ -1854,8 +1899,17 @@ void SendToPrinterDialog::ResetTunnelAndJob()
void SendToPrinterDialog::CreateMediaAbilityJob()
{
nlohmann::json media_ability = {{"cmd_type", 7}};
m_filetransfer_mediability_job = std::make_unique<FileTransferJob>(module(), std::string(media_ability.dump()));
nlohmann::json media_ability = {{"cmd_type", 7}};
try
{
m_filetransfer_mediability_job = std::make_unique<FileTransferJob>(module(), std::string(media_ability.dump()));
}
catch (const std::exception& e)
{
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": FileTransferJob unavailable: " << e.what();
show_status(PrintDialogStatus::PrintStatusPublicInitFailed);
return;
}
m_filetransfer_mediability_job->on_result([this](int res, int resp_ec, std::string json_res, std::vector<std::byte> bin_res) {
//this pl
CallAfter([this, res, resp_ec, json_res] {
@@ -1910,11 +1964,20 @@ void SendToPrinterDialog::CreateUploadFileJob(const std::string &path, const std
{"cmd_type", 5},
};
upload_params["dest_storage"] = m_selected_storage;
upload_params["dest_name"] = name; // filenme no path
upload_params["file_path"] = path;
upload_params["dest_name"] = name; // filenme no path
upload_params["file_path"] = path;
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Begin CreateUploadFileJob";
m_filetransfer_uploadfile_job = std::make_unique<FileTransferJob>(module(), std::string(upload_params.dump()));
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": Begin CreateUploadFileJob";
try
{
m_filetransfer_uploadfile_job = std::make_unique<FileTransferJob>(module(), std::string(upload_params.dump()));
}
catch (const std::exception& e)
{
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": FileTransferJob unavailable: " << e.what();
show_status(PrintDialogStatus::PrintStatusPublicUploadFiled);
return;
}
m_filetransfer_uploadfile_job->on_result([this](int res, int resp_ec, std::string json_res, std::vector<std::byte> bin_res) { //
CallAfter([this, res, resp_ec, json_res, bin_res] {
UploadFileRessultCallback(res, resp_ec,json_res, bin_res);

View File

@@ -12,6 +12,7 @@
#include "MsgDialog.hpp"
#include "slic3r/Utils/Http.hpp"
#include "slic3r/Utils/MoonrakerPrinterAgent.hpp"
#include "libslic3r/Thread.hpp"
#include "DeviceErrorDialog.hpp"
@@ -2311,6 +2312,40 @@ void StatusPanel::update_camera_state(MachineObject* obj)
{
if (!obj) return;
const bool has_printer_webcam = !obj->webcam_stream_url.empty();
if (has_printer_webcam) {
if (m_printer_webcam_url != obj->webcam_stream_url) {
// why: start timing belongs to the loaded camera.
// carrying it across printers suppresses the new camera's initial start.
m_camera_start_sent = std::chrono::steady_clock::time_point{};
m_custom_camera_view->LoadURL(obj->webcam_stream_url);
m_custom_camera_view->Show();
m_media_ctrl->Hide();
m_media_play_ctrl->Hide();
m_printer_webcam_url = obj->webcam_stream_url;
}
m_camera_switch_button->Hide();
if (!m_custom_camera_view->IsShown()) {
// why: do not compare or reload the WebView URL per tick, or redirects can cause a reload loop.
m_custom_camera_view->Show();
m_media_ctrl->Hide();
m_media_play_ctrl->Hide();
}
// why: printers like the U1 capture only while asked and retire the capture task ~362 s
// after each start, so the open camera view has to renew ahead of that. 300 s matches
// Snapmaker's own client. Agents that do not need it refuse the call silently.
const auto now = std::chrono::steady_clock::now();
if (m_camera_start_sent == std::chrono::steady_clock::time_point{} ||
now - m_camera_start_sent >= std::chrono::seconds(300)) {
obj->command_start_camera();
m_camera_start_sent = now;
}
} else if (!m_printer_webcam_url.empty()) {
handle_camera_source_change();
m_printer_webcam_url.clear();
m_camera_start_sent = std::chrono::steady_clock::time_point{};
}
//sdcard
auto sdcard_state = obj->GetStorage()->get_sdcard_state();
if (m_last_sdcard != sdcard_state) {
@@ -2342,7 +2377,12 @@ void StatusPanel::update_camera_state(MachineObject* obj)
m_last_recording = obj->is_recording() ? 1 : 0;
}
if (!m_bitmap_recording_img->IsShown()) {
if (has_printer_webcam) {
if (m_bitmap_recording_img->IsShown()) {
m_bitmap_recording_img->Hide();
m_panel_monitoring_title->Layout();
}
} else if (!m_bitmap_recording_img->IsShown()) {
m_bitmap_recording_img->Show();
m_panel_monitoring_title->Layout();
}
@@ -2399,6 +2439,8 @@ void StatusPanel::update_camera_state(MachineObject* obj)
bool show_vcamera = m_media_play_ctrl->IsStreaming();
m_camera_popup->update(show_vcamera);
}
m_setting_button->Show(!has_printer_webcam);
}
StatusPanel::StatusPanel(wxWindow *parent, wxWindowID id, const wxPoint &pos, const wxSize &size, long style, const wxString &name)
@@ -2686,7 +2728,8 @@ void StatusPanel::on_subtask_partskip(wxCommandEvent &event)
void StatusPanel::on_subtask_pause_resume(wxCommandEvent &event)
{
if (obj) {
if (obj->can_resume()) {
const bool was_resume = obj->can_resume();
if (was_resume) {
BOOST_LOG_TRIVIAL(info) << "monitor: resume current print task dev_id =" << obj->get_dev_id();
obj->command_task_resume();
}
@@ -2694,6 +2737,13 @@ void StatusPanel::on_subtask_pause_resume(wxCommandEvent &event)
BOOST_LOG_TRIVIAL(info) << "monitor: pause current print task dev_id =" << obj->get_dev_id();
obj->command_task_pause();
}
if (is_moonraker_agent()) {
m_pause_resume_pending = true;
m_pause_resume_was_resume = was_resume;
m_pause_resume_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(6);
m_pause_resume_machine_id = obj->get_dev_id();
m_project_task_panel->enable_pause_resume_button(false, was_resume ? "resume_disable" : "pause_disable");
}
}
}
@@ -2705,6 +2755,12 @@ void StatusPanel::on_subtask_abort(wxCommandEvent &event)
if (obj) {
BOOST_LOG_TRIVIAL(info) << "monitor: stop current print task dev_id =" << obj->get_dev_id();
obj->command_task_abort();
if (is_moonraker_agent()) {
m_abort_pending = true;
m_abort_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(6);
m_abort_machine_id = obj->get_dev_id();
m_project_task_panel->enable_abort_button(false);
}
}
});
}
@@ -3678,6 +3734,25 @@ void StatusPanel::update_model_info()
void StatusPanel::update_subtask(MachineObject *obj)
{
if (!obj) return;
const auto now = std::chrono::steady_clock::now();
if (m_pause_resume_pending) {
if (!is_moonraker_agent() || m_pause_resume_machine_id != obj->get_dev_id() ||
obj->can_resume() != m_pause_resume_was_resume) {
m_pause_resume_pending = false;
} else if (now >= m_pause_resume_deadline) {
BOOST_LOG_TRIVIAL(warning) << "StatusPanel: Moonraker pause/resume command did not change printer state";
m_pause_resume_pending = false;
}
}
if (m_abort_pending) {
if (!is_moonraker_agent() || m_abort_machine_id != obj->get_dev_id() || obj->print_status == "FAILED" ||
obj->print_status == "FINISH" || obj->print_status == "IDLE") {
m_abort_pending = false;
} else if (now >= m_abort_deadline) {
BOOST_LOG_TRIVIAL(warning) << "StatusPanel: Moonraker abort command did not change printer state";
m_abort_pending = false;
}
}
if (m_current_print_mode != PRINGINT) {
if (calib_bitmap == nullptr) {
m_calib_mode = get_obj_calibration_mode(obj, m_calib_method, cali_stage);
@@ -3785,10 +3860,12 @@ void StatusPanel::update_subtask(MachineObject *obj)
}
update_basic_print_data(false);
} else {
if (obj->can_resume()) {
m_project_task_panel->enable_pause_resume_button(true, "resume");
} else {
m_project_task_panel->enable_pause_resume_button(true, "pause");
if (!m_pause_resume_pending) {
if (obj->can_resume()) {
m_project_task_panel->enable_pause_resume_button(true, "resume");
} else {
m_project_task_panel->enable_pause_resume_button(true, "pause");
}
}
m_project_task_panel->enable_partskip_button(obj, true);
// update printing stage
@@ -3845,7 +3922,9 @@ void StatusPanel::update_subtask(MachineObject *obj)
m_project_task_panel->market_scoring_hide();
}
} else { // model printing is not finished, hide scoring page
m_project_task_panel->enable_abort_button(true);
if (!m_abort_pending) {
m_project_task_panel->enable_abort_button(true);
}
m_project_task_panel->market_scoring_hide();
m_project_task_panel->get_request_failed_panel()->Hide();
}
@@ -3920,39 +3999,61 @@ void StatusPanel::update_cloud_subtask(MachineObject *obj)
update_calib_bitmap();
if (obj->slice_info) {
m_request_url = wxString(obj->slice_info->thumbnail_url);
if (!m_request_url.IsEmpty()) {
wxImage img;
std::map<wxString, wxImage>::iterator it = img_list.find(m_request_url);
if (it != img_list.end()) {
if (m_current_print_mode != PrintingTaskType::CALIBRATION ||(m_calib_mode == CalibMode::Calib_Flow_Rate && m_calib_method == CalibrationMethod::CALI_METHOD_MANUAL)) {
img = it->second;
wxImage resize_img = img.Scale(m_project_task_panel->get_bitmap_thumbnail()->GetSize().x, m_project_task_panel->get_bitmap_thumbnail()->GetSize().y);
m_project_task_panel->set_thumbnail_img(resize_img, "");
m_project_task_panel->set_brightness_value(get_brightness_value(resize_img));
}
if (this->obj) {
m_project_task_panel->set_plate_index(obj->m_plate_index);
} else {
m_project_task_panel->set_plate_index(-1);
}
task_thumbnail_state = ThumbnailState::TASK_THUMBNAIL;
BOOST_LOG_TRIVIAL(trace) << "web_request: use cache image";
} else {
web_request = wxWebSession::GetDefault().CreateRequest(this, m_request_url);
BOOST_LOG_TRIVIAL(trace) << "monitor: start request thumbnail, url = " << m_request_url;
web_request.Start();
m_start_loading_thumbnail = false;
}
}
load_thumbnail_from_url(m_request_url, obj);
}
}
}
bool StatusPanel::load_thumbnail_from_url(const wxString &url, MachineObject *obj)
{
if (url.IsEmpty())
return false;
wxImage img;
std::map<wxString, wxImage>::iterator it = img_list.find(url);
if (it != img_list.end()) {
if (m_current_print_mode != PrintingTaskType::CALIBRATION ||(m_calib_mode == CalibMode::Calib_Flow_Rate && m_calib_method == CalibrationMethod::CALI_METHOD_MANUAL)) {
img = it->second;
wxImage resize_img = img.Scale(m_project_task_panel->get_bitmap_thumbnail()->GetSize().x, m_project_task_panel->get_bitmap_thumbnail()->GetSize().y);
m_project_task_panel->set_thumbnail_img(resize_img, "");
m_project_task_panel->set_brightness_value(get_brightness_value(resize_img));
}
if (this->obj) {
m_project_task_panel->set_plate_index(obj->m_plate_index);
} else {
m_project_task_panel->set_plate_index(-1);
}
task_thumbnail_state = ThumbnailState::TASK_THUMBNAIL;
BOOST_LOG_TRIVIAL(trace) << "web_request: use cache image";
} else {
m_request_url = url;
web_request = wxWebSession::GetDefault().CreateRequest(this, m_request_url);
BOOST_LOG_TRIVIAL(trace) << "monitor: start request thumbnail, url = " << m_request_url;
web_request.Start();
m_start_loading_thumbnail = false;
}
return true;
}
void StatusPanel::update_sdcard_subtask(MachineObject *obj)
{
if (!obj) return;
if (!m_load_sdcard_thumbnail) {
const wxString thumbnail_url = wxString(obj->m_agent_thumbnail_url);
if (!thumbnail_url.IsEmpty()) {
// why: Moonraker has no prediction or weight data, so keep it on the sdcard path.
if (m_request_url != thumbnail_url || !m_load_sdcard_thumbnail) {
if (web_request.IsOk() && web_request.GetState() == wxWebRequest::State_Active)
web_request.Cancel();
update_calib_bitmap();
m_request_url = thumbnail_url;
load_thumbnail_from_url(thumbnail_url, obj);
m_load_sdcard_thumbnail = true;
}
return;
}
if (!m_load_sdcard_thumbnail || !m_request_url.IsEmpty()) {
update_calib_bitmap();
if (m_current_print_mode != PrintingTaskType::CALIBRATION) {
m_project_task_panel->get_bitmap_thumbnail()->SetBitmap(m_thumbnail_sdcard.bmp());
@@ -3960,11 +4061,14 @@ void StatusPanel::update_sdcard_subtask(MachineObject *obj)
}
task_thumbnail_state = ThumbnailState::SDCARD_THUMBNAIL;
m_load_sdcard_thumbnail = true;
m_request_url.clear();
}
}
void StatusPanel::reset_printing_values()
{
m_pause_resume_pending = false;
m_abort_pending = false;
m_project_task_panel->enable_partskip_button(nullptr, false);
m_project_task_panel->enable_pause_resume_button(false, "pause_disable");
m_project_task_panel->enable_abort_button(false);
@@ -3990,6 +4094,12 @@ void StatusPanel::reset_printing_values()
this->Layout();
}
bool StatusPanel::is_moonraker_agent() const
{
auto* agent = wxGetApp().getAgent();
return agent && std::dynamic_pointer_cast<Slic3r::MoonrakerPrinterAgent>(agent->get_printer_agent()) != nullptr;
}
void StatusPanel::on_axis_ctrl_xy(wxCommandEvent &event)
{
if (!obj) return;
@@ -5171,6 +5281,10 @@ bool StatusPanel::is_stage_list_info_changed(MachineObject *obj)
void StatusPanel::set_default()
{
BOOST_LOG_TRIVIAL(trace) << "status_panel: set_default";
if (!m_printer_webcam_url.empty()) {
handle_camera_source_change();
m_printer_webcam_url.clear();
}
obj = nullptr;
last_subtask = nullptr;
last_tray_exist_bits = -1;

View File

@@ -14,6 +14,7 @@
#include <wx/sizer.h>
#include <wx/gbsizer.h>
#include <wx/webrequest.h>
#include <chrono>
#include "wxMediaCtrl2.h"
#include "MediaPlayCtrl.h"
#include "AMSSetting.hpp"
@@ -630,6 +631,7 @@ class StatusPanel : public StatusBasePanel
{
private:
friend class MonitorPanel;
bool load_thumbnail_from_url(const wxString &url, MachineObject *obj);
protected:
std::shared_ptr<SliceInfoPopup> m_slice_info_popup;
@@ -663,6 +665,9 @@ protected:
int m_last_timelapse = -1;
int m_last_extrusion = -1;
int m_last_vcamera = -1;
std::string m_printer_webcam_url;
// note: zero = not started; see update_camera_state() for the renew interval.
std::chrono::steady_clock::time_point m_camera_start_sent{};
int m_model_mall_request_count = 0;
bool m_is_load_with_temp = false;
json m_rating_result;
@@ -686,6 +691,13 @@ protected:
CalibrationMethod m_calib_method;
int cali_stage;
PrintingTaskType m_current_print_mode = PrintingTaskType::NOT_CLEAR;
bool m_pause_resume_pending = false;
bool m_pause_resume_was_resume = false;
std::chrono::steady_clock::time_point m_pause_resume_deadline;
std::string m_pause_resume_machine_id;
bool m_abort_pending = false;
std::chrono::steady_clock::time_point m_abort_deadline;
std::string m_abort_machine_id;
void init_scaled_buttons();
void create_tasklist_info();
@@ -788,6 +800,7 @@ protected:
void update_calib_bitmap();
void reset_printing_values();
bool is_moonraker_agent() const;
void on_webrequest_state(wxWebRequestEvent &evt);
bool is_task_changed(MachineObject* obj);

View File

@@ -3176,6 +3176,7 @@ SyncAmsInfoDialog::~SyncAmsInfoDialog() {
void SyncAmsInfoDialog::set_info(SyncInfo &info)
{
m_input_info = info;
reinit_dialog();
}
void SyncAmsInfoDialog::update_lan_machine_list()

View File

@@ -2,7 +2,9 @@
#include "BBLNetworkPlugin.hpp"
#include "NetworkAgentFactory.hpp"
#include <boost/format.hpp>
#include <boost/log/trivial.hpp>
#include <nlohmann/json.hpp>
namespace Slic3r {
@@ -20,6 +22,65 @@ void BBLPrinterAgent::set_cloud_agent(std::shared_ptr<ICloudServiceAgent> cloud)
// Communication
// ============================================================================
std::string BBLPrinterAgent::ams_refresh_rfid_gcode(const std::string& tray_id)
{
return (boost::format("M620 R%1% \n") % tray_id).str();
}
std::string BBLPrinterAgent::ams_calibrate_gcode(int ams_id)
{
return (boost::format("M620 C%1% \n") % ams_id).str();
}
std::string BBLPrinterAgent::ams_select_tray_gcode(const std::string& tray_id)
{
return (boost::format("M620 P%1% \n") % tray_id).str();
}
int BBLPrinterAgent::command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode)
{
const std::string gcode = ams_refresh_rfid_gcode(tray_id);
BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode;
nlohmann::json j;
j["print"]["command"] = "gcode_line";
j["print"]["param"] = gcode;
j["print"]["sequence_id"] = std::to_string(sequence_id);
return publish(dev_id, j, lan_mode);
}
int BBLPrinterAgent::command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode)
{
const std::string gcode = ams_calibrate_gcode(ams_id);
BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode;
nlohmann::json j;
j["print"]["command"] = "gcode_line";
j["print"]["param"] = gcode;
j["print"]["sequence_id"] = std::to_string(sequence_id);
return publish(dev_id, j, lan_mode);
}
int BBLPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode)
{
const std::string gcode = ams_select_tray_gcode(tray_id);
BOOST_LOG_TRIVIAL(trace) << "ams_debug: gcode_cmd" << gcode;
nlohmann::json j;
j["print"]["command"] = "gcode_line";
j["print"]["param"] = gcode;
j["print"]["sequence_id"] = std::to_string(sequence_id);
return publish(dev_id, j, 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);
if (rtn == 0) {
BOOST_LOG_TRIVIAL(info) << "publish_json: " << j.dump() << " code: " << rtn;
} else {
BOOST_LOG_TRIVIAL(error) << "publish_json: " << j.dump() << " code: " << rtn;
}
return rtn;
}
int BBLPrinterAgent::send_message(std::string dev_id, std::string json_str, int qos, int flag)
{
auto& plugin = BBLNetworkPlugin::instance();

View File

@@ -5,6 +5,7 @@
#include "ICloudServiceAgent.hpp"
#include <string>
#include <memory>
#include <nlohmann/json.hpp>
namespace Slic3r {
@@ -28,6 +29,12 @@ public:
// Communication
int send_message(std::string dev_id, std::string json_str, int qos, int flag) override;
static std::string ams_refresh_rfid_gcode(const std::string& tray_id);
static std::string ams_calibrate_gcode(int ams_id);
static std::string ams_select_tray_gcode(const std::string& tray_id);
int command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override;
int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode) override;
int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode) override;
int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override;
int disconnect_printer() override;
int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override;
@@ -85,6 +92,9 @@ public:
FilamentSyncMode get_filament_sync_mode() const override;
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);
std::shared_ptr<ICloudServiceAgent> m_cloud_agent;
};

View File

@@ -233,8 +233,11 @@ bool CrealityPrintAgent::parse_cfs_response(const std::string& response,
return true;
}
bool CrealityPrintAgent::fetch_filament_info(std::string dev_id)
bool CrealityPrintAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
{
if (sync_mode != get_filament_sync_mode())
return false;
if (device_info.dev_ip.empty()) {
BOOST_LOG_TRIVIAL(warning)
<< "CrealityPrintAgent::fetch_filament_info: no device IP, falling back to base agent";

View File

@@ -1,6 +1,7 @@
#ifndef __CREALITY_PRINT_AGENT_HPP__
#define __CREALITY_PRINT_AGENT_HPP__
#include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp"
#include <string>
@@ -41,7 +42,7 @@ public:
static AgentInfo get_agent_info_static();
AgentInfo get_agent_info() override { return get_agent_info_static(); }
bool fetch_filament_info(std::string dev_id) override;
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
// 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.

View File

@@ -84,6 +84,21 @@ public:
*/
virtual int send_message(std::string dev_id, std::string json_str, int qos, int flag) = 0;
// why: gcode is firmware dialect, not a waist concept - commands whose body is Bambu-dialect
// gcode live on the agent that speaks it; the default is an honest refusal that MachineObject's
// publish funnel turns into a dialog.
virtual int command_ams_refresh_rfid(std::string, std::string, int, bool)
{ return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; }
virtual int command_ams_calibrate(std::string, int, int, bool)
{ return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; }
virtual int command_ams_select_tray(std::string, std::string, int, bool)
{ return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; }
// why: some printers emit camera frames only while explicitly asked, and retire the
// capture task on their own - the camera view starts it and renews it. Printers with an
// always-on stream need nothing here, hence the honest refusal by default.
virtual int command_start_camera(std::string)
{ return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED; }
/**
* Establish a direct LAN connection to a printer.
*/
@@ -289,7 +304,7 @@ public:
* Should only be called when get_filament_sync_mode() returns FilamentSyncMode::pull.
* Populates the MachineObject's DevFilaSystem with fetched filament data.
*/
virtual bool fetch_filament_info(std::string dev_id) { return false; }
virtual bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) { return false; }
};
} // namespace Slic3r

File diff suppressed because it is too large Load Diff

View File

@@ -9,11 +9,16 @@
#include <set>
#include <string>
#include <thread>
#include <condition_variable>
#include <deque>
#include <functional>
#include <nlohmann/json.hpp>
namespace Slic3r {
bool moonraker_is_light_name(const std::string& name);
class MoonrakerPrinterAgent : public IPrinterAgent
{
public:
@@ -71,7 +76,7 @@ public:
// Pull-mode agent (on-demand filament sync)
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::pull; }
bool fetch_filament_info(std::string dev_id) override;
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
protected:
struct MoonrakerDeviceInfo
@@ -85,6 +90,7 @@ protected:
std::string dev_name;
std::string version;
std::string klippy_state;
float nozzle_diameter = 0.0f;
bool use_ssl = false;
} device_info;
@@ -105,14 +111,21 @@ protected:
// Methods that derived classes may need to override or access
virtual bool init_device_info(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl);
virtual bool fetch_device_info(const std::string& base_url, const std::string& api_key, MoonrakerDeviceInfo& info, std::string& error) const;
static float parse_nozzle_diameter(const nlohmann::json& response);
// State access for derived classes
mutable std::recursive_mutex state_mutex;
// Counts detached fetch_filament_info() background threads currently touching `this`
// (see QidiPrinterAgent::fetch_filament_info). Those threads hold a raw `this` with no
// other lifetime protection, so the destructor waits for this to reach 0 before any part
// of the object is torn down — see ~MoonrakerPrinterAgent().
std::atomic<int> filament_fetch_in_flight{0};
// Helpers
bool is_numeric(const std::string& value);
std::string normalize_base_url(std::string host, const std::string& port);
std::string sanitize_filename(const std::string& filename);
std::string sanitize_filename(const std::string& filename) const;
std::string join_url(const std::string& base_url, const std::string& path) const;
// Trim whitespace and convert to uppercase
@@ -121,6 +134,22 @@ protected:
// Map filament type to OrcaFilamentLibrary preset ID for AMS sync compatibility
static std::string map_filament_type_to_generic_id(const std::string& filament_type);
// Send a G-code script via Moonraker (/printer/gcode/script)
bool send_gcode(const std::string& dev_id, const std::string& gcode) const;
bool send_gcode(const std::string& dev_id, const std::string& gcode,
const std::string& base_url, const std::string& api_key) const;
bool post_print_action(const std::string& action) const;
bool post_print_action(const std::string& action,
const std::string& base_url, const std::string& api_key) const;
// Send one JSON-RPC call over a short-lived Moonraker websocket. Returns true when the
// request was written; it never waits for a reply.
bool send_ws_rpc(const std::string& method, const nlohmann::json& params);
// why: a printer with no /server/webcams/list entry can still name its stream directly;
// returning empty (the default) keeps the normal Moonraker discovery path.
virtual std::string webcam_stream_override(const std::string& base_url) const { return {}; }
private:
int handle_request(const std::string& dev_id, const std::string& json_str);
int send_version_info(const std::string& dev_id);
@@ -128,7 +157,7 @@ private:
bool fetch_object_list(const std::string& base_url, const std::string& api_key, std::set<std::string>& objects, std::string& error) const;
bool query_printer_status(const std::string& base_url, const std::string& api_key, nlohmann::json& status, std::string& error) const;
bool send_gcode(const std::string& dev_id, const std::string& gcode) const;
bool fetch_webcam_info(const std::string& base_url, const std::string& api_key, uint64_t generation);
void announce_printhost_device();
void dispatch_local_connect(int state, const std::string& dev_id, const std::string& msg);
@@ -137,7 +166,8 @@ private:
void start_status_stream(const std::string& dev_id, const std::string& base_url, const std::string& api_key);
void stop_status_stream();
void run_status_stream(std::string dev_id, std::string base_url, std::string api_key);
void handle_ws_message(const std::string& dev_id, const std::string& payload);
void handle_ws_message(std::string dev_id, std::string payload, std::string base_url, std::string api_key);
void refresh_thumbnail_url(std::string base_url, std::string api_key);
void update_status_cache(const nlohmann::json& updates);
nlohmann::json build_print_payload_locked() const;
@@ -151,9 +181,10 @@ private:
const std::string& base_url, const std::string& api_key,
OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn);
// JSON-RPC helper
bool send_jsonrpc_command(const std::string& base_url, const std::string& api_key,
const nlohmann::json& request, std::string& response) const;
// Start a print of a previously uploaded G-code file (path relative to the
// Moonraker gcodes root).
bool start_print_file(const std::string& base_url, const std::string& api_key,
const std::string& filename, std::string& error_msg) const;
// Connection thread management
void perform_connection_async(const std::string& dev_id,
@@ -189,15 +220,26 @@ private:
mutable std::recursive_mutex payload_mutex;
nlohmann::json status_cache;
// note: guarded by payload_mutex; filled by refresh_thumbnail_url(), empty url = looked up, none found
std::string thumbnail_filename;
std::string thumbnail_url;
std::string webcam_stream_url;
unsigned thumbnail_lookup_attempts = 0;
std::atomic<int> next_jsonrpc_id{1};
std::set<std::string> available_objects; // Track for feature detection
bool assumed_light_on = false;
std::atomic<bool> ws_stop{false};
std::atomic<bool> ws_reconnect_requested{false}; // Flag to trigger reconnection
std::atomic<uint64_t> ws_last_emit_ms{0};
std::thread ws_thread;
// AMS/filament refresh cadence, independent of telemetry dispatch so a steady
// stream of status updates can't starve it (ws_last_emit_ms is reset by those).
static constexpr uint64_t AMS_REFRESH_INTERVAL_MS = 10000;
std::atomic<uint64_t> ams_last_fetch_ms{0};
// Throttling configuration for WebSocket updates
// Critical changes (state transitions) dispatch immediately; telemetry is throttled
static constexpr uint64_t STATUS_UPDATE_INTERVAL_MS = 1000; // 1 update/sec for telemetry
@@ -207,7 +249,15 @@ private:
// Connection thread management
std::atomic<uint64_t> connect_generation{0};
std::thread connect_thread;
std::recursive_mutex connect_mutex;
mutable std::recursive_mutex connect_mutex;
void enqueue_command(std::function<void()> fn);
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

View File

@@ -767,6 +767,34 @@ int NetworkAgent::send_message(std::string dev_id, std::string json_str, int qos
return -1;
}
int NetworkAgent::command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode)
{
if (m_printer_agent)
return m_printer_agent->command_ams_refresh_rfid(dev_id, tray_id, sequence_id, lan_mode);
return -1;
}
int NetworkAgent::command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode)
{
if (m_printer_agent)
return m_printer_agent->command_ams_calibrate(dev_id, ams_id, sequence_id, lan_mode);
return -1;
}
int NetworkAgent::command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode)
{
if (m_printer_agent)
return m_printer_agent->command_ams_select_tray(dev_id, tray_id, sequence_id, lan_mode);
return -1;
}
int NetworkAgent::command_start_camera(std::string dev_id)
{
if (m_printer_agent)
return m_printer_agent->command_start_camera(dev_id);
return -1;
}
int NetworkAgent::connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl)
{
if (m_printer_agent)
@@ -921,10 +949,10 @@ FilamentSyncMode NetworkAgent::get_filament_sync_mode() const
return FilamentSyncMode::none;
}
bool NetworkAgent::fetch_filament_info(std::string dev_id)
bool NetworkAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
{
if (m_printer_agent) {
return m_printer_agent->fetch_filament_info(dev_id);
return m_printer_agent->fetch_filament_info(dev_id, sync_mode);
}
return false;
}

View File

@@ -142,6 +142,10 @@ public:
int set_on_local_message_fn(OnMessageFn fn);
int set_server_callback(OnServerErrFn fn);
int send_message(std::string dev_id, std::string json_str, int qos, int flag);
int command_ams_refresh_rfid(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode);
int command_ams_calibrate(std::string dev_id, int ams_id, int sequence_id, bool lan_mode);
int command_ams_select_tray(std::string dev_id, std::string tray_id, int sequence_id, bool lan_mode);
int command_start_camera(std::string dev_id);
int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl);
int disconnect_printer();
int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag);
@@ -164,7 +168,7 @@ public:
int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn);
int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn);
FilamentSyncMode get_filament_sync_mode() const;
bool fetch_filament_info(std::string dev_id);
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull);
int request_bind_ticket(std::string* ticket);
int get_hms_snapshot(std::string dev_id, std::string file_name, std::function<void(std::string, int)> callback);

View File

@@ -16,8 +16,10 @@
#include <mutex>
#include <utility>
#include <slic3r/GUI/GUI_App.hpp>
#include <slic3r/GUI/I18N.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <wx/msgdlg.h>
namespace Slic3r {
namespace {
@@ -314,6 +316,21 @@ void NetworkAgentFactory::register_python_printer_agent(const std::string& plugi
std::shared_ptr<IPrinterAgent> cached_agent;
auto reject_conflicting_capability = [plugin_key, capability_name](const std::string& error_message) {
if (!wxTheApp || GUI::wxGetApp().is_closing())
return;
GUI::wxGetApp().CallAfter([plugin_key, capability_name, error_message]() {
if (GUI::wxGetApp().is_closing())
return;
PluginManager& manager = PluginManager::instance();
manager.set_plugin_error(plugin_key, error_message);
// note: the unload callback triggered by disabling will call deregister,
// which will be a no-op since the printer agent is never registered
manager.set_capability_enabled({PluginCapabilityType::PrinterConnection, capability_name, plugin_key}, false);
wxMessageBox(wxString::FromUTF8(error_message.c_str()), _L("Plugins"), wxOK | wxICON_WARNING, GUI::wxGetApp().GetTopWindow());
});
};
{
std::lock_guard<std::mutex> lock(s_registry_mutex);
@@ -332,9 +349,10 @@ void NetworkAgentFactory::register_python_printer_agent(const std::string& plugi
auto& python_agent_ids = get_python_printer_agent_ids();
for (const auto& pair : python_agent_ids) {
if (pair.first != capability_key && pair.second == info.id) {
BOOST_LOG_TRIVIAL(warning) << "Printer-agent plugin '" << capability_name << "' uses duplicate agent ID '" << info.id
<< "' already registered by capability '" << pair.first.second << "' from plugin '"
<< pair.first.first << "'";
const std::string error_message = "Printer-agent '" + info.name + "' could not be enabled: agent ID '" + info.id +
"' is already registered by capability '" + pair.first.second + "' from plugin '" + pair.first.first + "'.";
BOOST_LOG_TRIVIAL(warning) << error_message;
reject_conflicting_capability(error_message);
return;
}
}
@@ -354,12 +372,22 @@ void NetworkAgentFactory::register_python_printer_agent(const std::string& plugi
auto& agents = get_printer_agents();
auto agent_it = agents.find(info.id);
// why: reject only when the ID is owned by SOMEONE ELSE - a built-in has an empty
// plugin_identifier, another plugin/capability has a different plugin_full_ref. When it
// IS the same plugin_full_ref, this capability is just re-registering itself, so fall
// through and refresh.
if (agent_it != agents.end() && agent_it->second.plugin_identifier != plugin_full_ref) {
BOOST_LOG_TRIVIAL(warning) << "Printer-agent plugin '" << capability_name << "' uses agent ID '" << info.id
<< "' already registered by '" << agent_it->second.display_name << "'";
const std::string error_message = "Printer-agent '" + info.name + "' could not be enabled: agent ID '" + info.id +
"' is already registered by '" + agent_it->second.display_name + "'.";
BOOST_LOG_TRIVIAL(warning) << error_message;
reject_conflicting_capability(error_message);
return;
}
// why: insert_or_assign, not emplace - reaching here means the ID is new, or the same
// capability is re-registering (same plugin_full_ref). In the re-register case we WANT to
// overwrite so the factory closure points at the current live capability instance; emplace
// would silently keep the stale entry.
agents.insert_or_assign(info.id, PrinterAgentInfo(info.id, info.name, plugin_full_ref, std::move(factory)));
python_agent_ids[capability_key] = info.id;

View File

@@ -8,6 +8,7 @@
#include <boost/log/trivial.hpp>
#include <cctype>
#include <sstream>
#include <thread>
namespace Slic3r {
@@ -25,6 +26,15 @@ bool has_visible_base_preset(const PresetCollection& filaments, const std::strin
return false;
}
// RAII decrement for MoonrakerPrinterAgent::filament_fetch_in_flight — guarantees the
// counter drops back down on every exit path (early return or fall-through) inside the
// detached fetch thread below, so ~MoonrakerPrinterAgent()'s wait loop can't stall forever.
struct InFlightGuard
{
std::atomic<int>& counter;
~InFlightGuard() { counter.fetch_sub(1, std::memory_order_relaxed); }
};
} // anonymous namespace
const std::string QidiPrinterAgent_VERSION = "0.0.1";
@@ -38,42 +48,134 @@ AgentInfo QidiPrinterAgent::get_agent_info_static()
return AgentInfo{"qidi", "Qidi", QidiPrinterAgent_VERSION, "Qidi printer agent"};
}
bool QidiPrinterAgent::fetch_filament_info(std::string dev_id)
bool QidiPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
{
std::string error;
if (sync_mode != get_filament_sync_mode())
return false;
// 1. Fetch device info and infer series_id
std::string series_id;
{
MoonrakerDeviceInfo info;
if (fetch_device_info(device_info.base_url, device_info.api_key, info, error)) {
series_id = infer_series_id(info.model_id, info.dev_name);
// Snapshot only what the fetch needs, rather than reading device_info live from the
// background thread below — device_info can be concurrently rewritten by a reconnect
// on another thread while this fetch is still in flight.
std::string base_url = device_info.base_url;
std::string api_key = device_info.api_key;
std::string model_id = device_info.model_id;
std::string model_name = device_info.model_name;
filament_fetch_in_flight.fetch_add(1, std::memory_order_relaxed);
std::thread([this, base_url, api_key, model_id, model_name]() {
InFlightGuard guard{filament_fetch_in_flight};
std::string error;
// 1. Fetch device info and infer series_id
std::string series_id;
{
MoonrakerDeviceInfo info;
if (fetch_device_info(base_url, api_key, info, error)) {
series_id = infer_series_id(info.model_id, info.dev_name);
}
}
if (series_id.empty()) {
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
series_id = infer_series_id(model_id, model_name);
}
}
if (series_id.empty()) {
// Fall back to the configured Orca model if Moonraker doesn't expose a usable identifier.
series_id = infer_series_id(device_info.model_id, device_info.model_name);
}
// 2. Fetch filament dictionary
QidiFilamentDict dict;
if (!fetch_filament_dict(device_info.base_url, device_info.api_key, dict, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch filament dict: " << error;
}
// 2. Fetch filament dictionary
QidiFilamentDict dict;
if (!fetch_filament_dict(base_url, api_key, dict, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch filament dict: " << error;
}
// 3. Fetch slot info and build AmsTrayData directly
std::vector<AmsTrayData> trays;
int box_count = 0;
if (!fetch_slot_info(device_info.base_url, device_info.api_key, dict, series_id, trays, box_count, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
// 3. Fetch slot info and build AmsTrayData directly
std::vector<AmsTrayData> trays;
int box_count = 0;
if (!fetch_slot_info(base_url, api_key, dict, series_id, trays, box_count, error)) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::fetch_filament_info: Failed to fetch slot info: " << error;
return;
}
// 4. Build the AMS payload
build_ams_payload(box_count, box_count * 4 - 1, trays);
}).detach();
return true;
}
bool QidiPrinterAgent::apply_box_mapping(const PrintParams& params) const
{
// enable_box mirrors task_use_ams: engage the multi-color box only when this
// job actually routes filament through it. (See qidi-ams-findings.md §2/§8.3 —
// if firmware treats enable_box as "a box exists" rather than "use it this job",
// switch this gate to HasAms()/box_count instead.)
const int enable = params.task_use_ams ? 1 : 0;
if (!send_gcode(device_info.dev_id, "SAVE_VARIABLE VARIABLE=enable_box VALUE=" + std::to_string(enable))) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to set enable_box";
return false;
}
// 4. Build the AMS payload
build_ams_payload(box_count, box_count * 4 - 1, trays);
// When the box isn't used this job, leave the existing value_t<tool> slot
// assignments untouched (enable_box=0 is enough to disengage it).
if (!enable)
return true;
if (params.ams_mapping.empty()) {
BOOST_LOG_TRIVIAL(warning) << "QidiPrinterAgent::apply_box_mapping: enable_box set but ams_mapping is empty";
return true;
}
// ams_mapping (v0) is a JSON array indexed by filament/tool; each value is the
// physical box slot (-1 = unmapped). Mirror it onto the printer's value_t<tool>
// variables: SAVE_VARIABLE VARIABLE=value_t<tool> VALUE='slot<n>'.
auto mapping = nlohmann::json::parse(params.ams_mapping, nullptr, /*allow_exceptions*/ false);
if (mapping.is_discarded() || !mapping.is_array()) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: invalid ams_mapping: " << params.ams_mapping;
return false;
}
for (size_t tool = 0; tool < mapping.size(); ++tool) {
if (!mapping[tool].is_number_integer())
continue;
const int slot = mapping[tool].get<int>();
if (slot < 0)
continue; // unmapped filament — skip
const std::string gcode = "SAVE_VARIABLE VARIABLE=value_t" + std::to_string(tool) +
" VALUE=\"'slot" + std::to_string(slot) + "'\"";
if (!send_gcode(device_info.dev_id, gcode)) {
BOOST_LOG_TRIVIAL(error) << "QidiPrinterAgent::apply_box_mapping: failed to set value_t" << tool;
return false;
}
}
return true;
}
int QidiPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_local_print(std::move(params), update_fn, cancel_fn);
}
int QidiPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_print(std::move(params), update_fn, cancel_fn, wait_fn);
}
int QidiPrinterAgent::start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_WR_UPLOAD_FTP_FAILED;
return MoonrakerPrinterAgent::start_local_print_with_record(std::move(params), update_fn, cancel_fn, wait_fn);
}
int QidiPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn)
{
if (!apply_box_mapping(params))
return BAMBU_NETWORK_ERR_PRINT_LP_PUBLISH_MSG_FAILED;
return MoonrakerPrinterAgent::start_sdcard_print(std::move(params), update_fn, cancel_fn);
}
bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
const std::string& api_key,
const QidiFilamentDict& dict,
@@ -118,20 +220,10 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
return false;
}
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
error = "Invalid JSON response";
nlohmann::json status;
nlohmann::json variables;
if (!parse_slot_response(response_body, status, variables, error))
return false;
}
if (!json.contains("result") || !json["result"].contains("status") || !json["result"]["status"].contains("save_variables") ||
!json["result"]["status"]["save_variables"].contains("variables")) {
error = "Unexpected JSON structure";
return false;
}
auto& variables = json["result"]["status"]["save_variables"]["variables"];
auto& status = json["result"]["status"];
box_count = variables.value("box_count", 1);
if (box_count < 0) {
@@ -206,6 +298,31 @@ bool QidiPrinterAgent::fetch_slot_info(const std::string& base_url,
return true;
}
bool QidiPrinterAgent::parse_slot_response(const std::string& response_body,
nlohmann::json& status,
nlohmann::json& variables,
std::string& error)
{
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
if (json.is_discarded()) {
error = "Invalid JSON response";
return false;
}
if (!json.is_object() || !json.contains("result") || !json["result"].is_object() || !json["result"].contains("status") ||
!json["result"]["status"].is_object() || !json["result"]["status"].contains("save_variables") ||
!json["result"]["status"]["save_variables"].is_object() || !json["result"]["status"]["save_variables"].contains("variables") ||
!json["result"]["status"]["save_variables"]["variables"].is_object()) {
// why: Qidi firmware may send null here, but json::value() throws for it.
error = "Unexpected JSON structure: save_variables.variables must be an object";
return false;
}
status = json["result"]["status"];
variables = status["save_variables"]["variables"];
return true;
}
bool QidiPrinterAgent::fetch_filament_dict(const std::string& base_url,
const std::string& api_key,
QidiFilamentDict& dict,

View File

@@ -1,7 +1,9 @@
#ifndef __QIDI_PRINTER_AGENT_HPP__
#define __QIDI_PRINTER_AGENT_HPP__
#include "IPrinterAgent.hpp"
#include "MoonrakerPrinterAgent.hpp"
#include "nlohmann/json_fwd.hpp"
#include <map>
#include <string>
@@ -19,9 +21,25 @@ public:
AgentInfo get_agent_info() override { return get_agent_info_static(); }
// Override filament sync (Qidi-specific implementation)
bool fetch_filament_info(std::string dev_id) 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.
int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override;
int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override;
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::subscription; }
private:
// Push enable_box + value_t<tool> SAVE_VARIABLEs before a print starts.
// Returns false if any command fails (caller should abort the print).
bool apply_box_mapping(const PrintParams& params) const;
struct QidiFilamentDict
{
std::map<int, std::string> colors;

View File

@@ -1,10 +1,14 @@
#include "SnapmakerPrinterAgent.hpp"
#include "Http.hpp"
#include "libslic3r/PresetBundle.hpp"
#include "libslic3r/Utils.hpp"
#include "slic3r/GUI/GUI_App.hpp"
#include "nlohmann/json.hpp"
#include <boost/filesystem.hpp>
#include <boost/log/trivial.hpp>
#include <boost/nowide/fstream.hpp>
#include <thread>
namespace Slic3r {
@@ -67,6 +71,69 @@ std::string find_closest_color_preset_by_vendor_and_type(const PresetCollection&
SnapmakerPrinterAgent::SnapmakerPrinterAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
int SnapmakerPrinterAgent::command_start_camera(std::string dev_id)
{
(void) dev_id;
// why: the printer executes this over the websocket but answers only over MQTT, and the
// call itself blocks on socket I/O - it fires from the camera view's renew timer on the UI
// thread, so run it detached rather than block the caller on a reply that never comes.
// note: interval is dead time in SECONDS on top of a ~0.455 s capture, so 0 is the 2.15 fps
// ceiling (1 measures 0.63 fps), and it cannot be changed while a capture task is running.
std::thread([this] {
send_ws_rpc("camera.start_monitor",
{{"domain", "lan"}, {"interval", 0}, {"expect_pw", false}});
}).detach();
return BAMBU_NETWORK_SUCCESS;
}
std::string SnapmakerPrinterAgent::webcam_stream_override(const std::string& base_url) const
{
const std::string snapshot_url = join_url(base_url, "/server/files/camera/monitor.jpg");
// why: one wrapper file per printer - two U1s would otherwise overwrite each other's URL.
const boost::filesystem::path page = boost::filesystem::path(data_dir()) / "cache" /
("snapmaker_camera_" + sanitize_filename(device_info.dev_ip) + ".html");
// why: the printer writes a still JPEG at ~2 fps, so the page polls it with a cache buster
// instead of consuming a stream. Chaining the next request off onload (never a bare
// setInterval) keeps requests from piling up when the printer is slow to answer.
const std::string html =
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Camera</title><style>"
"html,body{margin:0;height:100%;background:#000;overflow:hidden}"
"img{width:100%;height:100%;object-fit:contain;display:block}</style></head>"
"<body><img id=\"frame\" alt=\"\"><script>\n"
"var src=\"" + snapshot_url + "\";\n"
"var img=document.getElementById(\"frame\");\n"
"function next(){img.src=src+\"?_nocache=\"+Date.now()+\"_\"+Math.floor(Math.random()*10000);}\n"
"img.onload=function(){setTimeout(next,250);};\n"
"img.onerror=function(){setTimeout(next,1000);};\n"
"next();\n"
"</script></body></html>\n";
std::string write_error;
try {
boost::filesystem::create_directories(page.parent_path());
boost::nowide::ofstream out(page.string().c_str(), std::ios::binary | std::ios::trunc);
out << html;
out.close();
// note: an ofstream reports a failed write in its state, not by throwing.
if (!out) {
write_error = "write failed";
}
} catch (const std::exception& e) {
write_error = e.what();
}
if (!write_error.empty()) {
// why: no wrapper means no camera - a raw monitor.jpg URL would render one frozen frame
// and read as a broken feed, so fall back to showing nothing and say why in the log.
BOOST_LOG_TRIVIAL(warning) << "SnapmakerPrinterAgent: could not write camera page " << page.string()
<< ": " << write_error;
return {};
}
return "file://" + page.generic_string();
}
AgentInfo SnapmakerPrinterAgent::get_agent_info_static()
{
return AgentInfo{"snapmaker", "Snapmaker", SNAPMAKER_AGENT_VERSION, "Snapmaker printer agent"};
@@ -102,8 +169,11 @@ std::string SnapmakerPrinterAgent::combine_filament_type(const std::string& type
return base;
}
bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id)
bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
{
if (sync_mode != get_filament_sync_mode())
return false;
std::string url = join_url(device_info.base_url, "/printer/objects/query?print_task_config&filament_detect");
std::string response_body;

View File

@@ -15,7 +15,11 @@ public:
static AgentInfo get_agent_info_static();
AgentInfo get_agent_info() override { return get_agent_info_static(); }
bool fetch_filament_info(std::string dev_id) override;
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
int command_start_camera(std::string dev_id) override;
protected:
std::string webcam_stream_override(const std::string& base_url) const override;
private:
// Combine filament_type + filament_sub_type into a unified type string

View File

@@ -9,7 +9,6 @@
#include <slic3r/GUI/MsgDialog.hpp>
#include <slic3r/GUI/PluginProgressDialog.hpp>
#include <slic3r/GUI/PluginWebDialog.hpp>
#include <slic3r/GUI/NotificationManager.hpp>
#include <nlohmann/json.hpp>
#include <pybind11/pybind11.h>
@@ -20,7 +19,6 @@
#include <wx/defs.h>
#include <wx/window.h>
#include <atomic>
#include <cstdint>
#include <future>
#include <memory>
@@ -46,20 +44,16 @@ namespace {
struct GilSafeCallable
{
py::object fn;
std::atomic_bool active{true};
explicit GilSafeCallable(py::object f) : fn(std::move(f)) {}
void disable()
{
active.store(false, std::memory_order_release);
PythonGILState gil;
if (gil)
fn = py::object();
else
(void) fn.release();
}
~GilSafeCallable()
{
disable();
if (fn) {
PythonGILState gil;
if (gil)
fn = py::object();
else
(void) fn.release();
}
}
};
using CallablePtr = std::shared_ptr<GilSafeCallable>;
@@ -172,34 +166,11 @@ public:
}
return out;
}
void bind_callback(const CallablePtr& callback, const std::string& plugin_key)
{
if (!callback)
return;
std::lock_guard<std::mutex> lk(m_mtx);
m_callbacks[plugin_key].push_back(callback);
}
std::vector<CallablePtr> take_callbacks_for_plugin(const std::string& plugin_key)
{
std::lock_guard<std::mutex> lk(m_mtx);
auto it = m_callbacks.find(plugin_key);
if (it == m_callbacks.end())
return {};
std::vector<CallablePtr> callbacks;
callbacks.reserve(it->second.size());
for (const std::weak_ptr<GilSafeCallable>& weak_callback : it->second) {
if (auto callback = weak_callback.lock())
callbacks.push_back(std::move(callback));
}
m_callbacks.erase(it);
return callbacks;
}
private:
std::mutex m_mtx;
std::unordered_map<int, wxWindow*> m_resources;
std::unordered_map<int, std::string> m_owners;
std::unordered_map<std::string, std::vector<std::weak_ptr<GilSafeCallable>>> m_callbacks;
int m_next_id{1};
};
@@ -477,46 +448,6 @@ void progress_close(int id)
});
}
void plater_notification(NotificationManager::NotificationLevel notification_level, const std::string& text,
const std::string& hypertext, py::object on_click)
{
const std::string plugin_key = PluginAuditManager::instance().current_plugin();
CallablePtr holder = make_holder(std::move(on_click));
if (holder)
UiRegistry::instance().bind_callback(holder, plugin_key);
std::function<bool(wxEvtHandler*)> callback;
if (holder) {
callback = [holder](wxEvtHandler*) -> bool {
if (!holder->active.load(std::memory_order_acquire))
return false;
PythonGILState gil;
if (!gil)
return false;
try {
py::object result = holder->fn();
return result.is_none() || result.cast<bool>();
} catch (py::error_already_set& e) {
BOOST_LOG_TRIVIAL(error) << "orca.host.ui notification callback raised: " << e.what();
PyErr_Clear();
return false;
} catch (const std::exception& e) {
BOOST_LOG_TRIVIAL(error) << "orca.host.ui notification callback raised: " << e.what();
return false;
} catch (...) {
BOOST_LOG_TRIVIAL(error) << "orca.host.ui notification callback raised an unknown exception";
return false;
}
};
}
run_on_ui_blocking([notification_level, text, hypertext, callback = std::move(callback)]() mutable {
wxGetApp().plater()->get_notification_manager()->push_notification(NotificationType::CustomNotification, notification_level, text,
hypertext, std::move(callback));
});
}
} // namespace
void PluginHostUi::RegisterBindings(pybind11::module_& host)
@@ -599,23 +530,6 @@ void PluginHostUi::RegisterBindings(pybind11::module_& host)
ui.def("create_progress_dialog", &ui_create_progress_dialog, py::arg("title"), py::arg("message"),
py::arg("maximum") = 100, py::arg("style") = wxPD_APP_MODAL | wxPD_AUTO_HIDE,
"Create a native progress dialog and return a ProgressDialog handle.");
py::enum_<NotificationManager::NotificationLevel>(ui, "NotificationLevel")
.value("ProgressBarNotificationLevel", NotificationManager::NotificationLevel::ProgressBarNotificationLevel)
.value("HintNotificationLevel", NotificationManager::NotificationLevel::HintNotificationLevel)
.value("RegularNotificationLevel", NotificationManager::NotificationLevel::RegularNotificationLevel)
.value("PrintInfoNotificationLevel", NotificationManager::NotificationLevel::PrintInfoNotificationLevel)
.value("PrintInfoShortNotificationLevel", NotificationManager::NotificationLevel::PrintInfoShortNotificationLevel)
.value("ImportantNotificationLevel", NotificationManager::NotificationLevel::ImportantNotificationLevel)
.value("WarningNotificationLevel", NotificationManager::NotificationLevel::WarningNotificationLevel)
.value("SeriousWarningNotificationLevel", NotificationManager::NotificationLevel::SeriousWarningNotificationLevel)
.value("ErrorNotificationLevel", NotificationManager::NotificationLevel::ErrorNotificationLevel)
.export_values();
ui.def("push_notification", &plater_notification, py::arg("notification_level"), py::arg("text"),
py::arg("hyper_text") = "", py::arg("on_click") = py::none(),
"Push a plater notification. hyper_text is an underlined label; on_click() is called when it is clicked "
"and may return True to close the notification.");
}
void PluginHostUi::close_windows_for_plugin(const std::string& plugin_key)
@@ -624,9 +538,6 @@ void PluginHostUi::close_windows_for_plugin(const std::string& plugin_key)
return;
auto teardown = [plugin_key]() {
for (auto& callback : UiRegistry::instance().take_callbacks_for_plugin(plugin_key))
callback->disable();
// Destroy() bypasses wxEVT_CLOSE, so the plugin's on_close is not fired on
// forced teardown (intended); the resource destructor still cleans the registry.
for (auto* window : UiRegistry::instance().take_for_plugin(plugin_key)) {

View File

@@ -30,54 +30,50 @@ public:
AgentInfo get_agent_info() override = 0;
int connect_printer(
std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override = 0;
int send_message(std::string dev_id, std::string json_str, int qos, int flag) override = 0;
int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override = 0;
bool start_discovery(bool start, bool sending) override = 0;
int bind_detect(std::string dev_ip, std::string sec_link, detectResult& detect) override = 0;
std::string get_user_selected_machine() override = 0;
int set_user_selected_machine(std::string dev_id) override = 0;
int start_send_gcode_to_sdcard(PrintParams params,
OnUpdateStatusFn update_fn,
WasCancelledFn cancel_fn,
OnWaitFn wait_fn) override = 0;
int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override = 0;
FilamentSyncMode get_filament_sync_mode() const override = 0;
bool fetch_filament_info(std::string dev_id) override = 0;
int connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl) override = 0;
int send_message(std::string dev_id, std::string json_str, int qos, int flag) override = 0;
int send_message_to_printer(std::string dev_id, std::string json_str, int qos, int flag) override = 0;
bool start_discovery(bool start, bool sending) override = 0;
int bind_detect(std::string dev_ip, std::string sec_link, detectResult& detect) override = 0;
std::string get_user_selected_machine() override = 0;
int set_user_selected_machine(std::string dev_id) override = 0;
int start_send_gcode_to_sdcard(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override = 0;
int start_local_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override = 0;
FilamentSyncMode get_filament_sync_mode() const override = 0;
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override = 0;
int check_cert() override = 0;
void install_device_cert(std::string dev_id, bool lan_only) override = 0;
int ping_bind(std::string ping_code) override = 0;
int check_cert() override = 0;
void install_device_cert(std::string dev_id, bool lan_only) override = 0;
int ping_bind(std::string ping_code) override = 0;
int bind(std::string dev_ip,
std::string dev_id,
std::string dev_model,
std::string sec_link,
std::string timezone,
bool improved,
OnUpdateStatusFn update_fn) override = 0;
int unbind(std::string dev_id) override = 0;
OnUpdateStatusFn update_fn) override = 0;
int unbind(std::string dev_id) override = 0;
// request_bind_ticket has a std::string* out-param that cannot round-trip through a
// pybind11 override directly; the trampoline wraps it (the Python plugin returns a
// (result, ticket) tuple), so it stays pure here like the rest.
int request_bind_ticket(std::string* ticket) override = 0;
int request_bind_ticket(std::string* ticket) override = 0;
int get_hms_snapshot(std::string dev_id, std::string file_name, std::function<void(std::string, int)> callback) override = 0;
int set_server_callback(OnServerErrFn fn) override = 0;
int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override = 0;
int set_server_callback(OnServerErrFn fn) override = 0;
int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override = 0;
int start_local_print_with_record(PrintParams params,
OnUpdateStatusFn update_fn,
WasCancelledFn cancel_fn,
OnWaitFn wait_fn) override = 0;
int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override = 0;
OnWaitFn wait_fn) override = 0;
int start_sdcard_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn) override = 0;
int set_on_ssdp_msg_fn(OnMsgArrivedFn fn) override = 0;
int set_on_printer_connected_fn(OnPrinterConnectedFn fn) override = 0;
int set_on_subscribe_failure_fn(GetSubscribeFailureFn fn) override = 0;
int set_on_message_fn(OnMessageFn fn) override = 0;
int set_on_user_message_fn(OnMessageFn fn) override = 0;
int set_on_local_connect_fn(OnLocalConnectedFn fn) override = 0;
int set_on_local_message_fn(OnMessageFn fn) override = 0;
int set_queue_on_main_fn(QueueOnMainFn fn) override = 0;
int set_on_ssdp_msg_fn(OnMsgArrivedFn fn) override = 0;
int set_on_printer_connected_fn(OnPrinterConnectedFn fn) override = 0;
int set_on_subscribe_failure_fn(GetSubscribeFailureFn fn) override = 0;
int set_on_message_fn(OnMessageFn fn) override = 0;
int set_on_user_message_fn(OnMessageFn fn) override = 0;
int set_on_local_connect_fn(OnLocalConnectedFn fn) override = 0;
int set_on_local_message_fn(OnMessageFn fn) override = 0;
int set_queue_on_main_fn(QueueOnMainFn fn) override = 0;
};
} // namespace Slic3r

View File

@@ -96,7 +96,7 @@ public:
get_filament_sync_mode);
}
bool fetch_filament_info(std::string dev_id) override
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override
{
ORCA_PY_OVERRIDE_AUDITED(
::Slic3r::PluginAuditManager::AuditMode::Loading, [] {}, PYBIND11_OVERRIDE_PURE, bool, PrinterAgentPluginCapability, fetch_filament_info, dev_id);

View File

@@ -2,12 +2,16 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
add_executable(${_TEST_NAME}_tests
${_TEST_NAME}_tests_main.cpp
test_dev_mapping.cpp
test_device_progress.cpp
test_network_versions.cpp
test_action_source.cpp
test_plugin_host_api.cpp
test_plugin_capability_config.cpp
test_plugin_config.cpp
test_plugin_capabilities_in_use.cpp
test_plugin_status.cpp
test_printer_agent.cpp
test_qidi_printer_agent.cpp
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_slicing_pipeline_bindings.cpp
@@ -55,3 +59,22 @@ elseif (APPLE)
endif()
orcaslicer_discover_tests(${_TEST_NAME}_tests)
# why: the loader runs on a detached worker thread, so its Python interpreter
# ownership model cannot share the embedded interpreter in the main test binary.
add_executable(printer_agent_plugin_tests test_printer_agent_plugin.cpp)
if (MSVC)
target_link_libraries(printer_agent_plugin_tests Setupapi.lib)
endif ()
target_link_libraries(printer_agent_plugin_tests test_common libslic3r_gui libslic3r pybind11::embed Catch2::Catch2)
set_property(TARGET printer_agent_plugin_tests PROPERTY FOLDER "tests")
# why: the existing target stages the complete bundled Python home under
# python/, which is the layout PythonInterpreter discovers beside the test exe.
add_dependencies(printer_agent_plugin_tests ${_TEST_NAME}_tests)
orcaslicer_copy_test_dlls()
orcaslicer_discover_tests(printer_agent_plugin_tests)

View File

@@ -26,6 +26,37 @@
using json = nlohmann::json;
using namespace Slic3r;
TEST_CASE("AMS tray placeholder state follows the latest status", "[DevFilaSystem]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
const json empty_slot = json::parse(R"({
"ams": {
"tray_exist_bits": "0",
"ams": [ { "id": "0", "info": "00000001", "tray": [
{ "id": "0", "tray_slot_placeholder": "1", "tray_color": "00000000" }
] } ]
}
})");
DevFilaSystemParser::ParseV1_0(empty_slot, &obj, obj.GetFilaSystem().get(), false);
DevAmsTray* tray = obj.GetFilaSystem()->GetAmsTray("0", "0");
REQUIRE(tray != nullptr);
REQUIRE(tray->is_slot_placeholder);
const json loaded_slot = json::parse(R"({
"ams": {
"tray_exist_bits": "1",
"ams": [ { "id": "0", "info": "00000001", "tray": [
{ "id": "0", "tray_color": "FF0000FF" }
] } ]
}
})");
DevFilaSystemParser::ParseV1_0(loaded_slot, &obj, obj.GetFilaSystem().get(), false);
CHECK_FALSE(tray->is_slot_placeholder);
}
TEST_CASE("Switch-bound AMS trays map to the left extruder", "[DevMapping]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");

View File

@@ -0,0 +1,141 @@
// why: match the GUI include order to avoid rpcndr.h byte/std::byte
// ambiguity in the Windows COM headers.
// why: wx/timer.h must precede DeviceManager.hpp because
// DeviceErrorDialog.hpp uses wxTimerEvent.
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_all.hpp>
#include <stdexcept>
#include <wx/timer.h>
#include "slic3r/GUI/DeviceManager.hpp"
#include <nlohmann/json.hpp>
using json = nlohmann::json;
using namespace Slic3r;
TEST_CASE("Integer progress reaches the shared subtask", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.update_print_progress(json(37));
REQUIRE(machine.mc_print_percent == 37);
BBLSubTask* subtask = machine.get_subtask();
REQUIRE(subtask != nullptr);
CHECK(subtask->task_progress == 37);
}
TEST_CASE("String progress reaches the shared subtask", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.update_print_progress(json("41"));
REQUIRE(machine.mc_print_percent == 41);
BBLSubTask* subtask = machine.get_subtask();
REQUIRE(subtask != nullptr);
CHECK(subtask->task_progress == 41);
}
TEST_CASE("Floating-point progress preserves the previous shared value", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.update_print_progress(json(29));
REQUIRE(machine.mc_print_percent == 29);
BBLSubTask* subtask = machine.get_subtask();
REQUIRE(subtask != nullptr);
REQUIRE(subtask->task_progress == 29);
machine.update_print_progress(json(29.5));
BBLSubTask* current_subtask = machine.get_subtask();
REQUIRE(current_subtask != nullptr);
REQUIRE(current_subtask == subtask);
CHECK(machine.mc_print_percent == 29);
CHECK(current_subtask->task_progress == 29);
}
TEST_CASE("Unsupported progress values leave a fresh machine unchanged", "[DeviceManager][Progress]")
{
SECTION("boolean") {
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
machine.update_print_progress(json(true));
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
SECTION("null") {
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
machine.update_print_progress(json(nullptr));
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
SECTION("object") {
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
machine.update_print_progress(json::object());
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
SECTION("array") {
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
machine.update_print_progress(json::array());
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
}
TEST_CASE("Malformed string progress leaves a fresh machine unchanged", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
CHECK_THROWS_AS(machine.update_print_progress(json("not-a-percent")), std::invalid_argument);
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
TEST_CASE("Zero progress replaces active shared progress", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.update_print_progress(json(63));
BBLSubTask* subtask = machine.get_subtask();
REQUIRE(subtask != nullptr);
REQUIRE(machine.mc_print_percent == 63);
REQUIRE(subtask->task_progress == 63);
machine.set_print_state("FAILED");
machine.update_print_progress(json(0));
BBLSubTask* current_subtask = machine.get_subtask();
REQUIRE(current_subtask != nullptr);
REQUIRE(current_subtask == subtask);
REQUIRE(machine.mc_print_percent == 0);
CHECK(current_subtask->task_progress == 0);
}

View File

@@ -0,0 +1,23 @@
#include <catch2/catch_all.hpp>
#include <slic3r/GUI/PluginStatus.hpp>
using Slic3r::GUI::PluginStatus;
using Slic3r::GUI::resolve_plugin_status;
TEST_CASE("resolve_plugin_status precedence", "[plugin][status]") {
// the new branch: loaded module + error -> runtime fault, not a load failure.
REQUIRE(resolve_plugin_status(/*loading*/ false, /*has_error*/ true, /*is_loaded*/ true) == PluginStatus::RuntimeError);
// error without a live module is a load-time Error.
REQUIRE(resolve_plugin_status(false, true, false) == PluginStatus::Error);
// loading wins over a pending error so a reload never flashes red.
REQUIRE(resolve_plugin_status(true, true, true) == PluginStatus::Loading);
// healthy loaded plugin.
REQUIRE(resolve_plugin_status(false, false, true) == PluginStatus::Activated);
// nothing loaded, no error.
REQUIRE(resolve_plugin_status(false, false, false) == PluginStatus::Inactive);
}

View File

@@ -0,0 +1,327 @@
#include <catch2/catch_all.hpp>
#include <slic3r/Utils/BBLPrinterAgent.hpp>
#include <slic3r/Utils/MoonrakerPrinterAgent.hpp>
#include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <atomic>
#include <chrono>
#include <future>
#include <memory>
#include <string>
using namespace Slic3r;
namespace py = pybind11;
class MoonrakerParserProbe : public MoonrakerPrinterAgent
{
public:
using MoonrakerPrinterAgent::parse_nozzle_diameter;
explicit MoonrakerParserProbe(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
};
TEST_CASE("Moonraker parses nozzle diameter from configfile settings", "[unit][moonraker]")
{
const auto response = nlohmann::json::parse(R"({
"result": {
"status": {
"configfile": {
"settings": {
"extruder": {
"nozzle_diameter": 0.6
}
}
}
}
}
})");
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(response) == Catch::Approx(0.6f));
}
TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missing data", "[unit][moonraker]")
{
const auto raw_config_response = nlohmann::json::parse(R"({
"result": {
"status": {
"configfile": {
"config": {
"extruder": {
"nozzle_diameter": "0.8"
}
}
}
}
}
})");
const auto missing_response = nlohmann::json::object();
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(raw_config_response) == Catch::Approx(0.8f));
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(missing_response) == 0.0f);
}
// why: these builders preserve the Bambu firmware dialect byte-for-byte, including its trailing space.
TEST_CASE("unit: BBL AMS gcode builders preserve command bytes", "[unit][bbl]")
{
CHECK(BBLPrinterAgent::ams_refresh_rfid_gcode("123") == "M620 R123 \n");
CHECK(BBLPrinterAgent::ams_calibrate_gcode(123) == "M620 C123 \n");
CHECK(BBLPrinterAgent::ams_select_tray_gcode("123") == "M620 P123 \n");
}
// why: an agent without a Bambu-dialect translation must refuse these commands before any network or wx path.
TEST_CASE("unit: default AMS commands report not supported", "[unit][moonraker]")
{
MoonrakerPrinterAgent agent("");
CHECK(agent.command_ams_refresh_rfid("dev", "123", 1, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_calibrate("dev", 1, 2, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.command_ams_select_tray("dev", "123", 3, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
TEST_CASE("unit: Moonraker light name matching", "[unit][moonraker]")
{
CHECK(moonraker_is_light_name("caselight"));
CHECK(moonraker_is_light_name("LED_STRIP"));
CHECK_FALSE(moonraker_is_light_name("beeper"));
CHECK(moonraker_is_light_name("FLASHLIGHT_SWITCH"));
CHECK(moonraker_is_light_name("MODLELIGHT_SWITCH"));
}
// ===========================================================================
// UNIT - handle_request's not-supported default.
// The agent is the only thing that knows what it can translate, so an untranslated
// command has to say so instead of returning success and letting the UI believe the
// control worked. Guards the inverse too: the pushing namespace is genuinely
// satisfied by the websocket status stream, and it re-fires from the keepalive timer
// roughly once a second, so it must stay a success or it would raise a dialog on a
// timer. Only branches that touch neither the network nor wx are exercised.
// ===========================================================================
TEST_CASE("unit: Moonraker reports untranslated commands as not supported", "[unit][moonraker]")
{
MoonrakerPrinterAgent agent("");
CHECK(agent.send_message("dev", R"({"print":{"command":"ams_change_filament"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"system":{"command":"set_door_stat"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"xcam":{"command":"xcam_control_set"}})", 0, 0) ==
ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
CHECK(agent.send_message("dev", R"({"pushing":{"command":"pushall"}})", 0, 0) == BAMBU_NETWORK_SUCCESS);
CHECK(agent.send_message("dev", R"({"pushing":{"command":"start"}})", 0, 0) == BAMBU_NETWORK_SUCCESS);
// why: malformed input is a different failure than an untranslated command, and the
// default must not swallow it into a misleading not-supported verdict.
CHECK(agent.send_message("dev", "{not json", 0, 0) == BAMBU_NETWORK_ERR_INVALID_RESULT);
}
TEST_CASE("unit: MoonrakerPrinterAgent::fetch_filament_info is fire-and-forget and dispatches to the derived override",
"[unit][moonraker]")
{
class RecordingAgent : public Slic3r::MoonrakerPrinterAgent
{
public:
explicit RecordingAgent(std::string log_dir) : MoonrakerPrinterAgent(std::move(log_dir)) {}
std::atomic<bool> invoked{false};
std::promise<void> release_gate;
std::promise<void> done_promise;
bool do_fetch_filament_info(std::string /*dev_id*/) override
{
invoked.store(true);
// Block here until the test explicitly releases us, proving the caller
// (fetch_filament_info) does not wait for this to run.
release_gate.get_future().wait();
done_promise.set_value();
return true;
}
};
auto agent = std::make_shared<RecordingAgent>(std::string{});
auto done_future = agent->done_promise.get_future();
bool immediate_result = agent->fetch_filament_info("test-dev");
// fetch_filament_info must return before do_fetch_filament_info completes — prove
// it by confirming the background call is still blocked on the gate right now.
REQUIRE(immediate_result == true);
REQUIRE(done_future.wait_for(std::chrono::milliseconds(100)) == std::future_status::timeout);
// Now let the background call finish and confirm it actually ran (polymorphic dispatch).
agent->release_gate.set_value();
REQUIRE(done_future.wait_for(std::chrono::seconds(2)) == std::future_status::ready);
REQUIRE(agent->invoked.load() == true);
}
// ===========================================================================
// UNIT - printer-agent registry duplicate handling.
// Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in
// or previously registered agent.
// ===========================================================================
TEST_CASE("unit: printer-agent registry register / lookup / duplicate-reject", "[registry][unit]")
{
// why: the registry is process-global state shared by the test binary, and
// Catch2 may run cases in any order. Use an id that cannot collide with
// built-ins or other cases. Avoid SECTIONs because each section re-runs the
// body and would register the same id twice.
const std::string id = "orca-test::registry-probe-7f3a";
auto stub_factory = [](std::shared_ptr<ICloudServiceAgent>, const std::string&)
-> std::shared_ptr<IPrinterAgent> { return nullptr; };
REQUIRE_FALSE(NetworkAgentFactory::is_printer_agent_registered(id));
REQUIRE(NetworkAgentFactory::register_printer_agent(id, "Registry Probe", stub_factory));
REQUIRE(NetworkAgentFactory::is_printer_agent_registered(id));
// Re-registering the same id is rejected and does not replace the entry.
REQUIRE_FALSE(NetworkAgentFactory::register_printer_agent(id, "Impostor", stub_factory));
// The first registration's display name survives the rejected duplicate.
const PrinterAgentInfo* info = NetworkAgentFactory::get_printer_agent_info(id);
REQUIRE(info != nullptr);
CHECK(info->display_name == "Registry Probe");
// It appears exactly once in the UI-population list.
auto agents = NetworkAgentFactory::get_registered_printer_agents();
int count = 0;
for (const auto& a : agents)
if (a.id == id)
++count;
CHECK(count == 1);
}
// ===========================================================================
// INTEGRATION - the orca.printer_agent Python binding surface.
// Boots the embedded interpreter and asserts the C++ to Python contract that
// every printer-agent plugin subclasses. If a binding is renamed or removed,
// plugins fail at runtime even though C++ still compiles.
// ===========================================================================
namespace {
void ensure_python_initialized()
{
// why: the `orca` module is embedded in this binary, so a bare interpreter
// can import it without a bundled Python home. The app interpreter expects
// that deployed layout, which is not present beside this test binary.
if (!Py_IsInitialized()) {
static py::scoped_interpreter interpreter;
(void) interpreter;
}
}
py::module_ import_orca_module()
{
ensure_python_initialized();
// Force PythonPluginBridge.cpp into the binary so the embedded
// PYBIND11_EMBEDDED_MODULE(orca, ...) registration (incl. printer_agent) exists.
(void) Slic3r::PythonPluginBridge::instance();
return py::module_::import("orca");
}
} // namespace
TEST_CASE("integration: orca.printer_agent binding surface", "[integration][Python]")
{
py::module_ orca = import_orca_module();
REQUIRE(py::hasattr(orca, "printer_agent"));
py::object pa = orca.attr("printer_agent");
// The base class every printer-agent plugin subclasses.
REQUIRE(py::hasattr(pa, "PrinterAgentBase"));
py::object base = pa.attr("PrinterAgentBase");
for (const char* method : { "get_agent_info", "connect_printer", "disconnect_printer",
"send_message", "start_discovery", "bind_detect",
"start_print", "get_filament_sync_mode" }) {
CAPTURE(method);
CHECK(py::hasattr(base, method));
}
// AgentInfo value type - the registry identity the host reads (id is the key).
REQUIRE(py::hasattr(pa, "AgentInfo"));
py::object info = pa.attr("AgentInfo")("moonraker", "Moonraker", "1.0", "test agent");
CHECK(info.attr("id").cast<std::string>() == "moonraker");
CHECK(info.attr("name").cast<std::string>() == "Moonraker");
// FilamentSyncMode enum the host queries to pick pull vs subscription.
REQUIRE(py::hasattr(pa, "FilamentSyncMode"));
py::object mode = pa.attr("FilamentSyncMode");
CHECK(py::hasattr(mode, "Pull"));
CHECK(py::hasattr(mode, "Subscription"));
CHECK(py::hasattr(mode, "None_"));
// Plugin-type enum exposed at module root (host reads it without the GIL).
CHECK(py::hasattr(orca, "PluginType"));
}
// ===========================================================================
// INTEGRATION - plugin-registration API and discovery-context guards.
// These are the symbols every plugin package uses: the @orca.plugin decorator,
// orca.base, orca.register_capability, and the capability base modules. Checking
// them in the lightweight embedded-interpreter test catches binding breakage
// before the plugin-loader test needs to run.
// ===========================================================================
TEST_CASE("integration: orca plugin-registration API surface + discovery-context guards", "[integration][Python]")
{
py::module_ orca = import_orca_module();
// Module-level surface every plugin package relies on.
// note: no "gcode" module here - this branch has no G-code capability module;
// PostProcessing exists only as a PluginType value.
for (const char* name : { "plugin", "register_capability", "base", "PythonPluginBase",
"PluginType", "PluginResult", "script", "printer_agent", "host" }) {
CAPTURE(name);
CHECK(py::hasattr(orca, name));
}
// Plugin package base and capability base contract.
CHECK(py::hasattr(orca.attr("base"), "register_capabilities"));
py::object cap_base = orca.attr("PythonPluginBase");
for (const char* method : { "get_name", "get_type", "on_load", "on_unload" }) {
CAPTURE(method);
CHECK(py::hasattr(cap_base, method));
}
// The script capability module exposes its own base class.
CHECK(py::hasattr(orca.attr("script"), "ScriptPluginCapabilityBase"));
// PluginType enum carries the values that route a capability, including PrinterConnection.
// note: no PostProcessing value on this branch's binding.
py::object types = orca.attr("PluginType");
for (const char* value : { "PrinterConnection", "Script" }) {
CAPTURE(value);
CHECK(py::hasattr(types, value));
}
// note: this is testing behavior, not normal plugin loading.
// These APIs should only work while Orca is actively loading a plugin.
try {
// Calls Python's orca.register_capability(0) from C++.
// 0 is intentionally bogus. The important part is that there is no active
// plugin load context, so the function should reject the call immediately.
orca.attr("register_capability")(py::int_(0));
// If the call above does NOT throw, the test fails here.
FAIL("register_capability outside discovery context must raise");
} catch (const py::error_already_set& error) {
// pybind11 wraps Python exceptions as py::error_already_set.
// This checks the Python exception type is ValueError.
CHECK(error.matches(PyExc_ValueError));
}
try {
// This is the function behind @orca.plugin.
// Same logic as above.
orca.attr("plugin")(py::int_(0));
FAIL("@orca.plugin outside discovery context must raise");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_ValueError));
}
}

View File

@@ -0,0 +1,414 @@
#include <catch2/catch_all.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <libslic3r/Utils.hpp> // for set_data_dir
#include <boost/filesystem.hpp>
#include <boost/system/error_code.hpp>
#include <catch2/catch_session.hpp>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <string>
using namespace Slic3r;
namespace fs = boost::filesystem;
namespace {
// why: embedding the fake plugin keeps this test self-contained. The PEP 723
// block declares a printer-connection plugin, and the decorated plugin package
// registers one PrinterAgentBase capability whose AgentInfo.id is the registry
// key asserted below.
constexpr const char* kFakePluginSource = R"PY(# /// script
# requires-python = ">=3.12"
# dependencies = []
#
# [tool.orcaslicer.plugin]
# name = "Lifecycle Test Agent"
# description = "Minimal printer-agent plugin for the lifecycle test."
# author = "tests"
# version = "1.0.0"
# type = "printer-connection"
# ///
import orca
class LifecycleTestAgentCapability(orca.printer_agent.PrinterAgentBase):
def get_name(self):
return "Lifecycle Test Agent"
def get_agent_info(self):
return orca.printer_agent.AgentInfo(
id="lifecycle-test-agent",
name="Lifecycle Test Agent",
version="1.0.0",
description="Lifecycle test printer agent",
)
@orca.plugin
class LifecycleTestPlugin(orca.base):
def register_capabilities(self):
orca.register_capability(LifecycleTestAgentCapability)
)PY";
// why: in production GUI_App::init_plugin_gui_wiring subscribes the agent-registry
// callbacks; the test binary has no GUI, so install the same UNLOAD-side wiring
// once so the tests exercise the production deregister-on-unload path.
// note: the load-side (register) wiring is deliberately NOT installed - the two
// concurrent load_plugin calls in the duplicate-id test would race for the id;
// the tests register manually, in a deterministic order, instead.
void install_agent_registry_wiring()
{
static bool installed = false;
if (installed)
return;
installed = true;
PluginManager& mgr = PluginManager::instance();
mgr.subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin);
mgr.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) {
if (capability.type == PluginCapabilityType::PrinterConnection)
NetworkAgentFactory::deregister_python_printer_agent(capability.plugin_key, capability.name);
});
}
} // namespace
// ===========================================================================
// PRINTER-AGENT PLUGIN LIFECYCLE: load, register, unload, deregister.
//
// This test uses its own executable because load_plugin runs on a detached worker
// thread that needs the GIL released on the main thread (the PythonInterpreter
// model). slic3rutils_tests' other Python tests hold the GIL on the main thread
// via a bare scoped_interpreter; the two models can't share one process.
//
// When bundled Python is unavailable in the test environment, the test is
// skipped so source-only or partially staged builds can still run the rest of
// the suite.
// ===========================================================================
TEST_CASE("plugin lifecycle: printer-agent load registers and unload deregisters", "[plugin][lifecycle][Python]")
{
const std::string plugin_key = "LifecycleTestAgent"; // entry-file stem
const std::string agent_id = "lifecycle-test-agent"; // AgentInfo.id from the plugin
// Stage a throwaway data directory. Plugins are discovered under
// <data_dir>/orca_plugins, so this controls which plugin is loaded.
const fs::path data_dir = fs::temp_directory_path() / "orca-plugin-lifecycle-test";
const fs::path plugin_dir = data_dir / "orca_plugins" / plugin_key;
{
boost::system::error_code ec;
fs::remove_all(data_dir, ec); // clear any stale run
}
fs::create_directories(plugin_dir);
{
std::ofstream out((plugin_dir / (plugin_key + ".py")).string(), std::ios::binary);
out << kFakePluginSource;
}
// why: best-effort cleanup even if an assertion throws.
struct DirGuard
{
fs::path p;
~DirGuard()
{
boost::system::error_code ec;
fs::remove_all(p, ec);
}
} guard{data_dir};
Slic3r::set_data_dir(data_dir.string());
// Initialize the plugin system on this thread. If the bundled Python home
// is not reachable, skip gracefully.
PluginManager& mgr = PluginManager::instance();
if (!mgr.initialize())
SKIP("PythonInterpreter could not initialize (bundled Python home not found in this environment)");
install_agent_registry_wiring();
// Discover synchronously so the catalog holds the descriptor before loading.
mgr.discover_plugins(/*async=*/false, /*clear=*/true);
INFO("expected plugin_key (entry-file stem): " << plugin_key);
PluginDescriptor descriptor;
REQUIRE(mgr.try_get_valid_plugin_descriptor(plugin_key, descriptor));
// Load on the worker thread and block until it finishes.
std::string error;
mgr.load_plugin(plugin_key, /*skip_deps=*/true);
const bool loaded = mgr.wait_for_plugin_load(plugin_key, std::chrono::seconds(60), error);
INFO("plugin load error: " << error);
REQUIRE(loaded);
REQUIRE(mgr.is_plugin_loaded(plugin_key));
// Resolve the one PrinterConnection capability and register it as an agent.
auto caps = mgr.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection);
REQUIRE(caps.size() == 1);
REQUIRE(caps[0] != nullptr);
NetworkAgentFactory::register_python_printer_agent(plugin_key, caps[0]->name());
// The AgentInfo.id returned by the plugin is now in the registry.
CHECK(NetworkAgentFactory::is_printer_agent_registered(agent_id));
// Unloading the plugin deregisters its Python-backed agent.
REQUIRE(mgr.unload_plugin(plugin_key));
// The registry no longer contains the agent id after unload.
CHECK_FALSE(NetworkAgentFactory::is_printer_agent_registered(agent_id));
}
namespace {
// why: duplicate-id coverage needs two distinct plugins with different package
// keys and classes while both return the same AgentInfo.id.
std::string make_agent_plugin_source(const std::string& suffix, const std::string& display_name, const std::string& agent_id)
{
return std::string{}
+ "# /// script\n"
+ "# requires-python = \">=3.12\"\n"
+ "# dependencies = []\n"
+ "#\n"
+ "# [tool.orcaslicer.plugin]\n"
+ "# name = \"" + display_name + "\"\n"
+ "# description = \"Duplicate-id fake printer-agent plugin.\"\n"
+ "# author = \"tests\"\n"
+ "# version = \"1.0.0\"\n"
+ "# type = \"printer-connection\"\n"
+ "# ///\n"
+ "import orca\n"
+ "\n\n"
+ "class Cap" + suffix + "(orca.printer_agent.PrinterAgentBase):\n"
+ " def get_name(self):\n"
+ " return \"" + display_name + "\"\n"
+ "\n"
+ " def get_agent_info(self):\n"
+ " return orca.printer_agent.AgentInfo(\n"
+ " id=\"" + agent_id + "\", name=\"" + display_name + "\",\n"
+ " version=\"1.0.0\", description=\"duplicate id test\")\n"
+ "\n\n"
+ "@orca.plugin\n"
+ "class Plugin" + suffix + "(orca.base):\n"
+ " def register_capabilities(self):\n"
+ " orca.register_capability(Cap" + suffix + ")\n";
}
void stage_plugin(const fs::path& data_dir, const std::string& plugin_key, const std::string& source)
{
const fs::path dir = data_dir / "orca_plugins" / plugin_key;
fs::create_directories(dir);
std::ofstream out((dir / (plugin_key + ".py")).string(), std::ios::binary);
out << source;
}
} // namespace
// ===========================================================================
// DUPLICATE PRINTER-AGENT IDS
// When two loaded plugins return the same AgentInfo.id, the first registration
// keeps ownership. Unloading it removes the id, and the rejected plugin is not
// promoted automatically. Manual re-registration is required.
// ===========================================================================
TEST_CASE("duplicate agent id is rejected and the winner is not clobbered", "[plugin][lifecycle][Python]")
{
const std::string key_a = "DuplicateIdAgentA"; // winner, registered first
const std::string key_b = "DuplicateIdAgentB"; // duplicate, rejected
const std::string dup_id = "duplicate-id-agent"; // both plugins return this AgentInfo.id
const fs::path data_dir = fs::temp_directory_path() / "orca-duplicate-agent-test";
{
boost::system::error_code ec;
fs::remove_all(data_dir, ec);
}
stage_plugin(data_dir, key_a, make_agent_plugin_source("A", "Duplicate Id Agent A", dup_id));
stage_plugin(data_dir, key_b, make_agent_plugin_source("B", "Duplicate Id Agent B", dup_id));
struct DirGuard
{
fs::path p;
~DirGuard()
{
boost::system::error_code ec;
fs::remove_all(p, ec);
}
} guard{data_dir};
Slic3r::set_data_dir(data_dir.string());
PluginManager& mgr = PluginManager::instance();
if (!mgr.initialize())
SKIP("PythonInterpreter could not initialize (bundled Python home not found in this environment)");
install_agent_registry_wiring();
mgr.discover_plugins(/*async=*/false, /*clear=*/true);
PluginDescriptor descriptor_a;
PluginDescriptor descriptor_b;
REQUIRE(mgr.try_get_valid_plugin_descriptor(key_a, descriptor_a));
REQUIRE(mgr.try_get_valid_plugin_descriptor(key_b, descriptor_b));
std::string error;
mgr.load_plugin(key_a, /*skip_deps=*/true);
mgr.load_plugin(key_b, /*skip_deps=*/true);
const bool loaded_a = mgr.wait_for_plugin_load(key_a, std::chrono::seconds(60), error);
INFO("plugin A load error: " << error);
REQUIRE(loaded_a);
const bool loaded_b = mgr.wait_for_plugin_load(key_b, std::chrono::seconds(60), error);
INFO("plugin B load error: " << error);
REQUIRE(loaded_b);
auto caps_a = mgr.get_plugin_capabilities(key_a, PluginCapabilityType::PrinterConnection);
auto caps_b = mgr.get_plugin_capabilities(key_b, PluginCapabilityType::PrinterConnection);
REQUIRE(caps_a.size() == 1);
REQUIRE(caps_b.size() == 1);
// Register A first as the owner, then B with the duplicate id.
NetworkAgentFactory::register_python_printer_agent(key_a, caps_a[0]->name());
NetworkAgentFactory::register_python_printer_agent(key_b, caps_b[0]->name());
// The shared id is registered, and still owned by A; B did not replace it.
CHECK(NetworkAgentFactory::is_printer_agent_registered(dup_id));
const PrinterAgentInfo* info = NetworkAgentFactory::get_printer_agent_info(dup_id);
REQUIRE(info != nullptr);
CHECK(info->plugin_identifier.find(key_a) != std::string::npos); // owned by A
CHECK(info->plugin_identifier.find(key_b) == std::string::npos); // B never took ownership
// Unload the owner. The id is not promoted to the still loaded duplicate.
REQUIRE(mgr.unload_plugin(key_a));
CHECK_FALSE(NetworkAgentFactory::is_printer_agent_registered(dup_id));
mgr.unload_plugin(key_b); // the duplicate was loaded but never registered
}
// ===========================================================================
// NATIVE (BUILT-IN) AGENT ID COLLISION
// A plugin may not hijack a built-in agent id (e.g. "bbl"). The built-in keeps
// ownership and the plugin's agent is rejected.
// ===========================================================================
TEST_CASE("printer-agent plugin cannot claim a built-in agent id", "[plugin][lifecycle][Python]")
{
// Register the native built-ins so "bbl"/"orca" occupy the registry.
NetworkAgentFactory::register_all_agents();
REQUIRE(NetworkAgentFactory::is_printer_agent_registered(BBL_PRINTER_AGENT_ID));
const std::string plugin_key = "BuiltinClashAgent";
const fs::path data_dir = fs::temp_directory_path() / "orca-builtin-clash-test";
{
boost::system::error_code ec;
fs::remove_all(data_dir, ec);
}
stage_plugin(data_dir, plugin_key, make_agent_plugin_source("Clash", "Builtin Clash", BBL_PRINTER_AGENT_ID));
struct DirGuard
{
fs::path p;
~DirGuard()
{
boost::system::error_code ec;
fs::remove_all(p, ec);
}
} guard{data_dir};
Slic3r::set_data_dir(data_dir.string());
PluginManager& mgr = PluginManager::instance();
if (!mgr.initialize())
SKIP("PythonInterpreter could not initialize (bundled Python home not found in this environment)");
install_agent_registry_wiring();
mgr.discover_plugins(/*async=*/false, /*clear=*/true);
PluginDescriptor descriptor;
REQUIRE(mgr.try_get_valid_plugin_descriptor(plugin_key, descriptor));
std::string error;
mgr.load_plugin(plugin_key, /*skip_deps=*/true);
REQUIRE(mgr.wait_for_plugin_load(plugin_key, std::chrono::seconds(60), error));
auto caps = mgr.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection);
REQUIRE(caps.size() == 1);
NetworkAgentFactory::register_python_printer_agent(plugin_key, caps[0]->name());
// "bbl" is still the native built-in, not the plugin.
const PrinterAgentInfo* info = NetworkAgentFactory::get_printer_agent_info(BBL_PRINTER_AGENT_ID);
REQUIRE(info != nullptr);
CHECK_FALSE(info->is_plugin());
CHECK(info->plugin_identifier.find(plugin_key) == std::string::npos);
mgr.unload_plugin(plugin_key);
}
// ===========================================================================
// RE-REGISTERING THE SAME CAPABILITY IS A REFRESH, NOT A DUPLICATE
// The guard rejects only a DIFFERENT owner. The same capability registering its
// own id again must stay registered (insert_or_assign refreshes it in place).
// ===========================================================================
TEST_CASE("re-registering the same capability keeps its agent registered", "[plugin][lifecycle][Python]")
{
const std::string plugin_key = "ReRegisterAgent";
const std::string agent_id = "re-register-agent";
const fs::path data_dir = fs::temp_directory_path() / "orca-re-register-test";
{
boost::system::error_code ec;
fs::remove_all(data_dir, ec);
}
stage_plugin(data_dir, plugin_key, make_agent_plugin_source("Re", "Re Register", agent_id));
struct DirGuard
{
fs::path p;
~DirGuard()
{
boost::system::error_code ec;
fs::remove_all(p, ec);
}
} guard{data_dir};
Slic3r::set_data_dir(data_dir.string());
PluginManager& mgr = PluginManager::instance();
if (!mgr.initialize())
SKIP("PythonInterpreter could not initialize (bundled Python home not found in this environment)");
install_agent_registry_wiring();
mgr.discover_plugins(/*async=*/false, /*clear=*/true);
PluginDescriptor descriptor;
REQUIRE(mgr.try_get_valid_plugin_descriptor(plugin_key, descriptor));
std::string error;
mgr.load_plugin(plugin_key, /*skip_deps=*/true);
REQUIRE(mgr.wait_for_plugin_load(plugin_key, std::chrono::seconds(60), error));
auto caps = mgr.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection);
REQUIRE(caps.size() == 1);
NetworkAgentFactory::register_python_printer_agent(plugin_key, caps[0]->name());
REQUIRE(NetworkAgentFactory::is_printer_agent_registered(agent_id));
const PrinterAgentInfo* first = NetworkAgentFactory::get_printer_agent_info(agent_id);
REQUIRE(first != nullptr);
const std::string owner = first->plugin_identifier;
// The same capability registering again is a refresh: still registered, same owner, not rejected.
NetworkAgentFactory::register_python_printer_agent(plugin_key, caps[0]->name());
CHECK(NetworkAgentFactory::is_printer_agent_registered(agent_id));
const PrinterAgentInfo* second = NetworkAgentFactory::get_printer_agent_info(agent_id);
REQUIRE(second != nullptr);
CHECK(second->plugin_identifier == owner);
mgr.unload_plugin(plugin_key);
}
// why: this binary embeds CPython through the PythonInterpreter singleton, which
// lives for the full process. Normal static destruction can tear Python down
// while C++ objects still hold Python handles, producing a Windows heap
// corruption after the assertions have finished. The app has an ordered shutdown
// path; this test harness does not, so it returns the Catch2 result through
// _Exit after flushing output.
int main(int argc, char* argv[])
{
const int result = Catch::Session().run(argc, argv);
std::cout.flush();
std::cerr.flush();
std::fflush(nullptr);
std::_Exit(result);
}

View File

@@ -0,0 +1,131 @@
#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");
}