mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-24 17:26:47 +00:00
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:
@@ -152,6 +152,41 @@ The methods remain on the common interface so an agent that supports them can ov
|
||||
`sequence_id` remains part of the command contract because `DeviceManager` creates and tracks it as
|
||||
the command ID.
|
||||
|
||||
## Filament and AMS synchronization
|
||||
|
||||
The `OrcaPrinterAgent` bridges OrcaSonar's filament/AMS state. Its capability reply
|
||||
carries two independent signals: `protocol.features.fms` means a material system resolves,
|
||||
and `protocol.features.filament_slots` means the connector owns a filament-slot model that
|
||||
persists without any material hardware. Either one enables filament synchronization, so a
|
||||
printer with slots but no AMS still exposes the slot workflow. The agent also caches
|
||||
`protocol.ams_ops`, the canonical material write operations the resolved drivers implement,
|
||||
and uses it to gate AMS writes; `print.ams_filament_setting` is exempt because it persists
|
||||
connector state and is advertised by `filament_slots` alone. A write whose operation is not
|
||||
declared is refused with `CAP_NOT_AVAILABLE` before it reaches the wire.
|
||||
|
||||
Printer state arrives as the pushed `ams`/`vir_slot` projection inside `push_status`; the
|
||||
Orca agent does not poll the Moonraker `lane_data` namespace, which exists for
|
||||
Moonraker-channel consumers. Slot presence is the user's declaration, not sensed material:
|
||||
`tray_exist_bits` and the presence of a `vir_slot` entry mark a slot as present even with an
|
||||
empty `tray_type`, which is the `is_exists` state the filament UI reads. A full status frame
|
||||
(`msg=0`, or a LAN frame with no `msg`) is authoritative for removals, so a virtual tray id
|
||||
absent from a populated `vir_slot` is dropped; a delta frame (`msg=1`) only updates the
|
||||
entries it names and leaves omitted entries held.
|
||||
|
||||
Writes follow the same edge-translation rule as the rest of the agent. The shared
|
||||
`MachineObject` command builders emit Bambu-shaped `print.ams_*` payloads, and the agent's
|
||||
single send funnel decodes them into OrcaSonar's canonical bodies — `selector` with a flat
|
||||
lane or `ams_id`/`slot_id` coordinates for `ams_change_filament`, coordinates for
|
||||
`ams_filament_setting` — before publishing. The server resolves and rejects with its own
|
||||
`errno` backstop; the agent only withholds operations the device did not declare.
|
||||
|
||||
A filament frame can arrive before the capability reply that decides synchronization mode,
|
||||
for example when the topology bootstraps after connect or Klipper restarts. While a device's
|
||||
capabilities are still unknown, the agent re-requests `get_capabilities` and a full status on
|
||||
a filament frame, throttled per device, and stops once an answer arrives. Dedicated
|
||||
capability state is cleared when a cloud device is deselected, while an independently active
|
||||
LAN session for the same device id keeps its declaration.
|
||||
|
||||
## Device ownership and stale responses
|
||||
|
||||
Printer-agent ownership is represented by `printer_agent_id` on device records and `MachineObject`
|
||||
@@ -233,6 +268,10 @@ inside the Bambu agent and the Orca agent's v1 sink adapter can be removed as a
|
||||
cloud camera contract
|
||||
- [`NetworkAgent`](../../src/slic3r/Utils/NetworkAgent.hpp) — façade and dispatch between active agents
|
||||
- [`NetworkAgentFactory`](../../src/slic3r/Utils/NetworkAgentFactory.hpp) — built-in and Python agent registry
|
||||
- [`OrcaPrinterAgent`](../../src/slic3r/Utils/OrcaPrinterAgent.hpp) — OrcaSonar transport, capability
|
||||
discovery, and Bambu-to-canonical command translation
|
||||
- [`AmsPayload`](../../src/slic3r/Utils/AmsPayload.hpp) — shared filament payload rendering and the
|
||||
per-device AMS capability cache
|
||||
- [`DeviceManager`](../../src/slic3r/GUI/DeviceCore/DevManager.cpp) — device ownership, filtering, and
|
||||
stale-response checks
|
||||
- [`PrinterAgentPluginCapability`](../../src/slic3r/plugin/pluginTypes/printerAgent/PrinterAgentPluginCapability.cpp)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -5495,14 +5495,14 @@ TEST_CASE("filament_id_by_type resolves a modifier type to its generic family",
|
||||
pla.is_visible = true;
|
||||
pla.is_compatible = true;
|
||||
pla.filament_id = "GFL99";
|
||||
pla.config.option<ConfigOptionString>("filament_type")->value = "PLA";
|
||||
pla.config.option<ConfigOptionStrings>("filament_type")->values = {"PLA"};
|
||||
|
||||
Preset &petg = add_inmemory_preset(bundle.filaments, "Generic PETG @System");
|
||||
petg.is_system = true;
|
||||
petg.is_visible = true;
|
||||
petg.is_compatible = true;
|
||||
petg.filament_id = "GFG99";
|
||||
petg.config.option<ConfigOptionString>("filament_type")->value = "PETG";
|
||||
petg.config.option<ConfigOptionStrings>("filament_type")->values = {"PETG"};
|
||||
|
||||
CHECK(bundle.filaments.filament_id_by_type("PETG Basic") == "GFG99");
|
||||
CHECK(bundle.filaments.filament_id_by_type("PLA") == "GFL99");
|
||||
|
||||
@@ -88,6 +88,92 @@ TEST_CASE("An orphan deputy virtual tray is dropped, not indexed", "[DeviceManag
|
||||
CHECK(machine.vt_slot.empty());
|
||||
}
|
||||
|
||||
// OrcaSonar emits a virtual slot only when the layout has it, so it counts as
|
||||
// present even with no filament: the user's configuration wins.
|
||||
TEST_CASE("A configured vir_slot is present even with no material", "[DeviceManager]")
|
||||
{
|
||||
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
|
||||
|
||||
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
|
||||
|
||||
REQUIRE(machine.vt_slot.size() == 2);
|
||||
CHECK(machine.vt_slot[0].is_exists);
|
||||
CHECK(machine.vt_slot[1].is_exists);
|
||||
}
|
||||
|
||||
// A full vir_slot snapshot is authoritative: an id it omits is removed, so a
|
||||
// tool-count shrink does not leave a stale deputy tray.
|
||||
TEST_CASE("A populated vir_slot prunes virtual trays it omits", "[DeviceManager]")
|
||||
{
|
||||
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
|
||||
|
||||
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
|
||||
REQUIRE(machine.vt_slot.size() == 2);
|
||||
|
||||
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"}]}})", false);
|
||||
|
||||
REQUIRE(machine.vt_slot.size() == 1);
|
||||
CHECK(machine.vt_slot[0].id == "255");
|
||||
}
|
||||
|
||||
// A delta frame (msg=1) is not authoritative for removals: an entry it omits
|
||||
// stays held, and an entry it names is updated in place (spec §7.3).
|
||||
TEST_CASE("A delta vir_slot does not prune virtual trays it omits", "[DeviceManager]")
|
||||
{
|
||||
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
|
||||
|
||||
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
|
||||
REQUIRE(machine.vt_slot.size() == 2);
|
||||
|
||||
// Delta names only the deputy: the main must survive, the deputy updates.
|
||||
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":1,"vir_slot":[{"id":"254","tag_uid":"ABCDEF0123456789"}]}})", false);
|
||||
|
||||
REQUIRE(machine.vt_slot.size() == 2);
|
||||
CHECK(machine.vt_slot[0].id == "255");
|
||||
CHECK(machine.vt_slot[1].id == "254");
|
||||
CHECK(machine.vt_slot[1].tag_uid == "ABCDEF0123456789");
|
||||
}
|
||||
|
||||
// An empty delta is not authoritative and must preserve the known layout.
|
||||
TEST_CASE("An empty delta vir_slot keeps the virtual trays", "[DeviceManager]")
|
||||
{
|
||||
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
|
||||
|
||||
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":0,"vir_slot":[{"id":"255"},{"id":"254"}]}})", false);
|
||||
REQUIRE(machine.vt_slot.size() == 2);
|
||||
|
||||
machine.parse_json("lan", R"({"print":{"command":"push_status","msg":1,"vir_slot":[]}})", false);
|
||||
|
||||
REQUIRE(machine.vt_slot.size() == 2);
|
||||
CHECK(machine.vt_slot[0].id == "255");
|
||||
CHECK(machine.vt_slot[1].id == "254");
|
||||
CHECK(machine.ams_support_virtual_tray);
|
||||
}
|
||||
|
||||
TEST_CASE("Capability flags parse without a DeviceManager", "[DeviceManager]")
|
||||
{
|
||||
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
|
||||
|
||||
machine.parse_json("lan",
|
||||
R"({"info":{"command":"get_capabilities","capabilities":{"flags":{"support_tunnel_mqtt":true}}}})", false);
|
||||
|
||||
CHECK(machine.is_support_tunnel_mqtt);
|
||||
}
|
||||
|
||||
// A filament setting landing on the virtual tray also marks it present.
|
||||
TEST_CASE("A virtual tray setting marks the tray present", "[DeviceManager]")
|
||||
{
|
||||
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
|
||||
|
||||
machine.parse_json("lan", R"({"print":{"command":"push_status","vir_slot":[{"id":"255"}]}})", false);
|
||||
REQUIRE(machine.vt_slot.size() == 1);
|
||||
machine.vt_slot[0].is_exists = false;
|
||||
|
||||
machine.parse_json("lan", R"({"print":{"command":"ams_filament_setting","ams_id":255,"tray_id":255,"tray_color":"FF0000FF","tray_type":"PLA","tray_info_idx":"GFA00","nozzle_temp_min":190,"nozzle_temp_max":230}})", false);
|
||||
|
||||
CHECK(machine.vt_slot[0].is_exists);
|
||||
}
|
||||
|
||||
// acks that target the virtual tray must survive an emptied vt_slot.
|
||||
TEST_CASE("Virtual tray acks are safe with no virtual tray", "[DeviceManager]")
|
||||
{
|
||||
|
||||
@@ -394,6 +394,20 @@ TEST_CASE("OrcaPrinterAgent rewrites Bambu ams_* payloads onto the canonical Orc
|
||||
canon("dev-c2", R"({"print":{"command":"ams_change_filament","selector":"external"}})", &unsupported);
|
||||
CHECK(unsupported);
|
||||
|
||||
// A lane can resolve to an external slot server-side (§7.8), so a device
|
||||
// that declares only external still admits a canonical lane write.
|
||||
Slic3r::register_ams_ops("dev-c4", {"external"});
|
||||
unsupported = false;
|
||||
canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"lane","lane":1}})", &unsupported);
|
||||
CHECK(!unsupported);
|
||||
unsupported = false;
|
||||
canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"external"}})", &unsupported);
|
||||
CHECK(!unsupported);
|
||||
// Other selectors still gate on their own token.
|
||||
unsupported = false;
|
||||
canon("dev-c4", R"({"print":{"command":"ams_change_filament","selector":"unload"}})", &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);
|
||||
@@ -441,3 +455,72 @@ TEST_CASE("an AMS tray selection sends the tray's ams_id and slot_id", "[OrcaPri
|
||||
CHECK(body["print"]["ams_id"] == 0);
|
||||
CHECK(body["print"]["slot_id"] == 3);
|
||||
}
|
||||
|
||||
// A filament frame can arrive after the get_capabilities reply was missed (the
|
||||
// topology resolved late, or Klipper restarted). While the device is still
|
||||
// unconfirmed, one filament frame must re-ask, throttled, and stop once a reply
|
||||
// arrives so the session can leave FilamentSyncMode::none.
|
||||
TEST_CASE("a filament frame re-requests capabilities while the topology is unconfirmed", "[OrcaPrinterAgent]") {
|
||||
struct RefreshProbe : OrcaPrinterAgent {
|
||||
using OrcaPrinterAgent::OrcaPrinterAgent;
|
||||
using OrcaPrinterAgent::deliver_to_sink;
|
||||
std::vector<std::string> refreshes;
|
||||
void request_filament_capabilities(const std::string& dev_id, bool /*local*/) override {
|
||||
refreshes.push_back(dev_id);
|
||||
}
|
||||
} agent("/tmp");
|
||||
agent.set_on_message_fn([](std::string, std::string) {});
|
||||
|
||||
const std::string frame = R"({"print":{"command":"push_status","vir_slot":[{"id":"255"}]}})";
|
||||
agent.deliver_to_sink("dev-refresh-1", frame, /*local=*/true);
|
||||
REQUIRE(agent.refreshes.size() == 1);
|
||||
|
||||
// Throttled: a second frame immediately after does not ask again.
|
||||
agent.deliver_to_sink("dev-refresh-1", frame, /*local=*/true);
|
||||
CHECK(agent.refreshes.size() == 1);
|
||||
|
||||
// A capability reply confirms the topology: no further re-request.
|
||||
agent.deliver_to_sink("dev-refresh-1",
|
||||
R"({"info":{"command":"get_capabilities","capabilities":{"protocol":{"features":{"filament_slots":true}}}}})",
|
||||
/*local=*/true);
|
||||
agent.deliver_to_sink("dev-refresh-1", frame, /*local=*/true);
|
||||
CHECK(agent.refreshes.size() == 1);
|
||||
|
||||
// A status without filament state never triggers a request.
|
||||
agent.deliver_to_sink("dev-refresh-2", R"({"print":{"command":"push_status","mc_percent":10}})", /*local=*/true);
|
||||
CHECK(agent.refreshes.size() == 1);
|
||||
}
|
||||
|
||||
// Deselecting a cloud device forgets its declaration, so a later session starts
|
||||
// from "no reply yet" instead of a stale one. With no record an undeclared op is
|
||||
// admitted again, which is how a freshly selected device starts.
|
||||
TEST_CASE("deselecting a cloud device forgets its capabilities", "[OrcaPrinterAgent]") {
|
||||
OrcaPrinterAgent agent("/tmp");
|
||||
Slic3r::register_ams_ops("dev-cloud-clear", {"change_filament"});
|
||||
Slic3r::register_filament_slots("dev-cloud-clear", true);
|
||||
CHECK_FALSE(Slic3r::ams_op_supported("dev-cloud-clear", "external"));
|
||||
|
||||
agent.set_user_selected_machine("dev-cloud-clear");
|
||||
agent.set_user_selected_machine("");
|
||||
|
||||
CHECK(Slic3r::ams_op_supported("dev-cloud-clear", "external"));
|
||||
CHECK_FALSE(Slic3r::has_filament_slots("dev-cloud-clear"));
|
||||
}
|
||||
|
||||
// A LAN feed for the same device id must keep its declaration when the cloud
|
||||
// selection is cleared: clearing it would strand an independently live session.
|
||||
// why hidden: spawns the LAN connect worker, like the other connect tests.
|
||||
TEST_CASE("deselecting a cloud device keeps a live LAN declaration", "[OrcaPrinterAgent][.integration]") {
|
||||
OrcaPrinterAgent agent("/tmp");
|
||||
Slic3r::register_ams_ops("dev-lan-keep", {"change_filament"});
|
||||
Slic3r::register_filament_slots("dev-lan-keep", true);
|
||||
|
||||
REQUIRE(agent.connect_printer("dev-lan-keep", "10.255.255.1", "orcasonar", "code", false) == BAMBU_NETWORK_SUCCESS);
|
||||
agent.set_user_selected_machine("dev-lan-keep");
|
||||
agent.set_user_selected_machine("");
|
||||
|
||||
CHECK_FALSE(Slic3r::ams_op_supported("dev-lan-keep", "external"));
|
||||
CHECK(Slic3r::has_filament_slots("dev-lan-keep"));
|
||||
|
||||
agent.disconnect_printer();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user