mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 14:32:36 +00:00
Initial Commit for OrcaPrinterAgent AMS Sync
This commit is contained in:
@@ -24,7 +24,7 @@ NAMESPACE = "lane_data"
|
||||
|
||||
# Repo-relative paths for the offline generic-map check
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
MOONRAKER_AGENT_CPP = os.path.join(REPO_ROOT, "src", "slic3r", "Utils", "MoonrakerPrinterAgent.cpp")
|
||||
AMS_PAYLOAD_CPP = os.path.join(REPO_ROOT, "src", "slic3r", "Utils", "AmsPayload.cpp")
|
||||
OFL_FILAMENT_DIR = os.path.join(REPO_ROOT, "resources", "profiles", "OrcaFilamentLibrary", "filament")
|
||||
LANE_KEYS = [f"lane{i}" for i in range(1, 9)] # lane1-lane8
|
||||
MATERIALS = ["PLA", "ABS", "PETG", "ASA", "ASA Sparkle", "TPU", ""]
|
||||
@@ -41,16 +41,16 @@ MATERIAL_TEMPS = {
|
||||
}
|
||||
|
||||
def parse_cpp_type_map():
|
||||
"""Extract the normalized-type -> OFL generic family table from MoonrakerPrinterAgent.cpp.
|
||||
"""Extract the normalized-type -> OFL generic family table from AmsPayload.cpp.
|
||||
|
||||
Reads MoonrakerPrinterAgent::map_filament_type_to_generic_id's type_to_ofl_family
|
||||
initializer so the check tracks the C++ normalization without a duplicated list.
|
||||
Reads map_filament_type_to_generic_id's type_to_ofl_family initializer so the
|
||||
check tracks the C++ normalization without a duplicated list.
|
||||
"""
|
||||
with open(MOONRAKER_AGENT_CPP, encoding="utf-8") as f:
|
||||
with open(AMS_PAYLOAD_CPP, encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
m = re.search(r"type_to_ofl_family\s*=\s*\{(.*?)\n\s*\};", src, re.DOTALL)
|
||||
if not m:
|
||||
raise RuntimeError(f"type_to_ofl_family table not found in {MOONRAKER_AGENT_CPP}")
|
||||
raise RuntimeError(f"type_to_ofl_family table not found in {AMS_PAYLOAD_CPP}")
|
||||
pairs = re.findall(r'\{\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\}', m.group(1))
|
||||
if not pairs:
|
||||
raise RuntimeError("type_to_ofl_family table parsed empty")
|
||||
|
||||
@@ -740,6 +740,8 @@ set(SLIC3R_GUI_SOURCES
|
||||
Utils/OrcaMqttConnection.hpp
|
||||
Utils/OrcaPrinterAgent.cpp
|
||||
Utils/OrcaPrinterAgent.hpp
|
||||
Utils/AmsPayload.cpp
|
||||
Utils/AmsPayload.hpp
|
||||
Utils/OrcaCloudSignalingChannel.cpp
|
||||
Utils/OrcaCloudSignalingChannel.hpp
|
||||
Utils/QidiPrinterAgent.cpp
|
||||
|
||||
@@ -727,6 +727,7 @@ struct Sidebar::priv
|
||||
ScalableButton * m_bpButton_add_filament;
|
||||
ScalableButton * m_bpButton_del_filament;
|
||||
ScalableButton * m_bpButton_ams_filament;
|
||||
bool m_ams_sync_button_show{true}; // last applied AMS sync button visibility
|
||||
ScalableButton * m_bpButton_set_filament;
|
||||
int m_menu_filament_id = -1;
|
||||
|
||||
@@ -3427,6 +3428,22 @@ void Sidebar::remove_unused_filament_combos(const size_t current_extruder_count)
|
||||
}
|
||||
}
|
||||
|
||||
void Sidebar::update_ams_sync_button()
|
||||
{
|
||||
if (!p->m_bpButton_ams_filament || !wxGetApp().preset_bundle)
|
||||
return;
|
||||
// BBL printers always advertise AMS sync; other agents follow the active
|
||||
// agent's filament sync mode, which flips to subscription only after the
|
||||
// printer's get_capabilities reply arrives.
|
||||
const bool show = wxGetApp().preset_bundle->use_bbl_network() ||
|
||||
(wxGetApp().getAgent() && wxGetApp().getAgent()->get_filament_sync_mode() != FilamentSyncMode::none);
|
||||
if (p->m_ams_sync_button_show == show)
|
||||
return;
|
||||
p->m_ams_sync_button_show = show;
|
||||
p->m_bpButton_ams_filament->Show(show);
|
||||
p->m_bpButton_ams_filament->GetParent()->Layout();
|
||||
}
|
||||
|
||||
void Sidebar::update_all_preset_comboboxes()
|
||||
{
|
||||
PresetBundle &preset_bundle = *wxGetApp().preset_bundle;
|
||||
@@ -3444,7 +3461,7 @@ void Sidebar::update_all_preset_comboboxes()
|
||||
//p->btn_connect_printer->Hide();
|
||||
p->m_printer_connect->Hide();
|
||||
//only show sync-ams button for BBL printer
|
||||
p->m_bpButton_ams_filament->Show();
|
||||
update_ams_sync_button();
|
||||
//update print button default value for bbl or third-party printer
|
||||
p_mainframe->set_print_button_to_default(MainFrame::PrintSelectType::ePrintPlate);
|
||||
} else {
|
||||
@@ -3453,11 +3470,7 @@ void Sidebar::update_all_preset_comboboxes()
|
||||
p->m_printer_connect->Show(!use_printer_agents);
|
||||
|
||||
// ORCA: show/hide sync-ams button based on filament sync mode
|
||||
auto agent = wxGetApp().getAgent();
|
||||
if (agent && agent->get_filament_sync_mode() != FilamentSyncMode::none)
|
||||
p->m_bpButton_ams_filament->Show();
|
||||
else
|
||||
p->m_bpButton_ams_filament->Hide();
|
||||
update_ams_sync_button();
|
||||
|
||||
// Orca: with "Support 3MF as gcode" (use_3mf) the local export is a .gcode.3mf bundle, so when no
|
||||
// printer host/IP is configured the default action is "Export plate sliced file" (mirrors the
|
||||
@@ -22152,10 +22165,14 @@ void Plater::update_machine_sync_status()
|
||||
DeviceManager *dev_maneger = wxGetApp().getDeviceManager();
|
||||
if (!dev_maneger) {
|
||||
GUI::wxGetApp().sidebar().update_sync_status(nullptr);
|
||||
GUI::wxGetApp().sidebar().update_ams_sync_button();
|
||||
return;
|
||||
}
|
||||
MachineObject *obj = wxGetApp().getDeviceManager()->get_selected_machine();
|
||||
GUI::wxGetApp().sidebar().update_sync_status(obj);
|
||||
// The agent's sync mode flips once the printer's get_capabilities reply
|
||||
// arrives; re-evaluate the filament-sync button on every device update.
|
||||
GUI::wxGetApp().sidebar().update_ams_sync_button();
|
||||
}
|
||||
|
||||
bool Plater::get_machine_sync_status()
|
||||
|
||||
@@ -169,6 +169,8 @@ public:
|
||||
void init_filament_combo(PlaterPresetComboBox **combo, const int filament_idx);
|
||||
void remove_unused_filament_combos(const size_t current_extruder_count);
|
||||
void update_all_preset_comboboxes();
|
||||
// Show/hide the AMS filament-sync button from the active agent's sync mode.
|
||||
void update_ams_sync_button();
|
||||
//void update_partplate(PartPlateList& list);
|
||||
void update_presets(Slic3r::Preset::Type preset_type);
|
||||
//BBS
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
#include "AmsPayload.hpp"
|
||||
|
||||
#include "libslic3r/Preset.hpp"
|
||||
#include "libslic3r/PresetBundle.hpp"
|
||||
#include "slic3r/GUI/GUI_App.hpp"
|
||||
#include "slic3r/GUI/DeviceManager.hpp"
|
||||
#include "slic3r/GUI/DeviceCore/DevFilaSystem.h"
|
||||
#include "slic3r/GUI/DeviceCore/DevManager.h"
|
||||
#include "slic3r/GUI/DeviceCore/DevStorage.h"
|
||||
#include "slic3r/GUI/DeviceCore/DevFirmware.h"
|
||||
|
||||
#include <boost/algorithm/string.hpp>
|
||||
#include <boost/log/trivial.hpp>
|
||||
#include <wx/thread.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
std::string map_filament_type_to_generic_id(const std::string& filament_type)
|
||||
{
|
||||
std::string upper = filament_type;
|
||||
boost::trim(upper);
|
||||
std::transform(upper.begin(), upper.end(), upper.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
|
||||
|
||||
// Normalize reported material names to an OrcaFilamentLibrary generic family. The
|
||||
// family's filament_id is resolved from the loaded system presets below rather than
|
||||
// hardcoded, so profile id re-mints never require touching this table.
|
||||
// scripts/test_moonraker_lane_data.py parses this initializer; keep the {"A", "B"} format.
|
||||
static const std::map<std::string, std::string> type_to_ofl_family = {
|
||||
// PLA variants
|
||||
{"PLA", "PLA"},
|
||||
{"PLA-CF", "PLA-CF"},
|
||||
{"PLA SILK", "PLA Silk"},
|
||||
{"PLA-SILK", "PLA Silk"},
|
||||
{"PLA HIGH SPEED", "PLA High Speed"},
|
||||
{"PLA-HS", "PLA High Speed"},
|
||||
{"PLA HS", "PLA High Speed"},
|
||||
|
||||
// ABS/ASA variants
|
||||
{"ABS", "ABS"},
|
||||
{"ASA", "ASA"},
|
||||
|
||||
// PETG/PET variants
|
||||
{"PETG", "PETG"},
|
||||
{"PET", "PETG"},
|
||||
{"PCTG", "PCTG"},
|
||||
|
||||
// PA/Nylon variants
|
||||
{"PA", "PA"},
|
||||
{"NYLON", "PA"},
|
||||
{"PA-CF", "PA-CF"},
|
||||
{"PPA", "PPA-CF"},
|
||||
{"PPA-CF", "PPA-CF"},
|
||||
{"PPA-GF", "PPA-GF"},
|
||||
|
||||
// PC variants
|
||||
{"PC", "PC"},
|
||||
|
||||
// PP/PE variants
|
||||
{"PE", "PE"},
|
||||
{"PP", "PP"},
|
||||
|
||||
// Support materials
|
||||
{"PVA", "PVA"},
|
||||
{"HIPS", "HIPS"},
|
||||
{"BVOH", "BVOH"},
|
||||
|
||||
// TPU variants
|
||||
{"TPU", "TPU"},
|
||||
|
||||
// Other materials
|
||||
{"EVA", "EVA"},
|
||||
{"PHA", "PHA"},
|
||||
{"COPE", "CoPE"},
|
||||
{"SBS", "SBS"},
|
||||
};
|
||||
|
||||
auto it = type_to_ofl_family.find(upper);
|
||||
if (it == type_to_ofl_family.end())
|
||||
return UNKNOWN_FILAMENT_ID;
|
||||
|
||||
if (auto* bundle = GUI::wxGetApp().preset_bundle) {
|
||||
const Preset* preset = bundle->filaments.find_preset("Generic " + it->second + " @System");
|
||||
if (preset != nullptr && preset->is_system && !preset->filament_id.empty())
|
||||
return preset->filament_id;
|
||||
}
|
||||
|
||||
// Unknown material, or no loaded preset data to resolve against
|
||||
return UNKNOWN_FILAMENT_ID;
|
||||
}
|
||||
|
||||
std::string normalize_ams_color(const std::string& color)
|
||||
{
|
||||
std::string value = color;
|
||||
boost::trim(value);
|
||||
|
||||
// Remove 0x or 0X prefix if present
|
||||
if (value.size() >= 2 && (value.rfind("0x", 0) == 0 || value.rfind("0X", 0) == 0)) {
|
||||
value = value.substr(2);
|
||||
}
|
||||
// Remove # prefix if present
|
||||
if (!value.empty() && value[0] == '#') {
|
||||
value = value.substr(1);
|
||||
}
|
||||
|
||||
// Extract only hex digits
|
||||
std::string normalized;
|
||||
for (char c : value) {
|
||||
if (std::isxdigit(static_cast<unsigned char>(c))) {
|
||||
normalized.push_back(static_cast<char>(std::toupper(static_cast<unsigned char>(c))));
|
||||
}
|
||||
}
|
||||
|
||||
// If 6 hex digits, add FF alpha
|
||||
if (normalized.size() == 6) {
|
||||
normalized += "FF";
|
||||
}
|
||||
|
||||
// Validate length - return default if invalid
|
||||
if (normalized.size() != 8) {
|
||||
return "00000000";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
bool parse_moonraker_lane_data(const nlohmann::json& body,
|
||||
std::vector<AmsTrayData>& trays,
|
||||
int& max_lane_index)
|
||||
{
|
||||
// Expected structure: { "result": { "namespace": "lane_data", "value": { "lane1": {...}, ... } } }
|
||||
if (!body.is_object() || !body.contains("result") || !body["result"].is_object() ||
|
||||
!body["result"].contains("value") || !body["result"]["value"].is_object()) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "AmsPayload: unexpected lane_data response structure";
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto& value = body["result"]["value"];
|
||||
trays.clear();
|
||||
max_lane_index = 0;
|
||||
|
||||
for (const auto& lane_item : value.items()) {
|
||||
const auto& lane_obj = lane_item.value();
|
||||
if (!lane_obj.is_object()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract lane index from the "lane" field (tool number, 0-based)
|
||||
int lane_index = -1;
|
||||
const auto lane_it = lane_obj.find("lane");
|
||||
if (lane_it != lane_obj.end() && lane_it->is_string()) {
|
||||
try {
|
||||
lane_index = std::stoi(lane_it->get<std::string>());
|
||||
} catch (...) {
|
||||
lane_index = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (lane_index < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AmsTrayData tray;
|
||||
tray.slot_index = lane_index;
|
||||
if (const auto it = lane_obj.find("color"); it != lane_obj.end() && it->is_string())
|
||||
tray.tray_color = it->get<std::string>();
|
||||
if (const auto it = lane_obj.find("material"); it != lane_obj.end() && it->is_string())
|
||||
tray.tray_type = it->get<std::string>();
|
||||
if (const auto it = lane_obj.find("bed_temp"); it != lane_obj.end() && it->is_number())
|
||||
tray.bed_temp = it->get<int>();
|
||||
if (const auto it = lane_obj.find("nozzle_temp"); it != lane_obj.end() && it->is_number())
|
||||
tray.nozzle_temp = it->get<int>();
|
||||
|
||||
// Presence is independent of declared material: a physically loaded lane
|
||||
// with no user-declared material must not read as empty. Prefer an
|
||||
// explicit signal, fall back to the legacy "has a material type" rule.
|
||||
const auto has_it = lane_obj.find("has_filament");
|
||||
const auto loaded_it = lane_obj.find("loaded");
|
||||
if (has_it != lane_obj.end() && has_it->is_boolean())
|
||||
tray.has_filament = has_it->get<bool>();
|
||||
else if (loaded_it != lane_obj.end() && loaded_it->is_boolean())
|
||||
tray.has_filament = loaded_it->get<bool>();
|
||||
else
|
||||
tray.has_filament = !tray.tray_type.empty();
|
||||
|
||||
max_lane_index = std::max(max_lane_index, lane_index);
|
||||
trays.push_back(tray);
|
||||
}
|
||||
|
||||
if (trays.empty()) {
|
||||
BOOST_LOG_TRIVIAL(info) << "AmsPayload: no lanes found";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void resolve_tray_info_idx(std::vector<AmsTrayData>& trays)
|
||||
{
|
||||
auto* bundle = GUI::wxGetApp().preset_bundle;
|
||||
for (auto& tray : trays) {
|
||||
// Absent lanes render as placeholders; resolving an empty type is busy
|
||||
// work at best and could bind a bogus id.
|
||||
if (!tray.has_filament)
|
||||
continue;
|
||||
// A vendor-aware resolver may already have matched this lane; only fill
|
||||
// what is still empty so the generic fallback cannot overwrite it.
|
||||
if (!tray.tray_info_idx.empty())
|
||||
continue;
|
||||
tray.tray_info_idx = bundle
|
||||
? bundle->filaments.filament_id_by_type(tray.tray_type)
|
||||
: map_filament_type_to_generic_id(tray.tray_type);
|
||||
}
|
||||
}
|
||||
|
||||
nlohmann::json build_bbl_ams_json(const std::vector<AmsTrayData>& trays,
|
||||
int ams_count,
|
||||
int max_lane_index)
|
||||
{
|
||||
nlohmann::json ams_array = nlohmann::json::array();
|
||||
|
||||
// ams_exist_bits marks the units; tray_exist_bits marks the occupied
|
||||
// slots. uint64_t: unsigned long is 32-bit on MSVC, so 1UL << 32+ is UB.
|
||||
// BBL's fields are 64-bit; a lane past 63 cannot be represented at all.
|
||||
uint64_t ams_exist_bits = 0;
|
||||
uint64_t tray_exist_bits = 0;
|
||||
|
||||
auto set_exist_bit = [](uint64_t& bits, int index) {
|
||||
if (index < 0)
|
||||
return;
|
||||
if (index >= 64) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "AmsPayload: lane index " << index << " exceeds the 64-bit exist-bits field; bit dropped";
|
||||
return;
|
||||
}
|
||||
bits |= (uint64_t{1} << index);
|
||||
};
|
||||
|
||||
for (int ams_id = 0; ams_id < ams_count; ++ams_id) {
|
||||
set_exist_bit(ams_exist_bits, ams_id);
|
||||
|
||||
nlohmann::json ams_unit = nlohmann::json::object();
|
||||
ams_unit["id"] = std::to_string(ams_id);
|
||||
ams_unit["info"] = "0002"; // treat as AMS_LITE
|
||||
|
||||
nlohmann::json tray_array = nlohmann::json::array();
|
||||
int max_slot_in_this_ams = std::min(3, max_lane_index - ams_id * 4);
|
||||
for (int slot_id = 0; slot_id <= max_slot_in_this_ams; ++slot_id) {
|
||||
int slot_index = ams_id * 4 + slot_id;
|
||||
|
||||
// Find tray with matching slot_index
|
||||
const AmsTrayData* tray = nullptr;
|
||||
for (const auto& t : trays) {
|
||||
if (t.slot_index == slot_index) {
|
||||
tray = &t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
nlohmann::json tray_json = nlohmann::json::object();
|
||||
tray_json["id"] = std::to_string(slot_id);
|
||||
tray_json["tag_uid"] = "0000000000000000";
|
||||
|
||||
if (tray && tray->has_filament) {
|
||||
set_exist_bit(tray_exist_bits, slot_index);
|
||||
|
||||
// Present lanes never carry tray_slot_placeholder: a loaded but
|
||||
// undeclared lane stays occupied with an empty type, which is
|
||||
// what distinguishes it from an absent slot.
|
||||
tray_json["tray_info_idx"] = tray->tray_info_idx;
|
||||
tray_json["tray_type"] = tray->tray_type;
|
||||
tray_json["tray_color"] = normalize_ams_color(tray->tray_color);
|
||||
|
||||
// Add temperature data if provided
|
||||
if (tray->bed_temp > 0) {
|
||||
tray_json["bed_temp"] = std::to_string(tray->bed_temp);
|
||||
}
|
||||
if (tray->nozzle_temp > 0) {
|
||||
tray_json["nozzle_temp_max"] = std::to_string(tray->nozzle_temp);
|
||||
}
|
||||
} else {
|
||||
tray_json["tray_info_idx"] = "";
|
||||
tray_json["tray_type"] = "";
|
||||
tray_json["tray_color"] = "00000000";
|
||||
tray_json["tray_slot_placeholder"] = "1";
|
||||
}
|
||||
|
||||
tray_array.push_back(tray_json);
|
||||
}
|
||||
ams_unit["tray"] = tray_array;
|
||||
ams_array.push_back(ams_unit);
|
||||
}
|
||||
|
||||
// Format as hex strings (matching BBL protocol)
|
||||
std::ostringstream ams_exist_ss;
|
||||
ams_exist_ss << std::hex << std::uppercase << ams_exist_bits;
|
||||
std::ostringstream tray_exist_ss;
|
||||
tray_exist_ss << std::hex << std::uppercase << tray_exist_bits;
|
||||
|
||||
nlohmann::json ams_json = nlohmann::json::object();
|
||||
ams_json["ams"] = ams_array;
|
||||
ams_json["ams_exist_bits"] = ams_exist_ss.str();
|
||||
ams_json["tray_exist_bits"] = tray_exist_ss.str();
|
||||
return ams_json;
|
||||
}
|
||||
|
||||
// --- Removal/absence state and op capability ---------------------------------
|
||||
|
||||
// Last ams_count rendered per device: clear_ams_payload_for_device walks the
|
||||
// same unit set to mark them all absent.
|
||||
static std::mutex g_ams_state_mutex;
|
||||
static std::map<std::string, int> g_ams_last_count;
|
||||
static std::map<std::string, std::vector<std::string>> g_ams_ops;
|
||||
static std::map<std::string, bool> g_ams_capability;
|
||||
|
||||
static void remember_ams_count(const std::string& dev_id, int ams_count)
|
||||
{
|
||||
if (dev_id.empty() || ams_count <= 0)
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
|
||||
g_ams_last_count[dev_id] = std::max(g_ams_last_count[dev_id], ams_count);
|
||||
}
|
||||
|
||||
void register_ams_ops(const std::string& dev_id, const std::vector<std::string>& ops)
|
||||
{
|
||||
if (dev_id.empty())
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
|
||||
g_ams_ops[dev_id] = ops;
|
||||
}
|
||||
|
||||
bool ams_op_supported(const std::string& dev_id, const std::string& op)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
|
||||
auto it = g_ams_ops.find(dev_id);
|
||||
if (it == g_ams_ops.end())
|
||||
return true; // no OrcaSonar capabilities seen: never gate
|
||||
return std::find(it->second.begin(), it->second.end(), op) != it->second.end();
|
||||
}
|
||||
|
||||
void register_ams_capability(const std::string& dev_id, bool has_ams)
|
||||
{
|
||||
if (dev_id.empty())
|
||||
return;
|
||||
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
|
||||
g_ams_capability[dev_id] = has_ams;
|
||||
}
|
||||
|
||||
bool has_ams_capability(const std::string& dev_id)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
|
||||
auto it = g_ams_capability.find(dev_id);
|
||||
return it != g_ams_capability.end() && it->second;
|
||||
}
|
||||
|
||||
void build_ams_payload_for_device(const std::string& dev_id,
|
||||
const std::optional<std::string>& printer_type,
|
||||
int ams_count,
|
||||
int max_lane_index,
|
||||
const std::vector<AmsTrayData>& trays,
|
||||
const QueueOnMainFn& queue_fn,
|
||||
const TrayInfoResolver& vendor_resolver)
|
||||
{
|
||||
remember_ams_count(dev_id, ams_count);
|
||||
// A caller on the GUI thread must mutate DeviceManager inline: invoking
|
||||
// queue_fn (CallAfter) would defer the work until after the caller has
|
||||
// already read DevFilaSystem. Background callers route through queue_fn.
|
||||
const bool on_main = wxIsMainThread();
|
||||
auto apply = [dev_id, printer_type, ams_count, max_lane_index, trays, vendor_resolver]() {
|
||||
// Look up MachineObject via DeviceManager
|
||||
auto* dev_manager = GUI::wxGetApp().getDeviceManager();
|
||||
if (!dev_manager) {
|
||||
return;
|
||||
}
|
||||
MachineObject* obj = dev_manager->get_my_machine(dev_id);
|
||||
if (!obj) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve preset ids here: reads the GUI preset bundle, which is only safe
|
||||
// on the main thread. trays is a copy, so mutating it is fine. The optional
|
||||
// vendor resolver runs first; the generic resolver fills the rest.
|
||||
std::vector<AmsTrayData> resolved = trays;
|
||||
if (vendor_resolver)
|
||||
vendor_resolver(resolved);
|
||||
resolve_tray_info_idx(resolved);
|
||||
|
||||
// Wrap in the expected structure for ParseV1_0
|
||||
nlohmann::json print_json = nlohmann::json::object();
|
||||
print_json["ams"] = build_bbl_ams_json(resolved, ams_count, max_lane_index);
|
||||
|
||||
// Call the parser to populate DevFilaSystem
|
||||
DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false);
|
||||
BOOST_LOG_TRIVIAL(info) << "AmsPayload: parsed " << resolved.size() << " trays";
|
||||
|
||||
// Set printer_type so update_sync_status() can match it against the preset's
|
||||
// printer type. nullopt = leave it (the caller's push_status already carries
|
||||
// it); a value = assign, including empty, preserving the old behavior.
|
||||
if (printer_type.has_value()) {
|
||||
obj->printer_type = *printer_type;
|
||||
}
|
||||
|
||||
// Set push counters so is_info_ready() returns true for pull-mode agents.
|
||||
if (obj->m_push_count == 0) {
|
||||
obj->m_push_count = 1;
|
||||
}
|
||||
if (obj->m_full_msg_count == 0) {
|
||||
obj->m_full_msg_count = 1;
|
||||
}
|
||||
obj->last_push_time = std::chrono::system_clock::now();
|
||||
|
||||
// Set storage state - Moonraker printers use virtual_sdcard, storage is always available.
|
||||
// This is required for SelectMachineDialog to allow printing (otherwise it blocks with "No SD card").
|
||||
obj->GetStorage()->set_sdcard_state(DevStorage::HAS_SDCARD_NORMAL);
|
||||
|
||||
// Populate module_vers so is_info_ready() passes the version check.
|
||||
if (obj->module_vers.empty()) {
|
||||
DevFirmwareVersionInfo ota_info;
|
||||
ota_info.name = "ota";
|
||||
ota_info.sw_ver = "1.0.0"; // Placeholder version
|
||||
obj->module_vers.emplace("ota", ota_info);
|
||||
}
|
||||
};
|
||||
|
||||
if (queue_fn && !on_main) {
|
||||
queue_fn(apply);
|
||||
} else {
|
||||
apply();
|
||||
}
|
||||
}
|
||||
|
||||
void clear_ams_payload_for_device(const std::string& dev_id, const QueueOnMainFn& queue_fn)
|
||||
{
|
||||
int count = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
|
||||
auto it = g_ams_last_count.find(dev_id);
|
||||
if (it != g_ams_last_count.end())
|
||||
count = it->second;
|
||||
g_ams_last_count[dev_id] = 0;
|
||||
}
|
||||
if (count == 0)
|
||||
return; // nothing was ever rendered: nothing to clear
|
||||
|
||||
const bool on_main = wxIsMainThread();
|
||||
auto apply = [dev_id, count]() {
|
||||
auto* dev_manager = GUI::wxGetApp().getDeviceManager();
|
||||
if (!dev_manager)
|
||||
return;
|
||||
MachineObject* obj = dev_manager->get_my_machine(dev_id);
|
||||
if (!obj)
|
||||
return;
|
||||
|
||||
// All units present-but-empty: exist bits 0 marks them absent while
|
||||
// placeholder trays flush stale type/color data out of DevFilaSystem.
|
||||
nlohmann::json units = nlohmann::json::array();
|
||||
for (int ams_id = 0; ams_id < count; ++ams_id) {
|
||||
nlohmann::json trays = nlohmann::json::array();
|
||||
for (int slot_id = 0; slot_id < 4; ++slot_id) {
|
||||
trays.push_back(nlohmann::json{
|
||||
{"id", std::to_string(slot_id)},
|
||||
{"tag_uid", "0000000000000000"},
|
||||
{"tray_info_idx", ""},
|
||||
{"tray_type", ""},
|
||||
{"tray_color", "00000000"},
|
||||
{"tray_slot_placeholder", "1"},
|
||||
});
|
||||
}
|
||||
units.push_back(nlohmann::json{{"id", std::to_string(ams_id)}, {"info", "0002"}, {"tray", trays}});
|
||||
}
|
||||
nlohmann::json ams_json;
|
||||
ams_json["ams"] = units;
|
||||
ams_json["ams_exist_bits"] = "0";
|
||||
ams_json["tray_exist_bits"] = "0";
|
||||
nlohmann::json print_json;
|
||||
print_json["ams"] = ams_json;
|
||||
DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false);
|
||||
BOOST_LOG_TRIVIAL(info) << "AmsPayload: cleared " << count << " AMS units for " << dev_id;
|
||||
};
|
||||
if (queue_fn && !on_main)
|
||||
queue_fn(apply);
|
||||
else
|
||||
apply();
|
||||
}
|
||||
|
||||
} // namespace Slic3r
|
||||
@@ -0,0 +1,93 @@
|
||||
#ifndef __AMS_PAYLOAD_HPP__
|
||||
#define __AMS_PAYLOAD_HPP__
|
||||
|
||||
#include "bambu_networking.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// One lane of a multi-material system in the neutral shape every printer agent
|
||||
// shares before it is rendered into the BBL AMS payload.
|
||||
struct AmsTrayData {
|
||||
int slot_index = 0; // 0-based global slot index
|
||||
bool has_filament = false;
|
||||
std::string tray_type; // Material type (e.g. "PLA", "ASA")
|
||||
std::string tray_color; // Raw color (#RRGGBB, 0xRRGGBB, or RRGGBBAA)
|
||||
std::string tray_info_idx; // OrcaFilamentLibrary preset id (optional)
|
||||
int bed_temp = 0; // Optional
|
||||
int nozzle_temp = 0; // Optional
|
||||
};
|
||||
|
||||
// Normalize a driver color string to the BBL RRGGBBAA form; "00000000" when invalid.
|
||||
std::string normalize_ams_color(const std::string& color);
|
||||
|
||||
// Map a reported material type to an OrcaFilamentLibrary generic family id.
|
||||
std::string map_filament_type_to_generic_id(const std::string& filament_type);
|
||||
|
||||
// Parse a Moonraker `/server/database/item?namespace=lane_data` response body:
|
||||
// result.value is keyed by lane, each entry carrying "lane", optional
|
||||
// "material"/"color"/"bed_temp"/"nozzle_temp" and an optional presence signal
|
||||
// ("has_filament" or "loaded"). Presence is preferred over material: a loaded
|
||||
// lane with no declared material must not read as empty. Returns false when
|
||||
// malformed or empty. Pure: tray_info_idx is left for resolve_tray_info_idx().
|
||||
bool parse_moonraker_lane_data(const nlohmann::json& body,
|
||||
std::vector<AmsTrayData>& trays,
|
||||
int& max_lane_index);
|
||||
|
||||
// Fill each tray's tray_info_idx from the loaded preset bundle (falling back to
|
||||
// the generic family map). Reads GUI preset state, so it MUST run on the main
|
||||
// thread. Ids already set by a vendor-aware resolver are left untouched.
|
||||
void resolve_tray_info_idx(std::vector<AmsTrayData>& trays);
|
||||
|
||||
// Optional vendor-aware resolver, run on the main thread before the generic
|
||||
// resolver. Agents with brand/color matching (Snapmaker, Creality) supply one
|
||||
// so their results survive the shared path; the generic resolver only fills
|
||||
// ids this leaves empty.
|
||||
using TrayInfoResolver = std::function<void(std::vector<AmsTrayData>&)>;
|
||||
|
||||
// Assemble the BBL-format AMS JSON (ams[] units + ams_exist_bits/tray_exist_bits)
|
||||
// that DevFilaSystemParser::ParseV1_0 consumes. Pure and GUI-free: the
|
||||
// MachineObject mutation stays in build_ams_payload_for_device.
|
||||
nlohmann::json build_bbl_ams_json(const std::vector<AmsTrayData>& trays,
|
||||
int ams_count,
|
||||
int max_lane_index);
|
||||
|
||||
// Render trays into the BBL AMS payload and populate the device's DevFilaSystem.
|
||||
// The resolver and the MachineObject mutation both run on the main thread via
|
||||
// queue_fn when set. printer_type: nullopt leaves obj->printer_type untouched
|
||||
// (OrcaSonar, whose push_status already carries it); a value assigns it
|
||||
// verbatim (Moonraker).
|
||||
void build_ams_payload_for_device(const std::string& dev_id,
|
||||
const std::optional<std::string>& printer_type,
|
||||
int ams_count,
|
||||
int max_lane_index,
|
||||
const std::vector<AmsTrayData>& trays,
|
||||
const QueueOnMainFn& queue_fn,
|
||||
const TrayInfoResolver& vendor_resolver = {});
|
||||
|
||||
// Clear the device's AMS view: render every previously-seen unit as absent
|
||||
// with placeholder trays so a removed/absent material system never leaves
|
||||
// stale filament data behind (lane_data read as authoritative empty).
|
||||
void clear_ams_payload_for_device(const std::string& dev_id, const QueueOnMainFn& queue_fn);
|
||||
|
||||
// Process-wide canonical AMS write capability (OrcaSonar REQ-STS-008), parsed
|
||||
// from the info.get_capabilities reply. Devices with no record (Bambu, cloud
|
||||
// profiles) report every op supported: gating only ever applies to OrcaSonar
|
||||
// printers that answered.
|
||||
void register_ams_ops(const std::string& dev_id, const std::vector<std::string>& ops);
|
||||
bool ams_op_supported(const std::string& dev_id, const std::string& op);
|
||||
|
||||
// Whether the device has a material system, from the get_capabilities reply's
|
||||
// protocol.features.fms (falling back to a non-empty ams_ops). No record reads
|
||||
// false: an unconfirmed printer must not advertise filament sync.
|
||||
void register_ams_capability(const std::string& dev_id, bool has_ams);
|
||||
bool has_ams_capability(const std::string& dev_id);
|
||||
|
||||
} // namespace Slic3r
|
||||
|
||||
#endif
|
||||
@@ -533,142 +533,27 @@ int MoonrakerPrinterAgent::set_queue_on_main_fn(QueueOnMainFn fn)
|
||||
|
||||
void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays)
|
||||
{
|
||||
// This may be called from a background thread (e.g. run_status_stream's read loop,
|
||||
// for subscription-mode agents) as well as from the GUI thread (Sidebar's pull-mode
|
||||
// path). Everything below touches MachineObject/DevFilaSystem, which the GUI thread
|
||||
// reads without locking — so the actual mutation must run on the main thread. Snapshot
|
||||
// queue_on_main_fn and the two device_info fields we need up front, then defer the rest,
|
||||
// mirroring dispatch_message's existing queue_fn ? queue_fn(x) : x() idiom.
|
||||
// May run from the status-stream thread (subscription agents) or the GUI
|
||||
// thread (Sidebar's pull path). Snapshot queue_on_main_fn here; the shared
|
||||
// builder defers the preset lookup and MachineObject mutation to the main
|
||||
// thread. A heavy-handed empty model_id must still be assigned, so the
|
||||
// optional wrapper always carries the value here.
|
||||
QueueOnMainFn queue_fn;
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(state_mutex);
|
||||
queue_fn = queue_on_main_fn;
|
||||
}
|
||||
build_ams_payload_for_device(device_info.dev_id, std::optional<std::string>(device_info.model_id), ams_count, max_lane_index, trays, queue_fn);
|
||||
}
|
||||
|
||||
std::string dev_id = device_info.dev_id;
|
||||
std::string model_id = device_info.model_id;
|
||||
|
||||
auto apply = [dev_id, model_id, ams_count, max_lane_index, trays]() {
|
||||
// Look up MachineObject via DeviceManager
|
||||
auto* dev_manager = GUI::wxGetApp().getDeviceManager();
|
||||
if (!dev_manager) {
|
||||
return;
|
||||
}
|
||||
MachineObject* obj = dev_manager->get_my_machine(dev_id);
|
||||
if (!obj) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build BBL-format JSON for DevFilaSystemParser::ParseV1_0
|
||||
nlohmann::json ams_json = nlohmann::json::object();
|
||||
nlohmann::json ams_array = nlohmann::json::array();
|
||||
|
||||
// Calculate ams_exist_bits and tray_exist_bits
|
||||
unsigned long ams_exist_bits = 0;
|
||||
unsigned long tray_exist_bits = 0;
|
||||
|
||||
for (int ams_id = 0; ams_id < ams_count; ++ams_id) {
|
||||
ams_exist_bits |= (1 << ams_id);
|
||||
|
||||
nlohmann::json ams_unit = nlohmann::json::object();
|
||||
ams_unit["id"] = std::to_string(ams_id);
|
||||
ams_unit["info"] = "0002"; // treat as AMS_LITE
|
||||
|
||||
nlohmann::json tray_array = nlohmann::json::array();
|
||||
int max_slot_in_this_ams = std::min(3, max_lane_index - ams_id * 4);
|
||||
for (int slot_id = 0; slot_id <= max_slot_in_this_ams; ++slot_id) {
|
||||
int slot_index = ams_id * 4 + slot_id;
|
||||
|
||||
// Find tray with matching slot_index
|
||||
const AmsTrayData* tray = nullptr;
|
||||
for (const auto& t : trays) {
|
||||
if (t.slot_index == slot_index) {
|
||||
tray = &t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
nlohmann::json tray_json = nlohmann::json::object();
|
||||
tray_json["id"] = std::to_string(slot_id);
|
||||
tray_json["tag_uid"] = "0000000000000000";
|
||||
|
||||
if (tray && tray->has_filament) {
|
||||
tray_exist_bits |= (1 << slot_index);
|
||||
|
||||
tray_json["tray_info_idx"] = tray->tray_info_idx;
|
||||
tray_json["tray_type"] = tray->tray_type;
|
||||
tray_json["tray_color"] = normalize_color_value(tray->tray_color);
|
||||
|
||||
// Add temperature data if provided
|
||||
if (tray->bed_temp > 0) {
|
||||
tray_json["bed_temp"] = std::to_string(tray->bed_temp);
|
||||
}
|
||||
if (tray->nozzle_temp > 0) {
|
||||
tray_json["nozzle_temp_max"] = std::to_string(tray->nozzle_temp);
|
||||
}
|
||||
} else {
|
||||
tray_json["tray_info_idx"] = "";
|
||||
tray_json["tray_type"] = "";
|
||||
tray_json["tray_color"] = "00000000";
|
||||
tray_json["tray_slot_placeholder"] = "1";
|
||||
}
|
||||
|
||||
tray_array.push_back(tray_json);
|
||||
}
|
||||
ams_unit["tray"] = tray_array;
|
||||
ams_array.push_back(ams_unit);
|
||||
}
|
||||
|
||||
// Format as hex strings (matching BBL protocol)
|
||||
std::ostringstream ams_exist_ss;
|
||||
ams_exist_ss << std::hex << std::uppercase << ams_exist_bits;
|
||||
std::ostringstream tray_exist_ss;
|
||||
tray_exist_ss << std::hex << std::uppercase << tray_exist_bits;
|
||||
|
||||
ams_json["ams"] = ams_array;
|
||||
ams_json["ams_exist_bits"] = ams_exist_ss.str();
|
||||
ams_json["tray_exist_bits"] = tray_exist_ss.str();
|
||||
|
||||
// Wrap in the expected structure for ParseV1_0
|
||||
nlohmann::json print_json = nlohmann::json::object();
|
||||
print_json["ams"] = ams_json;
|
||||
|
||||
// Call the parser to populate DevFilaSystem
|
||||
DevFilaSystemParser::ParseV1_0(print_json, obj, obj->GetFilaSystem().get(), false);
|
||||
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::build_ams_payload: Parsed " << trays.size() << " trays";
|
||||
|
||||
// Set printer_type so update_sync_status() can match it against the preset's printer type.
|
||||
// Without this, the comparison fails and all sync badges are cleared.
|
||||
obj->printer_type = model_id;
|
||||
|
||||
// Set push counters so is_info_ready() returns true for pull-mode agents.
|
||||
if (obj->m_push_count == 0) {
|
||||
obj->m_push_count = 1;
|
||||
}
|
||||
if (obj->m_full_msg_count == 0) {
|
||||
obj->m_full_msg_count = 1;
|
||||
}
|
||||
obj->last_push_time = std::chrono::system_clock::now();
|
||||
|
||||
// Set storage state - Moonraker printers use virtual_sdcard, storage is always available.
|
||||
// This is required for SelectMachineDialog to allow printing (otherwise it blocks with "No SD card").
|
||||
obj->GetStorage()->set_sdcard_state(DevStorage::HAS_SDCARD_NORMAL);
|
||||
|
||||
// Populate module_vers so is_info_ready() passes the version check.
|
||||
// Moonraker printers don't have BBL-style version info, but we need a non-empty map.
|
||||
if (obj->module_vers.empty()) {
|
||||
DevFirmwareVersionInfo ota_info;
|
||||
ota_info.name = "ota";
|
||||
ota_info.sw_ver = "1.0.0"; // Placeholder version for Moonraker printers
|
||||
obj->module_vers.emplace("ota", ota_info);
|
||||
}
|
||||
};
|
||||
|
||||
if (queue_fn) {
|
||||
queue_fn(apply);
|
||||
} else {
|
||||
apply();
|
||||
void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays, const TrayInfoResolver& vendor_resolver)
|
||||
{
|
||||
QueueOnMainFn queue_fn;
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(state_mutex);
|
||||
queue_fn = queue_on_main_fn;
|
||||
}
|
||||
build_ams_payload_for_device(device_info.dev_id, std::optional<std::string>(device_info.model_id), ams_count, max_lane_index, trays, queue_fn, vendor_resolver);
|
||||
}
|
||||
|
||||
bool MoonrakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
|
||||
@@ -764,94 +649,7 @@ std::string MoonrakerPrinterAgent::trim_and_upper(const std::string& input)
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string MoonrakerPrinterAgent::map_filament_type_to_generic_id(const std::string& filament_type)
|
||||
{
|
||||
const std::string upper = trim_and_upper(filament_type);
|
||||
|
||||
// Normalize reported material names (trimmed, uppercased) to an OrcaFilamentLibrary
|
||||
// generic family. The family's filament_id is resolved from the loaded system presets
|
||||
// below rather than hardcoded, so profile id re-mints never require touching this table.
|
||||
// scripts/test_moonraker_lane_data.py parses this initializer; keep the {"A", "B"} format.
|
||||
static const std::map<std::string, std::string> type_to_ofl_family = {
|
||||
// PLA variants
|
||||
{"PLA", "PLA"},
|
||||
{"PLA-CF", "PLA-CF"},
|
||||
{"PLA SILK", "PLA Silk"},
|
||||
{"PLA-SILK", "PLA Silk"},
|
||||
{"PLA HIGH SPEED", "PLA High Speed"},
|
||||
{"PLA-HS", "PLA High Speed"},
|
||||
{"PLA HS", "PLA High Speed"},
|
||||
|
||||
// ABS/ASA variants
|
||||
{"ABS", "ABS"},
|
||||
{"ASA", "ASA"},
|
||||
|
||||
// PETG/PET variants
|
||||
{"PETG", "PETG"},
|
||||
{"PET", "PETG"},
|
||||
{"PCTG", "PCTG"},
|
||||
|
||||
// PA/Nylon variants
|
||||
{"PA", "PA"},
|
||||
{"NYLON", "PA"},
|
||||
{"PA-CF", "PA-CF"},
|
||||
{"PPA", "PPA-CF"},
|
||||
{"PPA-CF", "PPA-CF"},
|
||||
{"PPA-GF", "PPA-GF"},
|
||||
|
||||
// PC variants
|
||||
{"PC", "PC"},
|
||||
|
||||
// PP/PE variants
|
||||
{"PE", "PE"},
|
||||
{"PP", "PP"},
|
||||
|
||||
// Support materials
|
||||
{"PVA", "PVA"},
|
||||
{"HIPS", "HIPS"},
|
||||
{"BVOH", "BVOH"},
|
||||
|
||||
// TPU variants
|
||||
{"TPU", "TPU"},
|
||||
|
||||
// Other materials
|
||||
{"EVA", "EVA"},
|
||||
{"PHA", "PHA"},
|
||||
{"COPE", "CoPE"},
|
||||
{"SBS", "SBS"},
|
||||
};
|
||||
|
||||
auto it = type_to_ofl_family.find(upper);
|
||||
if (it == type_to_ofl_family.end())
|
||||
return UNKNOWN_FILAMENT_ID;
|
||||
|
||||
if (auto* bundle = GUI::wxGetApp().preset_bundle) {
|
||||
const Preset* preset = bundle->filaments.find_preset("Generic " + it->second + " @System");
|
||||
if (preset != nullptr && preset->is_system && !preset->filament_id.empty())
|
||||
return preset->filament_id;
|
||||
}
|
||||
|
||||
// Unknown material, or no loaded preset data to resolve against
|
||||
return UNKNOWN_FILAMENT_ID;
|
||||
}
|
||||
|
||||
// JSON helper methods - null-safe accessors
|
||||
std::string MoonrakerPrinterAgent::safe_json_string(const nlohmann::json& obj, const char* key)
|
||||
{
|
||||
auto it = obj.find(key);
|
||||
if (it != obj.end() && it->is_string())
|
||||
return it->get<std::string>();
|
||||
return "";
|
||||
}
|
||||
|
||||
int MoonrakerPrinterAgent::safe_json_int(const nlohmann::json& obj, const char* key)
|
||||
{
|
||||
auto it = obj.find(key);
|
||||
if (it != obj.end() && it->is_number())
|
||||
return it->get<int>();
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string MoonrakerPrinterAgent::safe_array_string(const nlohmann::json& arr, int idx)
|
||||
{
|
||||
if (arr.is_array() && idx >= 0 && idx < static_cast<int>(arr.size()) && arr[idx].is_string())
|
||||
@@ -866,41 +664,6 @@ int MoonrakerPrinterAgent::safe_array_int(const nlohmann::json& arr, int idx)
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string MoonrakerPrinterAgent::normalize_color_value(const std::string& color)
|
||||
{
|
||||
std::string value = color;
|
||||
boost::trim(value);
|
||||
|
||||
// Remove 0x or 0X prefix if present
|
||||
if (value.size() >= 2 && (value.rfind("0x", 0) == 0 || value.rfind("0X", 0) == 0)) {
|
||||
value = value.substr(2);
|
||||
}
|
||||
// Remove # prefix if present
|
||||
if (!value.empty() && value[0] == '#') {
|
||||
value = value.substr(1);
|
||||
}
|
||||
|
||||
// Extract only hex digits
|
||||
std::string normalized;
|
||||
for (char c : value) {
|
||||
if (std::isxdigit(static_cast<unsigned char>(c))) {
|
||||
normalized.push_back(static_cast<char>(std::toupper(static_cast<unsigned char>(c))));
|
||||
}
|
||||
}
|
||||
|
||||
// If 6 hex digits, add FF alpha
|
||||
if (normalized.size() == 6) {
|
||||
normalized += "FF";
|
||||
}
|
||||
|
||||
// Validate length - return default if invalid
|
||||
if (normalized.size() != 8) {
|
||||
return "00000000";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Fetch filament info from moonraker database
|
||||
bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index)
|
||||
{
|
||||
@@ -945,56 +708,11 @@ bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector<AmsTrayDat
|
||||
}
|
||||
|
||||
// Expected structure: { "result": { "namespace": "lane_data", "value": { "lane1": {...}, ... } } }
|
||||
if (!json.contains("result") || !json["result"].contains("value") || !json["result"]["value"].is_object()) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: Unexpected JSON structure or no lane_data found";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse response into AmsTrayData
|
||||
const auto& value = json["result"]["value"];
|
||||
trays.clear();
|
||||
max_lane_index = 0;
|
||||
|
||||
for (const auto& [lane_key, lane_obj] : value.items()) {
|
||||
if (!lane_obj.is_object()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract lane index from the "lane" field (tool number, 0-based)
|
||||
std::string lane_str = safe_json_string(lane_obj, "lane");
|
||||
int lane_index = -1;
|
||||
if (!lane_str.empty()) {
|
||||
try {
|
||||
lane_index = std::stoi(lane_str);
|
||||
} catch (...) {
|
||||
lane_index = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (lane_index < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AmsTrayData tray;
|
||||
tray.slot_index = lane_index;
|
||||
tray.tray_color = safe_json_string(lane_obj, "color");
|
||||
tray.tray_type = safe_json_string(lane_obj, "material");
|
||||
tray.bed_temp = safe_json_int(lane_obj, "bed_temp");
|
||||
tray.nozzle_temp = safe_json_int(lane_obj, "nozzle_temp");
|
||||
tray.has_filament = !tray.tray_type.empty();
|
||||
auto* bundle = GUI::wxGetApp().preset_bundle;
|
||||
tray.tray_info_idx = bundle
|
||||
? bundle->filaments.filament_id_by_type(tray.tray_type)
|
||||
: map_filament_type_to_generic_id(tray.tray_type);
|
||||
|
||||
max_lane_index = std::max(max_lane_index, lane_index);
|
||||
trays.push_back(tray);
|
||||
}
|
||||
|
||||
if (trays.empty()) {
|
||||
BOOST_LOG_TRIVIAL(info) << "MoonrakerPrinterAgent::fetch_moonraker_filament_data: No lanes found";
|
||||
if (!parse_moonraker_lane_data(json, trays, max_lane_index)) {
|
||||
return false;
|
||||
}
|
||||
// tray_info_idx is resolved later, on the main thread, inside
|
||||
// build_ams_payload_for_device.
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1110,11 +828,6 @@ bool MoonrakerPrinterAgent::fetch_hh_filament_info(std::vector<AmsTrayData>& tra
|
||||
tray.bed_temp = 0; // HH doesn't provide bed temp in gate arrays
|
||||
tray.has_filament = true;
|
||||
|
||||
auto* bundle = GUI::wxGetApp().preset_bundle;
|
||||
tray.tray_info_idx = bundle
|
||||
? bundle->filaments.filament_id_by_type(tray.tray_type)
|
||||
: map_filament_type_to_generic_id(tray.tray_type);
|
||||
|
||||
max_lane_index = std::max(max_lane_index, gate_idx);
|
||||
trays.push_back(tray);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include "IPrinterAgent.hpp"
|
||||
#include "ICloudServiceAgent.hpp"
|
||||
#include "AmsPayload.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -84,19 +85,12 @@ protected:
|
||||
bool use_ssl = false;
|
||||
} device_info;
|
||||
|
||||
// Tray data for AMS payload building
|
||||
struct AmsTrayData {
|
||||
int slot_index = 0; // 0-based slot index
|
||||
bool has_filament = false;
|
||||
std::string tray_type; // Material type (e.g., "PLA", "ASA")
|
||||
std::string tray_color; // Raw color (#RRGGBB, 0xRRGGBB, or RRGGBBAA)
|
||||
std::string tray_info_idx; // Setting ID (optional)
|
||||
int bed_temp = 0; // Optional
|
||||
int nozzle_temp = 0; // Optional
|
||||
};
|
||||
|
||||
// Build ams JSON and call parser
|
||||
// Build ams JSON and call parser (AmsTrayData is shared; see AmsPayload.hpp).
|
||||
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays);
|
||||
// Overload for agents with vendor-aware matching (Snapmaker): the resolver
|
||||
// runs on the main thread inside the shared builder, preserving the match
|
||||
// without touching preset state on the fetch worker.
|
||||
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays, const TrayInfoResolver& vendor_resolver);
|
||||
|
||||
// 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);
|
||||
@@ -121,9 +115,6 @@ protected:
|
||||
// Trim whitespace and convert to uppercase
|
||||
static std::string trim_and_upper(const std::string& input);
|
||||
|
||||
// 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,
|
||||
@@ -188,11 +179,8 @@ private:
|
||||
bool fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index);
|
||||
|
||||
// JSON helper methods
|
||||
static std::string safe_json_string(const nlohmann::json& obj, const char* key);
|
||||
static int safe_json_int(const nlohmann::json& obj, const char* key);
|
||||
static std::string safe_array_string(const nlohmann::json& arr, int idx);
|
||||
static int safe_array_int(const nlohmann::json& arr, int idx);
|
||||
static std::string normalize_color_value(const std::string& color);
|
||||
|
||||
std::string ssdp_announced_host;
|
||||
std::string ssdp_announced_id;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "OrcaPrinterAgent.hpp"
|
||||
#include "OrcaCloudSignalingChannel.hpp"
|
||||
#include "AmsPayload.hpp"
|
||||
#include "Http.hpp"
|
||||
#include "IPrinterAgent.hpp"
|
||||
#include "NetworkAgentFactory.hpp"
|
||||
@@ -448,6 +449,15 @@ OrcaPrinterAgent::~OrcaPrinterAgent()
|
||||
start_discovery(false, false);
|
||||
++m_lan_generation; // fence any late worker callback
|
||||
++m_cloud_generation;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
m_shutting_down = true; // workers stop arming new HTTP fetches
|
||||
}
|
||||
|
||||
// Drain the detached filament-refresh workers: the flag and generation bump
|
||||
// above end their loops, so this waits at most one in-flight HTTP fetch.
|
||||
while (m_filament_in_flight.load() > 0)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
|
||||
// Drop the cloud status callback before anything else: it holds `this`, and the
|
||||
// cloud agent outlives the printer agent (NetworkAgent::set_printer_agent swaps
|
||||
@@ -541,7 +551,7 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons
|
||||
// removes its storage too. Process-wide and keyed by the globally-unique
|
||||
// dev_id, with its own mutex; C++11 makes the one-time init thread-safe.
|
||||
static std::unordered_map<std::string, double> nozzle_diameter_cache;
|
||||
static std::mutex nozzle_diameter_cache_mutex;
|
||||
static std::mutex nozzle_diameter_cache_mutex;
|
||||
|
||||
bool modified = false;
|
||||
|
||||
@@ -557,7 +567,7 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons
|
||||
// ("N/A" -> NozzleType::ntUndefine, as MoonrakerPrinterAgent
|
||||
// does; Klipper has no Bambu nozzle type) onto later push_status
|
||||
// frames that carry no real nozzle data.
|
||||
const auto info_it = envelope.find("info");
|
||||
const auto info_it = envelope.find("info");
|
||||
const bool is_capabilities_reply = info_it != envelope.end() && info_it->is_object() &&
|
||||
info_it->value("command", "") == "get_capabilities";
|
||||
|
||||
@@ -590,13 +600,46 @@ 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;
|
||||
}
|
||||
// The capabilities reply itself is forwarded unchanged.
|
||||
}
|
||||
else {
|
||||
const auto print_it = envelope.find("print");
|
||||
if (print_it != envelope.end() && print_it->is_object() &&
|
||||
print_it->value("command", "") == "push_status" && !print_it->contains("nozzle_diameter")) {
|
||||
// The capabilities reply itself is forwarded unchanged. Its declared
|
||||
// ams_ops (OPCP §7.8) gate every AMS control, and protocol.features.fms
|
||||
// declares whether a material system actually exists.
|
||||
{
|
||||
const auto caps_it = info_it->find("capabilities");
|
||||
if (caps_it != info_it->end() && caps_it->is_object()) {
|
||||
const auto proto_it = caps_it->find("protocol");
|
||||
if (proto_it != caps_it->end() && proto_it->is_object()) {
|
||||
const auto ops_it = proto_it->find("ams_ops");
|
||||
std::vector<std::string> ops;
|
||||
if (ops_it != proto_it->end() && ops_it->is_array()) {
|
||||
for (const auto& op : *ops_it)
|
||||
if (op.is_string())
|
||||
ops.push_back(op.get<std::string>());
|
||||
register_ams_ops(dev_id, ops);
|
||||
}
|
||||
|
||||
// features.fms is the authoritative "has a material system"
|
||||
// flag (topology.material_units non-empty). Payloads without
|
||||
// it fall back to a non-empty ams_ops.
|
||||
bool has_ams = false;
|
||||
bool fms_known = false;
|
||||
const auto features_it = proto_it->find("features");
|
||||
if (features_it != proto_it->end() && features_it->is_object()) {
|
||||
const auto fms_it = features_it->find("fms");
|
||||
if (fms_it != features_it->end() && fms_it->is_boolean()) {
|
||||
has_ams = fms_it->get<bool>();
|
||||
fms_known = true;
|
||||
}
|
||||
}
|
||||
if (!fms_known)
|
||||
has_ams = !ops.empty();
|
||||
register_ams_capability(dev_id, has_ams);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const auto print_it = envelope.find("print");
|
||||
if (print_it != envelope.end() && print_it->is_object() && print_it->value("command", "") == "push_status" &&
|
||||
!print_it->contains("nozzle_diameter")) {
|
||||
double nozzle_dia = 0.0;
|
||||
{
|
||||
std::lock_guard<std::mutex> l(nozzle_diameter_cache_mutex);
|
||||
@@ -607,7 +650,7 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons
|
||||
if (nozzle_dia > 0.0) {
|
||||
(*print_it)["nozzle_diameter"] = nozzle_dia;
|
||||
(*print_it)["nozzle_type"] = "N/A";
|
||||
modified = true;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -618,6 +661,11 @@ std::string OrcaPrinterAgent::merge_capabilities(const std::string& dev_id, cons
|
||||
|
||||
void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::string& payload, bool local)
|
||||
{
|
||||
// Subscription doorbell, on the raw payload before the UI marshal so the
|
||||
// (possibly blocking) lane_data refresh never queues behind it.
|
||||
if (local && filament_doorbell_needed(dev_id, payload))
|
||||
request_filament_refresh(dev_id);
|
||||
|
||||
parse_ipcam_info(dev_id, payload);
|
||||
std::string merged_payload = merge_capabilities(dev_id, payload);
|
||||
|
||||
@@ -629,10 +677,11 @@ void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::str
|
||||
q = queue_on_main_fn;
|
||||
}
|
||||
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering " << (local ? "local" : "cloud") << " report payload dev_id=" << dev_id
|
||||
<< " payload=" << merged_payload
|
||||
<< " callback=" << (fn ? "set" : "null") << " queue_on_main=" << (q ? "set" : "null");
|
||||
<< " payload=" << merged_payload << " callback=" << (fn ? "set" : "null")
|
||||
<< " queue_on_main=" << (q ? "set" : "null");
|
||||
if (!fn) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping " << (local ? "local" : "cloud") << " message because on_message_fn is not set"
|
||||
BOOST_LOG_TRIVIAL(warning) << "Orca diagnostic: dropping " << (local ? "local" : "cloud")
|
||||
<< " message because on_message_fn is not set"
|
||||
<< " dev_id=" << dev_id;
|
||||
return;
|
||||
}
|
||||
@@ -654,6 +703,20 @@ void OrcaPrinterAgent::dispatch_local_connect(int state, const std::string& dev_
|
||||
|
||||
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: LAN connection callback state=" << state << " dev_id=" << dev_id << " message=" << message
|
||||
<< " callback=" << (callback ? "set" : "null") << " queue_on_main=" << (queue ? "set" : "null");
|
||||
|
||||
// Eager filament sync on every (re)connect, like the Moonraker agents: the
|
||||
// cached DevFilaSystem may predate the drop. The doorbell cache resets too,
|
||||
// so the post-connect frame re-arms if the lane content moved meanwhile.
|
||||
// Runs before the callback check so it also fires when no GUI listener is
|
||||
// installed yet.
|
||||
if (state == ConnectStatusOk) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
m_material_hash.clear();
|
||||
}
|
||||
request_filament_refresh(dev_id);
|
||||
}
|
||||
|
||||
if (!callback)
|
||||
return;
|
||||
|
||||
@@ -745,7 +808,9 @@ int OrcaPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tr
|
||||
nlohmann::json j;
|
||||
j["print"]["command"] = "ams_change_filament";
|
||||
j["print"]["sequence_id"] = std::to_string(sequence_id);
|
||||
j["print"]["target"] = tray_number;
|
||||
// tray_id here is the flat global lane (DevFilaSystem slot index).
|
||||
j["print"]["selector"] = "lane";
|
||||
j["print"]["lane"] = tray_number;
|
||||
return route_send(lan_mode, dev_id, j.dump());
|
||||
}
|
||||
|
||||
@@ -843,6 +908,240 @@ int OrcaPrinterAgent::command_axis_control(std::string dev_id,
|
||||
return route_send(lan_mode, dev_id, j.dump());
|
||||
}
|
||||
|
||||
FilamentSyncMode OrcaPrinterAgent::get_filament_sync_mode() const
|
||||
{
|
||||
std::string dev_id;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
dev_id = (m_current_connection == LAN) ? m_lan_dev_id : selected_machine;
|
||||
}
|
||||
if (!dev_id.empty() && has_ams_capability(dev_id)) {
|
||||
return FilamentSyncMode::subscription;
|
||||
}
|
||||
return FilamentSyncMode::none;
|
||||
}
|
||||
|
||||
bool OrcaPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode)
|
||||
{
|
||||
if (sync_mode != get_filament_sync_mode())
|
||||
return false;
|
||||
return fetch_lane_data(dev_id) == LaneDataState::synced;
|
||||
}
|
||||
|
||||
// Read the lane_data projection once and apply the REQ-STS-007 tri-state to
|
||||
// DevFilaSystem. Only LaneDataState::error arms the retry latch: 404 means
|
||||
// "not knowable yet" (pre-bootstrap or acknowledged-unknown topology) and {}
|
||||
// means "authoritatively no lanes" — both are settled states, and a printer
|
||||
// without a material system must not turn into a per-frame 404 poll.
|
||||
OrcaPrinterAgent::LaneDataState OrcaPrinterAgent::fetch_lane_data(const std::string& dev_id)
|
||||
{
|
||||
std::string origin;
|
||||
QueueOnMainFn queue_fn;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
if (m_shutting_down || m_current_connection != LAN || m_lan_dev_id != dev_id)
|
||||
return LaneDataState::unknown;
|
||||
origin = m_lan_http_origin;
|
||||
queue_fn = queue_on_main_fn;
|
||||
}
|
||||
if (origin.empty())
|
||||
return LaneDataState::unknown;
|
||||
const std::string api_key = lan_api_key(origin);
|
||||
|
||||
// OrcaSonar serves the canonical topology's lane projection on its
|
||||
// Moonraker-compatible façade. Called on the refresh worker (subscription
|
||||
// mode), so the payload mutation is marshalled onto the main thread through
|
||||
// queue_fn; a GUI-thread caller reads DevFilaSystem inline.
|
||||
const std::string url = origin + "/server/database/item?namespace=lane_data";
|
||||
|
||||
std::string response_body;
|
||||
unsigned http_status = 0;
|
||||
bool transport_error = false;
|
||||
std::string http_error;
|
||||
auto http = Http::get(url);
|
||||
if (!api_key.empty())
|
||||
http.header("X-Api-Key", api_key);
|
||||
http.timeout_connect(5)
|
||||
.timeout_max(10)
|
||||
.on_complete([&](std::string body, unsigned status) {
|
||||
http_status = status;
|
||||
if (status == 200) {
|
||||
response_body = std::move(body);
|
||||
} else {
|
||||
http_error = "HTTP error: " + std::to_string(status);
|
||||
}
|
||||
})
|
||||
.on_error([&](std::string, std::string err, unsigned status) {
|
||||
transport_error = true;
|
||||
http_status = status;
|
||||
http_error = err;
|
||||
if (status > 0)
|
||||
http_error += " (HTTP " + std::to_string(status) + ")";
|
||||
})
|
||||
.perform_sync();
|
||||
|
||||
if (http_status == 404 && !transport_error) {
|
||||
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::fetch_lane_data: lane_data not served yet (unknown topology)";
|
||||
return LaneDataState::unknown;
|
||||
}
|
||||
if (http_status != 200) {
|
||||
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::fetch_lane_data: lane_data fetch failed: " << http_error;
|
||||
return LaneDataState::error;
|
||||
}
|
||||
|
||||
auto json = nlohmann::json::parse(response_body, nullptr, false, true);
|
||||
if (json.is_discarded()) {
|
||||
BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::fetch_lane_data: invalid lane_data JSON";
|
||||
return LaneDataState::error;
|
||||
}
|
||||
const bool empty_value = json.is_object() && json.contains("result") && json["result"].is_object() &&
|
||||
json["result"].contains("value") && json["result"]["value"].is_object() && json["result"]["value"].empty();
|
||||
if (empty_value) {
|
||||
// Authoritative empty: flush stale trays so a removed AMS does not
|
||||
// linger in the device panel.
|
||||
clear_ams_payload_for_device(dev_id, queue_fn);
|
||||
return LaneDataState::none;
|
||||
}
|
||||
|
||||
std::vector<AmsTrayData> trays;
|
||||
int max_lane_index = 0;
|
||||
if (!parse_moonraker_lane_data(json, trays, max_lane_index))
|
||||
return LaneDataState::error;
|
||||
|
||||
const int ams_count = (max_lane_index + 4) / 4;
|
||||
// printer_type stays unset: push_status already carries the OrcaSonar printer
|
||||
// type, and overwriting it here would clear it. build_ams_payload_for_device
|
||||
// marshals the DevFilaSystem mutation through queue_fn when set.
|
||||
build_ams_payload_for_device(dev_id, std::nullopt, ams_count, max_lane_index, trays, queue_fn);
|
||||
return LaneDataState::synced;
|
||||
}
|
||||
|
||||
// Subscription scheduler for filament sync. A single detached worker drains
|
||||
// m_filament_wanted; extra requests arriving while it runs fold into its loop, so
|
||||
// a burst of topology_state doorbells costs one extra fetch that picks up the
|
||||
// trailing change. A failed fetch does not busy-retry: m_filament_failed makes
|
||||
// the next inbound LAN frame request again (an idle printer is silent, but it
|
||||
// cannot have changed lanes either), and a reconnect re-primes via the
|
||||
// ConnectStatusOk path in dispatch_local_connect().
|
||||
void OrcaPrinterAgent::request_filament_refresh(const std::string& dev_id)
|
||||
{
|
||||
uint64_t gen;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
if (m_current_connection != LAN || m_lan_dev_id != dev_id)
|
||||
return;
|
||||
gen = m_lan_generation.load();
|
||||
m_filament_wanted = true;
|
||||
if (m_filament_working)
|
||||
return; // the running worker will take the flag
|
||||
m_filament_working = true;
|
||||
}
|
||||
|
||||
m_filament_in_flight.fetch_add(1, std::memory_order_relaxed);
|
||||
std::thread([this, dev_id, gen] {
|
||||
struct InFlightGuard
|
||||
{
|
||||
std::atomic<int>& counter;
|
||||
~InFlightGuard() { counter.fetch_sub(1, std::memory_order_relaxed); }
|
||||
} guard{m_filament_in_flight};
|
||||
|
||||
for (;;) {
|
||||
bool needed;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
needed = !m_shutting_down && m_filament_wanted && m_lan_generation.load() == gen && m_current_connection == LAN &&
|
||||
m_lan_dev_id == dev_id;
|
||||
if (needed)
|
||||
m_filament_wanted = false;
|
||||
else
|
||||
m_filament_working = false; // same critical section that saw no work: no lost wake-up
|
||||
}
|
||||
if (!needed)
|
||||
return;
|
||||
const auto state = fetch_lane_data(dev_id);
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
m_filament_failed = state == LaneDataState::error; // latch read by filament_doorbell_needed()
|
||||
}
|
||||
}).detach();
|
||||
}
|
||||
|
||||
// A LAN frame is a refresh trigger when it carries an OrcaSonar material
|
||||
// change (a new print.topology_state.material_hash, spec REQ-STS-007 §7.7) or
|
||||
// when the last fetch errored and this frame is the retry beat. The substring
|
||||
// guard keeps the JSON parse off the steady temp-tick cadence, and hash
|
||||
// equality absorbs the tick's topology_state re-emissions.
|
||||
bool OrcaPrinterAgent::filament_doorbell_needed(const std::string& dev_id, const std::string& payload)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
if (m_current_connection != LAN || m_lan_dev_id != dev_id)
|
||||
return false;
|
||||
if (m_filament_failed)
|
||||
return true;
|
||||
}
|
||||
// The cheap guard is the §7.7 doorbell token itself, so the steady
|
||||
// temperature-only tick never reaches the JSON parse.
|
||||
if (payload.find("material_hash") == std::string::npos)
|
||||
return false;
|
||||
auto json = nlohmann::json::parse(payload, nullptr, false);
|
||||
if (json.is_discarded() || !json.is_object())
|
||||
return false;
|
||||
const auto print_it = json.find("print");
|
||||
if (print_it == json.end() || !print_it->is_object())
|
||||
return false;
|
||||
const auto topo_it = print_it->find("topology_state");
|
||||
if (topo_it == print_it->end() || !topo_it->is_object())
|
||||
return false;
|
||||
const auto hash_it = topo_it->find("material_hash");
|
||||
if (hash_it == topo_it->end() || !hash_it->is_string())
|
||||
return false;
|
||||
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
if (hash_it->get<std::string>() == m_material_hash)
|
||||
return false; // content unchanged: not a doorbell
|
||||
m_material_hash = hash_it->get<std::string>();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Moonraker's client bootstrap: /access/api_key hands the façade key to a
|
||||
// trusted source (the default LAN ranges include the slicer). Cache it per
|
||||
// connection generation. If the request is refused — a hardened trusted_clients
|
||||
// list, or no façade — fall back to the access code, which is what earlier
|
||||
// builds sent, so behavior degrades to the status quo rather than breaking.
|
||||
std::string OrcaPrinterAgent::lan_api_key(const std::string& origin)
|
||||
{
|
||||
if (origin.empty())
|
||||
return {};
|
||||
|
||||
std::string fallback;
|
||||
uint64_t gen;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
if (!m_lan_api_key.empty() && m_lan_api_key_gen == m_lan_generation.load())
|
||||
return m_lan_api_key;
|
||||
fallback = m_lan_password;
|
||||
gen = m_lan_generation.load();
|
||||
}
|
||||
|
||||
std::string resolved;
|
||||
if (std::string body; fetch_orcasonar_body(origin + "/access/api_key", body)) {
|
||||
auto j = nlohmann::json::parse(body, nullptr, false, true);
|
||||
if (!j.is_discarded() && j.contains("result") && j["result"].is_string())
|
||||
resolved = j["result"].get<std::string>();
|
||||
}
|
||||
if (resolved.empty()) {
|
||||
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent: /access/api_key unavailable; using the access code as X-Api-Key";
|
||||
resolved = fallback;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
if (m_lan_generation.load() == gen && !resolved.empty()) {
|
||||
m_lan_api_key = resolved;
|
||||
m_lan_api_key_gen = gen;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
bool OrcaPrinterAgent::parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port)
|
||||
{
|
||||
std::string s = dev_ip;
|
||||
@@ -1040,9 +1339,13 @@ int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, st
|
||||
CurrentConn previous_connection;
|
||||
{
|
||||
std::lock_guard<std::mutex> l(state_mutex);
|
||||
previous_connection = m_current_connection;
|
||||
m_lan_dev_id = dev_id;
|
||||
m_lan_url = cfg.url;
|
||||
previous_connection = m_current_connection;
|
||||
m_lan_dev_id = dev_id;
|
||||
m_lan_url = cfg.url;
|
||||
m_lan_http_origin = http_origin_from_lan_ws(cfg.url);
|
||||
m_lan_password = password; // access code; the façade key is bootstrapped lazily
|
||||
m_lan_api_key.clear();
|
||||
m_lan_api_key_gen = gen;
|
||||
m_camera_stream_mode = CameraStreamMode::none;
|
||||
m_camera_url.clear();
|
||||
m_current_connection = LAN;
|
||||
@@ -1116,6 +1419,10 @@ int OrcaPrinterAgent::disconnect_printer()
|
||||
doomed = std::move(lan_mqtt_connection);
|
||||
prev_dev = m_lan_dev_id;
|
||||
m_lan_dev_id.clear();
|
||||
m_lan_http_origin.clear();
|
||||
m_lan_api_key.clear();
|
||||
m_filament_wanted = false; // a stale worker self-exits on the generation mismatch
|
||||
m_filament_failed = false;
|
||||
if (m_current_connection == LAN) {
|
||||
m_current_connection = NONE;
|
||||
m_camera_stream_mode = CameraStreamMode::none;
|
||||
@@ -1145,11 +1452,107 @@ int OrcaPrinterAgent::disconnect_printer()
|
||||
int OrcaPrinterAgent::send_message_to_printer(std::string dev_id, std::string json_str, int /*qos*/, int /*flag*/)
|
||||
{ return route_send(/*is_lan=*/true, dev_id, json_str); }
|
||||
|
||||
// Rewrite Bambu-convention print.ams_* payloads onto the canonical OrcaSonar
|
||||
// bodies (OPCP spec §7.8) and enforce the device's declared ams_ops. DevFilaSystem
|
||||
// is built from flat lane indices, so the Bambu encodings the shared
|
||||
// MachineObject command builders emit — ams_id/slot_id 4-tray pseudo-groups,
|
||||
// virtual tray ids 254/255 — are decoded here, at the single funnel all print
|
||||
// commands cross, and never reach the server. A command whose op token the
|
||||
// device did not declare short-circuits as *unsupported (CAP_NOT_AVAILABLE),
|
||||
// which publish_json already renders as the friendly unsupported dialog.
|
||||
std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id, const std::string& json_str, bool* unsupported)
|
||||
{
|
||||
if (unsupported)
|
||||
*unsupported = false;
|
||||
try {
|
||||
auto envelope = nlohmann::json::parse(json_str, nullptr, false);
|
||||
if (envelope.is_discarded() || !envelope.is_object() || !envelope.contains("print") || !envelope["print"].is_object())
|
||||
return json_str;
|
||||
auto& print = envelope["print"];
|
||||
const std::string cmd = print.value("command", std::string());
|
||||
if (cmd.empty() || (cmd.rfind("ams_", 0) != 0 && cmd != "auto_stop_ams_dry"))
|
||||
return json_str;
|
||||
if (cmd == "ams_change_filament" && print.contains("selector"))
|
||||
return json_str; // already canonical (e.g. command_ams_select_tray)
|
||||
|
||||
auto int_or = [&print](const char* key, int fallback) {
|
||||
const auto it = print.find(key);
|
||||
return (it != print.end() && it->is_number_integer()) ? it->get<int>() : fallback;
|
||||
};
|
||||
std::string op;
|
||||
if (cmd == "ams_change_filament") {
|
||||
const int target = int_or("target", -1);
|
||||
const int slot = int_or("slot_id", -1);
|
||||
const int ams = int_or("ams_id", -1);
|
||||
print.erase("target");
|
||||
print.erase("slot_id");
|
||||
print.erase("tray_id");
|
||||
print.erase("ams_id");
|
||||
if (target == 255 && slot == 255) {
|
||||
print["selector"] = "unload";
|
||||
op = "unload";
|
||||
} else if (target == 255 || ams == 254 || ams == 255) {
|
||||
print["selector"] = "external";
|
||||
op = "external";
|
||||
} else {
|
||||
const int lane = target >= 0 ? target : (ams >= 0 ? ams * 4 + slot : slot);
|
||||
print["selector"] = "lane";
|
||||
print["lane"] = lane;
|
||||
op = "change_filament";
|
||||
}
|
||||
} else if (cmd == "ams_filament_setting") {
|
||||
const int ams = int_or("ams_id", -1);
|
||||
const int slot = int_or("slot_id", -1);
|
||||
op = "filament_setting";
|
||||
if (ams >= 254) {
|
||||
if (unsupported)
|
||||
*unsupported = true; // no lane for a virtual tray
|
||||
return json_str;
|
||||
}
|
||||
print["lane"] = ams * 4 + slot;
|
||||
print.erase("slot_id");
|
||||
print.erase("tray_id");
|
||||
print.erase("ams_id");
|
||||
} else if (cmd == "ams_control") {
|
||||
std::string action = print.value("action", print.value("param", std::string()));
|
||||
op = action; // only "pause" is ever declared; the rest gate out
|
||||
} else if (cmd == "ams_user_setting") {
|
||||
op = "user_setting";
|
||||
} else if (cmd == "ams_get_rfid") {
|
||||
op = "get_rfid";
|
||||
if (!print.contains("tray_id")) {
|
||||
const int ams = int_or("ams_id", -1);
|
||||
const int slot = int_or("slot_id", -1);
|
||||
if (ams >= 0 && slot >= 0) {
|
||||
print["tray_id"] = ams * 4 + slot; // legacy ams+slot call shape
|
||||
print.erase("ams_id");
|
||||
print.erase("slot_id");
|
||||
}
|
||||
}
|
||||
} else if (cmd == "auto_stop_ams_dry") {
|
||||
op = "stop_dry";
|
||||
}
|
||||
if (!op.empty() && !ams_op_supported(dev_id, op)) {
|
||||
if (unsupported)
|
||||
*unsupported = true;
|
||||
}
|
||||
return envelope.dump();
|
||||
} catch (const std::exception&) {
|
||||
return json_str;
|
||||
}
|
||||
}
|
||||
|
||||
int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const std::string& json_str)
|
||||
{
|
||||
bool unsupported = false;
|
||||
const std::string canonical = canonicalize_ams_payload(dev_id, json_str, &unsupported);
|
||||
if (unsupported) {
|
||||
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send: ams op not declared by device capabilities, dev_id=" << dev_id;
|
||||
return ORCA_NETWORK_ERR_CAP_NOT_AVAILABLE;
|
||||
}
|
||||
std::string command = "<unparsed>";
|
||||
try {
|
||||
const nlohmann::json envelope = nlohmann::json::parse(json_str);
|
||||
const nlohmann::json envelope = nlohmann::json::parse(canonical);
|
||||
for (const char* namespace_name : {"pushing", "info", "print", "system", "camera", "xcam", "upgrade", "event", "files"}) {
|
||||
const auto namespace_it = envelope.find(namespace_name);
|
||||
if (namespace_it != envelope.end() && namespace_it->is_object()) {
|
||||
@@ -1171,7 +1574,7 @@ int OrcaPrinterAgent::route_send(bool is_lan, const std::string& dev_id, const s
|
||||
OrcaMqttConnection* conn = get_appropriate_mqtt_connection(is_lan);
|
||||
if (!conn)
|
||||
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
|
||||
const bool queued = conn->send_request(dev_id, json_str);
|
||||
const bool queued = conn->send_request(dev_id, canonical);
|
||||
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send command=" << command << " queued=" << queued << " is_lan=" << is_lan
|
||||
<< " dev_id=" << dev_id;
|
||||
return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
|
||||
@@ -1345,9 +1748,9 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id)
|
||||
auto state_handler = [this](bool connected, bool initial) {
|
||||
if (!connected || initial)
|
||||
return;
|
||||
auto* current_cloud = get_orca_cloud_agent();
|
||||
auto* current_cloud = get_orca_cloud_agent();
|
||||
OrcaMqttConnection* current_conn = current_cloud ? current_cloud->get_mqtt_connection() : nullptr;
|
||||
const std::string selected = get_user_selected_machine();
|
||||
const std::string selected = get_user_selected_machine();
|
||||
if (current_conn && !selected.empty())
|
||||
on_connected(selected, current_conn, m_cloud_generation.load());
|
||||
};
|
||||
@@ -1370,7 +1773,8 @@ AgentInfo OrcaPrinterAgent::get_agent_info_static()
|
||||
// Print Job Operations - All Stubs
|
||||
// ============================================================================
|
||||
|
||||
// Simply uploads the file to the printer via HTTP (cloud) then sends a HTTP request to start print. In the future, this might be a MQTT command to start print instead of HTTP.
|
||||
// Simply uploads the file to the printer via HTTP (cloud) then sends a HTTP request to start print. In the future, this might be a MQTT
|
||||
// command to start print instead of HTTP.
|
||||
int OrcaPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
|
||||
{
|
||||
(void) wait_fn;
|
||||
@@ -1476,37 +1880,49 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params,
|
||||
std::string http_error;
|
||||
std::string response_body;
|
||||
|
||||
// One façade key for the whole upload exchange (directory probe + POST).
|
||||
// If the bootstrapped key is unavailable (and there was no stored access
|
||||
// code), fall back to the job's own password rather than sending none.
|
||||
std::string api_key = lan_api_key(origin);
|
||||
if (api_key.empty())
|
||||
api_key = params.password;
|
||||
|
||||
// check if printer has enough storage
|
||||
Http::get(origin + "/server/files/directory?path=gcodes")
|
||||
.on_complete([&](std::string body, unsigned status) {
|
||||
if (body.empty()) {
|
||||
http_status = 400;
|
||||
http_error = "Failed to get gcodes directory.";
|
||||
}
|
||||
|
||||
int free = 0;
|
||||
|
||||
nlohmann::json json = nlohmann::json::parse(body);
|
||||
if (json.contains("result")) {
|
||||
json = json["result"];
|
||||
if (json.contains("disk_usage")) {
|
||||
json = json["disk_usage"];
|
||||
if (json.contains("free"))
|
||||
free = json["free"].get<int>();
|
||||
{
|
||||
auto dir_req = Http::get(origin + "/server/files/directory?path=gcodes");
|
||||
if (!api_key.empty())
|
||||
dir_req.header("X-Api-Key", api_key);
|
||||
dir_req
|
||||
.on_complete([&](std::string body, unsigned status) {
|
||||
if (body.empty()) {
|
||||
http_status = 400;
|
||||
http_error = "Failed to get gcodes directory.";
|
||||
}
|
||||
}
|
||||
|
||||
if (free < file_size) {
|
||||
http_status = 507;
|
||||
http_error = "Not enough storage on the printer.";
|
||||
}
|
||||
})
|
||||
.on_error([&](std::string body, std::string err, unsigned status) {
|
||||
http_status = status;
|
||||
http_error = std::move(err);
|
||||
response_body = std::move(body);
|
||||
})
|
||||
.perform_sync();
|
||||
int free = 0;
|
||||
|
||||
nlohmann::json json = nlohmann::json::parse(body);
|
||||
if (json.contains("result")) {
|
||||
json = json["result"];
|
||||
if (json.contains("disk_usage")) {
|
||||
json = json["disk_usage"];
|
||||
if (json.contains("free"))
|
||||
free = json["free"].get<int>();
|
||||
}
|
||||
}
|
||||
|
||||
if (free < file_size) {
|
||||
http_status = 507;
|
||||
http_error = "Not enough storage on the printer.";
|
||||
}
|
||||
})
|
||||
.on_error([&](std::string body, std::string err, unsigned status) {
|
||||
http_status = status;
|
||||
http_error = std::move(err);
|
||||
response_body = std::move(body);
|
||||
})
|
||||
.perform_sync();
|
||||
}
|
||||
|
||||
if (http_status >= 400) {
|
||||
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " failed with error code: " << http_status << ", " << http_error;
|
||||
@@ -1514,8 +1930,8 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params,
|
||||
}
|
||||
|
||||
auto http = Http::post(origin + "/server/files/upload");
|
||||
if (!params.password.empty())
|
||||
http.header("X-Api-Key", params.password); // trusted LAN facades may not require it; harmless when they do not
|
||||
if (!api_key.empty())
|
||||
http.header("X-Api-Key", api_key); // bootstrapped façade key, or the access code fallback
|
||||
http.form_add("root", "gcodes")
|
||||
.form_add("print", "false")
|
||||
.form_add_file("file", source, upload_name)
|
||||
|
||||
@@ -36,8 +36,7 @@ public:
|
||||
void set_cloud_agent(std::shared_ptr<ICloudServiceAgent> cloud) override;
|
||||
CameraStreamMode get_camera_stream_mode() const override;
|
||||
std::string get_camera_url() const override;
|
||||
std::unique_ptr<ICameraSignalingChannel>
|
||||
create_camera_signaling_channel(const std::string& dev_id) override;
|
||||
std::unique_ptr<ICameraSignalingChannel> create_camera_signaling_channel(const std::string& dev_id) override;
|
||||
|
||||
// Communication
|
||||
int send_message(std::string dev_id, std::string json_str, int qos, int flag) override;
|
||||
@@ -100,6 +99,14 @@ public:
|
||||
int sequence_id,
|
||||
bool lan_mode) override;
|
||||
|
||||
// Filament sync (subscription): `subscription` when the selected printer
|
||||
// reports a material system (get_capabilities protocol.features.fms),
|
||||
// `none` otherwise. The mode is transport-agnostic; the lane_data fetch and
|
||||
// topology_state doorbell that keep DevFilaSystem fresh are LAN-only, and
|
||||
// cloud printers get their AMS view from the mirrored push_status.
|
||||
FilamentSyncMode get_filament_sync_mode() const override;
|
||||
bool fetch_filament_info(std::string dev_id, FilamentSyncMode sync_mode = FilamentSyncMode::pull) override;
|
||||
|
||||
// Test-only: drive emit_connect_sequence directly (no socket).
|
||||
void run_connect_sequence_for_test(const std::string& dev_id)
|
||||
{
|
||||
@@ -111,7 +118,22 @@ public:
|
||||
// Test-only: the same for the (independent) cloud selection epoch.
|
||||
void bump_cloud_generation_for_test() { ++m_cloud_generation; }
|
||||
|
||||
FilamentSyncMode get_filament_sync_mode() const override { return FilamentSyncMode::subscription; }
|
||||
// Test-only: observe the subscription doorbell policy without spawning the refresh worker.
|
||||
bool filament_doorbell_needed_for_test(const std::string& dev_id, const std::string& payload)
|
||||
{
|
||||
return filament_doorbell_needed(dev_id, payload);
|
||||
}
|
||||
// Test-only: arm the fetch-failure latch (retry-on-next-LAN-frame rule).
|
||||
void set_filament_failed_for_test(bool v)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state_mutex);
|
||||
m_filament_failed = v;
|
||||
}
|
||||
// Test-only: Bambu ams_* wire JSON -> canonical OrcaSonar bodies (§7.8).
|
||||
// Returns the (possibly rewritten) payload; *unsupported is set when the
|
||||
// device's declared ams_ops exclude the operation (CAP_NOT_AVAILABLE in
|
||||
// the live route_send path).
|
||||
static std::string canonicalize_ams_payload(const std::string& dev_id, const std::string& json_str, bool* unsupported);
|
||||
|
||||
protected:
|
||||
// Forward one inbound printer message to on_message_fn or on_local_message_fn (marshalled onto the UI
|
||||
@@ -191,10 +213,45 @@ private:
|
||||
|
||||
std::unique_ptr<OrcaSonarDiscovery> m_discovery;
|
||||
|
||||
std::string m_lan_dev_id; // guarded by state_mutex
|
||||
std::string m_lan_url; // guarded by state_mutex — the Config.url of the live LAN session
|
||||
std::string m_lan_dev_id; // guarded by state_mutex
|
||||
std::string m_lan_url; // guarded by state_mutex — the Config.url of the live LAN session
|
||||
std::string m_lan_http_origin; // guarded by state_mutex — http(s) origin of the LAN façade
|
||||
std::string m_lan_password; // guarded by state_mutex — MQTT access code; X-Api-Key fallback
|
||||
std::string m_lan_api_key; // guarded by state_mutex — cached Moonraker-façade key, "" = unresolved
|
||||
uint64_t m_lan_api_key_gen = 0; // m_lan_generation the cached key belongs to
|
||||
CameraStreamMode m_camera_stream_mode = CameraStreamMode::none; // guarded by state_mutex
|
||||
std::string m_camera_url; // guarded by state_mutex
|
||||
std::string m_camera_url; // guarded by state_mutex
|
||||
|
||||
// The Moonraker-façade X-Api-Key, bootstrapped from /access/api_key (trusted
|
||||
// clients only) and cached per connection generation; falls back to the
|
||||
// access code when the endpoint is unavailable (untrusted/hardened config).
|
||||
std::string lan_api_key(const std::string& origin);
|
||||
|
||||
// Subscription-mode filament sync. One detached worker per burst drains
|
||||
// m_filament_wanted by re-reading lane_data; doorbell frames (a pushed
|
||||
// print.topology_state change) and the failure latch are the only triggers —
|
||||
// no timer: an idle OrcaSonar emits no frames, and cannot change lanes either.
|
||||
void request_filament_refresh(const std::string& dev_id);
|
||||
// OPCP §7.7: the doorbell is a CHANGE in print.topology_state.material_hash
|
||||
// (lane content only — tool temperature churn inside topology_state must not
|
||||
// re-fetch); consuming a new value updates m_material_hash.
|
||||
bool filament_doorbell_needed(const std::string& dev_id, const std::string& payload);
|
||||
|
||||
// Tri-state lane_data fetch outcome (REQ-STS-007 §7.7 read semantics):
|
||||
// synced — 200 with lane entries, payload built;
|
||||
// none — 200 with an empty value: authoritative "no lanes", AMS view cleared;
|
||||
// unknown — 404: topology not bootstrapped yet or material_units unknown;
|
||||
// never latched, the next doorbell or reconnect retries;
|
||||
// error — transport/5xx: latched, retried on the next inbound frame.
|
||||
enum class LaneDataState { synced, none, unknown, error };
|
||||
LaneDataState fetch_lane_data(const std::string& dev_id);
|
||||
|
||||
bool m_filament_wanted = false; // guarded by state_mutex; a fetch is pending
|
||||
bool m_filament_working = false; // guarded by state_mutex; a worker owns the queue
|
||||
bool m_filament_failed = false; // guarded by state_mutex; retry-on-next-LAN-frame rule
|
||||
bool m_shutting_down = false; // guarded by state_mutex; workers stop taking fetches
|
||||
std::string m_material_hash; // guarded by state_mutex; last doorbell value seen
|
||||
std::atomic<int> m_filament_in_flight{0}; // detached workers; drained by the destructor
|
||||
|
||||
OrcaCloudServiceAgent* get_orca_cloud_agent();
|
||||
|
||||
|
||||
@@ -239,25 +239,6 @@ bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
|
||||
tray.tray_type = combine_filament_type(safe_at(filament_type, i, empty_str), safe_at(filament_sub_type, i, empty_str));
|
||||
tray.tray_color = safe_at(filament_color, i, default_color);
|
||||
|
||||
auto* bundle = GUI::wxGetApp().preset_bundle;
|
||||
// Try to find a matching preset for this filament based on vendor, type and color.
|
||||
// If not found, default to traditional search by type only or generic type mapping.
|
||||
if (bundle) {
|
||||
std::string vendor = safe_at(filament_vendor, i, empty_str);
|
||||
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type,
|
||||
tray.tray_color);
|
||||
|
||||
if (!filament_id.empty()) {
|
||||
tray.tray_info_idx = filament_id;
|
||||
BOOST_LOG_TRIVIAL(warning)
|
||||
<< "Filament sync: Found manufacturer-specific profile for slot " << i << ": " << filament_id;
|
||||
} else {
|
||||
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
|
||||
}
|
||||
} else {
|
||||
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
|
||||
}
|
||||
|
||||
// Extract NFC temperature data if available
|
||||
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
|
||||
auto& nfc_slot = nfc_info[i];
|
||||
@@ -272,12 +253,46 @@ bool SnapmakerPrinterAgent::fetch_filament_info(std::string dev_id, FilamentSync
|
||||
trays.emplace_back(std::move(tray));
|
||||
}
|
||||
|
||||
build_ams_payload(1, slot_count - 1, trays);
|
||||
// Preset matching (vendor + closest color) reads GUI preset state, so it
|
||||
// runs on the main thread via this resolver, not on the fetch worker.
|
||||
std::vector<std::string> vendors;
|
||||
vendors.reserve(slot_count);
|
||||
for (int i = 0; i < slot_count; ++i)
|
||||
vendors.push_back(safe_at(filament_vendor, i, empty_str));
|
||||
|
||||
build_ams_payload(1, slot_count - 1, trays,
|
||||
[vendors](std::vector<AmsTrayData>& resolved) { resolve_snapmaker_tray_info(resolved, vendors); });
|
||||
}).detach();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void SnapmakerPrinterAgent::resolve_snapmaker_tray_info(std::vector<AmsTrayData>& trays,
|
||||
const std::vector<std::string>& vendors)
|
||||
{
|
||||
auto* bundle = GUI::wxGetApp().preset_bundle;
|
||||
for (auto& tray : trays) {
|
||||
if (!tray.has_filament)
|
||||
continue;
|
||||
const std::string vendor = (tray.slot_index >= 0 && tray.slot_index < static_cast<int>(vendors.size()))
|
||||
? vendors[tray.slot_index]
|
||||
: std::string();
|
||||
if (bundle) {
|
||||
// Try a matching preset by vendor, type and color; fall back to
|
||||
// type only, then to the generic family map.
|
||||
std::string filament_id = find_closest_color_preset_by_vendor_and_type(bundle->filaments, vendor, tray.tray_type, tray.tray_color);
|
||||
if (!filament_id.empty()) {
|
||||
tray.tray_info_idx = filament_id;
|
||||
BOOST_LOG_TRIVIAL(warning) << "Filament sync: Found manufacturer-specific profile for slot " << tray.slot_index << ": " << filament_id;
|
||||
} else {
|
||||
tray.tray_info_idx = bundle->filaments.filament_id_by_type(tray.tray_type);
|
||||
}
|
||||
} else {
|
||||
tray.tray_info_idx = map_filament_type_to_generic_id(tray.tray_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FilamentSyncMode SnapmakerPrinterAgent::get_filament_sync_mode() const
|
||||
{
|
||||
if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
@@ -28,6 +29,12 @@ private:
|
||||
// Combine filament_type + filament_sub_type into a unified type string
|
||||
static std::string combine_filament_type(const std::string& type, const std::string& sub_type);
|
||||
|
||||
// Resolve tray_info_idx for the collected trays. Reads the GUI preset
|
||||
// bundle, so it is invoked on the main thread from within the shared
|
||||
// build_ams_payload_for_device() (never on the fetch worker).
|
||||
static void resolve_snapmaker_tray_info(std::vector<AmsTrayData>& trays,
|
||||
const std::vector<std::string>& vendors);
|
||||
|
||||
void start_camera_monitor();
|
||||
void on_status_loop_tick(const std::string& dev_id) override;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <slic3r/Utils/AmsPayload.hpp>
|
||||
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp>
|
||||
#include <slic3r/Utils/OrcaPrinterAgent.hpp>
|
||||
|
||||
@@ -46,7 +48,7 @@ TEST_CASE("OrcaPrinterAgent stamps the get_capabilities nozzle diameter onto pus
|
||||
// is cached for the device.
|
||||
agent.deliver_to_sink(
|
||||
"dev-1",
|
||||
R"({"info":{"command":"get_capabilities","capabilities":{"topology":{"tools":[{"id":"T0","nozzle":{"diameter_mm":0.4}}]}}}})",
|
||||
R"({"info":{"command":"get_capabilities","capabilities":{"topology":{"tools":[{"id":"T0","nozzle":{"diameter_mm":0.4}}]},"protocol":{"ams_ops":["change_filament"]}}}})",
|
||||
/*local=*/false);
|
||||
CHECK(last_payload.find("\"command\":\"get_capabilities\"") != std::string::npos);
|
||||
CHECK(last_payload.find("\"print\"") == std::string::npos);
|
||||
@@ -65,6 +67,14 @@ TEST_CASE("OrcaPrinterAgent stamps the get_capabilities nozzle diameter onto pus
|
||||
agent.deliver_to_sink("dev-1", R"({"print":{"command":"push_status","nozzle_diameter":0.6}})", /*local=*/false);
|
||||
CHECK(last_payload.find("\"nozzle_diameter\":0.6") != std::string::npos);
|
||||
CHECK(last_payload.find("N/A") == std::string::npos);
|
||||
|
||||
// The reply's declared ams_ops register on the device (OPCP §7.8): the one
|
||||
// declared op passes the client gate, an undeclared one is rejected.
|
||||
bool unsupported = false;
|
||||
OrcaPrinterAgent::canonicalize_ams_payload("dev-1", R"({"print":{"command":"ams_change_filament","target":1}})", &unsupported);
|
||||
CHECK_FALSE(unsupported);
|
||||
OrcaPrinterAgent::canonicalize_ams_payload("dev-1", R"({"print":{"command":"ams_control","param":"pause"}})", &unsupported);
|
||||
CHECK(unsupported);
|
||||
}
|
||||
|
||||
TEST_CASE("OrcaPrinterAgent::parse_lan_endpoint", "[OrcaPrinterAgent]") {
|
||||
@@ -92,6 +102,89 @@ TEST_CASE("connect_printer wires up a LAN Config", "[OrcaPrinterAgent][.integrat
|
||||
agent.disconnect_printer();
|
||||
}
|
||||
|
||||
// why hidden: connect_printer starts a detached connect worker, like the LAN
|
||||
// test above. The sync mode follows the printer's own AMS capability, not the
|
||||
// transport: a connected printer stays `none` until its get_capabilities reply
|
||||
// declares a material system. The registry is process-wide, so this test uses
|
||||
// ids no other test feeds capabilities for.
|
||||
TEST_CASE("filament sync follows the printer's AMS capability", "[OrcaPrinterAgent][.integration]") {
|
||||
Probe agent("/tmp");
|
||||
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none);
|
||||
|
||||
REQUIRE(agent.connect_printer("dev-ams-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS);
|
||||
// Connected, but no capability reply yet: still none.
|
||||
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none);
|
||||
|
||||
// features.fms=true is the authoritative material-system flag.
|
||||
agent.deliver_to_sink("dev-ams-1",
|
||||
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":true},"ams_ops":["change_filament"]}}}})",
|
||||
/*local=*/true);
|
||||
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::subscription);
|
||||
|
||||
// An explicit false clears it again.
|
||||
agent.deliver_to_sink("dev-ams-1",
|
||||
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"fms":false}}}}})",
|
||||
/*local=*/true);
|
||||
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none);
|
||||
|
||||
// Older payloads without features.fms fall back to a non-empty ams_ops.
|
||||
agent.deliver_to_sink("dev-ams-1",
|
||||
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"ams_ops":["change_filament"]}}}})",
|
||||
/*local=*/true);
|
||||
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::subscription);
|
||||
|
||||
// The pull contract (Sidebar's blocking path) is gone: a pull-mode fetch is
|
||||
// refused by the mode guard, and a subscription fetch for a device that is
|
||||
// not the active printer is refused before any HTTP.
|
||||
CHECK_FALSE(agent.fetch_filament_info("dev-ams-1", Slic3r::FilamentSyncMode::pull));
|
||||
CHECK_FALSE(agent.fetch_filament_info("dev-ams-2", Slic3r::FilamentSyncMode::subscription));
|
||||
|
||||
agent.disconnect_printer();
|
||||
CHECK(agent.get_filament_sync_mode() == Slic3r::FilamentSyncMode::none);
|
||||
}
|
||||
|
||||
// The subscription refresh is self-triggered: a pushed frame whose print block
|
||||
// carries a CHANGED topology_state.material_hash (spec REQ-STS-007 §7.7) is
|
||||
// the doorbell; repeat hashes, temperature-only frames and hash-less blocks
|
||||
// are not (no per-frame polling regression).
|
||||
TEST_CASE("a material_hash change is the filament-sync doorbell", "[OrcaPrinterAgent][.integration]") {
|
||||
Probe agent("/tmp");
|
||||
REQUIRE(agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS);
|
||||
|
||||
const std::string frame_a = R"({"print":{"command":"push_status","topology_state":{"material_hash":"sha256:aaaaaaaaaaaaaaaa","units":[]}}})";
|
||||
CHECK(agent.filament_doorbell_needed_for_test("dev-1", frame_a));
|
||||
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", frame_a));
|
||||
CHECK(agent.filament_doorbell_needed_for_test("dev-1",
|
||||
R"({"print":{"command":"push_status","topology_state":{"material_hash":"sha256:bbbbbbbbbbbbbbbb","units":[]}}})"));
|
||||
// Topology block without the doorbell token (older/foreign payload): stays quiet.
|
||||
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1",
|
||||
R"({"print":{"command":"push_status","topology_state":{"units":[]}}})"));
|
||||
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", R"({"print":{"command":"push_status","mc_percent":10}})"));
|
||||
// Not the active LAN device: never a doorbell.
|
||||
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-2", frame_a));
|
||||
|
||||
agent.disconnect_printer();
|
||||
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", frame_a));
|
||||
}
|
||||
|
||||
// After a failed lane_data fetch there is no retry timer: any next LAN frame
|
||||
// re-arms the refresh, because a changing printer keeps pushing.
|
||||
TEST_CASE("a failed filament fetch retries on the next LAN frame", "[OrcaPrinterAgent][.integration]") {
|
||||
Probe agent("/tmp");
|
||||
REQUIRE(agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS);
|
||||
|
||||
const std::string telemetry = R"({"print":{"command":"push_status","mc_percent":10}})";
|
||||
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", telemetry));
|
||||
agent.set_filament_failed_for_test(true);
|
||||
CHECK(agent.filament_doorbell_needed_for_test("dev-1", telemetry));
|
||||
|
||||
agent.disconnect_printer();
|
||||
// disconnect clears the latch; the next connect eager-fetches instead.
|
||||
REQUIRE(agent.connect_printer("dev-1", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS);
|
||||
CHECK_FALSE(agent.filament_doorbell_needed_for_test("dev-1", telemetry));
|
||||
agent.disconnect_printer();
|
||||
}
|
||||
|
||||
TEST_CASE("post-connect sequence is subscribe then 4 requests in order", "[OrcaPrinterAgent]") {
|
||||
struct SeqProbe : OrcaPrinterAgent {
|
||||
using OrcaPrinterAgent::OrcaPrinterAgent;
|
||||
@@ -202,3 +295,65 @@ TEST_CASE("destroying an agent mid-connect does not hang or crash", "[OrcaPrinte
|
||||
}
|
||||
SUCCEED();
|
||||
}
|
||||
|
||||
// OPCP spec §7.8: Bambu wire conventions must be decoded at the agent funnel,
|
||||
// never smuggled through as numeric lanes (an external-spool select once read
|
||||
// as slot_id=0 and physically loaded gate 0).
|
||||
TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical OrcaSonar bodies", "[OrcaPrinterAgent]") {
|
||||
auto canon = [](const std::string& dev, const std::string& in, bool* unsupported = nullptr) {
|
||||
bool local = false;
|
||||
return OrcaPrinterAgent::canonicalize_ams_payload(dev, in, unsupported ? unsupported : &local);
|
||||
};
|
||||
|
||||
auto out = nlohmann::json::parse(canon("dev-c1",
|
||||
R"({"print":{"command":"ams_change_filament","sequence_id":"1","target":5,"slot_id":1,"ams_id":1,"curr_temp":210,"tar_temp":220}})"));
|
||||
CHECK(out["print"]["selector"] == "lane");
|
||||
CHECK(out["print"]["lane"] == 5);
|
||||
CHECK(!out["print"].contains("target"));
|
||||
CHECK(!out["print"].contains("slot_id"));
|
||||
CHECK(!out["print"].contains("ams_id"));
|
||||
CHECK(out["print"]["tar_temp"] == 220);
|
||||
|
||||
// External-spool selection ("254" arrives hacked to 255 with slot_id=0):
|
||||
// must become the selector op, never a lane.
|
||||
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","ams_id":255,"target":255,"slot_id":0}})"));
|
||||
CHECK(out["print"]["selector"] == "external");
|
||||
CHECK(!out["print"].contains("lane"));
|
||||
|
||||
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","ams_id":0,"target":255,"slot_id":255}})"));
|
||||
CHECK(out["print"]["selector"] == "unload");
|
||||
|
||||
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_change_filament","ams_id":1,"slot_id":2}})"));
|
||||
CHECK(out["print"]["lane"] == 6);
|
||||
|
||||
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_filament_setting","ams_id":1,"slot_id":2,"tray_id":2,"tray_type":"PLA"}})"));
|
||||
CHECK(out["print"]["lane"] == 6);
|
||||
CHECK(out["print"]["tray_type"] == "PLA");
|
||||
|
||||
// Legacy RFID call shape (ams_id+slot_id, no tray_id) flattens to tray_id.
|
||||
out = nlohmann::json::parse(canon("dev-c1", R"({"print":{"command":"ams_get_rfid","ams_id":1,"slot_id":2}})"));
|
||||
CHECK(out["print"]["tray_id"] == 6);
|
||||
CHECK(!out["print"].contains("ams_id"));
|
||||
|
||||
const std::string canonical = R"({"print":{"command":"ams_change_filament","selector":"lane","lane":3}})";
|
||||
CHECK(canon("dev-c1", canonical) == canonical);
|
||||
CHECK(canon("dev-c1", R"({"print":{"command":"pause"}})") == R"({"print":{"command":"pause"}})");
|
||||
CHECK(canon("dev-c1", "not json at all") == "not json at all");
|
||||
|
||||
// Declared ams_ops gate at the client: only change_filament is supported.
|
||||
Slic3r::register_ams_ops("dev-c2", {"change_filament"});
|
||||
bool unsupported = false;
|
||||
canon("dev-c2", R"({"print":{"command":"ams_change_filament","target":255,"slot_id":255}})", &unsupported);
|
||||
CHECK(unsupported);
|
||||
unsupported = false;
|
||||
canon("dev-c2", R"({"print":{"command":"ams_control","param":"pause"}})", &unsupported);
|
||||
CHECK(unsupported);
|
||||
unsupported = false;
|
||||
canon("dev-c2", R"({"print":{"command":"ams_change_filament","target":1}})", &unsupported);
|
||||
CHECK(!unsupported);
|
||||
|
||||
// A device with no capabilities record is never gated (server backstops).
|
||||
unsupported = false;
|
||||
canon("dev-c3", R"({"print":{"command":"ams_user_setting","ams_id":0}})", &unsupported);
|
||||
CHECK(!unsupported);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,136 @@ TEST_CASE("Moonraker parses nozzle diameter from raw config and tolerates missin
|
||||
CHECK(MoonrakerParserProbe::parse_nozzle_diameter(missing_response) == 0.0f);
|
||||
}
|
||||
|
||||
// why: the lane_data projection is shared by Moonraker and OrcaSonar; the
|
||||
// parser is pure so it can be checked without a live printer or a GUI.
|
||||
TEST_CASE("unit: lane_data projection maps lanes into AMS trays", "[unit][moonraker]")
|
||||
{
|
||||
const auto body = nlohmann::json::parse(R"({
|
||||
"result": {
|
||||
"namespace": "lane_data",
|
||||
"value": {
|
||||
"lane0": {"lane": "0", "material": "PLA", "color": "FF0000FF"},
|
||||
"lane1": {"lane": "1"},
|
||||
"lane2": {"lane": "not-a-number"},
|
||||
"lane3": {"lane": "3", "material": "PETG", "color": "#00FF00", "nozzle_temp": 240}
|
||||
}
|
||||
}
|
||||
})");
|
||||
|
||||
std::vector<AmsTrayData> trays;
|
||||
int max_lane_index = -1;
|
||||
REQUIRE(parse_moonraker_lane_data(body, trays, max_lane_index));
|
||||
REQUIRE(trays.size() == 3);
|
||||
CHECK(trays[0].slot_index == 0);
|
||||
CHECK(trays[0].has_filament);
|
||||
CHECK(trays[0].tray_type == "PLA");
|
||||
CHECK(trays[0].tray_color == "FF0000FF");
|
||||
CHECK(trays[1].slot_index == 1);
|
||||
CHECK_FALSE(trays[1].has_filament);
|
||||
CHECK(trays[1].tray_type.empty());
|
||||
CHECK(trays[2].slot_index == 3);
|
||||
CHECK(trays[2].nozzle_temp == 240);
|
||||
CHECK(max_lane_index == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("unit: lane_data projection rejects malformed and empty responses", "[unit][moonraker]")
|
||||
{
|
||||
std::vector<AmsTrayData> trays;
|
||||
int max_lane_index = -1;
|
||||
CHECK_FALSE(parse_moonraker_lane_data(nlohmann::json::object(), trays, max_lane_index));
|
||||
CHECK_FALSE(parse_moonraker_lane_data(nlohmann::json::parse(R"({"result":{"value":{}}})"), trays, max_lane_index));
|
||||
CHECK_FALSE(parse_moonraker_lane_data(
|
||||
nlohmann::json::parse(R"({"result":{"value":{"lane0":{"lane":"x"},"lane1":42}}})"), trays, max_lane_index));
|
||||
}
|
||||
|
||||
// why: OrcaSonar projects sensed presence separately from declared material, so a
|
||||
// loaded lane with no type must not collapse to "empty".
|
||||
TEST_CASE("unit: lane_data presence is independent of declared material", "[unit][moonraker]")
|
||||
{
|
||||
const auto body = nlohmann::json::parse(R"({
|
||||
"result": {"value": {
|
||||
"lane0": {"lane": "0", "has_filament": true},
|
||||
"lane1": {"lane": "1", "has_filament": false, "material": "PLA"},
|
||||
"lane2": {"lane": "2", "loaded": true},
|
||||
"lane3": {"lane": "3", "material": "ASA"}
|
||||
}}
|
||||
})");
|
||||
|
||||
std::vector<AmsTrayData> trays;
|
||||
int max_lane_index = -1;
|
||||
REQUIRE(parse_moonraker_lane_data(body, trays, max_lane_index));
|
||||
REQUIRE(trays.size() == 4);
|
||||
|
||||
CHECK(trays[0].has_filament);
|
||||
CHECK(trays[0].tray_type.empty());
|
||||
|
||||
CHECK_FALSE(trays[1].has_filament);
|
||||
CHECK(trays[1].tray_type == "PLA");
|
||||
|
||||
CHECK(trays[2].has_filament);
|
||||
|
||||
CHECK(trays[3].has_filament);
|
||||
CHECK(trays[3].tray_type == "ASA");
|
||||
}
|
||||
|
||||
// why: the exist bits and per-slot placeholder flag drive the sidebar's occupied
|
||||
// vs empty rendering; the builder is pure so this needs no GUI.
|
||||
TEST_CASE("unit: AMS payload sets exist bits and omits placeholder for loaded lanes", "[unit][moonraker]")
|
||||
{
|
||||
std::vector<AmsTrayData> trays = {
|
||||
{0, true, "PLA", "FF0000FF", "", 0, 0},
|
||||
{2, true, "", "", "", 0, 0}, // loaded but undeclared
|
||||
{5, true, "PETG", "00FF00", "", 0, 0},
|
||||
};
|
||||
const auto ams = build_bbl_ams_json(trays, 2, 5);
|
||||
|
||||
// Units 0 and 1 exist; slots 0, 2 and 5 hold filament (0b100101).
|
||||
CHECK(ams["ams_exist_bits"].get<std::string>() == "3");
|
||||
CHECK(ams["tray_exist_bits"].get<std::string>() == "25");
|
||||
|
||||
const auto& u0 = ams["ams"][0]["tray"];
|
||||
CHECK_FALSE(u0[0].contains("tray_slot_placeholder"));
|
||||
CHECK(u0[1].contains("tray_slot_placeholder"));
|
||||
CHECK_FALSE(u0[2].contains("tray_slot_placeholder"));
|
||||
CHECK(u0[2]["tray_type"].get<std::string>() == "");
|
||||
CHECK(u0[2]["tray_color"].get<std::string>() == "00000000");
|
||||
|
||||
const auto& u1 = ams["ams"][1]["tray"];
|
||||
CHECK(u1[0].contains("tray_slot_placeholder")); // slot 4 absent
|
||||
CHECK_FALSE(u1[1].contains("tray_slot_placeholder")); // slot 5 present
|
||||
CHECK(u1[1]["tray_color"].get<std::string>() == "00FF00FF"); // 6-hex padded
|
||||
}
|
||||
|
||||
// why: the exist-bits fields are 64-bit; multi-box rigs past lane 31 would be
|
||||
// corrupted by a 32-bit unsigned long shift on MSVC. Guards the 64-bit path.
|
||||
TEST_CASE("unit: AMS payload sets exist bits beyond 31 lanes", "[unit][moonraker]")
|
||||
{
|
||||
std::vector<AmsTrayData> trays = {
|
||||
{33, true, "PLA", "FF0000FF", "", 0, 0},
|
||||
};
|
||||
const auto ams = build_bbl_ams_json(trays, 9, 33);
|
||||
|
||||
CHECK(ams["ams_exist_bits"].get<std::string>() == "1FF"); // units 0..8
|
||||
CHECK(ams["tray_exist_bits"].get<std::string>() == "200000000"); // 1 << 33
|
||||
|
||||
const auto& u8 = ams["ams"][8]["tray"];
|
||||
CHECK_FALSE(u8[1].contains("tray_slot_placeholder")); // slot 33 present
|
||||
}
|
||||
|
||||
// why: the sync mode keys off the printer's declared material system; a device
|
||||
// with no capability record must never read as AMS-capable.
|
||||
TEST_CASE("unit: AMS capability registry reports only declared material systems", "[unit][moonraker]")
|
||||
{
|
||||
CHECK_FALSE(has_ams_capability("cap-none"));
|
||||
register_ams_capability("cap-true", true);
|
||||
CHECK(has_ams_capability("cap-true"));
|
||||
register_ams_capability("cap-false", false);
|
||||
CHECK_FALSE(has_ams_capability("cap-false"));
|
||||
// Later replies overwrite: a removed material system must clear the flag.
|
||||
register_ams_capability("cap-true", false);
|
||||
CHECK_FALSE(has_ams_capability("cap-true"));
|
||||
}
|
||||
|
||||
// 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]")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user