feat: Filament Track Switch (H2-series O2L-FTS) support

The Filament Track Switch (H2-series accessory, product code O2L-FTS) feeds
every AMS to both extruders through a two-track switch. Port full support
across the device layer, project config, and GUI.

Device / config:
- Model the switch-aware AMS binding (the set of extruders an AMS can feed
  and which input track A/B feeds it), switch readiness, the O2L-FTS firmware
  module, and the fun2 capability bit for checking a slice against installed
  hardware.
- Register has_filament_switcher and enable_filament_dynamic_map as project
  config that persists with the project and restores from a saved 3mf, and
  force both back to false on every project/printer/CLI load path. Live
  device sync is the only thing that sets them true.

GUI:
- Sidebar sync activates the switch from live device state, attributes each
  AMS to the extruder its input track feeds, shows a floating status icon
  (ready / not-calibrated), and surfaces a one-time tip / not-calibrated
  warning.
- Send dialog gains a non-blocking slice-vs-hardware mismatch warning and a
  blocking error when a slice needs dynamic nozzle mapping but the switch is
  missing or not set up.
- AMS load/unload guards, AMS-view routing glyph + un-calibrated banner +
  hidden external-spool road, and mapping-popup external-spool lockout.
- Filament pickers collapse the per-extruder split into a single deduplicated
  "AMS filaments" group with a smart-assign toggle when the switch is ready.
- Firmware-upgrade panel lists the O2L-FTS accessory and its version.
- Device-provided filament-change steps (ams.cfs) drive the change-step
  display when firmware sends them, including the three switch steps.
- "Load current filament" asks which extruder to feed via a
  FeedDirectionDialog when the switch is calibrated.

Inert without the accessory: every path is gated on the switch being
installed (MQTT aux bit 29, default off) or ready, both project flags default
false, and the per-extruder AMS attribution is byte-identical, so AMS state,
the send/load UI, and sliced g-code are unchanged for every printer that does
not report a Filament Track Switch.
This commit is contained in:
SoftFever
2026-07-09 18:57:05 +08:00
parent 26a0caff96
commit 0d31325df0
38 changed files with 1655 additions and 38 deletions

View File

