## Summary

Follow-up to the feature/OrcaSonar review. Scopes the new behavior to the
OrcaSonar agent, fixes several AMS-sync and lifecycle bugs, and makes the
MQTT tests run in CI. Non-OrcaSonar agents keep their existing behavior.

## Changes

Discovery
- Gate the background SSDP scan to a configured OrcaSonar machine instead of
  running for every non-BBL profile; drive discovery on demand from the
  machine-select popup and back off idle rounds from 5s to 60s.
- Add NSLocalNetworkUsageDescription and NSBonjourServices to both macOS
  Info.plist templates.

AMS sync
- Resolve id-less printer-set trays by material type in get_ams_cobox_infos
  and the sidebar badge loop, matching sync_ams_list, so combo arrays and
  badges stay aligned.
- Keep a printer-set tray that resolved by type out of the "unknown or
  incompatible filaments" dialog.

AMS device UI
- Gate the "Empty" tray classification, and the editable temperature fields,
  to the OrcaSonar agent; Bambu keeps "?" and read-only presets.
- Validate AMS material temps on confirm: reject out-of-range values and
  min > max before the command is sent.

Storage tab
- Bind the show/hide handler on the panel's ancestors (StoragePanel and
  MonitorPanel) and unbind on destruction, so the Bambu file system stops
  when the tab is left.

Transport
- Load /etc/ssl/cert.pem on macOS in add_platform_root_certificates, after
  set_default_verify_paths.
- Release lifecycle_mutex before start()'s connect wait so stop() can cancel
  the attempt and signal completion instead of stalling the UI thread.

Localization
- Add OrcaFilesPanel.cpp to list.txt and regenerate the catalogs.

## Tests
- Un-hide the loopback MockBroker integration tests (ephemeral port, no
  network) so ctest registers and runs them.
- Mock broker tracks SUBSCRIBE/UNSUBSCRIBE and delivers push_report only to a
  subscribed topic; the reconnect test asserts the re-SUBSCRIBE.
- Add bbl empty-tray cases, an AMS combo-alignment case, and unknowns
  assertions to the AMS sync tests.
