feat(orca-agent): integrate OrcaSonar AMS synchronization

- Translate slicer AMS commands to OrcaSonar’s canonical write format
- Gate AMS operations using printer capabilities
- Refresh capabilities when filament topology becomes available
- Apply virtual-slot removals only from full status snapshots
- Add synchronization tests and document the integration
This commit is contained in:
Lam Wei Lun
2026-09-24 12:34:51 +08:00
parent 4cc23e6492
commit aa934409a7
11 changed files with 364 additions and 36 deletions
+2 -1
View File
@@ -45,7 +45,8 @@ struct DevFilamentDryingPreset;
* - color: Hex color string without '#' prefix (e.g., "FF0000")
* - cols: Multi-color component list for gradient/multi-color filaments
* - ctype: Color type indicator
* - is_exists: Whether filament is currently loaded in the tray
* - is_exists: Whether the slot is present/configured (the user's layout wins);
* not whether filament is loaded, so a configured empty slot stays present
*/
class DevAmsTray
{
+4 -2
View File
@@ -67,7 +67,9 @@ namespace Slic3r
AppConfig* DeviceManager::get_app_config() const
{
return m_app_config ? m_app_config : GUI::wxGetApp().app_config;
if (m_app_config)
return m_app_config;
return wxTheApp ? GUI::wxGetApp().app_config : nullptr;
}
void DeviceManager::load_local_machines_from_config()
@@ -459,7 +461,7 @@ namespace Slic3r
void DeviceManager::update_local_machine(const MachineObject& m)
{
update_local_machine(m, GUI::wxGetApp().app_config);
update_local_machine(m, wxTheApp ? GUI::wxGetApp().app_config : nullptr);
}
int DeviceManager::query_bind_status(std::string& msg, const std::string& provider)
+62 -29
View File
@@ -477,7 +477,8 @@ void MachineObject::set_access_code(std::string code, bool only_refresh)
{
this->access_code = code;
if (only_refresh) {
AppConfig* config = m_manager ? m_manager->get_app_config() : GUI::wxGetApp().app_config;
AppConfig* config = m_manager ? m_manager->get_app_config()
: (wxTheApp ? GUI::wxGetApp().app_config : nullptr);
if (config) {
if (is_lan_mode_printer()) {
// why: LAN codes are scoped via BBLocalMachine::access_code, keyed by dev_id and
@@ -2843,6 +2844,10 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
try {
bool restored_json = false;
// A frame is authoritative for removals only when it is a full snapshot
// (msg=0, or a LAN frame with no msg). Delta frames (msg=1) merge into
// the stored state and MUST NOT remove entries they omit (spec §7.3).
bool full_snapshot = true;
json j;
if (!parse_ok)
j_pre = json::parse(payload);
@@ -2859,11 +2864,12 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
BOOST_LOG_TRIVIAL(trace) << "static: get push_all msg, dev_id=" << dev_id;
m_push_count++;
m_full_msg_count++;
full_snapshot = true;
if (!printer_type.empty())
print_json.load_compatible_settings(printer_type, "");
print_json.diff2all_base_reset(j_pre);
} else if (j_pre["print"]["msg"].get<int>() == 1) { //diff message
full_snapshot = false;
if (print_json.diff2all(j_pre, j) == 0) {
restored_json = true;
} else {
@@ -3091,7 +3097,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
}
if (!key_field_only) {
if (!m_manager->IsMultiMachineEnabled() && !is_support_agora) {
if ((!m_manager || !m_manager->IsMultiMachineEnabled()) && !is_support_agora) {
if (jj.contains("support_tunnel_mqtt")) {
if (jj["support_tunnel_mqtt"].is_boolean()) {
is_support_tunnel_mqtt = jj["support_tunnel_mqtt"].get<bool>();
@@ -4015,39 +4021,60 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
if (jj.contains("vir_slot") && jj["vir_slot"].is_array()) {
if (jj["vir_slot"].empty()) {
// Authoritative empty: OrcaSonar pushes [] when the
// topology is known but has no slots; clear stale trays.
vt_slot.clear();
ams_support_virtual_tray = false;
// Only a full snapshot can authoritatively clear the layout.
if (full_snapshot) {
vt_slot.clear();
ams_support_virtual_tray = false;
}
}
else {
// A keyed, populated vir_slot means virtual trays
// are supported; without this a prior clear left
// the flag false and the trays were ignored.
ams_support_virtual_tray = true;
}
if (full_snapshot) {
// A full snapshot is authoritative: rebuild
// from it so an id absent from the list is
// removed, not left stale (spec §7.3).
std::vector<DevAmsTray> fresh;
for (auto it = jj["vir_slot"].begin(); it != jj["vir_slot"].end(); it++) {
auto vslot = parse_vt_tray(it.value().get<json>());
for (auto it = jj["vir_slot"].begin(); it != jj["vir_slot"].end(); it++) {
auto vslot = parse_vt_tray(it.value().get<json>());
if (vslot.id == std::to_string(VIRTUAL_TRAY_MAIN_ID)) {
auto it = std::next(vt_slot.begin(), 0);
if (it != vt_slot.end()) {
vt_slot[0] = vslot;
}
else {
vt_slot.push_back(vslot);
if (vslot.id == std::to_string(VIRTUAL_TRAY_MAIN_ID)) {
if (fresh.empty()) {
fresh.push_back(vslot);
}
else {
fresh[0] = vslot;
}
}
else if (vslot.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID)) {
// vt_slot[1] is the deputy. Only the main
// branch creates index 0, so an orphan
// deputy (no main) is dropped, not indexed.
if (!fresh.empty()) {
if (fresh.size() > 1) {
fresh[1] = vslot;
}
else {
fresh.push_back(vslot);
}
}
}
}
vt_slot = std::move(fresh);
}
else if (vslot.id == std::to_string(VIRTUAL_TRAY_DEPUTY_ID)) {
// vt_slot[1] is the deputy. Only the main
// branch creates index 0, so an orphan
// deputy (no main) is dropped, not indexed.
if (vt_slot.size() > 1) {
vt_slot[1] = vslot;
}
else if (vt_slot.size() == 1) {
vt_slot.push_back(vslot);
else {
// A delta only updates the entries it names;
// an omitted entry stays held (spec §7.3).
for (auto it = jj["vir_slot"].begin(); it != jj["vir_slot"].end(); it++) {
auto vslot = parse_vt_tray(it.value().get<json>());
for (auto& held : vt_slot) {
if (held.id == vslot.id) {
held = vslot;
break;
}
}
}
}
}
@@ -4137,6 +4164,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
vt_slot[0].setting_id = jj["tray_info_idx"].get<std::string>();
//vt_tray.type = jj["tray_type"].get<std::string>();
vt_slot[0].m_fila_type = setting_id_to_type(vt_slot[0].setting_id, jj["tray_type"].get<std::string>());
vt_slot[0].is_exists = true;
// delay update
vt_slot[0].set_hold_count();
} else {
@@ -4682,7 +4710,9 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
if (diff.count() > 10.0f) {
BOOST_LOG_TRIVIAL(trace) << "parse_json timeout = " << diff.count();
}
DeviceManager::update_local_machine(*this, m_manager ? m_manager->get_app_config() : GUI::wxGetApp().app_config);
AppConfig* app_config = m_manager ? m_manager->get_app_config()
: (wxTheApp ? GUI::wxGetApp().app_config : nullptr);
DeviceManager::update_local_machine(*this, app_config);
return 0;
}
@@ -5084,6 +5114,9 @@ bool MachineObject::is_firmware_info_valid()
DevAmsTray MachineObject::parse_vt_tray(json vtray)
{
auto vt_tray = DevAmsTray(std::to_string(VIRTUAL_TRAY_MAIN_ID));
// OrcaSonar emits a virtual slot only when the layout has it: the user's
// configuration wins, so it is present even with no material loaded.
vt_tray.is_exists = true;
if (vtray.contains("id"))
vt_tray.id = vtray["id"].get<std::string>();
@@ -5530,7 +5563,7 @@ void MachineObject::parse_new_info2(const json& info)
parse_bool("support_build_plate_marker_detect", is_support_build_plate_marker_detect);
parse_bool("support_nozzle_blob_detect", is_support_nozzle_blob_detection);
if (!m_manager->IsMultiMachineEnabled() && !is_support_agora)
if ((!m_manager || !m_manager->IsMultiMachineEnabled()) && !is_support_agora)
parse_bool("support_tunnel_mqtt", is_support_tunnel_mqtt);
const auto bed_leveling_it = flags.find("support_bed_leveling");
+7
View File
@@ -425,6 +425,13 @@ bool has_ams_capability(const std::string& dev_id)
return it != g_ams_caps.end() && it->second.has_ams;
}
bool ams_caps_known(const std::string& dev_id)
{
std::lock_guard<std::mutex> lock(g_ams_state_mutex);
auto it = g_ams_caps.find(dev_id);
return it != g_ams_caps.end() && it->second.ops_known;
}
void register_filament_slots(const std::string& dev_id, bool has_slots)
{
if (dev_id.empty())
+5
View File
@@ -104,6 +104,11 @@ bool ams_op_supported(const std::string& dev_id, const std::string& op);
void register_ams_capability(const std::string& dev_id, bool has_ams);
bool has_ams_capability(const std::string& dev_id);
// Whether the device has answered get_capabilities at all (any reply, even one
// declaring no material system). Lets a client re-request capabilities only
// while the topology is still unconfirmed, instead of on every filament frame.
bool ams_caps_known(const std::string& dev_id);
// Whether the device exposes the filament-slot model, from the
// get_capabilities reply's protocol.features.filament_slots. The slot model is
// connector state, independent of fms: a printer with no material hardware
+57 -2
View File
@@ -112,6 +112,18 @@ std::string next_gcode_file_sequence_id()
return std::to_string(counter.fetch_add(1, std::memory_order_relaxed));
}
// pushall is replay-cached per (namespace, command, sequence_id), so a refresh
// must not reuse the connect-band ids (20001..20004). Seed from the wall clock
// like next_gcode_file_sequence_id and bump once per call within a run.
std::string next_filament_refresh_sequence_id()
{
static std::atomic<uint64_t> counter{[] {
const auto now = std::chrono::system_clock::now().time_since_epoch();
return static_cast<uint64_t>(std::chrono::duration_cast<std::chrono::seconds>(now).count()) + 100000;
}()};
return std::to_string(counter.fetch_add(1, std::memory_order_relaxed));
}
static constexpr const char* ORCASONAR_FALLBACK = "orcasonar";
bool fetch_orcasonar_body(const std::string& url, std::string& body)
@@ -677,6 +689,7 @@ void OrcaPrinterAgent::deliver_to_sink(const std::string& dev_id, const std::str
{
parse_ipcam_info(dev_id, payload);
register_ams_capabilities(dev_id, payload);
maybe_refresh_filament_capabilities(dev_id, payload, local);
std::string merged_payload = merge_capabilities(dev_id, payload);
OnMessageFn fn;
@@ -1061,6 +1074,38 @@ void OrcaPrinterAgent::emit_connect_sequence(const std::string& dev_id,
request(build_get_capabilities(seq(4)));
}
void OrcaPrinterAgent::request_filament_capabilities(const std::string& dev_id, bool local)
{
if (dev_id.empty())
return;
OrcaMqttConnection* conn = get_appropriate_mqtt_connection(local);
if (!conn)
return;
conn->send_request(dev_id, build_get_capabilities(next_filament_refresh_sequence_id()));
conn->send_request(dev_id, build_pushall(next_filament_refresh_sequence_id()));
}
void OrcaPrinterAgent::maybe_refresh_filament_capabilities(const std::string& dev_id, const std::string& payload, bool local)
{
if (dev_id.empty() || ams_caps_known(dev_id))
return;
if (payload.find("push_status") == std::string::npos)
return;
// Only filament state is a reason to ask: ams_exist_bits/tray_exist_bits or
// the vir_slot array. A status without either cannot resolve topology.
if (payload.find("\"vir_slot\"") == std::string::npos && payload.find("\"ams\":") == std::string::npos)
return;
{
std::lock_guard<std::mutex> l(state_mutex);
const auto now = std::chrono::steady_clock::now();
const auto it = m_filament_caps_refresh_at.find(dev_id);
if (it != m_filament_caps_refresh_at.end() && now - it->second < std::chrono::seconds(5))
return;
m_filament_caps_refresh_at[dev_id] = now;
}
request_filament_capabilities(dev_id, local);
}
void OrcaPrinterAgent::on_connected(const std::string& dev_id, OrcaMqttConnection* conn, uint64_t generation)
{
// Called from both connect paths with whichever epoch that path captured; the two
@@ -1251,8 +1296,12 @@ std::string OrcaPrinterAgent::canonicalize_ams_payload(const std::string& dev_id
// Already canonical (e.g. command_ams_select_tray): gate the op,
// but never rewrite the body.
const std::string sel = print.value("selector", std::string());
const std::string sel_op = (sel == "lane") ? "change_filament" : sel;
if (!op_allowed(sel_op) && unsupported)
// A lane can resolve to an external slot server-side (§7.8), so
// either write op admits it; other selectors gate on their own token.
const bool allowed = (sel == "lane")
? (op_allowed("change_filament") || op_allowed("external"))
: op_allowed(sel);
if (!allowed && unsupported)
*unsupported = true;
return json_str;
}
@@ -1465,11 +1514,13 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id)
{
auto* cloud = get_orca_cloud_agent();
std::string previous;
std::string lan_dev_id;
CurrentConn previous_connection;
CurrentConn current_connection;
{
std::lock_guard<std::mutex> lock(state_mutex);
previous_connection = m_current_connection;
lan_dev_id = m_lan_dev_id;
// An empty cloud selection must not clear an independently active LAN
// selection. Conversely, selecting a cloud machine with the same id
// while LAN is active is still a transport switch and must proceed.
@@ -1498,6 +1549,10 @@ int OrcaPrinterAgent::set_user_selected_machine(std::string dev_id)
BOOST_LOG_TRIVIAL(info) << "OrcaPrinterAgent::set_user_selected_machine: previous=" << previous << " new=" << dev_id
<< " cloud=" << (cloud ? "set" : "<null>") << " transport=" << connection_type_name(previous_connection) << "->"
<< connection_type_name(current_connection);
// Forget the deselected cloud device's declaration so a later session starts
// from "no reply yet". A LAN feed for the same id keeps its capabilities.
if (!previous.empty() && previous != dev_id && previous != lan_dev_id)
clear_ams_caps(previous);
if (!cloud) {
BOOST_LOG_TRIVIAL(warning) << "OrcaPrinterAgent::set_user_selected_machine: no Orca cloud agent";
return BAMBU_NETWORK_SUCCESS;
+17
View File
@@ -6,12 +6,14 @@
#include "OrcaCloudServiceAgent.hpp"
#include "OrcaMqttConnection.hpp"
#include <atomic>
#include <chrono>
#include <cstdint>
#include <functional>
#include <string>
#include <mutex>
#include <memory>
#include <thread>
#include <unordered_map>
namespace Slic3r {
@@ -165,6 +167,17 @@ protected:
virtual void emit_connect_sequence(const std::string& dev_id,
std::function<void(const std::string&)> subscribe,
std::function<void(const std::string&)> request);
// Re-ask a device for its capabilities and a full status. Sent when a
// filament frame arrives before the capabilities reply (the topology
// bootstrapped after connect, or Klipper restarted), so a session that
// latched FilamentSyncMode::none can still reach subscription mode.
virtual void request_filament_capabilities(const std::string& dev_id, bool local);
// deliver_to_sink hook: re-request capabilities on a filament frame while
// the device's topology is still unconfirmed, throttled per device.
void maybe_refresh_filament_capabilities(const std::string& dev_id, const std::string& payload, bool local);
static std::string seq(int n); // decimal string in the OrcaSlicer 20000..29999 band
static std::string build_pushing_start(const std::string& sequence_id);
static std::string build_pushing_stop(const std::string& sequence_id);
@@ -211,6 +224,10 @@ private:
CameraStreamMode m_camera_stream_mode = CameraStreamMode::none; // guarded by state_mutex
std::string m_camera_url; // guarded by state_mutex
// Last capability re-request per device; bounds the refresh to one per
// device while a filament frame keeps arriving without a reply.
std::unordered_map<std::string, std::chrono::steady_clock::time_point> m_filament_caps_refresh_at; // 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).