Initial Commit for OrcaPrinterAgent AMS Sync

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