feat: add filament mapping and AMS capability gates for the orca printer agent

Parse connector-scope capabilities from get_capabilities: supported_features, capabilities.protocol.features and supported_commands are read for orca devices even when the reply has no flags block, reset before each parse, and left untouched for Bambu.

Gate macro-backed AMS commands on fms plus the advertised command (change filament, select tray, control, user settings, RFID, drying stop); gate slot metadata writes on filament_slots. Unknown capability is not support.

Serialize PrintParams::ams_mapping2 into print.gcode_file.filament_mapping with the array position as filament_index and {255,255} dropped. The field is omitted when empty, so an unmapped print stays byte-identical. A mapped print is refused visibly while ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED is false or the connector did not advertise filament_mapping; the GUI predicate shares the serializer's definition so both gates agree. Refuse a partially mapped print that leaves a used filament without a target.

Return a non-success result from start_local_print_with_record so PrintJob falls back to start_print instead of treating an unsent print as success.

Tests cover serializer keying and omission, GUI/agent gate agreement, payload shape, the with_record result, capability parsing, and the per-command AMS gate.
This commit is contained in:
Lam Wei Lun
2026-09-26 22:50:58 +08:00
parent 87eb8de001
commit adcc470cff
11 changed files with 607 additions and 12 deletions
+111 -3
View File
@@ -1676,6 +1676,10 @@ int MachineObject::check_resume_condition()
}
int MachineObject::command_ams_change_filament(bool load, std::string ams_id, std::string slot_id, int old_temp, int new_temp, std::optional<int> extruder_id)
{
if (!orca_ams_command_supported("print.ams_change_filament")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_change_filament: connector does not advertise the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
json j;
try {
auto tray_id = 0;
@@ -1719,6 +1723,10 @@ int MachineObject::command_ams_change_filament(bool load, std::string ams_id, st
int MachineObject::command_ams_user_settings(bool start_read_opt, bool tray_read_opt, bool remain_flag)
{
if (!orca_ams_command_supported("print.ams_user_setting")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_user_settings: connector does not advertise the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
json j;
j["print"]["command"] = "ams_user_setting";
j["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++);
@@ -1741,8 +1749,25 @@ int MachineObject::command_ams_calibrate(int ams_id)
return command_with_dialog(m_agent->command_ams_calibrate(get_dev_id(), ams_id, MachineObject::m_sequence_id++, is_lan_mode_printer()));
}
bool MachineObject::orca_ams_command_supported(const char* command) const
{
if (printer_agent_id != ORCA_PRINTER_AGENT_ID)
return true;
// fms is the AMS axis; the advertised command set is the per-command gate.
if (!is_support_fms)
return false;
return command != nullptr && supported_commands.count(command) != 0;
}
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)
{
// OrcaSonar: writing slot metadata is gated on the filament_slots capability.
// Absent/false means unsupported; Bambu keeps the legacy behaviour.
if (printer_agent_id == ORCA_PRINTER_AGENT_ID && !is_support_filament_slots) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_filament_settings: printer does not advertise filament_slots";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
int tag_tray_id = 0;
int tag_ams_id = ams_id;
int tag_slot_id = slot_id;
@@ -1777,6 +1802,13 @@ int MachineObject::command_ams_filament_settings(int ams_id, int slot_id, std::s
int MachineObject::command_ams_refresh_rfid(int ams_id, int slot_id)
{
if (!m_agent) return -1;
// OrcaSonar: RFID read requires the connector to advertise the
// `print.ams_get_rfid` command (which already implies fms plus the macro).
// Unknown capability is not support; Bambu is untouched.
if (!orca_ams_command_supported("print.ams_get_rfid")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_refresh_rfid: connector does not advertise the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
return command_with_dialog(m_agent->command_ams_refresh_rfid(get_dev_id(), ams_id, slot_id, MachineObject::m_sequence_id++, is_lan_mode_printer()));
}
@@ -1790,11 +1822,21 @@ int MachineObject::command_start_camera()
int MachineObject::command_ams_select_tray(std::string tray_id)
{
if (!m_agent) return -1;
// OrcaSonar: this publishes the same macro-backed print.ams_change_filament
// as command_ams_change_filament, so it takes the same capability gate.
if (!orca_ams_command_supported("print.ams_change_filament")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_select_tray: connector does not advertise the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
return command_with_dialog(m_agent->command_ams_select_tray(get_dev_id(), tray_id, MachineObject::m_sequence_id++, is_lan_mode_printer()));
}
int MachineObject::command_ams_control(std::string action)
{
if (!orca_ams_command_supported("print.ams_control")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_control: connector does not advertise the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
if (action == "resume" && check_resume_condition()) return 0;
//valid actions
@@ -1810,6 +1852,10 @@ int MachineObject::command_ams_control(std::string action)
int MachineObject::command_ams_drying_stop()
{
if (!orca_ams_command_supported("print.auto_stop_ams_dry")) {
BOOST_LOG_TRIVIAL(warning) << "command_ams_drying_stop: connector does not advertise the command";
return command_with_dialog(ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
json j;
j["print"]["command"] = "auto_stop_ams_dry";
j["print"]["sequence_id"] = std::to_string(MachineObject::m_sequence_id++);
@@ -5490,10 +5536,14 @@ void MachineObject::parse_new_info2(const json& info)
if (capabilities_it == info.end() || !capabilities_it->is_object())
return;
const auto flags_it = capabilities_it->find("flags");
if (flags_it == capabilities_it->end() || !flags_it->is_object())
const bool has_flags = flags_it != capabilities_it->end() && flags_it->is_object();
// Bambu keeps the legacy behavior: a reply without flags is ignored. Orca
// connector-scope features/commands must still be parsed, so fall through
// with an empty flags object (every parse_bool below then no-ops).
if (!has_flags && printer_agent_id != ORCA_PRINTER_AGENT_ID)
return;
const json& flags = *flags_it;
static const json empty_flags = json::object();
const json& flags = has_flags ? *flags_it : empty_flags;
BOOST_LOG_TRIVIAL(info) << "parse_new_info2: OrcaSonar capability flags=" << flags.dump();
auto parse_bool = [&flags](const char* name, bool& target) {
@@ -5559,6 +5609,64 @@ void MachineObject::parse_new_info2(const json& info)
m_config->ParseConfig(device_config);
m_fan->ParseV2_0(fan_config);
// OrcaSonar connector-scope capabilities. Absent or non-boolean means
// unsupported. A reply without `flags` still parses them (the fall-through
// above); Bambu (bbl) devices never enter this branch and keep the
// flags-only path.
if (printer_agent_id == ORCA_PRINTER_AGENT_ID) {
auto parse_features = [this](const json& features) {
if (!features.is_object())
return;
auto set_flag = [&features](const char* name, bool& target) {
const auto it = features.find(name);
if (it != features.end() && it->is_boolean())
target = it->get<bool>();
};
set_flag("fms", is_support_fms);
set_flag("filament_slots", is_support_filament_slots);
set_flag("filament_mapping", is_support_filament_mapping);
};
auto parse_commands = [this](const json& commands) {
if (!commands.is_array())
return;
for (const auto& command : commands) {
if (command.is_string())
supported_commands.insert(command.get<std::string>());
}
};
// Clear before filling so a reply without commands does not retain stale values.
supported_commands.clear();
is_support_fms = false;
is_support_filament_slots = false;
is_support_filament_mapping = false;
const auto supported_features_it = info.find("supported_features");
if (supported_features_it != info.end())
parse_features(*supported_features_it);
const auto supported_commands_it = info.find("supported_commands");
if (supported_commands_it != info.end())
parse_commands(*supported_commands_it);
const auto protocol_it = capabilities_it->find("protocol");
if (protocol_it != capabilities_it->end() && protocol_it->is_object()) {
const auto protocol_features_it = protocol_it->find("features");
if (protocol_features_it != protocol_it->end())
parse_features(*protocol_features_it);
const auto protocol_commands_it = protocol_it->find("supported_commands");
if (protocol_commands_it != protocol_it->end())
parse_commands(*protocol_commands_it);
}
BOOST_LOG_TRIVIAL(info) << "parse_new_info2: fms=" << is_support_fms
<< " filament_slots=" << is_support_filament_slots
<< " filament_mapping=" << is_support_filament_mapping
<< " supported_commands=" << supported_commands.size();
}
}
static bool is_hex_digit(char c) {
+12
View File
@@ -8,6 +8,7 @@
#include <string>
#include <memory>
#include <chrono>
#include <set>
#include <unordered_set>
#include <optional>
#include <boost/thread.hpp>
@@ -646,6 +647,13 @@ public:
bool is_support_partskip{false};
bool is_support_refresh_nozzle{false};
// OrcaSonar connector-scope capabilities (printer_agent_id == "orca"). Parsed from the
// get_capabilities reply; never consulted on Bambu paths.
bool is_support_fms{false};
bool is_support_filament_slots{false};
bool is_support_filament_mapping{false};
std::set<std::string> supported_commands;
// refine printer function options
bool is_support_spaghetti_detection{false};
bool is_support_purgechutepileup_detection{false};
@@ -787,6 +795,10 @@ public:
int command_refresh_nozzle();
int command_set_chamber(int temp);
int check_resume_condition();
// OrcaSonar: true when a macro-backed AMS command may be sent. Bambu paths
// always allow; an Orca device must have advertised the command (unknown
// capability is not support).
bool orca_ams_command_supported(const char* command) const;
// ams controls
//int command_ams_switch(int tray_index, int old_temp = 210, int new_temp = 210);
int command_ams_change_filament(bool load, std::string ams_id, std::string slot_id, int old_temp = 210, int new_temp = 210, std::optional<int> extruder_id = std::nullopt);
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "libslic3r/ProjectTask.hpp"
namespace Slic3r {
namespace GUI {
// True when the normalized ams_mapping2 carries any entry the agent's serializer
// would put on the wire: every integer pair except the {255,255} unmatched
// sentinel, external slots ({255,0}/{254,0}) included. This mirrors
// OrcaPrinterAgent::build_filament_mapping exactly, so the GUI gate and the
// agent gate agree; a mismatch lets an entry reach the agent and be refused late
// with a generic publish error instead of the designed message.
inline bool has_engaged_filament_mapping(const std::string& ams_mapping2)
{
const nlohmann::json mapping = nlohmann::json::parse(ams_mapping2, nullptr, false);
if (mapping.is_discarded() || !mapping.is_array())
return false;
for (const auto& entry : mapping) {
if (!entry.is_object())
continue;
const auto ams_id_it = entry.find("ams_id");
const auto slot_id_it = entry.find("slot_id");
if (ams_id_it == entry.end() || slot_id_it == entry.end())
continue;
if (!ams_id_it->is_number_integer() || !slot_id_it->is_number_integer())
continue;
if (ams_id_it->get<int>() == 255 && slot_id_it->get<int>() == 255)
continue; // unmatched/unused sentinel: the serializer drops it
return true;
}
return false;
}
// A used filament with no target would be silently dropped from the wire
// mapping, so the print must be refused rather than run the wrong material.
// m_ams_mapping_result carries exactly the filaments the slice uses.
inline bool has_used_filament_without_target(const std::vector<FilamentInfo>& result)
{
for (const auto& f : result) {
if (f.get_ams_id() < 0 || f.get_slot_id() < 0)
return true;
}
return false;
}
// True when at least one used filament already has a target. The used-unmapped
// refusal applies to a partially mapped print; a print with no mapping at all
// is handled by the existing all-invalid send-path flow.
inline bool has_any_mapped_target(const std::vector<FilamentInfo>& result)
{
for (const auto& f : result) {
if (f.get_ams_id() >= 0 && f.get_slot_id() >= 0)
return true;
}
return false;
}
} // namespace GUI
} // namespace Slic3r
+25
View File
@@ -37,6 +37,8 @@
#include "libslic3r/MultiNozzleUtils.hpp" // filament-change-gap model for the best-position popup
#include "BackgroundSlicingProcess.hpp" // complete type for background_process().get_current_gcode_result()
#include "DeviceCore/DevStorage.h"
#include "slic3r/Utils/NetworkAgentFactory.hpp"
#include "FilamentMappingUtils.hpp"
#include <wx/progdlg.h>
#include <wx/clipbrd.h>
@@ -3478,6 +3480,9 @@ void SelectMachineDialog::navigate_to_timelapse_page()
this->EndModal(wxID_CANCEL);
}
// Mapping helpers live in FilamentMappingUtils.hpp (shared with
// SendMultiMachinePage); they mirror the agent serializer exactly.
void SelectMachineDialog::on_send_print()
{
BOOST_LOG_TRIVIAL(info) << "print_job: on_ok to send";
@@ -3532,6 +3537,26 @@ void SelectMachineDialog::on_send_print()
get_ams_mapping_result(ams_mapping_array,ams_mapping_array2, ams_mapping_info);
// OrcaSonar: a mapped print requires the connector to advertise
// filament_mapping and the index correlation to be verified. Refuse rather
// than start with the map silently dropped; Bambu keeps its behavior.
if (obj_->printer_agent_id == ORCA_PRINTER_AGENT_ID) {
const bool mapping_available = obj_->is_support_filament_mapping && ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED;
if (!mapping_available && has_engaged_filament_mapping(ams_mapping_array2)) {
BOOST_LOG_TRIVIAL(warning) << "print_job: filament mapping unavailable (capability=" << obj_->is_support_filament_mapping
<< ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << "); refusing mapped print";
m_status_bar->set_status_text(_L("AMS filament mapping is not available for this printer. Clear the AMS mapping before printing."));
Enable_Send_Button(true);
return;
}
if (has_any_mapped_target(m_ams_mapping_result) && has_used_filament_without_target(m_ams_mapping_result)) {
BOOST_LOG_TRIVIAL(warning) << "print_job: a used filament has no AMS target; refusing print";
m_status_bar->set_status_text(_L("A filament used by this print has no AMS mapping. Assign it before printing."));
Enable_Send_Button(true);
return;
}
}
if (m_print_type == PrintFromType::FROM_NORMAL) {
result = m_plater->send_gcode(m_print_plate_idx, [this](int export_stage, int current, int total, bool& cancel) {
if (this->m_is_canceled) return;
+27
View File
@@ -10,6 +10,8 @@
#include "DeviceCore/DevManager.h"
#include "DeviceCore/DevStorage.h"
#include "slic3r/Utils/NetworkAgentFactory.hpp"
#include "FilamentMappingUtils.hpp"
namespace Slic3r {
namespace GUI {
@@ -692,6 +694,8 @@ bool SendMultiMachinePage::get_ams_mapping_result(std::string &mapping_array_str
return true;
}
// Mapping helpers live in FilamentMappingUtils.hpp, shared with SelectMachine.
void SendMultiMachinePage::on_send(wxCommandEvent& event)
{
event.Skip();
@@ -740,6 +744,29 @@ void SendMultiMachinePage::on_send(wxCommandEvent& event)
if (!wxGetApp().is_blocking_printing(obj)) {
PrintParams params = request_params(obj);
// OrcaSonar: a mapped print requires the connector to advertise
// filament_mapping, and a partially mapped print must not silently drop a
// used filament. Any entry the serializer would put on the wire engages the
// gate, external slots ({255,0}/{254,0}) included; the extra-spool branch
// rewrites to exactly those. Bambu is unchanged.
if (obj->printer_agent_id == ORCA_PRINTER_AGENT_ID) {
const bool mapping_available = obj->is_support_filament_mapping && ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED;
if (!mapping_available && has_engaged_filament_mapping(params.ams_mapping2)) {
BOOST_LOG_TRIVIAL(warning) << "SendMultiMachinePage: filament mapping unavailable (capability=" << obj->is_support_filament_mapping
<< ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << "); refusing mapped print for "
<< obj->get_dev_id();
MessageDialog msg_wingow(nullptr, _L("AMS filament mapping is not available for this printer. Clear the AMS mapping before printing."), "", wxICON_WARNING | wxOK);
msg_wingow.ShowModal();
return;
}
if (params.task_use_ams && has_any_mapped_target(m_ams_mapping_result) &&
has_used_filament_without_target(m_ams_mapping_result)) {
BOOST_LOG_TRIVIAL(warning) << "SendMultiMachinePage: a used filament has no target; refusing print for " << obj->get_dev_id();
MessageDialog msg_wingow(nullptr, _L("A filament used by this print has no AMS mapping. Assign it before printing."), "", wxICON_WARNING | wxOK);
msg_wingow.ShowModal();
return;
}
}
print_params.push_back(params);
}
}
+6
View File
@@ -1373,6 +1373,12 @@ bool SyncAmsInfoDialog::get_ams_mapping_result(std::string &mapping_array_str, s
BOOST_LOG_TRIVIAL(error) << "get_ams_mapping_result, plater is nullptr";
}
// mapping_v1_json is built one entry per filament preset, in preset order, so the
// array position is the logical filament index the generated G-code toolchange
// references (the identifier handed to the Klipper toolchange macro; see
// OrcaPrinterAgent::build_filament_mapping and the index-correlation test in
// tests/slic3rutils/test_orca_printer_agent.cpp). Never re-densify after dropping
// sentinel entries, or a used filament would be aimed at the wrong lane.
for (int i = 0; i < wxGetApp().preset_bundle->filament_presets.size(); i++) {
int tray_id = -1;
json mapping_item_v1;
+8
View File
@@ -17,6 +17,14 @@ namespace Slic3r {
static constexpr char ORCA_PRINTER_AGENT_ID[] = "orca";
static constexpr char BBL_PRINTER_AGENT_ID[] = "bbl";
// Index-correlation merge gate (plan PR 3). The per-print `filament_mapping`
// serializer is shipped disabled until a slice-level test proves that the
// `ams_mapping2` array position equals the toolchange identifier the generated
// G-code emits (T<filament_id> / next_filament_id). Both the GUI send gates and
// OrcaPrinterAgent::start_sdcard_print consult this, so mapping is refused
// visibly rather than silently dropped. Flip to true only with that test.
static constexpr bool ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED = false;
// Factory function type for creating printer agents
using PrinterAgentFactory =
std::function<std::shared_ptr<IPrinterAgent>(std::shared_ptr<ICloudServiceAgent> cloud_agent, const std::string& log_dir)>;
+125 -9
View File
@@ -43,6 +43,23 @@ namespace {
namespace fs = boost::filesystem;
// Per-device filament_mapping capability, mirrored from the get_capabilities
// reply in merge_capabilities and read by start_sdcard_print so the field is
// refused defensively when the connector never advertised it or the
// index-correlation merge gate (ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED) is
// not satisfied.
std::mutex g_filament_mapping_mutex;
std::unordered_map<std::string, bool> g_filament_mapping_cache;
// True only when the connector's get_capabilities reply advertised
// filament_mapping for this device. Unknown is not support.
bool filament_mapping_advertised(const std::string& dev_id)
{
std::lock_guard<std::mutex> l(g_filament_mapping_mutex);
const auto it = g_filament_mapping_cache.find(dev_id);
return it != g_filament_mapping_cache.end() && it->second;
}
// params.filename is normally the exported .3mf archive; the sliced G-code sits
// beside it with the same stem (".12345.0.3mf" -> ".12345.0.gcode"). params.dst_file,
// when set, already points straight at a file (the "print a file already on the
@@ -588,6 +605,33 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons
std::lock_guard<std::mutex> l(nozzle_diameter_cache_mutex);
nozzle_diameter_cache[dev_id] = nozzle_dia;
}
// Per-device filament_mapping capability. Both feature maps carry it;
// either being true means the connector advertised it.
bool mapping_advertised = false;
{
const auto top_features = info_it->find("supported_features");
if (top_features != info_it->end() && top_features->is_object()) {
const auto it = top_features->find("filament_mapping");
if (it != top_features->end() && it->is_boolean())
mapping_advertised = it->get<bool>();
}
if (!mapping_advertised && caps_it != info_it->end() && caps_it->is_object()) {
const auto protocol_it = caps_it->find("protocol");
if (protocol_it != caps_it->end() && protocol_it->is_object()) {
const auto features_it = protocol_it->find("features");
if (features_it != protocol_it->end() && features_it->is_object()) {
const auto it = features_it->find("filament_mapping");
if (it != features_it->end() && it->is_boolean())
mapping_advertised = it->get<bool>();
}
}
}
}
{
std::lock_guard<std::mutex> l(g_filament_mapping_mutex);
g_filament_mapping_cache[dev_id] = mapping_advertised;
}
// The capabilities reply itself is forwarded unchanged.
}
else {
@@ -1415,11 +1459,17 @@ int OrcaPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn
return BAMBU_NETWORK_SUCCESS;
}
int OrcaPrinterAgent::start_local_print_with_record(PrintParams params,
OnUpdateStatusFn update_fn,
WasCancelledFn cancel_fn,
OnWaitFn wait_fn)
{ return BAMBU_NETWORK_SUCCESS; }
int OrcaPrinterAgent::start_local_print_with_record(PrintParams /*params*/,
OnUpdateStatusFn /*update_fn*/,
WasCancelledFn /*cancel_fn*/,
OnWaitFn /*wait_fn*/)
{
// OrcaSonar has no FTP "send with record" path. Report a non-success result so
// PrintJob falls back to start_print() (cloud upload + start_sdcard_print) instead
// of treating a print that was never sent as successful.
BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: start_local_print_with_record is unimplemented; deferring to start_print";
return BAMBU_NETWORK_ERR_FTP_UPLOAD_FAILED;
}
// Upload one G-code file to the printer's `gcodes` root over OrcaSonar's
// Moonraker-compatible HTTP facade. No print is started here (print=false); the
@@ -1587,6 +1637,59 @@ int OrcaPrinterAgent::start_local_print(PrintParams params, OnUpdateStatusFn upd
return start_sdcard_print(params, update_fn, cancel_fn);
}
// Serialize PrintParams::ams_mapping2 (the dialog's mapping_v1_json, one entry per logical
// filament in preset order) into the print.gcode_file `filament_mapping` array. The array
// position becomes `filament_index`; {255,255} (unmatched/unused) is dropped. Returns an
// empty array when nothing usable remains so the caller can omit the field entirely.
nlohmann::json OrcaPrinterAgent::build_filament_mapping(const std::string& ams_mapping2)
{
nlohmann::json mapping = nlohmann::json::array();
if (ams_mapping2.empty())
return mapping;
const nlohmann::json parsed = nlohmann::json::parse(ams_mapping2, nullptr, false);
if (parsed.is_discarded() || !parsed.is_array()) {
BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: ams_mapping2 is not a JSON array; not sending filament_mapping";
return mapping;
}
for (std::size_t i = 0; i < parsed.size(); ++i) {
const nlohmann::json& entry = parsed[i];
if (!entry.is_object())
continue;
const auto ams_id_it = entry.find("ams_id");
const auto slot_id_it = entry.find("slot_id");
if (ams_id_it == entry.end() || slot_id_it == entry.end())
continue;
if (!ams_id_it->is_number_integer() || !slot_id_it->is_number_integer())
continue;
const int ams_id = ams_id_it->get<int>();
const int slot_id = slot_id_it->get<int>();
if (ams_id == 255 && slot_id == 255)
continue;
mapping.push_back({{"filament_index", static_cast<int>(i)}, {"ams_id", ams_id}, {"slot_id", slot_id}});
}
return mapping;
}
// Pure builder for the print.gcode_file payload: the base command plus, only
// when non-empty, the filament_mapping array. An empty mapping leaves the
// payload byte-identical to today's unmapped command.
nlohmann::json OrcaPrinterAgent::build_gcode_file_payload(const std::string& sequence_id,
const std::string& target,
const nlohmann::json& filament_mapping)
{
nlohmann::json j;
j["print"]["command"] = "gcode_file";
j["print"]["sequence_id"] = sequence_id;
j["print"]["param"] = target;
if (filament_mapping.is_array() && !filament_mapping.empty())
j["print"]["filament_mapping"] = filament_mapping;
return j;
}
// Start a file that already lives on the printer by publishing the canonical
// OPCP print.gcode_file command to device/<dev_id>/request. The acknowledgement
// and lifecycle progress arrive asynchronously as print.push_status on the
@@ -1604,10 +1707,23 @@ int OrcaPrinterAgent::start_sdcard_print(PrintParams params, OnUpdateStatusFn up
// otherwise start what start_send_gcode_to_sdcard just uploaded to `gcodes`.
const std::string target = params.dst_file.empty() ? remote_gcode_name(params) : fs::path(params.dst_file).filename().string();
nlohmann::json j;
j["print"]["command"] = "gcode_file";
j["print"]["sequence_id"] = next_gcode_file_sequence_id();
j["print"]["param"] = target;
// Per-print mapping. A mapped print is refused when the connector did not
// advertise filament_mapping or the index correlation is unverified: the GUI
// send gates make this visible first, and this is the defensive gate for
// callers that bypass them (calibration, plugin). Never start a mapped print
// with the map silently dropped.
const nlohmann::json filament_mapping = build_filament_mapping(params.ams_mapping2);
if (!filament_mapping.empty()) {
const bool mapping_capable = filament_mapping_advertised(params.dev_id);
if (!mapping_capable || !ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED) {
BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent: refusing mapped print (capable=" << mapping_capable
<< ", correlation_verified=" << ORCA_FILAMENT_MAPPING_CORRELATION_VERIFIED << ")";
return ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED;
}
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: start_sdcard_print emitting filament_mapping entries=" << filament_mapping.size();
}
nlohmann::json j = build_gcode_file_payload(next_gcode_file_sequence_id(), target, filament_mapping);
if (update_fn)
update_fn(PrintingStageSending, 0, "Starting print...");
+14
View File
@@ -64,6 +64,8 @@ public:
// Print Job Operations
int start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
// Unimplemented on OrcaSonar: reports a non-success result so callers fall back to
// start_print() instead of treating the missing send as success.
int start_local_print_with_record(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn) override;
int start_send_gcode_to_sdcard(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;
@@ -140,6 +142,18 @@ protected:
// Pure LAN-address parsing + client-id. protected static so the test Probe reaches them.
static bool parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port);
static std::string make_lan_client_id(const std::string& dev_id);
// Pure serializer for PrintParams::ams_mapping2 -> print.gcode_file.filament_mapping.
// Entries are re-keyed by their array position; {255,255} is dropped. Empty when the
// input is empty, malformed, or has no usable entries. protected static for the test Probe.
static nlohmann::json build_filament_mapping(const std::string& ams_mapping2);
// Pure builder for the print.gcode_file command payload. A non-empty mapping is
// included as filament_mapping; an empty one is omitted so the payload is
// byte-identical to an unmapped print. protected static for the test Probe.
static nlohmann::json build_gcode_file_payload(const std::string& sequence_id,
const std::string& target,
const nlohmann::json& filament_mapping);
// Test hook: the ws:// URL connect_printer built for the current LAN session ("" if none).
std::string lan_connection_target() const;
// Shared post-connect sequence: SUBSCRIBE, then pushing.start, pushall,
@@ -157,3 +157,119 @@ TEST_CASE("Device manager filters and rehomes devices by printer-agent ownership
CHECK(manager.get_my_machine_list("integration-agent-a").empty());
CHECK(manager.get_my_machine_list("integration-agent-b").count(machine.dev_id) == 1);
}
TEST_CASE("Orca capability reply populates connector-scope features and commands", "[DeviceManager][integration]")
{
ScopedAppConfig app_config;
NetworkAgent network(nullptr, std::make_shared<TestPrinterAgent>("orca"));
DeviceManager manager(&network, false, &app_config.config);
BBLocalMachine orca_machine;
orca_machine.dev_id = "orca-device";
orca_machine.dev_name = "Orca device";
orca_machine.dev_ip = "192.0.2.21";
orca_machine.printer_type = "C11";
MachineObject* orca_obj = manager.insert_local_device(orca_machine, "lan", "free", "", "access-code");
REQUIRE(orca_obj != nullptr);
orca_obj->printer_agent_id = "orca";
orca_obj->parse_new_info2(json::parse(R"({
"command": "get_capabilities",
"supported_features": {"fms": true, "filament_slots": true, "filament_mapping": true},
"supported_commands": ["print.push_status", "print.ams_get_rfid"],
"capabilities": {
"flags": {},
"protocol": {
"features": {"filament_mapping": true},
"supported_commands": ["print.ams_change_filament"]
}
}
})"));
CHECK(orca_obj->is_support_fms);
CHECK(orca_obj->is_support_filament_slots);
CHECK(orca_obj->is_support_filament_mapping);
CHECK(orca_obj->supported_commands.count("print.push_status") == 1);
CHECK(orca_obj->supported_commands.count("print.ams_get_rfid") == 1);
CHECK(orca_obj->supported_commands.count("print.ams_change_filament") == 1);
// Absent or false reads as unsupported, and a later reply without commands clears the set.
orca_obj->parse_new_info2(json::parse(R"({
"command": "get_capabilities",
"supported_features": {"fms": false, "filament_slots": false, "filament_mapping": false},
"capabilities": {"flags": {}}
})"));
CHECK_FALSE(orca_obj->is_support_fms);
CHECK_FALSE(orca_obj->is_support_filament_slots);
CHECK_FALSE(orca_obj->is_support_filament_mapping);
CHECK(orca_obj->supported_commands.empty());
// A reply without capabilities.flags must still parse the Orca features and
// commands (fail-closed: don't retain stale "supported" values).
orca_obj->parse_new_info2(json::parse(R"({
"command": "get_capabilities",
"supported_features": {"filament_mapping": true},
"capabilities": {
"protocol": {"supported_commands": ["print.ams_get_rfid"]}
}
})"));
CHECK(orca_obj->is_support_filament_mapping);
CHECK_FALSE(orca_obj->is_support_filament_slots);
CHECK(orca_obj->supported_commands.count("print.ams_get_rfid") == 1);
// A non-Orca agent id leaves the connector-scope fields untouched.
BBLocalMachine bbl_machine;
bbl_machine.dev_id = "bbl-device";
bbl_machine.dev_name = "Bambu device";
bbl_machine.dev_ip = "192.0.2.22";
bbl_machine.printer_type = "C11";
MachineObject* bbl_obj = manager.insert_local_device(bbl_machine, "lan", "free", "", "access-code");
REQUIRE(bbl_obj != nullptr);
bbl_obj->printer_agent_id = "bbl";
bbl_obj->parse_new_info2(json::parse(R"({
"command": "get_capabilities",
"supported_features": {"fms": true, "filament_slots": true, "filament_mapping": true},
"supported_commands": ["print.push_status"],
"capabilities": {
"flags": {},
"protocol": {
"features": {"fms": true, "filament_slots": true, "filament_mapping": true},
"supported_commands": ["print.ams_get_rfid"]
}
}
})"));
CHECK_FALSE(bbl_obj->is_support_fms);
CHECK_FALSE(bbl_obj->is_support_filament_slots);
CHECK_FALSE(bbl_obj->is_support_filament_mapping);
CHECK(bbl_obj->supported_commands.empty());
}
TEST_CASE("Orca per-command AMS gate requires fms and the advertised command", "[DeviceManager][integration]")
{
ScopedAppConfig app_config;
NetworkAgent network(nullptr, std::make_shared<TestPrinterAgent>("orca"));
DeviceManager manager(&network, false, &app_config.config);
BBLocalMachine machine;
machine.dev_id = "orca-gate";
machine.dev_name = "Orca gate";
machine.dev_ip = "192.0.2.30";
machine.printer_type = "C11";
MachineObject* obj = manager.insert_local_device(machine, "lan", "free", "", "access-code");
REQUIRE(obj != nullptr);
obj->printer_agent_id = "orca";
// fms off: no macro-backed AMS command is allowed even if listed.
obj->is_support_fms = false;
obj->supported_commands.insert("print.ams_control");
CHECK_FALSE(obj->orca_ams_command_supported("print.ams_control"));
// fms on: only the commands actually advertised are allowed.
obj->is_support_fms = true;
CHECK(obj->orca_ams_command_supported("print.ams_control"));
CHECK_FALSE(obj->orca_ams_command_supported("print.ams_get_rfid"));
// Bambu keeps the legacy permissive path.
obj->printer_agent_id = "bbl";
CHECK(obj->orca_ams_command_supported("print.anything"));
}
@@ -1,4 +1,5 @@
#include <catch2/catch_test_macros.hpp>
#include <slic3r/GUI/FilamentMappingUtils.hpp>
#include <slic3r/Utils/IPrinterAgent.hpp>
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp>
#include <slic3r/Utils/OrcaPrinterAgent.hpp>
@@ -22,6 +23,8 @@ struct Probe : OrcaPrinterAgent {
using OrcaPrinterAgent::parse_lan_endpoint;
using OrcaPrinterAgent::make_lan_client_id;
using OrcaPrinterAgent::lan_connection_target;
using OrcaPrinterAgent::build_filament_mapping;
using OrcaPrinterAgent::build_gcode_file_payload;
};
}
@@ -84,6 +87,101 @@ TEST_CASE("OrcaPrinterAgent::make_lan_client_id is stable and prefixed", "[OrcaP
CHECK(a.rfind("orcaslicer-lan-dev-1-", 0) == 0);
}
TEST_CASE("filament mapping is keyed by the ams_mapping2 array position", "[OrcaPrinterAgent]") {
const nlohmann::json mapping = Probe::build_filament_mapping(
R"([{"ams_id":1,"slot_id":5},{"ams_id":255,"slot_id":255},{"ams_id":255,"slot_id":0}])");
REQUIRE(mapping.is_array());
REQUIRE(mapping.size() == 2);
CHECK(mapping[0]["filament_index"] == 0);
CHECK(mapping[0]["ams_id"] == 1);
CHECK(mapping[0]["slot_id"] == 5);
// The unmatched middle entry is dropped; the third entry keeps index 2.
CHECK(mapping[1]["filament_index"] == 2);
CHECK(mapping[1]["ams_id"] == 255);
CHECK(mapping[1]["slot_id"] == 0);
}
// Index-correlation merge gate (plan PR 3). `ams_mapping2` is built one entry
// per logical filament, so its array position is the identifier the generated
// G-code toolchange passes to the Klipper macro (`next_filament_id`). The
// serializer must key `filament_index` by that position and must never
// re-densify after dropping unused/sentinel entries, or a used filament would
// be aimed at the wrong lane. This test covers the plan's matrix: preset order
// differing from used order, a middle filament unused, and external slots.
TEST_CASE("filament mapping index correlates with the ams_mapping2 position", "[OrcaPrinterAgent]") {
// Positions 0..4. Used filaments are 0, 2 and 4; 1 and 3 are unused.
const nlohmann::json mapping = Probe::build_filament_mapping(
R"([{"ams_id":0,"slot_id":1},{"ams_id":255,"slot_id":255},{"ams_id":2,"slot_id":3},{"ams_id":255,"slot_id":255},{"ams_id":255,"slot_id":0}])");
REQUIRE(mapping.size() == 3);
CHECK(mapping[0]["filament_index"] == 0);
CHECK(mapping[1]["filament_index"] == 2);
CHECK(mapping[2]["filament_index"] == 4); // external slot keeps its position
CHECK(mapping[2]["ams_id"] == 255);
CHECK(mapping[2]["slot_id"] == 0);
// No re-densification: a used filament after a dropped sentinel keeps its
// original logical index.
for (const auto& entry : mapping)
CHECK(entry.contains("filament_index"));
}
TEST_CASE("filament mapping is omitted when nothing remains", "[OrcaPrinterAgent]") {
CHECK(Probe::build_filament_mapping("").empty());
CHECK(Probe::build_filament_mapping(R"([{"ams_id":255,"slot_id":255}])").empty());
CHECK(Probe::build_filament_mapping("not json").empty());
CHECK(Probe::build_filament_mapping(R"({"ams_id":1,"slot_id":0})").empty()); // not an array
}
// The GUI capability gate and the agent serializer must classify the same
// entries as engaged. External slots ({255,0}/{254,0}) are normalized as-is, so
// they engage; only the {255,255} unmatched sentinel is dropped. A mismatch lets
// an entry past the GUI and refused late with a generic publish error.
TEST_CASE("the GUI mapping gate engages exactly the entries the serializer sends", "[OrcaPrinterAgent]") {
using Slic3r::GUI::has_engaged_filament_mapping;
CHECK_FALSE(has_engaged_filament_mapping(""));
CHECK_FALSE(has_engaged_filament_mapping("[]"));
CHECK_FALSE(has_engaged_filament_mapping("not json"));
CHECK_FALSE(has_engaged_filament_mapping(R"([{"ams_id":255,"slot_id":255}])"));
CHECK(has_engaged_filament_mapping(R"([{"ams_id":255,"slot_id":0}])")); // external main
CHECK(has_engaged_filament_mapping(R"([{"ams_id":254,"slot_id":0}])")); // external deputy
CHECK(has_engaged_filament_mapping(R"([{"ams_id":0,"slot_id":0}])")); // box slot
CHECK(has_engaged_filament_mapping(R"([{"ams_id":255,"slot_id":255},{"ams_id":1,"slot_id":2}])"));
for (const char* s : {"", "[]", "not json", R"([{"ams_id":255,"slot_id":255}])",
R"([{"ams_id":255,"slot_id":0}])", R"([{"ams_id":254,"slot_id":0}])",
R"([{"ams_id":0,"slot_id":0}])",
R"([{"ams_id":255,"slot_id":255},{"ams_id":1,"slot_id":2}])"}) {
CHECK(has_engaged_filament_mapping(s) == !Probe::build_filament_mapping(s).empty());
}
}
// An empty mapping must leave the gcode_file payload byte-identical to today:
// exactly command, sequence_id and param, with no filament_mapping key.
TEST_CASE("gcode_file payload omits filament_mapping when the map is empty", "[OrcaPrinterAgent]") {
const nlohmann::json empty = Probe::build_gcode_file_payload("7", "job.gcode", nlohmann::json::array());
REQUIRE(empty.contains("print"));
CHECK(empty["print"].size() == 3);
CHECK(empty["print"]["command"] == "gcode_file");
CHECK(empty["print"]["sequence_id"] == "7");
CHECK(empty["print"]["param"] == "job.gcode");
CHECK_FALSE(empty["print"].contains("filament_mapping"));
const nlohmann::json mapping = Probe::build_filament_mapping(R"([{"ams_id":1,"slot_id":0},{"ams_id":255,"slot_id":255}])");
const nlohmann::json with = Probe::build_gcode_file_payload("8", "job.gcode", mapping);
REQUIRE(with["print"].contains("filament_mapping"));
REQUIRE(with["print"]["filament_mapping"].size() == 1);
CHECK(with["print"]["filament_mapping"][0]["filament_index"] == 0);
}
// The FTP "send with record" transport does not exist on OrcaSonar. It must
// report a non-success result so PrintJob falls back to start_print() rather
// than treating a print that was never sent as successful.
TEST_CASE("start_local_print_with_record never reports silent success", "[OrcaPrinterAgent]") {
Probe agent("/tmp");
Slic3r::PrintParams params;
const int rc = agent.start_local_print_with_record(params, {}, {}, {});
CHECK(rc < 0);
}
TEST_CASE("connect_printer wires up a LAN Config", "[OrcaPrinterAgent][.integration]") {
Probe agent("/tmp");
Slic3r::PrinterConnectionParams params{