Compare commits

..

9 Commits

Author SHA1 Message Date
Ian Chua
f3f44bffcb Merge branch 'feat/printer-agent-ui' of https://github.com/OrcaSlicer/OrcaSlicer into refactor/printer-agent-interface 2026-08-05 13:20:18 +08:00
Ian Chua
2d3e911efa Merge branch 'main' into refactor/printer-agent-interface 2026-08-05 13:16:47 +08:00
Ian Chua
56236f56a8 Add unsupported-command feedback to the device UI 2026-08-04 21:26:56 +08:00
Ian Chua
df5a08517a Keep printer-agent error codes with the interface 2026-08-04 19:44:39 +08:00
Andrew
5d953f915a 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:20 +08:00
Andrew
dd2cb92685 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:20 +08:00
Andrew
b2f08c3ff8 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:19 +08:00
Andrew
75a2460649 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:19 +08:00
Ian Chua
01493d4e3a Add developer flag for printer agents 2026-08-04 18:12:19 +08:00
13 changed files with 138 additions and 194 deletions

View File

@@ -617,18 +617,6 @@ Polygon generate_rectange_polygon(const Vec2f &wt_box_min ,const Vec2f & wt_box_
return res;
}
const char* flush_planner_queue_command(GCodeFlavor flavor)
{
return flavor == gcfKlipper ? "M400\n" : "G4 S0\n";
}
std::string wait_command(GCodeFlavor flavor, float seconds)
{
if (flavor == gcfKlipper)
return "G4 P" + std::to_string(std::lround(seconds * 1000.f)) + "\n";
return "G4 S" + Slic3r::float_to_string_decimal_point(seconds, 3) + "\n";
}
class WipeTowerWriter
{
public:
@@ -1157,7 +1145,7 @@ public:
{
if (time==0.f)
return *this;
m_gcode += wait_command(m_gcode_flavor, time);
m_gcode += "G4 S" + Slic3r::float_to_string_decimal_point(time, 3) + "\n";
return *this;
}
@@ -1202,7 +1190,7 @@ public:
WipeTowerWriter& flush_planner_queue()
{
m_gcode += flush_planner_queue_command(m_gcode_flavor);
m_gcode += "G4 S0\n";
return *this;
}
@@ -1345,8 +1333,6 @@ public:
{
std::string buffer;
if (wait_for_moves)
// Not flush_planner_queue_command(): this BBL precool path wants M400, which every
// flavor it reaches understands, not the zero dwell the other flavors flush with.
buffer += "M400\n";
buffer += "M104";
if (target_extruder != -1)

View File

@@ -26,12 +26,6 @@ enum GCodeFlavor : unsigned char;
Polylines construct_gap_for_skip_points(
const Polygon& polygon, const std::vector<Vec2f>& skip_points, float wt_width, float gap_length, Polygon& insert_skip_polygon);
// Klipper acts on commands the instant it parses them, and its G4 reads only P (milliseconds),
// so the zero-second and seconds-valued dwells every other flavor uses neither synchronize nor
// pause there. Both defined in WipeTower.cpp, shared by WipeTower and WipeTower2.
const char* flush_planner_queue_command(GCodeFlavor flavor); // finish queued moves, e.g. around M104/M109
std::string wait_command(GCodeFlavor flavor, float seconds); // pause for `seconds`
class WipeTower
{
public:

View File

@@ -386,8 +386,7 @@ public:
}
WipeTowerWriter2& switch_filament_monitoring(bool enable) {
flush_planner_queue();
m_gcode += enable ? "M591 R\n" : "M591 S0\n";
m_gcode += std::string("G4 S0\n") + "M591 " + (enable ? "R" : "S0") + "\n";
return *this;
}
@@ -626,7 +625,7 @@ public:
// Set extruder temperature, don't wait by default.
WipeTowerWriter2& set_extruder_temp(int temperature, bool wait = false)
{
flush_planner_queue();
m_gcode += "G4 S0\n"; // to flush planner queue
m_gcode += "M" + std::to_string(wait ? 109 : 104) + " S" + std::to_string(temperature) + "\n";
return *this;
}
@@ -636,7 +635,7 @@ public:
{
if (time==0.f)
return *this;
m_gcode += wait_command(m_gcode_flavor, time);
m_gcode += "G4 S" + Slic3r::float_to_string_decimal_point(time, 3) + "\n";
return *this;
}
@@ -678,8 +677,8 @@ public:
}
WipeTowerWriter2& flush_planner_queue()
{
m_gcode += flush_planner_queue_command(m_gcode_flavor);
{
m_gcode += "G4 S0\n";
return *this;
}

View File

@@ -611,7 +611,6 @@ namespace Slic3r
}
selected_machine = dev_id;
record_user_last_machine(selected_machine);
return true;
}
@@ -876,38 +875,20 @@ namespace Slic3r
}
}
void DeviceManager::record_user_last_machine(const std::string& dev_id)
{
if (Slic3r::GUI::wxGetApp().app_config) {
Slic3r::GUI::wxGetApp().app_config->set("user_last_selected_machine", dev_id);
}
}
std::string DeviceManager::get_user_last_machine() const
{
if (Slic3r::GUI::wxGetApp().app_config) {
const auto& user_last_machine = Slic3r::GUI::wxGetApp().app_config->get("user_last_selected_machine");
if (!user_last_machine.empty()) {
return user_last_machine;
} else if (m_agent) {
return m_agent->get_user_selected_machine();
}
}
return "";
}
void DeviceManager::load_last_machine()
{
// Only reconnect the remembered cloud machine. Do not select an arbitrary
// first machine: agent swaps intentionally leave the selection empty until
// the new agent explicitly selects its configured printer.
if (userMachineList.empty())
// Get all available machines, include cloud machines and lan machines that have access right
auto all_machines = get_my_machine_list();
if (all_machines.empty())
return;
const auto& last_monitor_machine = get_user_last_machine();
if (userMachineList.find(last_monitor_machine) != userMachineList.end())
set_selected_machine(last_monitor_machine);
// Reconnect the machine the user last selected, if it's still available.
// why: no first-available fallback - auto-connecting an arbitrary machine
// fights the agent-swap reset, which intentionally leaves nothing selected.
const std::string last_monitor_machine = m_agent ? m_agent->get_user_selected_machine() : "";
const auto last_machine = all_machines.find(last_monitor_machine);
if (last_machine != all_machines.end())
this->set_selected_machine(last_machine->second->get_dev_id());
}
void DeviceManager::OnMachineBindStateChanged(MachineObject* obj, const std::string& new_state)