@@ -564,6 +564,9 @@ struct Sidebar::priv
ScalableButton* m_printer_setting = nullptr;
wxStaticText * m_text_printer_settings = nullptr;
wxPanel* m_panel_printer_content = nullptr;
// Filament Track Switch status overlay: an icon floated over the left/single extruder AMS area,
// shown only when the switch is installed (green when ready, red when not calibrated).
wxStaticBitmap* extruder_separator_icon = nullptr;
ObjectList *m_object_list{ nullptr };
ObjectSettings *object_settings{ nullptr };
@@ -594,6 +597,19 @@ struct Sidebar::priv
bool switch_diameter(bool single);
void update_sync_status(const MachineObject* obj);
// Filament Track Switch (H2-family accessory): true only when the connected printer is the
// selected one, online, and reports the switch installed and calibrated.
bool is_fila_switch_ready();
// One-time info tip when the switch is ready, and a not-calibrated warning otherwise.
void show_filament_switcher_dialog(bool is_ready, bool is_manual);
void show_fila_switch_msg(bool ready);
bool fila_switch_warning_shown = false;
// Show/hide and reposition the switcher status icon; caches the last state to avoid churn.
void update_extruder_separator_icon(bool show, bool ready);
// Orca: BBS resets the switcher UI from DeviceManager::OnSelectedMachineChanged, which Orca lacks.
// Track the last-synced device id here so update_sync_status can detect a machine change.
std::string last_sync_dev_id;
#ifdef _WIN32
wxString btn_reslice_tip;
void show_rich_tip(const wxString& tooltip, wxButton* btn);
@@ -642,6 +658,18 @@ void Sidebar::priv::layout_printer(bool isBBL, bool isDual)
extruder_dual_sizer->AddSpacer(FromDIP(4));
extruder_dual_sizer->Add(right_extruder->sizer, 1, wxEXPAND, 0);
// Filament Track Switch status icon, floated over the extruder AMS area (positioned in
// update_extruder_separator_icon). Created hidden; a click re-shows the ready/not-ready tip.
if (!extruder_separator_icon) {
auto bitmap = ScalableBitmap(m_panel_printer_content, "fila_switch", 10);
extruder_separator_icon = new wxStaticBitmap(m_panel_printer_content, wxID_ANY, bitmap.bmp(), wxDefaultPosition, bitmap.GetBmpSize());
extruder_separator_icon->Hide();
extruder_separator_icon->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent& evt) {
show_fila_switch_msg(is_fila_switch_ready());
evt.Skip();
});
}
// single
extruder_single_sizer = single_extruder->sizer;
wxBoxSizer * extruder_sizer = new wxBoxSizer(wxVERTICAL);
@@ -1610,6 +1638,105 @@ std::optional<NozzleOption> Sidebar::priv::get_nozzle_options(MachineObject* obj
return nozzle_option;
}
bool Sidebar::priv::is_fila_switch_ready()
{
if (!wxGetApp().plater()->is_same_printer_for_connected_and_selected(false))
return false;
auto device_manager = wxGetApp().getDeviceManager();
if (device_manager == nullptr)
return false;
auto obj = device_manager->get_selected_machine();
if (obj == nullptr || !obj->is_online())
return false;
auto fila_switch = obj->GetFilaSwitch();
if (fila_switch == nullptr)
return false;
return fila_switch->IsInstalled() && fila_switch->IsReady();
}
void Sidebar::priv::show_fila_switch_msg(bool ready)
{
wxString msg = ready ? _L("Filament switcher detected. All AMS filaments are now available for both extruders. "
"The slicer will auto-assign for optimal printing. ") :
_L("A filament switcher is detected but not calibrated and thus currently unavailable. "
"Please calibrate it on the printer and synchronize before use. ");
long style = ready ? (wxICON_INFORMATION | wxOK) : (wxICON_WARNING | wxOK);
// Orca: drop the vendor "Learn more" tracking link; there is no Orca help page for the switch yet.
MessageDialog dlg(static_cast<wxWindow *>(wxGetApp().mainframe), msg, _L("Tips"), style);
dlg.CenterOnParent();
dlg.ShowModal();
}
void Sidebar::priv::show_filament_switcher_dialog(bool is_ready, bool is_manual)
{
if (is_ready) {
// Show the "switch is ready" tip only once, ever.
if (wxGetApp().app_config->get("show_fila_switch_tips") == "true") {
wxGetApp().app_config->set("show_fila_switch_tips", "false");
show_fila_switch_msg(true);
}
} else {
// A manual sync always warns; an automatic update warns once per session.
if (is_manual || !fila_switch_warning_shown) {
fila_switch_warning_shown = true;
show_fila_switch_msg(false);
}
}
}
void Sidebar::priv::update_extruder_separator_icon(bool show, bool ready)
{
if (!extruder_separator_icon)
return;
static bool last_show = false;
static bool last_ready = false;
static bool first_call = true;
if (!first_call && last_show == show && last_ready == ready)
return;
first_call = false;
last_show = show;
last_ready = ready;
// Never overlay the icon when the connected printer is not the selected preset.
if (!wxGetApp().plater()->is_same_printer_for_connected_and_selected(false)) {
extruder_separator_icon->Hide();
if (m_panel_printer_content)
m_panel_printer_content->Refresh();
return;
}
if (show && left_extruder && left_extruder->hsizer_ams && left_extruder->sizer) {
// Orca: center the icon over the seam between the two extruder cards, vertically aligned with
// the AMS row. left_extruder and the icon share m_panel_printer_content as parent, so
// left_extruder->GetPosition() and the icon position live in the same coordinate space.
wxPoint left_box_pos = left_extruder->GetPosition();
wxPoint ams_local_pos = left_extruder->hsizer_ams->GetPosition();
wxSize left_size = left_extruder->sizer->GetSize();
wxSize ams_size = left_extruder->hsizer_ams->GetSize();
wxSize icon_size = extruder_separator_icon->GetSize();
int ams_abs_y = left_box_pos.y + ams_local_pos.y + FromDIP(4);
int center_x = left_size.GetWidth() + FromDIP(6);
int center_y = ams_abs_y + (ams_size.GetHeight() - icon_size.GetHeight()) / 2;
center_x -= icon_size.GetWidth() / 2;
center_y -= icon_size.GetHeight() / 2;
extruder_separator_icon->SetPosition(wxPoint(center_x, center_y));
auto normal_bitmap = ScalableBitmap(m_panel_printer_content, "fila_switch", 10);
auto error_bitmap = ScalableBitmap(m_panel_printer_content, "fila_switch_error", 10);
extruder_separator_icon->SetBitmap(ready ? normal_bitmap.bmp() : error_bitmap.bmp());
extruder_separator_icon->Show();
extruder_separator_icon->Raise();
} else {
extruder_separator_icon->Hide();
}
if (m_panel_printer_content)
m_panel_printer_content->Refresh();
}
bool Sidebar::priv::sync_extruder_list(bool &only_external_material, bool is_manual)
{
MachineObject *obj = wxGetApp().getDeviceManager()->get_selected_machine();
@@ -1728,18 +1855,29 @@ bool Sidebar::priv::sync_extruder_list(bool &only_external_material, bool is_man
}
int deputy_4 = 0, main_4 = 0, deputy_1 = 0, main_1 = 0;
const bool switch_ready = is_fila_switch_ready();
for (auto ams : obj->GetFilaSystem()->GetAmsList()) {
// Main (first) extruder at right
if (ams.second->GetExtruderId() == 0) {
if (ams.second->GetAmsType() == DevAms::N3S) // N3S
++main_1;
else
++main_4;
} else if (ams.second->GetExtruderId() == 1) {
if (ams.second->GetAmsType() == DevAms::N3S) // N3S
++deputy_1;
else
++deputy_4;
for (int extruder_id : ams.second->GetBindedExtruderSet()) {
// With the switch installed every AMS binds to both extruders; once it is ready, attribute
// each to the extruder its input track feeds so the per-extruder counts stay correct. Without
// a switch the binding set is the single extruder and the filter is skipped, matching the
// pre-switch counts.
if (switch_ready) {
auto switcher_pos = ams.second->GetSwitcherPos();
if (!switcher_pos)
continue;
int switcher_id = obj->is_main_extruder_on_left() ? (1 - static_cast<int>(switcher_pos.value()))
: static_cast<int>(switcher_pos.value());
if (extruder_id != switcher_id)
continue;
}
const bool is_n3s = ams.second->GetAmsType() == DevAms::N3S;
// Main (first) extruder is id 0, deputy is id 1.
if (extruder_id == 0) {
if (is_n3s) ++main_1; else ++main_4;
} else if (extruder_id == 1) {
if (is_n3s) ++deputy_1; else ++deputy_4;
}
}
}
only_external_material = !obj->GetFilaSystem()->HasAms();
@@ -1774,6 +1912,22 @@ bool Sidebar::priv::sync_extruder_list(bool &only_external_material, bool is_man
printer_tab->set_extruder_volume_type(idx, target_types[idx]);
}
if (extruder_nums > 1) {
auto fila_switch = obj->GetFilaSwitch();
if (fila_switch->IsInstalled())
show_filament_switcher_dialog(fila_switch->IsReady(), is_manual);
else
fila_switch_warning_shown = false;
}
// Copy the live switch state into the project so slicing and the send dialog honor it. Both
// resolve to false unless the switch is installed and calibrated, so nothing changes without one.
auto &project_config = wxGetApp().preset_bundle->project_config;
if (auto *dynamic_filament = project_config.opt<ConfigOptionBool>("enable_filament_dynamic_map"))
dynamic_filament->value = is_fila_switch_ready();
if (auto *has_switcher = project_config.opt<ConfigOptionBool>("has_filament_switcher"))
has_switcher->value = is_fila_switch_ready();
BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " finish sync_extruder_list";
return true;
}
@@ -1791,6 +1945,7 @@ void Sidebar::priv::update_sync_status(const MachineObject *obj)
right_extruder->sync_ams(nullptr, {}, {});
single_extruder->ShowBadge(false);
single_extruder->sync_ams(nullptr, {}, {});
update_extruder_separator_icon(false, false);
//btn_sync_printer->SetBorderColor(not_synced_colour);
//btn_sync_printer->SetIcon("printer_sync");
m_printer_bbl_sync->SetBitmap_("printer_sync_not");
@@ -1801,6 +1956,16 @@ void Sidebar::priv::update_sync_status(const MachineObject *obj)
return;
}
// Orca: replaces BBS's reset_fila_switch hook on DeviceManager::OnSelectedMachineChanged (absent
// in Orca). Selection/MQTT updates all funnel through here, so a change of the selected device id
// resets the switcher icon and the once-per-session not-ready warning for the new printer.
const std::string cur_dev_id = obj->get_dev_id();
if (cur_dev_id != last_sync_dev_id) {
last_sync_dev_id = cur_dev_id;
update_extruder_separator_icon(false, false);
fila_switch_warning_shown = false;
}
PresetBundle *preset_bundle = wxGetApp().preset_bundle;
if (!preset_bundle) {
clear_all_sync_status();
@@ -1852,6 +2017,21 @@ void Sidebar::priv::update_sync_status(const MachineObject *obj)
if (extruder_nums != obj->GetExtderSystem()->GetTotalExtderCount())
return;
// Filament Track Switch: only ever surfaced on multi-extruder machines. When installed, tip/warn
// and show the status icon (green ready / red not-calibrated); when absent the icon stays hidden.
// Inert on any printer without a switch: GetFilaSwitch()->IsInstalled() is false.
const bool fila_switch_flag = is_fila_switch_ready();
if (extruder_nums > 1) {
auto fila_switch = obj->GetFilaSwitch();
if (fila_switch->IsInstalled()) {
show_filament_switcher_dialog(fila_switch->IsReady(), false);
update_extruder_separator_icon(true, fila_switch->IsReady());
} else {
update_extruder_separator_icon(false, false);
fila_switch_warning_shown = false;
}
}
std::vector<ExtruderInfo> extruder_infos(extruder_nums);
std::vector<int> nozzle_volume_types = wxGetApp().preset_bundle->project_config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type")->values;
//for (size_t i = 0; i < nozzle_volume_types.size(); ++i) {
@@ -1893,16 +2073,34 @@ void Sidebar::priv::update_sync_status(const MachineObject *obj)
machine_extruder_infos[extruder.GetExtId()].diameter = extruder.GetNozzleDiameter();
}
for (auto &item : obj->GetFilaSystem()->GetAmsList()) {
if (item.second->GetExtruderId() >= machine_extruder_infos.size())
int extruder_id;
// With the switch ready every AMS feeds both extruders, so attribute each to the extruder its
// input track feeds. Without a switch the binding is a single extruder
// (GetUniqueBindedExtruderId() == GetExtruderId()) and the switcher position is empty, so this
// yields the same attribution as before.
if (fila_switch_flag) {
auto switcher_pos = item.second->GetSwitcherPos();
if (!switcher_pos)
continue;
extruder_id = obj->is_main_extruder_on_left() ? (1 - static_cast<int>(switcher_pos.value()))
: static_cast<int>(switcher_pos.value());
} else {
const auto &uniq_extruder_id = item.second->GetUniqueBindedExtruderId();
if (!uniq_extruder_id)
continue;
extruder_id = uniq_extruder_id.value();
}
if (extruder_id >= machine_extruder_infos.size())
continue;
if (item.second->GetAmsType() == DevAms::N3S)
{ // N3S
machine_extruder_infos[item.second->GetExtruderId()].ams_1++;
machine_extruder_infos[item.second->GetExtruderId()].ams_v1.push_back(item.second);
machine_extruder_infos[extruder_id].ams_1++;
machine_extruder_infos[extruder_id].ams_v1.push_back(item.second);
} else {
machine_extruder_infos[item.second->GetExtruderId()].ams_4++;
machine_extruder_infos[item.second->GetExtruderId()].ams_v4.push_back(item.second);
machine_extruder_infos[extruder_id].ams_4++;
machine_extruder_infos[extruder_id].ams_v4.push_back(item.second);
}
}
@@ -1956,6 +2154,20 @@ void Sidebar::priv::update_sync_status(const MachineObject *obj)
// btn_sync_printer->SetIcon("printer_sync");
m_printer_bbl_sync->SetBitmap_("printer_sync_not");
}
// When the switch is ready every AMS filament is available to both extruders; refresh the
// filament combos whenever the AMS list changes so both extruders pick up the full set.
if (fila_switch_flag) {
static std::map<int, DynamicPrintConfig> last_filament_ams_list;
bool is_same_ams_list = last_filament_ams_list == wxGetApp().preset_bundle->filament_ams_list;
if (!is_same_ams_list)
last_filament_ams_list = wxGetApp().preset_bundle->filament_ams_list;
const auto print_tech = wxGetApp().preset_bundle->printers.get_edited_preset().printer_technology();
if (print_tech == ptFFF && !is_same_ams_list) {
for (PlaterPresetComboBox *cb : combos_filament)
cb->update();
}
}
}
void Sidebar::update_sync_ams_btn_enable(wxUpdateUIEvent &e)
@@ -2408,6 +2620,24 @@ Sidebar::Sidebar(Plater *parent)
p->left_extruder = new ExtruderGroup(p->m_panel_printer_content, 0, _L("Left Nozzle"));
p->right_extruder = new ExtruderGroup(p->m_panel_printer_content, 1, _L("Right Nozzle"));
p->single_extruder = new ExtruderGroup(p->m_panel_printer_content, -1, _L("Nozzle"));
// Orca: keep the floating switcher icon aligned with the left extruder's AMS row when the
// extruder card is resized (the overlay is absolutely positioned, not managed by a sizer).
p->left_extruder->Bind(wxEVT_SIZE, [this](wxSizeEvent &evt) {
if (p->extruder_separator_icon && p->extruder_separator_icon->IsShown()) {
wxPoint left_box_pos = p->left_extruder->GetPosition();
wxPoint ams_local_pos = p->left_extruder->hsizer_ams->GetPosition();
wxSize left_size = p->left_extruder->sizer->GetSize();
wxSize ams_size = p->left_extruder->hsizer_ams->GetSize();
wxSize icon_size = p->extruder_separator_icon->GetSize();
int ams_abs_y = left_box_pos.y + ams_local_pos.y + FromDIP(4);
int center_x = left_size.GetWidth() + FromDIP(6) - icon_size.GetWidth() / 2;
int center_y = ams_abs_y + (ams_size.GetHeight() - icon_size.GetHeight()) / 2 - icon_size.GetHeight() / 2;
p->extruder_separator_icon->SetPosition(wxPoint(center_x, center_y));
if (p->m_panel_printer_content)
p->m_panel_printer_content->Refresh();
}
evt.Skip();
});
auto switch_diameter = [this](wxCommandEvent & evt) {
auto extruder = dynamic_cast<ExtruderGroup *>(dynamic_cast<ComboBox *>(evt.GetEventObject())->GetParent());
p->is_switching_diameter = true;
@@ -3821,6 +4051,16 @@ bool Sidebar::sync_extruder_list()
return p->sync_extruder_list(only_external_material);
}
bool Sidebar::is_fila_switch_ready()
{
return p->is_fila_switch_ready();
}
void Sidebar::reset_fila_switch()
{
p->update_extruder_separator_icon(false, false);
}
bool Sidebar::need_auto_sync_extruder_list_after_connect_priner(const MachineObject *obj)
{
if(!obj)
@@ -6947,6 +7187,14 @@ std::vector<size_t> Plater::priv::load_files(const std::vector<fs::path>& input_
}
}
}
// Filament Track Switch state is derived live from the connected
// printer; a loaded project must not carry a stale installed/active
// flag, so clear both and let device sync re-derive them.
if (auto* has_switcher = proj_cfg.opt<ConfigOptionBool>("has_filament_switcher"))
has_switcher->value = false;
if (auto* dynamic_map = proj_cfg.opt<ConfigOptionBool>("enable_filament_dynamic_map"))
dynamic_map->value = false;
}
// Update filament combobox after loading config
wxGetApp().plater()->sidebar().update_presets(Preset::TYPE_FILAMENT);