mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-27 19:01:02 +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-relative paths for the offline generic-map check
|
||||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
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")
|
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
|
LANE_KEYS = [f"lane{i}" for i in range(1, 9)] # lane1-lane8
|
||||||
MATERIALS = ["PLA", "ABS", "PETG", "ASA", "ASA Sparkle", "TPU", ""]
|
MATERIALS = ["PLA", "ABS", "PETG", "ASA", "ASA Sparkle", "TPU", ""]
|
||||||
@@ -41,16 +41,16 @@ MATERIAL_TEMPS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
def parse_cpp_type_map():
|
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
|
Reads map_filament_type_to_generic_id's type_to_ofl_family initializer so the
|
||||||
initializer so the check tracks the C++ normalization without a duplicated list.
|
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()
|
src = f.read()
|
||||||
m = re.search(r"type_to_ofl_family\s*=\s*\{(.*?)\n\s*\};", src, re.DOTALL)
|
m = re.search(r"type_to_ofl_family\s*=\s*\{(.*?)\n\s*\};", src, re.DOTALL)
|
||||||
if not m:
|
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))
|
pairs = re.findall(r'\{\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\}', m.group(1))
|
||||||
if not pairs:
|
if not pairs:
|
||||||
raise RuntimeError("type_to_ofl_family table parsed empty")
|
raise RuntimeError("type_to_ofl_family table parsed empty")
|
||||||
|
|||||||
@@ -740,6 +740,8 @@ set(SLIC3R_GUI_SOURCES
|
|||||||
Utils/OrcaMqttConnection.hpp
|
Utils/OrcaMqttConnection.hpp
|
||||||
Utils/OrcaPrinterAgent.cpp
|
Utils/OrcaPrinterAgent.cpp
|
||||||
Utils/OrcaPrinterAgent.hpp
|
Utils/OrcaPrinterAgent.hpp
|
||||||
|
Utils/AmsPayload.cpp
|
||||||
|
Utils/AmsPayload.hpp
|
||||||
Utils/OrcaCloudSignalingChannel.cpp
|
Utils/OrcaCloudSignalingChannel.cpp
|
||||||
Utils/OrcaCloudSignalingChannel.hpp
|
Utils/OrcaCloudSignalingChannel.hpp
|
||||||
Utils/QidiPrinterAgent.cpp
|
Utils/QidiPrinterAgent.cpp
|
||||||
|
|||||||
@@ -727,6 +727,7 @@ struct Sidebar::priv
|
|||||||
ScalableButton * m_bpButton_add_filament;
|
ScalableButton * m_bpButton_add_filament;
|
||||||
ScalableButton * m_bpButton_del_filament;
|
ScalableButton * m_bpButton_del_filament;
|
||||||
ScalableButton * m_bpButton_ams_filament;
|
ScalableButton * m_bpButton_ams_filament;
|
||||||
|
bool m_ams_sync_button_show{true}; // last applied AMS sync button visibility
|
||||||
ScalableButton * m_bpButton_set_filament;
|
ScalableButton * m_bpButton_set_filament;
|
||||||
int m_menu_filament_id = -1;
|
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()
|
void Sidebar::update_all_preset_comboboxes()
|
||||||
{
|
{
|
||||||
PresetBundle &preset_bundle = *wxGetApp().preset_bundle;
|
PresetBundle &preset_bundle = *wxGetApp().preset_bundle;
|
||||||
@@ -3444,7 +3461,7 @@ void Sidebar::update_all_preset_comboboxes()
|
|||||||
//p->btn_connect_printer->Hide();
|
//p->btn_connect_printer->Hide();
|
||||||
p->m_printer_connect->Hide();
|
p->m_printer_connect->Hide();
|
||||||
//only show sync-ams button for BBL printer
|
//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
|
//update print button default value for bbl or third-party printer
|
||||||
p_mainframe->set_print_button_to_default(MainFrame::PrintSelectType::ePrintPlate);
|
p_mainframe->set_print_button_to_default(MainFrame::PrintSelectType::ePrintPlate);
|
||||||
} else {
|
} else {
|
||||||
@@ -3453,11 +3470,7 @@ void Sidebar::update_all_preset_comboboxes()
|
|||||||
p->m_printer_connect->Show(!use_printer_agents);
|
p->m_printer_connect->Show(!use_printer_agents);
|
||||||
|
|
||||||
// ORCA: show/hide sync-ams button based on filament sync mode
|
// ORCA: show/hide sync-ams button based on filament sync mode
|
||||||
auto agent = wxGetApp().getAgent();
|
update_ams_sync_button();
|
||||||
if (agent && agent->get_filament_sync_mode() != FilamentSyncMode::none)
|
|
||||||
p->m_bpButton_ams_filament->Show();
|
|
||||||
else
|
|
||||||
p->m_bpButton_ams_filament->Hide();
|
|
||||||
|
|
||||||
// Orca: with "Support 3MF as gcode" (use_3mf) the local export is a .gcode.3mf bundle, so when no
|
// 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
|
// 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();
|
DeviceManager *dev_maneger = wxGetApp().getDeviceManager();
|
||||||
if (!dev_maneger) {
|
if (!dev_maneger) {
|
||||||
GUI::wxGetApp().sidebar().update_sync_status(nullptr);
|
GUI::wxGetApp().sidebar().update_sync_status(nullptr);
|
||||||
|
GUI::wxGetApp().sidebar().update_ams_sync_button();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MachineObject *obj = wxGetApp().getDeviceManager()->get_selected_machine();
|
MachineObject *obj = wxGetApp().getDeviceManager()->get_selected_machine();
|
||||||
GUI::wxGetApp().sidebar().update_sync_status(obj);
|
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()
|
bool Plater::get_machine_sync_status()
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ public:
|
|||||||
void init_filament_combo(PlaterPresetComboBox **combo, const int filament_idx);
|
void init_filament_combo(PlaterPresetComboBox **combo, const int filament_idx);
|
||||||
void remove_unused_filament_combos(const size_t current_extruder_count);
|
void remove_unused_filament_combos(const size_t current_extruder_count);
|
||||||
void update_all_preset_comboboxes();
|
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_partplate(PartPlateList& list);
|
||||||
void update_presets(Slic3r::Preset::Type preset_type);
|
void update_presets(Slic3r::Preset::Type preset_type);
|
||||||
//BBS
|
//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)
|
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,
|
// May run from the status-stream thread (subscription agents) or the GUI
|
||||||
// for subscription-mode agents) as well as from the GUI thread (Sidebar's pull-mode
|
// thread (Sidebar's pull path). Snapshot queue_on_main_fn here; the shared
|
||||||
// path). Everything below touches MachineObject/DevFilaSystem, which the GUI thread
|
// builder defers the preset lookup and MachineObject mutation to the main
|
||||||
// reads without locking — so the actual mutation must run on the main thread. Snapshot
|
// thread. A heavy-handed empty model_id must still be assigned, so the
|
||||||
// queue_on_main_fn and the two device_info fields we need up front, then defer the rest,
|
// optional wrapper always carries the value here.
|
||||||
// mirroring dispatch_message's existing queue_fn ? queue_fn(x) : x() idiom.
|
|
||||||
QueueOnMainFn queue_fn;
|
QueueOnMainFn queue_fn;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::recursive_mutex> lock(state_mutex);
|
std::lock_guard<std::recursive_mutex> lock(state_mutex);
|
||||||
queue_fn = queue_on_main_fn;
|
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
|
void MoonrakerPrinterAgent::build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays, const TrayInfoResolver& vendor_resolver)
|
||||||
nlohmann::json ams_json = nlohmann::json::object();
|
{
|
||||||
nlohmann::json ams_array = nlohmann::json::array();
|
QueueOnMainFn queue_fn;
|
||||||
|
{
|
||||||
// Calculate ams_exist_bits and tray_exist_bits
|
std::lock_guard<std::recursive_mutex> lock(state_mutex);
|
||||||
unsigned long ams_exist_bits = 0;
|
queue_fn = queue_on_main_fn;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
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)
|
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;
|
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
|
// 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)
|
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())
|
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;
|
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
|
// Fetch filament info from moonraker database
|
||||||
bool MoonrakerPrinterAgent::fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index)
|
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": {...}, ... } } }
|
// Expected structure: { "result": { "namespace": "lane_data", "value": { "lane1": {...}, ... } } }
|
||||||
if (!json.contains("result") || !json["result"].contains("value") || !json["result"]["value"].is_object()) {
|
if (!parse_moonraker_lane_data(json, trays, max_lane_index)) {
|
||||||
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";
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// tray_info_idx is resolved later, on the main thread, inside
|
||||||
|
// build_ams_payload_for_device.
|
||||||
|
|
||||||
return true;
|
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.bed_temp = 0; // HH doesn't provide bed temp in gate arrays
|
||||||
tray.has_filament = true;
|
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);
|
max_lane_index = std::max(max_lane_index, gate_idx);
|
||||||
trays.push_back(tray);
|
trays.push_back(tray);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "IPrinterAgent.hpp"
|
#include "IPrinterAgent.hpp"
|
||||||
#include "ICloudServiceAgent.hpp"
|
#include "ICloudServiceAgent.hpp"
|
||||||
|
#include "AmsPayload.hpp"
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
@@ -84,19 +85,12 @@ protected:
|
|||||||
bool use_ssl = false;
|
bool use_ssl = false;
|
||||||
} device_info;
|
} device_info;
|
||||||
|
|
||||||
// Tray data for AMS payload building
|
// Build ams JSON and call parser (AmsTrayData is shared; see AmsPayload.hpp).
|
||||||
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
|
|
||||||
void build_ams_payload(int ams_count, int max_lane_index, const std::vector<AmsTrayData>& trays);
|
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
|
// Methods that derived classes may need to override or access
|
||||||
virtual bool init_device_info(std::string dev_id, std::string dev_ip, std::string username, std::string password, bool use_ssl);
|
virtual bool 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
|
// Trim whitespace and convert to uppercase
|
||||||
static std::string trim_and_upper(const std::string& input);
|
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)
|
// Send a G-code script via Moonraker (/printer/gcode/script)
|
||||||
bool send_gcode(const std::string& dev_id, const std::string& gcode) const;
|
bool send_gcode(const std::string& dev_id, const std::string& gcode) const;
|
||||||
bool send_gcode(const std::string& dev_id, const std::string& gcode,
|
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);
|
bool fetch_moonraker_filament_data(std::vector<AmsTrayData>& trays, int& max_lane_index);
|
||||||
|
|
||||||
// JSON helper methods
|
// JSON helper methods
|
||||||
static std::string safe_json_string(const nlohmann::json& obj, const char* key);
|
|
||||||
static int safe_json_int(const nlohmann::json& obj, const char* key);
|
|
||||||
static std::string safe_array_string(const nlohmann::json& arr, int idx);
|
static std::string safe_array_string(const nlohmann::json& arr, int idx);
|
||||||
static int safe_array_int(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_host;
|
||||||
std::string ssdp_announced_id;
|
std::string ssdp_announced_id;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "OrcaPrinterAgent.hpp"
|
#include "OrcaPrinterAgent.hpp"
|
||||||
#include "OrcaCloudSignalingChannel.hpp"
|
#include "OrcaCloudSignalingChannel.hpp"
|
||||||
|
#include "AmsPayload.hpp"
|
||||||
#include "Http.hpp"
|
#include "Http.hpp"
|
||||||
#include "IPrinterAgent.hpp"
|
#include "IPrinterAgent.hpp"
|
||||||
#include "NetworkAgentFactory.hpp"
|
#include "NetworkAgentFactory.hpp"
|
||||||
@@ -448,6 +449,15 @@ OrcaPrinterAgent::~OrcaPrinterAgent()
|
|||||||
start_discovery(false, false);
|
start_discovery(false, false);
|
||||||
++m_lan_generation; // fence any late worker callback
|
++m_lan_generation; // fence any late worker callback
|
||||||
++m_cloud_generation;
|
++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
|
// Drop the cloud status callback before anything else: it holds `this`, and the
|
||||||
// cloud agent outlives the printer agent (NetworkAgent::set_printer_agent swaps
|
// cloud agent outlives the printer agent (NetworkAgent::set_printer_agent swaps
|
||||||
@@ -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);
|
std::lock_guard<std::mutex> l(nozzle_diameter_cache_mutex);
|
||||||
nozzle_diameter_cache[dev_id] = nozzle_dia;
|
nozzle_diameter_cache[dev_id] = nozzle_dia;
|
||||||
}
|
}
|
||||||
// The capabilities reply itself is forwarded unchanged.
|
// 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);
|
||||||
}
|
}
|
||||||
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")) {
|
|
||||||
|
|
||||||
|
// 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;
|
double nozzle_dia = 0.0;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> l(nozzle_diameter_cache_mutex);
|
std::lock_guard<std::mutex> l(nozzle_diameter_cache_mutex);
|
||||||
@@ -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)
|
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);
|
parse_ipcam_info(dev_id, payload);
|
||||||
std::string merged_payload = merge_capabilities(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;
|
q = queue_on_main_fn;
|
||||||
}
|
}
|
||||||
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering " << (local ? "local" : "cloud") << " report payload dev_id=" << dev_id
|
BOOST_LOG_TRIVIAL(info) << "Orca diagnostic: delivering " << (local ? "local" : "cloud") << " report payload dev_id=" << dev_id
|
||||||
<< " payload=" << merged_payload
|
<< " payload=" << merged_payload << " callback=" << (fn ? "set" : "null")
|
||||||
<< " callback=" << (fn ? "set" : "null") << " queue_on_main=" << (q ? "set" : "null");
|
<< " queue_on_main=" << (q ? "set" : "null");
|
||||||
if (!fn) {
|
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;
|
<< " dev_id=" << dev_id;
|
||||||
return;
|
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
|
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");
|
<< " 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)
|
if (!callback)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -745,7 +808,9 @@ int OrcaPrinterAgent::command_ams_select_tray(std::string dev_id, std::string tr
|
|||||||
nlohmann::json j;
|
nlohmann::json j;
|
||||||
j["print"]["command"] = "ams_change_filament";
|
j["print"]["command"] = "ams_change_filament";
|
||||||
j["print"]["sequence_id"] = std::to_string(sequence_id);
|
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());
|
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());
|
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)
|
bool OrcaPrinterAgent::parse_lan_endpoint(const std::string& dev_ip, std::string& host, std::string& port)
|
||||||
{
|
{
|
||||||
std::string s = dev_ip;
|
std::string s = dev_ip;
|
||||||
@@ -1043,6 +1342,10 @@ int OrcaPrinterAgent::connect_printer(std::string dev_id, std::string dev_ip, st
|
|||||||
previous_connection = m_current_connection;
|
previous_connection = m_current_connection;
|
||||||
m_lan_dev_id = dev_id;
|
m_lan_dev_id = dev_id;
|
||||||
m_lan_url = cfg.url;
|
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_stream_mode = CameraStreamMode::none;
|
||||||
m_camera_url.clear();
|
m_camera_url.clear();
|
||||||
m_current_connection = LAN;
|
m_current_connection = LAN;
|
||||||
@@ -1116,6 +1419,10 @@ int OrcaPrinterAgent::disconnect_printer()
|
|||||||
doomed = std::move(lan_mqtt_connection);
|
doomed = std::move(lan_mqtt_connection);
|
||||||
prev_dev = m_lan_dev_id;
|
prev_dev = m_lan_dev_id;
|
||||||
m_lan_dev_id.clear();
|
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) {
|
if (m_current_connection == LAN) {
|
||||||
m_current_connection = NONE;
|
m_current_connection = NONE;
|
||||||
m_camera_stream_mode = CameraStreamMode::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*/)
|
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); }
|
{ 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)
|
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>";
|
std::string command = "<unparsed>";
|
||||||
try {
|
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"}) {
|
for (const char* namespace_name : {"pushing", "info", "print", "system", "camera", "xcam", "upgrade", "event", "files"}) {
|
||||||
const auto namespace_it = envelope.find(namespace_name);
|
const auto namespace_it = envelope.find(namespace_name);
|
||||||
if (namespace_it != envelope.end() && namespace_it->is_object()) {
|
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);
|
OrcaMqttConnection* conn = get_appropriate_mqtt_connection(is_lan);
|
||||||
if (!conn)
|
if (!conn)
|
||||||
return BAMBU_NETWORK_ERR_INVALID_HANDLE;
|
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
|
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::route_send command=" << command << " queued=" << queued << " is_lan=" << is_lan
|
||||||
<< " dev_id=" << dev_id;
|
<< " dev_id=" << dev_id;
|
||||||
return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
|
return queued ? BAMBU_NETWORK_SUCCESS : BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED;
|
||||||
@@ -1370,7 +1773,8 @@ AgentInfo OrcaPrinterAgent::get_agent_info_static()
|
|||||||
// Print Job Operations - All Stubs
|
// 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)
|
int OrcaPrinterAgent::start_print(PrintParams params, OnUpdateStatusFn update_fn, WasCancelledFn cancel_fn, OnWaitFn wait_fn)
|
||||||
{
|
{
|
||||||
(void) wait_fn;
|
(void) wait_fn;
|
||||||
@@ -1476,8 +1880,19 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params,
|
|||||||
std::string http_error;
|
std::string http_error;
|
||||||
std::string response_body;
|
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
|
// check if printer has enough storage
|
||||||
Http::get(origin + "/server/files/directory?path=gcodes")
|
{
|
||||||
|
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) {
|
.on_complete([&](std::string body, unsigned status) {
|
||||||
if (body.empty()) {
|
if (body.empty()) {
|
||||||
http_status = 400;
|
http_status = 400;
|
||||||
@@ -1507,6 +1922,7 @@ int OrcaPrinterAgent::start_send_gcode_to_sdcard(PrintParams params,
|
|||||||
response_body = std::move(body);
|
response_body = std::move(body);
|
||||||
})
|
})
|
||||||
.perform_sync();
|
.perform_sync();
|
||||||
|
}
|
||||||
|
|
||||||
if (http_status >= 400) {
|
if (http_status >= 400) {
|
||||||
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << " failed with error code: " << http_status << ", " << http_error;
|
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");
|
auto http = Http::post(origin + "/server/files/upload");
|
||||||
if (!params.password.empty())
|
if (!api_key.empty())
|
||||||
http.header("X-Api-Key", params.password); // trusted LAN facades may not require it; harmless when they do not
|
http.header("X-Api-Key", api_key); // bootstrapped façade key, or the access code fallback
|
||||||
http.form_add("root", "gcodes")
|
http.form_add("root", "gcodes")
|
||||||
.form_add("print", "false")
|
.form_add("print", "false")
|
||||||
.form_add_file("file", source, upload_name)
|
.form_add_file("file", source, upload_name)
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ public:
|
|||||||
void set_cloud_agent(std::shared_ptr<ICloudServiceAgent> cloud) override;
|
void set_cloud_agent(std::shared_ptr<ICloudServiceAgent> cloud) override;
|
||||||
CameraStreamMode get_camera_stream_mode() const override;
|
CameraStreamMode get_camera_stream_mode() const override;
|
||||||
std::string get_camera_url() const override;
|
std::string get_camera_url() const override;
|
||||||
std::unique_ptr<ICameraSignalingChannel>
|
std::unique_ptr<ICameraSignalingChannel> create_camera_signaling_channel(const std::string& dev_id) override;
|
||||||
create_camera_signaling_channel(const std::string& dev_id) override;
|
|
||||||
|
|
||||||
// Communication
|
// Communication
|
||||||
int send_message(std::string dev_id, std::string json_str, int qos, int flag) override;
|
int send_message(std::string dev_id, std::string json_str, int qos, int flag) override;
|
||||||
@@ -100,6 +99,14 @@ public:
|
|||||||
int sequence_id,
|
int sequence_id,
|
||||||
bool lan_mode) override;
|
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).
|
// Test-only: drive emit_connect_sequence directly (no socket).
|
||||||
void run_connect_sequence_for_test(const std::string& dev_id)
|
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.
|
// Test-only: the same for the (independent) cloud selection epoch.
|
||||||
void bump_cloud_generation_for_test() { ++m_cloud_generation; }
|
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:
|
protected:
|
||||||
// Forward one inbound printer message to on_message_fn or on_local_message_fn (marshalled onto the UI
|
// Forward one inbound printer message to on_message_fn or on_local_message_fn (marshalled onto the UI
|
||||||
@@ -193,9 +215,44 @@ private:
|
|||||||
|
|
||||||
std::string m_lan_dev_id; // guarded by state_mutex
|
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_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
|
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();
|
OrcaCloudServiceAgent* get_orca_cloud_agent();
|
||||||
|
|
||||||
OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan = true);
|
OrcaMqttConnection* get_appropriate_mqtt_connection(bool is_lan = true);
|
||||||
|
|||||||
@@ -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_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);
|
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
|
// Extract NFC temperature data if available
|
||||||
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
|
if (nfc_info.is_array() && i < static_cast<int>(nfc_info.size()) && nfc_info[i].is_object()) {
|
||||||
auto& nfc_slot = nfc_info[i];
|
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));
|
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();
|
}).detach();
|
||||||
|
|
||||||
return true;
|
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
|
FilamentSyncMode SnapmakerPrinterAgent::get_filament_sync_mode() const
|
||||||
{
|
{
|
||||||
if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
|
if (GUI::wxGetApp().app_config->get_bool("use_printer_agents"))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace Slic3r {
|
namespace Slic3r {
|
||||||
|
|
||||||
@@ -28,6 +29,12 @@ private:
|
|||||||
// Combine filament_type + filament_sub_type into a unified type string
|
// Combine filament_type + filament_sub_type into a unified type string
|
||||||
static std::string combine_filament_type(const std::string& type, const std::string& sub_type);
|
static std::string combine_filament_type(const std::string& type, const std::string& sub_type);
|
||||||
|
|
||||||
|
// 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 start_camera_monitor();
|
||||||
void on_status_loop_tick(const std::string& dev_id) override;
|
void on_status_loop_tick(const std::string& dev_id) override;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
#include <slic3r/Utils/AmsPayload.hpp>
|
||||||
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp>
|
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp>
|
||||||
#include <slic3r/Utils/OrcaPrinterAgent.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.
|
// is cached for the device.
|
||||||
agent.deliver_to_sink(
|
agent.deliver_to_sink(
|
||||||
"dev-1",
|
"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);
|
/*local=*/false);
|
||||||
CHECK(last_payload.find("\"command\":\"get_capabilities\"") != std::string::npos);
|
CHECK(last_payload.find("\"command\":\"get_capabilities\"") != std::string::npos);
|
||||||
CHECK(last_payload.find("\"print\"") == 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);
|
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("\"nozzle_diameter\":0.6") != std::string::npos);
|
||||||
CHECK(last_payload.find("N/A") == 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]") {
|
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();
|
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]") {
|
TEST_CASE("post-connect sequence is subscribe then 4 requests in order", "[OrcaPrinterAgent]") {
|
||||||
struct SeqProbe : OrcaPrinterAgent {
|
struct SeqProbe : OrcaPrinterAgent {
|
||||||
using OrcaPrinterAgent::OrcaPrinterAgent;
|
using OrcaPrinterAgent::OrcaPrinterAgent;
|
||||||
@@ -202,3 +295,65 @@ TEST_CASE("destroying an agent mid-connect does not hang or crash", "[OrcaPrinte
|
|||||||
}
|
}
|
||||||
SUCCEED();
|
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);
|
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.
|
// 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]")
|
TEST_CASE("unit: BBL AMS gcode builders preserve command bytes", "[unit][bbl]")
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user