View File

@@ -52,9 +52,6 @@ public:
// swap path can reuse it instead of duplicating the two sidebar calls.
void OnSelectedMachineLost();
void record_user_last_machine(const std::string& dev_id);
std::string get_user_last_machine() const;
// local machine
void set_local_selected_machine(std::string dev_id) { local_selected_machine = dev_id; };
MachineObject* get_local_selected_machine() const { return get_local_machine(local_selected_machine); }

View File

@@ -1733,9 +1733,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)
@@ -1773,9 +1775,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)
@@ -1791,9 +1795,11 @@ int MachineObject::command_ams_refresh_rfid2(int ams_id, int slot_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)

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

@@ -84,6 +84,16 @@ 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; }
/**
* Establish a direct LAN connection to a printer.
*/

View File

@@ -767,6 +767,27 @@ 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::connect_printer(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl)
{
if (m_printer_agent)

View File

@@ -142,6 +142,9 @@ 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 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);

View File

@@ -18,7 +18,6 @@ add_executable(${_TEST_NAME}_tests
test_slicing_pipeline_hook.cpp
test_support_material.cpp
test_trianglemesh.cpp
test_wipe_tower.cpp
)
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r Catch2::Catch2WithMain)
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")

View File

@@ -1,123 +0,0 @@
#include <catch2/catch_all.hpp>
#include <string>
#include <vector>
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/GCode/WipeTower.hpp"
#include "libslic3r/PrintConfig.hpp"
#include "test_helpers.hpp"
using namespace Slic3r;
using namespace Slic3r::Test;
// Taken from the config enum map rather than hand-listed, so a flavor added to GCodeFlavor later
// is covered here without editing this file.
static std::vector<GCodeFlavor> non_klipper_flavors()
{
std::vector<GCodeFlavor> flavors;
for (const auto &[name, value] : ConfigOptionEnum<GCodeFlavor>::get_enum_values())
if (GCodeFlavor(value) != gcfKlipper)
flavors.push_back(GCodeFlavor(value));
return flavors;
}
static std::string flavor_name(GCodeFlavor flavor)
{
return ConfigOptionEnum<GCodeFlavor>::get_enum_names()[int(flavor)];
}
TEST_CASE("Klipper flushes the wipe tower planner queue with M400", "[WipeTower]")
{
CHECK(std::string(flush_planner_queue_command(gcfKlipper)) == "M400\n");
}
TEST_CASE("Other flavors flush the wipe tower planner queue with a zero dwell", "[WipeTower]")
{
const GCodeFlavor flavor = GENERATE(from_range(non_klipper_flavors()));
INFO("gcode flavor: " << flavor_name(flavor));
CHECK(std::string(flush_planner_queue_command(flavor)) == "G4 S0\n");
}
// 1.5s is exactly representable as a float, so neither form can drift when rounded.
TEST_CASE("Klipper waits in the wipe tower with a millisecond dwell", "[WipeTower]")
{
CHECK(wait_command(gcfKlipper, 1.5f) == "G4 P1500\n");
}
TEST_CASE("Other flavors wait in the wipe tower with a seconds dwell", "[WipeTower]")
{
const GCodeFlavor flavor = GENERATE(from_range(non_klipper_flavors()));
INFO("gcode flavor: " << flavor_name(flavor));
CHECK(wait_command(flavor, 1.5f) == "G4 S1.500\n");
}
// The cases above only exercise the helpers in isolation. The one below slices a real
// two-filament print, so it also covers the binding constraint of both changes: that the
// configured `gcode_flavor` reaches the wipe tower writer and lands in the exported G-code.
// The G-code inside each WIPE_TOWER_START/WIPE_TOWER_END pair, concatenated, so an M400 emitted
// outside the tower (e.g. GCodeProcessor's pre-heat injector) cannot create a false match.
static std::string wipe_tower_regions(const std::string &gcode)
{
const std::string &start_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Tower_Start);
const std::string &end_tag = GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Wipe_Tower_End);
std::string regions;
size_t pos = 0;
while (true) {
size_t start = gcode.find(start_tag, pos);
if (start == std::string::npos)
break;
size_t end = gcode.find(end_tag, start);
if (end == std::string::npos)
break;
regions.append(gcode, start, end - start);
pos = end + 1;
}
return regions;
}
// A per-layer toolchange between the wall and infill filaments, same shape as
// test_multifilament.cpp's "Each feature prints with its assigned filament", so the wipe tower
// runs its toolchange path (and so `flush_planner_queue()`) on every layer.
static DynamicPrintConfig wipe_tower_toolchange_config(const std::string &gcode_flavor)
{
return multifilament_config(2, {
{ "sparse_infill_filament_id", 1 },
{ "internal_solid_filament_id", 1 },
{ "top_surface_filament_id", 1 },
{ "bottom_surface_filament_id", 1 },
{ "outer_wall_filament_id", 2 },
{ "inner_wall_filament_id", 2 },
{ "enable_prime_tower", true },
{ "layer_height", 0.3 },
{ "gcode_flavor", gcode_flavor },
});
}
// Slices a 10mm cube under `config`. Not plain Test::slice: a brand-new Print's first `apply()`
// counts one filament in use, and DynamicPrintConfig::normalize_fdm_2's single-filament rule then
// clears `enable_prime_tower`. A second apply, once init_print's regions have settled, sees both
// filaments and the tower survives.
static std::string slice_with_prime_tower(const DynamicPrintConfig &config)
{
Print print;
Model model;
init_print({ cube(10) }, print, model, config);
print.apply(model, config);
return gcode(print);
}
TEST_CASE("The wipe tower's toolchange planner flush follows the gcode flavor", "[WipeTower]")
{
auto [flavor, expected, unexpected] = GENERATE(table<std::string, std::string, std::string>({
{ "klipper", "M400", "G4 S0" },
{ "marlin", "G4 S0", "M400" } }));
DYNAMIC_SECTION(flavor) {
const std::string tower = wipe_tower_regions(slice_with_prime_tower(wipe_tower_toolchange_config(flavor)));
REQUIRE_FALSE(tower.empty());
CHECK_THAT(tower, Catch::Matchers::ContainsSubstring(expected));
CHECK_THAT(tower, !Catch::Matchers::ContainsSubstring(unexpected));
}
}