This commit is contained in:
Lam Wei Lun
2026-10-06 12:09:23 +08:00
parent 029bc7928f
commit ccbb73baaf
46 changed files with 15790 additions and 9552 deletions
+7
View File
@@ -131,6 +131,13 @@
<true/>
<key>NSHumanReadableCopyright</key>
<string>${MACOSX_BUNDLE_COPYRIGHT}</string>
<key>NSLocalNetworkUsageDescription</key>
<string>OrcaSlicer uses your local network to discover and connect to printers.</string>
<key>NSBonjourServices</key>
<array>
<string>_octoprint._tcp</string>
<string>_http._tcp</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<!-- Disable App Transport Security. Resolves https://github.com/OrcaSlicer/OrcaSlicer/issues/791 -->
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -149,6 +149,7 @@ src/slic3r/GUI/HMSPanel.cpp
src/slic3r/GUI/MainFrame.cpp
src/slic3r/GUI/MediaPlayCtrl.cpp
src/slic3r/GUI/MediaFilePanel.cpp
src/slic3r/GUI/OrcaFilesPanel.cpp
src/slic3r/GUI/ImageGrid.cpp
src/slic3r/GUI/Widgets/ImageSwitchButton.cpp
src/slic3r/GUI/Printer/PrinterFileSystem.cpp
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7
View File
@@ -126,6 +126,13 @@
</array>
<key>LSMinimumSystemVersion</key>
<string>10.10</string>
<key>NSLocalNetworkUsageDescription</key>
<string>OrcaSlicer uses your local network to discover and connect to printers.</string>
<key>NSBonjourServices</key>
<array>
<string>_octoprint._tcp</string>
<string>_http._tcp</string>
</array>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSHighResolutionCapable</key>
+50 -7
View File
@@ -3813,7 +3813,11 @@ void PresetBundle::get_ams_cobox_infos(AMSComboInfo& combox_info)
auto ams_name = ams.opt_string("tray_name", 0u);
auto filament_changed = !ams.has("filament_changed") || ams.opt_bool("filament_changed");
auto filament_multi_color = ams.opt<ConfigOptionStrings>("filament_multi_colour")->values;
if (filament_id.empty()) {
auto filament_type = ams.opt_string("filament_type", 0u);
auto is_placeholder = ams.has("filament_slot_placeholder") && ams.opt_bool("filament_slot_placeholder", 0u);
// A printer-set tray carries a material type but no preset id; resolve it to the
// matching Generic preset so it is not dropped and the combo array keeps aligning.
if (filament_id.empty() && (is_placeholder || filament_type.empty())) {
continue;
}
if (!filament_changed && this->filament_presets.size() > combox_info.ams_filament_presets.size()) {
@@ -3823,16 +3827,48 @@ void PresetBundle::get_ams_cobox_infos(AMSComboInfo& combox_info)
combox_info.ams_names.push_back(ams_name);
continue;
}
auto iter = std::find_if(filaments.begin(), filaments.end(),
[this, &filament_id](auto &f) { return f.is_compatible && filaments.get_preset_base(f) == &f && f.filament_id == filament_id; });
warn_ambiguous_filament_id_match(filaments, iter, filament_id);
auto iter = filaments.end();
if (!filament_id.empty()) {
iter = std::find_if(filaments.begin(), filaments.end(),
[this, &filament_id](auto &f) { return f.is_compatible && filaments.get_preset_base(f) == &f && f.filament_id == filament_id; });
warn_ambiguous_filament_id_match(filaments, iter, filament_id);
}
if (iter == filaments.end()) {
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << boost::format(": filament_id %1% not found or system or compatible") % filament_id;
auto filament_type = ams.opt_string("filament_type", 0u);
if (!filament_type.empty()) {
auto original_type = filament_type;
filament_type = "Generic " + filament_type;
iter = std::find_if(filaments.begin(), filaments.end(),
[&filament_type](auto &f) { return f.is_compatible && f.is_system && boost::algorithm::starts_with(f.name, filament_type); });
if (iter == filaments.end()) {
// Similarity fallback: find a generic preset whose filament_type
// appears as a whole word in the AMS type (e.g. "ASA" in "ASA Sparkle").
auto upper_type = boost::to_upper_copy(original_type);
auto contains_word = [](const std::string& haystack, const std::string& needle) {
auto pos = haystack.find(needle);
while (pos != std::string::npos) {
bool start_ok = (pos == 0 || !std::isalnum(static_cast<unsigned char>(haystack[pos - 1])));
bool end_ok = (pos + needle.size() >= haystack.size() ||
!std::isalnum(static_cast<unsigned char>(haystack[pos + needle.size()])));
if (start_ok && end_ok)
return true;
pos = haystack.find(needle, pos + 1);
}
return false;
};
// Find the longest-matching preset type to prefer e.g. "PA-CF" over "PA".
size_t best_len = 0;
for (auto it = filaments.begin(); it != filaments.end(); ++it) {
if (!it->is_compatible || !it->is_system || !boost::algorithm::starts_with(it->name, "Generic "))
continue;
auto preset_type = boost::to_upper_copy(it->config.opt_string("filament_type", 0u));
if (preset_type.size() > best_len && contains_word(upper_type, preset_type)) {
iter = it;
best_len = preset_type.size();
filament_type = "Generic " + it->config.opt_string("filament_type", 0u);
}
}
}
}
if (iter == filaments.end()) {
// Prefer old selection
@@ -3843,7 +3879,11 @@ void PresetBundle::get_ams_cobox_infos(AMSComboInfo& combox_info)
combox_info.ams_names.push_back(ams_name);
continue;
}
iter = std::find_if(filaments.begin(), filaments.end(), [](auto &f) { return f.is_compatible && f.is_system; });
iter = std::find_if(filaments.begin(), filaments.end(), [](auto &f) {
return f.is_compatible && f.is_system && boost::algorithm::starts_with(f.name, "Generic ");
});
if (iter == filaments.end())
iter = std::find_if(filaments.begin(), filaments.end(), [](auto &f) { return f.is_compatible && f.is_system; });
if (iter == filaments.end())
continue;
}
@@ -3881,6 +3921,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
for (auto &entry : filament_ams_list) {
auto & ams = entry.second;
auto filament_id = ams.opt_string("filament_id", 0u);
const bool printer_set_tray = filament_id.empty();
auto filament_color = ams.opt_string("filament_colour", 0u);
auto filament_color_type = ams.opt_string("filament_colour_type", 0u);
auto filament_changed = !ams.has("filament_changed") || ams.opt_bool("filament_changed");
@@ -3984,6 +4025,7 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
}
}
}
const bool resolved_by_material_type = printer_set_tray && iter != filaments.end();
if (iter == filaments.end()) {
// Prefer old selection
if (ams_filament_presets.size() < this->filament_presets.size()) {
@@ -4007,7 +4049,8 @@ unsigned int PresetBundle::sync_ams_list(std::vector<std::pair<DynamicPrintConfi
if (iter == filaments.end())
continue;
}
unknowns.emplace_back(&ams, boost::algorithm::starts_with(iter->name, filament_type) ?
if (!resolved_by_material_type)
unknowns.emplace_back(&ams, boost::algorithm::starts_with(iter->name, filament_type) ?
(has_type ? L("The filament may not be compatible with the current machine settings. Generic filament presets will be used.") :
L("The filament model is unknown. Generic filament presets will be used.")) :
(has_type ? L("The filament may not be compatible with the current machine settings. A random filament preset will be used.") :
+20 -2
View File
@@ -4,6 +4,7 @@
#include "GUI_App.hpp"
#include "GUI.hpp"
#include "slic3r/Utils/NetworkAgent.hpp"
#include "slic3r/Utils/NetworkAgentFactory.hpp"
#include "libslic3r/Preset.hpp"
#include "I18N.hpp"
#include <algorithm>
@@ -473,9 +474,11 @@ void AMSMaterialsSetting::update_filament_editing(bool is_printing)
}
// A third-party tray owns its temp range; BBL RFID trays keep the read-only preset values.
// Only the OrcaSonar agent accepts the temp fields, so other agents stay read-only.
const bool is_orca_agent = obj && obj->printer_agent_id == ORCA_PRINTER_AGENT_ID;
const bool can_edit = !is_printing || obj->is_support_filament_setting_inprinting;
m_input_nozzle_min->Enable(m_is_third && can_edit);
m_input_nozzle_max->Enable(m_is_third && can_edit);
m_input_nozzle_min->Enable(m_is_third && can_edit && is_orca_agent);
m_input_nozzle_max->Enable(m_is_third && can_edit && is_orca_agent);
if (!m_is_third) {
m_tip_readonly->SetLabelText(wxEmptyString);
@@ -730,6 +733,21 @@ void AMSMaterialsSetting::on_select_ok(wxCommandEvent &event)
return;
}
if (nozzle_temp_min_int < FILAMENT_MIN_TEMP || nozzle_temp_min_int > FILAMENT_MAX_TEMP ||
nozzle_temp_max_int < FILAMENT_MIN_TEMP || nozzle_temp_max_int > FILAMENT_MAX_TEMP) {
MessageDialog msg_dlg(nullptr,
wxString::Format(_L("The input value should be greater than %1% and less than %2%"), FILAMENT_MIN_TEMP, FILAMENT_MAX_TEMP),
wxEmptyString, wxICON_WARNING | wxOK);
msg_dlg.ShowModal();
return;
}
if (nozzle_temp_min_int > nozzle_temp_max_int) {
MessageDialog msg_dlg(nullptr, _L("The minimum temperature cannot be greater than the maximum temperature."),
wxEmptyString, wxICON_WARNING | wxOK);
msg_dlg.ShowModal();
return;
}
// Orca: log the tray payload this dialog hands the printer, so the filament_id resolved from the
// dropdown selection can be checked against the tray_info_idx the AMS actually receives. A
+5 -3
View File
@@ -20,6 +20,7 @@
#include "DevFilaSystem.h"
#include "json_diff.hpp"
#include "slic3r/Utils/NetworkAgent.hpp"
#include "slic3r/Utils/NetworkAgentFactory.hpp" // ORCA_PRINTER_AGENT_ID
#include "DevNozzleSystem.h" // DevNozzle / DevNozzleSystem for GetNozzleFlowStringByAmsId
// TODO: remove this include
@@ -63,12 +64,12 @@ void DevAmsTray::UpdateColorFromStr(const std::string& color)
}
}
void DevAmsTray::UpdateEmptyState(bool material_fields_present)
void DevAmsTray::UpdateEmptyState(bool material_fields_present, bool classify_empty)
{
const auto is_zero_or_empty = [](const std::string& value) {
return value.empty() || std::all_of(value.begin(), value.end(), [](char c) { return c == '0'; });
};
is_empty = material_fields_present && setting_id.empty() && m_fila_type.empty() &&
is_empty = material_fields_present && classify_empty && setting_id.empty() && m_fila_type.empty() &&
is_zero_or_empty(color) && is_zero_or_empty(tag_uid);
}
@@ -793,7 +794,8 @@ void DevFilaSystemParser::ParseV1_0(const json& jj, MachineObject* obj, DevFilaS
{
curr_tray->remain = -1;
}
curr_tray->UpdateEmptyState(tray_it->contains("tray_info_idx") && tray_it->contains("tray_type"));
curr_tray->UpdateEmptyState(tray_it->contains("tray_info_idx") && tray_it->contains("tray_type"),
obj->printer_agent_id == ORCA_PRINTER_AGENT_ID);
// The tray objects are reused across status updates. Reset this
// state when a previously empty slot receives a filament again.
curr_tray->is_slot_placeholder = tray_it->contains("tray_slot_placeholder");
+1 -1
View File
@@ -100,7 +100,7 @@ public:
// setters
void reset();
void UpdateColorFromStr(const std::string& color);
void UpdateEmptyState(bool material_fields_present);
void UpdateEmptyState(bool material_fields_present, bool classify_empty);
void set_hold_count() { hold_count = HOLD_COUNT_MAX; }
// getter
+4 -3
View File
@@ -4290,7 +4290,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
vt_slot[0].m_fila_type = setting_id_to_type(vt_slot[0].setting_id, jj["tray_type"].get<std::string>());
// The ack carries the whole slot; re-derive empty so the panel flips off
// "Empty" without waiting out the hold.
vt_slot[0].UpdateEmptyState(true);
vt_slot[0].UpdateEmptyState(true, printer_agent_id == ORCA_PRINTER_AGENT_ID);
// delay update
vt_slot[0].set_hold_count();
} else {
@@ -4318,7 +4318,7 @@ int MachineObject::parse_json(std::string tunnel, std::string payload, bool key_
tray_it->second->m_fila_type = setting_id_to_type(tray_it->second->setting_id, jj["tray_type"].get<std::string>());
// The ack carries the whole slot; re-derive empty so the panel flips off
// "Empty" without waiting out the hold.
tray_it->second->UpdateEmptyState(true);
tray_it->second->UpdateEmptyState(true, printer_agent_id == ORCA_PRINTER_AGENT_ID);
// delay update
tray_it->second->set_hold_count();
} else {
@@ -5352,7 +5352,8 @@ DevAmsTray MachineObject::parse_vt_tray(json vtray)
else {
vt_tray.remain = -1;
}
vt_tray.UpdateEmptyState(vtray.contains("tray_info_idx") && vtray.contains("tray_type"));
vt_tray.UpdateEmptyState(vtray.contains("tray_info_idx") && vtray.contains("tray_type"),
printer_agent_id == ORCA_PRINTER_AGENT_ID);
}
return vt_tray;
+16 -2
View File
@@ -1137,7 +1137,8 @@ void GUI_App::post_init()
m_agent->set_on_http_error_fn([this](CloudEvent event, unsigned int status, std::string body) {
this->handle_http_error(status, body, event.provider);
});
m_agent->start_discovery(true, false);
if (should_start_ssdp_discovery())
m_agent->start_discovery(true, false);
}
//update the plugin tips
@@ -4109,6 +4110,18 @@ std::string GUI_App::resolve_printer_agent_id(const std::string& stored_id) cons
return (preset_bundle && preset_bundle->is_bbl_vendor()) ? BBL_PRINTER_AGENT_ID : ORCA_PRINTER_AGENT_ID;
}
bool GUI_App::should_start_ssdp_discovery()
{
// Non-Orca agents (BBL, plugins) keep their own discovery; OrcaSonar only scans in the
// background when one of its machines is already configured, otherwise the popup drives it.
const std::string agent_id = resolve_printer_agent_id(
preset_bundle ? preset_bundle->printers.get_edited_preset().config.opt_string("printer_agent")
: std::string());
if (agent_id != ORCA_PRINTER_AGENT_ID)
return true;
return m_device_manager && !m_device_manager->get_my_machine_list(ORCA_PRINTER_AGENT_ID).empty();
}
std::string GUI_App::canonical_printer_agent_id(const std::string& picked_id)
{
return picked_id == resolve_printer_agent_id("") ? std::string() : picked_id;
@@ -4180,7 +4193,8 @@ void GUI_App::switch_printer_agent()
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << ": printer agent switched to " << effective_agent_id;
// Start discovery so Python agents can populate the device list via SSDP callback
m_agent->start_discovery(true, false);
if (should_start_ssdp_discovery())
m_agent->start_discovery(true, false);
// Auto-switch MachineObject (new agent has empty device_info, so always re-select)
select_machine(effective_agent_id);
+3
View File
@@ -399,6 +399,9 @@ public:
// Reconcile the live printer agent with the stored preset selection.
void switch_printer_agent();
// Whether the background SSDP scan should run for the effective printer agent.
bool should_start_ssdp_discovery();
std::string resolve_printer_agent_id(const std::string& stored_id) const;
// ORCA TODO: in the future, bbl presets should specify "bbl" printer agent id
// then, all resolve and canonical would just be ORCA<->""
+25 -17
View File
@@ -220,32 +220,40 @@ MediaFilePanel::MediaFilePanel(wxWindow * parent)
SetSelecting(false);
});
auto onShowHide = [this](auto &e) {
e.Skip();
if (auto w = dynamic_cast<wxWindow *>(e.GetEventObject()); !w || w->IsBeingDeleted())
return;
CallAfter([this] {
auto fs = m_image_grid ? m_image_grid->GetFileSystem() : nullptr;
if (fs) IsShownOnScreen() ? fs->Start() : fs->Stop();
});
};
Bind(wxEVT_SHOW, onShowHide);
// Watch the page host's show/hide. Walk up past any wrapper containers (e.g. a
// StoragePanel) so the target stays the same regardless of nesting depth.
wxWindow* host = parent;
while (host && !host->IsTopLevel() && host->GetParent() && !host->GetParent()->IsTopLevel())
host = host->GetParent();
if (host && host != this)
host->Bind(wxEVT_SHOW, onShowHide);
Bind(wxEVT_SHOW, &MediaFilePanel::on_show_hide, this);
// A Device sub-tab switch hides the page container (StoragePanel); a main tab
// switch hides MonitorPanel inside LazyPage. Neither event reaches the other,
// so watch every ancestor below the top-level frame.
for (wxWindow* w = GetParent(); w && !w->IsTopLevel(); w = w->GetParent()) {
w->Bind(wxEVT_SHOW, &MediaFilePanel::on_show_hide, this);
m_show_hosts.push_back(w);
}
m_lan_user = "bblp";
}
MediaFilePanel::~MediaFilePanel()
{
Unbind(wxEVT_SHOW, &MediaFilePanel::on_show_hide, this);
for (wxWindow* w : m_show_hosts)
if (w && !w->IsBeingDeleted())
w->Unbind(wxEVT_SHOW, &MediaFilePanel::on_show_hide, this);
m_show_hosts.clear();
UpdateByObj(nullptr);
}
void MediaFilePanel::on_show_hide(wxShowEvent& e)
{
e.Skip();
if (auto w = dynamic_cast<wxWindow*>(e.GetEventObject()); !w || w->IsBeingDeleted())
return;
CallAfter([this] {
auto fs = m_image_grid ? m_image_grid->GetFileSystem() : nullptr;
if (fs) IsShownOnScreen() ? fs->Start() : fs->Stop();
});
}
void MediaFilePanel::UpdateByObj(MachineObject* obj)
{
bool sdcard_state_changed = false;
+5
View File
@@ -11,6 +11,7 @@
#include <boost/smart_ptr/shared_ptr.hpp>
#include <cstddef>
#include <set>
#include <vector>
#include "GUI_Utils.hpp"
#include "wxExtensions.hpp"
@@ -53,6 +54,8 @@ public:
private:
void modeChanged(wxCommandEvent & e);
void on_show_hide(wxShowEvent & e);
void fetchUrl(boost::weak_ptr<PrinterFileSystem> fs);
void doAction(size_t index, int action);
@@ -99,6 +102,8 @@ private:
int m_last_mode = 0;
int m_last_type = 0;
std::set<int> m_last_errors;
std::vector<wxWindow*> m_show_hosts;
};
+2 -1
View File
@@ -6420,9 +6420,10 @@ void Sidebar::sync_ams_list(bool is_from_big_sync_btn)
for (const auto &entry : wxGetApp().preset_bundle->filament_ams_list) {
const auto &tray = entry.second;
const bool has_filament = !tray.opt_string("filament_id", 0u).empty();
const bool has_type = !tray.opt_string("filament_type", 0u).empty();
const bool is_placeholder = tray.has("filament_slot_placeholder") &&
tray.opt_bool("filament_slot_placeholder", 0u);
if (!has_filament && !is_placeholder) {
if (!has_filament && !is_placeholder && !has_type) {
continue;
}
if (combo_index >= p->combos_filament.size()) {
+3 -2
View File
@@ -8,6 +8,7 @@
#include "GUI.hpp"
#include "GUI_App.hpp"
#include "slic3r/Utils/NetworkAgent.hpp"
#include "GUI_Preview.hpp"
#include "MainFrame.hpp"
#include "format.hpp"
@@ -848,8 +849,8 @@ void SelectMachinePopup::update_machine_list(wxCommandEvent &event)
void SelectMachinePopup::start_ssdp(bool start)
{
return;
//if (wxGetApp().getAgent()) { wxGetApp().getAgent()->start_discovery(true, start); }
if (auto* agent = wxGetApp().getAgent())
agent->start_discovery(true, start);
}
void SelectMachinePopup::OnLeftUp(wxMouseEvent &event)
+9
View File
@@ -36,6 +36,10 @@
#include <openssl/x509.h>
#include <openssl/x509err.h>
#ifdef __APPLE__
# include <unistd.h>
#endif
#ifdef _WIN32
# ifndef NOMINMAX
# define NOMINMAX
@@ -1001,6 +1005,11 @@ void Http::add_platform_root_certificates(SSL_CTX* ssl_context)
load_store(CERT_SYSTEM_STORE_CURRENT_USER);
load_store(CERT_SYSTEM_STORE_LOCAL_MACHINE);
#elif defined(__APPLE__)
// macOS ships its system trust roots as a PEM bundle; the bundled OpenSSL's
// compiled-in default path is the build directory, so load this explicitly.
if (ssl_context && ::access("/etc/ssl/cert.pem", R_OK) == 0)
SSL_CTX_load_verify_locations(ssl_context, "/etc/ssl/cert.pem", nullptr);
#else
(void)ssl_context;
#endif
+16 -12
View File
@@ -58,21 +58,25 @@ template<class Conn> void expires_never(Conn& conn) {
OrcaMqttConnection::~OrcaMqttConnection() { stop(); }
bool OrcaMqttConnection::start(const Config& config, MessageHandler on_message, StateHandler on_state) {
std::lock_guard<std::recursive_mutex> lifecycle_lock(lifecycle_mutex);
stop();
{
std::lock_guard<std::mutex> lock(mutex);
current_config = config;
this->on_message = std::move(on_message);
this->on_state = std::move(on_state);
initial_result = false;
initial_completed = false;
connected = false;
m_last_connack_rc.store(-1);
std::lock_guard<std::recursive_mutex> lifecycle_lock(lifecycle_mutex);
stop();
{
std::lock_guard<std::mutex> lock(mutex);
current_config = config;
this->on_message = std::move(on_message);
this->on_state = std::move(on_state);
initial_result = false;
initial_completed = false;
connected = false;
m_last_connack_rc.store(-1);
}
stopping.store(false);
worker = std::thread(&OrcaMqttConnection::run, this);
}
stopping.store(false);
worker = std::thread(&OrcaMqttConnection::run, this);
// Wait without holding lifecycle_mutex so a concurrent stop() can cancel the
// attempt and signal completion instead of blocking until the timeout.
std::unique_lock<std::mutex> lock(mutex);
if (!initial_cv.wait_for(lock, std::chrono::seconds(10), [this] { return initial_completed; })) {
initial_completed = true;
+12 -4
View File
@@ -397,8 +397,9 @@ private:
return true;
}
void ssdp_round()
bool ssdp_round()
{
bool emitted = false;
namespace asio = boost::asio;
using asio::ip::udp;
try {
@@ -436,8 +437,10 @@ private:
if (make_machine_alive_json(usn, sender.address().to_string(), location, machine_alive)) {
nlohmann::json machine = nlohmann::json::parse(machine_alive);
const std::string device_id = machine["dev_id"].get<std::string>();
if (seen_ids.insert(device_id).second && m_emit)
if (seen_ids.insert(device_id).second && m_emit) {
m_emit(machine_alive);
emitted = true;
}
}
}
} else if (error != asio::error::would_block && error != asio::error::try_again) {
@@ -450,14 +453,19 @@ private:
} catch (const std::exception& error) {
BOOST_LOG_TRIVIAL(warning) << "OrcaSonarDiscovery: SSDP round failed: " << error.what();
}
return emitted;
}
void browse_loop()
{
// Idle rounds back off to avoid a permanent 8s M-SEARCH when no OrcaSonar is present.
std::chrono::seconds interval(5);
while (m_running.load()) {
ssdp_round();
const bool found = ssdp_round();
std::unique_lock<std::mutex> lock(m_wait_mutex);
m_wait_cv.wait_for(lock, std::chrono::seconds(5), [this] { return !m_running.load(); });
m_wait_cv.wait_for(lock, found ? std::chrono::seconds(5) : interval, [this] { return !m_running.load(); });
interval = found ? std::chrono::seconds(5)
: std::chrono::seconds(std::min<long long>(interval.count() * 2, 60));
}
}
@@ -61,6 +61,7 @@ TEST_CASE("AMS sync resolves a printer-set tray to Generic by material type", "[
const unsigned int count = bundle.sync_ams_list(unknowns, /*use_map=*/false, maps, /*enable_append=*/false, merge);
CHECK(count == 3);
CHECK(unknowns.empty());
REQUIRE(bundle.filament_presets.size() == 3);
CHECK(bundle.filament_presets[0] == "Generic PETG @Q2");
CHECK(bundle.filament_presets[1] == "Generic PETG @Q2");
@@ -102,6 +103,7 @@ TEST_CASE("AMS sync still resolves an unmatched preset id by material type", "[P
MergeFilamentInfo merge;
CHECK(bundle.sync_ams_list(unknowns, /*use_map=*/false, maps, /*enable_append=*/false, merge) == 1);
CHECK(unknowns.size() == 1);
REQUIRE(bundle.filament_presets.size() == 1);
CHECK(bundle.filament_presets[0] == "Generic PETG @Q2");
}
@@ -128,6 +130,7 @@ TEST_CASE("AMS sync in mapping mode resolves a printer-set tray to Generic, not
const unsigned int count = bundle.sync_ams_list(unknowns, /*use_map=*/true, maps, /*enable_append=*/false, merge);
CHECK(count == 1);
CHECK(unknowns.empty());
REQUIRE(bundle.filament_presets.size() == 1);
CHECK(bundle.filament_presets[0] == "Generic PETG @Q2");
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values[0] == "#898F9B");
@@ -153,3 +156,40 @@ TEST_CASE("Full config filament arrays follow the selected filament preset order
// The AMS sync matches trays by filament_id, so this array must follow the same order.
CHECK(full.option<ConfigOptionStrings>("filament_ids")->values == std::vector<std::string>{"GFL99", "OFYPdQJh"});
}
// get_ams_cobox_infos builds the combo arrays that SyncAmsInfoDialog pairs with AMS colors
// positionally. A printer-set tray must appear so the array stays aligned with the sync output.
TEST_CASE("AMS combo info keeps a printer-set tray and stays aligned", "[Preset][AMS]")
{
PresetBundle bundle;
add_system_filament(bundle, "Generic PETG @Q2", "OFYPdQJh", "PETG");
add_system_filament(bundle, "Generic PLA @Q2", "GFL99", "PLA");
auto tray_a = make_tray("OFYPdQJh", "PETG", "#FE717A", "0", "0");
tray_a.set_key_value("tray_name", new ConfigOptionStrings{"A"});
auto tray_b = make_tray("", "PETG", "#898F9B", "0", "1");
tray_b.set_key_value("tray_name", new ConfigOptionStrings{"B"});
auto tray_c = make_tray("GFL99", "PLA", "#FAFAFA", "0", "2");
tray_c.set_key_value("tray_name", new ConfigOptionStrings{"C"});
bundle.filament_ams_list[0] = tray_a;
bundle.filament_ams_list[1] = tray_b;
bundle.filament_ams_list[2] = tray_c;
AMSComboInfo combo_info;
bundle.get_ams_cobox_infos(combo_info);
// The id-less PETG tray resolves to a generic preset instead of being dropped, so C keeps
// its index and its color.
REQUIRE(combo_info.ams_filament_presets.size() == 3);
CHECK(combo_info.ams_filament_presets[0] == "Generic PETG @Q2");
CHECK(combo_info.ams_filament_presets[1] == "Generic PETG @Q2");
CHECK(combo_info.ams_filament_presets[2] == "Generic PLA @Q2");
REQUIRE(combo_info.ams_filament_colors.size() == 3);
CHECK(combo_info.ams_filament_colors[0] == "#FE717A");
CHECK(combo_info.ams_filament_colors[1] == "#898F9B");
CHECK(combo_info.ams_filament_colors[2] == "#FAFAFA");
REQUIRE(combo_info.ams_names.size() == 3);
CHECK(combo_info.ams_names[0] == "A");
CHECK(combo_info.ams_names[1] == "B");
CHECK(combo_info.ams_names[2] == "C");
}
+47 -3
View File
@@ -22,6 +22,7 @@
#include <cstdint>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <thread>
#include <utility>
@@ -96,13 +97,15 @@ public:
std::pair<std::string, std::string> host_port() const { return {std::string("127.0.0.1"), m_port}; }
// Server -> client PUBLISH on device/<dev_id>/report.
// Server -> client PUBLISH on device/<dev_id>/report, but only to a client
// that actually subscribed to that topic.
void push_report(const std::string& dev_id, const std::string& payload)
{
const std::string topic = "device/" + dev_id + "/report";
const std::vector<std::uint8_t> packet =
Slic3r::OrcaMqttConnection::make_publish_packet("device/" + dev_id + "/report", payload);
Slic3r::OrcaMqttConnection::make_publish_packet(topic, payload);
std::lock_guard<std::mutex> lock(m_mutex);
if (!m_stream || !m_stream_ready)
if (!m_stream || !m_stream_ready || m_subscriptions.find(topic) == m_subscriptions.end())
return;
boost::system::error_code ec;
m_stream->binary(true);
@@ -124,6 +127,13 @@ public:
return m_received_requests;
}
// Whether a topic filter has been SUBSCRIBEd and not UNSUBSCRIBEd since.
bool is_subscribed(const std::string& topic) const
{
std::lock_guard<std::mutex> lock(m_mutex);
return m_subscriptions.find(topic) != m_subscriptions.end();
}
// MQTT CONNECTs seen; increments again after a reconnect.
int connect_count() const { return m_connect_count.load(); }
@@ -152,6 +162,7 @@ private:
std::lock_guard<std::mutex> lock(m_mutex);
close_client_locked();
m_stream.reset();
m_subscriptions.clear(); // clean session: filters die with the connection
}
} catch (...) {
// never let an exception escape the broker thread
@@ -221,12 +232,22 @@ private:
const auto id = packet_id(packet);
if (id)
write_packet(stream, {0x90, 0x03, id->first, id->second, 0x00}); // SUBACK, QoS 0
{
std::lock_guard<std::mutex> lock(m_mutex);
for (const std::string& topic : packet_topics(packet, /*qos_per_topic=*/true))
m_subscriptions.insert(topic);
}
return true;
}
case 0xa0: { // UNSUBSCRIBE (0xa2)
const auto id = packet_id(packet);
if (id)
write_packet(stream, {0xb0, 0x02, id->first, id->second}); // UNSUBACK
{
std::lock_guard<std::mutex> lock(m_mutex);
for (const std::string& topic : packet_topics(packet, /*qos_per_topic=*/false))
m_subscriptions.erase(topic);
}
return true;
}
case 0x30: { // PUBLISH, QoS 0 (no packet identifier)
@@ -256,6 +277,28 @@ private:
return std::make_pair(static_cast<std::uint8_t>(packet[pos]), static_cast<std::uint8_t>(packet[pos + 1]));
}
// Topic filters carried by a SUBSCRIBE/UNSUBSCRIBE payload: repeated
// [2-byte length][UTF-8 topic], each followed by a QoS byte for SUBSCRIBE.
static std::vector<std::string> packet_topics(const std::string& packet, bool qos_per_topic)
{
std::vector<std::string> topics;
const auto varint = mqtt_decode_remaining_length(packet, 1);
if (varint.second == 0)
return topics;
const std::size_t end = std::min(packet.size(), 1 + varint.second + varint.first);
std::size_t pos = 1 + varint.second + 2; // after the packet identifier
while (pos + 2 <= end) {
const std::size_t len = (static_cast<std::size_t>(static_cast<std::uint8_t>(packet[pos])) << 8) |
static_cast<std::uint8_t>(packet[pos + 1]);
pos += 2;
if (pos + len > end)
break;
topics.push_back(packet.substr(pos, len));
pos += len + (qos_per_topic ? 1 : 0);
}
return topics;
}
void record_publish(const std::string& packet)
{
const auto varint = mqtt_decode_remaining_length(packet, 1);
@@ -320,6 +363,7 @@ private:
std::optional<ws::stream<beast::tcp_stream>> m_stream; // guarded by m_mutex
bool m_stream_ready = false; // guarded by m_mutex
std::vector<std::string> m_received_requests; // guarded by m_mutex
std::set<std::string> m_subscriptions; // guarded by m_mutex
};
} // namespace orca_mqtt_test
+49
View File
@@ -84,3 +84,52 @@ TEST_CASE("Empty external slots remain distinct from unknown slots", "[AMSItem]"
CHECK_FALSE(unknown_info.cans[0].is_empty);
CHECK(unknown_info.cans[0].material_state == AMSCanType::AMS_CAN_TYPE_VIRTUAL);
}
// Only the OrcaSonar agent opts into the "Empty" classification. Bambu and other
// agents keep the unknown "?" for a present-but-blank tray, so they must leave the
// empty flag untouched.
TEST_CASE("Bambu-style agents keep blank AMS trays as unknown", "[AMSItem]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "bbl";
const json print_json = json::parse(R"({
"ams": {
"ams_exist_bits": "1",
"tray_exist_bits": "3",
"ams": [ { "id": "0", "info": "0001", "tray": [
{ "id": "0", "tag_uid": "0000000000000000", "tray_info_idx": "", "tray_type": "", "tray_color": "00000000" },
{ "id": "1" }
] } ]
}
})");
DevFilaSystemParser::ParseV1_0(print_json, &machine, machine.GetFilaSystem().get(), false);
const auto& ams_list = machine.GetFilaSystem()->GetAmsList();
const auto ams_it = ams_list.find("0");
REQUIRE(ams_it != ams_list.end());
auto* ams = ams_it->second;
REQUIRE(ams != nullptr);
REQUIRE(ams->GetTray("0") != nullptr);
CHECK_FALSE(ams->GetTray("0")->is_empty);
AMSinfo info;
REQUIRE(info.parse_ams_info(&machine, ams));
REQUIRE(info.cans.size() == 2);
CHECK_FALSE(info.cans[0].is_empty);
}
TEST_CASE("Bambu-style agents keep blank external slots as unknown", "[AMSItem]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.printer_agent_id = "bbl";
DevAmsTray empty_slot = machine.parse_vt_tray(json::parse(R"({
"id": "255", "tag_uid": "0000000000000000", "tray_info_idx": "", "tray_type": "", "tray_color": "00000000"
})"));
CHECK_FALSE(empty_slot.is_empty);
AMSinfo info;
info.parse_ext_info(&machine, empty_slot);
CHECK_FALSE(info.cans[0].is_empty);
}
+23 -12
View File
@@ -67,7 +67,7 @@ TEST_CASE("OrcaMqtt CONNECT packet - username/password (LAN form)", "[OrcaMqtt]"
// Auth precedence (spec O3): when a bearer_provider is configured, connect_and_read
// passes empty CONNECT credentials, so the packet must carry clean-session only and
// no username/password flags or payload fields. (The precedence branch itself lives
// in connect_and_read; the [.integration] cloud-style round trip exercises it live.)
// in connect_and_read; the cloud-style round trip test exercises it live.)
TEST_CASE("OrcaMqtt CONNECT omits creds when a bearer is configured", "[OrcaMqtt]") {
auto p = OrcaMqttConnection::make_connect_packet("cid", "", "", 60);
const size_t v = mqtt_varheader_offset(p);
@@ -115,17 +115,25 @@ TEST_CASE("OrcaMqtt start takes a Config", "[OrcaMqtt]") {
conn.stop();
}
TEST_CASE("MockBroker starts and reports a url", "[OrcaMqtt][.integration]") {
TEST_CASE("MockBroker starts and reports a url", "[OrcaMqtt]") {
orca_mqtt_test::MockBroker b;
CHECK(b.ws_url().rfind("ws://127.0.0.1:", 0) == 0);
CHECK(b.connect_count() == 0);
}
// --- End-to-end integration: OrcaMqttConnection against the in-process MockBroker.
// All hidden behind [.integration] (run explicitly). These prove a LAN-style config
// (CONNECT username/password) and a cloud-style config (bearer on the WS upgrade,
// no CONNECT creds) drive the *same* OrcaMqttConnection code path with identical
// assertions.
// --- End-to-end loopback: OrcaMqttConnection against the in-process MockBroker.
// These prove a LAN-style config (CONNECT username/password) and a cloud-style
// config (bearer on the WS upgrade, no CONNECT creds) drive the *same*
// OrcaMqttConnection code path with identical assertions.
// The client's SUBSCRIBE is written asynchronously; wait until the broker records it.
static bool wait_subscribed(orca_mqtt_test::MockBroker& broker, const std::string& topic) {
for (int i = 0; i < 200; ++i) {
if (broker.is_subscribed(topic)) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return false;
}
static void run_round_trip(bool use_tls_flag_only) {
orca_mqtt_test::MockBroker broker;
@@ -154,6 +162,7 @@ static void run_round_trip(bool use_tls_flag_only) {
},
[](bool,bool){}));
REQUIRE(conn.subscribe("dev-1"));
REQUIRE(wait_subscribed(broker, "device/dev-1/report"));
REQUIRE(conn.send_request("dev-1", R"({"pushing":{"command":"pushall","sequence_id":"20001"}})"));
broker.push_report("dev-1", R"({"print":{"command":"push_status","sequence_id":"20001","result":"success"}})");
@@ -179,10 +188,10 @@ static void run_round_trip(bool use_tls_flag_only) {
conn.stop();
}
TEST_CASE("OrcaMqtt round-trip — LAN-style config", "[OrcaMqtt][.integration]") { run_round_trip(false); }
TEST_CASE("OrcaMqtt round-trip — cloud-style config", "[OrcaMqtt][.integration]") { run_round_trip(true); }
TEST_CASE("OrcaMqtt round-trip — LAN-style config", "[OrcaMqtt]") { run_round_trip(false); }
TEST_CASE("OrcaMqtt round-trip — cloud-style config", "[OrcaMqtt]") { run_round_trip(true); }
TEST_CASE("OrcaMqtt keepalive runs while the connection is idle", "[OrcaMqtt][.integration]") {
TEST_CASE("OrcaMqtt keepalive runs while the connection is idle", "[OrcaMqtt]") {
orca_mqtt_test::MockBroker broker;
OrcaMqttConnection conn;
OrcaMqttConnection::Config cfg;
@@ -196,7 +205,7 @@ TEST_CASE("OrcaMqtt keepalive runs while the connection is idle", "[OrcaMqtt][.i
conn.stop();
}
TEST_CASE("OrcaMqtt reconnects and re-subscribes after a socket drop", "[OrcaMqtt][.integration]") {
TEST_CASE("OrcaMqtt reconnects and re-subscribes after a socket drop", "[OrcaMqtt]") {
orca_mqtt_test::MockBroker broker;
OrcaMqttConnection conn;
OrcaMqttConnection::Config cfg; cfg.url = broker.ws_url(); cfg.use_tls = false; cfg.username = "u"; cfg.password = "p";
@@ -206,6 +215,7 @@ TEST_CASE("OrcaMqtt reconnects and re-subscribes after a socket drop", "[OrcaMqt
[&](const std::string&, const std::string& p){ std::lock_guard<std::mutex> l(m); got.push_back(p); },
[](bool,bool){}));
REQUIRE(conn.subscribe("dev-1"));
REQUIRE(wait_subscribed(broker, "device/dev-1/report"));
broker.drop_client();
// the worker reconnects with ~1s backoff
@@ -214,6 +224,7 @@ TEST_CASE("OrcaMqtt reconnects and re-subscribes after a socket drop", "[OrcaMqt
CHECK(broker.connect_count() >= 2);
// a report after the reconnect must still be delivered -> the SUBSCRIBE was re-sent
REQUIRE(wait_subscribed(broker, "device/dev-1/report"));
broker.push_report("dev-1", R"({"print":{"command":"push_status","sequence_id":"20002"}})");
bool delivered = false;
for (int i = 0; i < 200 && !delivered; ++i) {
@@ -224,7 +235,7 @@ TEST_CASE("OrcaMqtt reconnects and re-subscribes after a socket drop", "[OrcaMqt
conn.stop();
}
TEST_CASE("OrcaMqtt auth rejection is terminal (no retry storm)", "[OrcaMqtt][.integration]") {
TEST_CASE("OrcaMqtt auth rejection is terminal (no retry storm)", "[OrcaMqtt]") {
orca_mqtt_test::MockBroker broker(/*refuse_auth=*/true);
OrcaMqttConnection conn;
OrcaMqttConnection::Config cfg; cfg.url = broker.ws_url(); cfg.use_tls = false; cfg.username = "u"; cfg.password = "bad";
@@ -538,7 +538,7 @@ TEST_CASE("send_message* reject when there is no connection", "[OrcaPrinterAgent
CHECK(agent.send_message("", "{}", 0, 0) == BAMBU_NETWORK_ERR_INVALID_HANDLE); // empty dev_id
}
TEST_CASE("send_message_to_printer publishes on the LAN connection", "[OrcaPrinterAgent][.integration]") {
TEST_CASE("send_message_to_printer publishes on the LAN connection", "[OrcaPrinterAgent]") {
orca_mqtt_test::MockBroker broker;
OrcaPrinterAgent agent("/tmp");
const auto ep = broker.host_port();