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

@@ -742,6 +742,63 @@ void AMSExtImage::doRender(wxDC& dc)
}
// Orca: SwitcherImage — routing glyph drawn when a Filament Track Switch is installed.
void SwitcherImage::paintEvent(wxPaintEvent &evt)
{
wxPaintDC dc(this);
render(dc);
}
void SwitcherImage::render(wxDC &dc)
{
#ifdef __WXMSW__
wxSize size = GetSize();
wxMemoryDC memdc;
wxBitmap bmp(size.x, size.y);
memdc.SelectObject(bmp);
memdc.Blit({0, 0}, size, &dc, {0, 0});
{
wxGCDC dc2(memdc);
doRender(dc2);
}
memdc.SelectObject(wxNullBitmap);
dc.DrawBitmap(bmp, 0, 0);
#else
doRender(dc);
#endif
}
void SwitcherImage::doRender(wxDC &dc)
{
auto size = GetSize();
if (m_show_state){
dc.SetPen(*wxTRANSPARENT_PEN);
dc.SetBrush(*wxWHITE);
dc.DrawBitmap(m_switcher.bmp(), wxPoint((size.x - m_switcher.GetBmpSize().x) / 2, 0));
}
Layout();
}
SwitcherImage::SwitcherImage(wxWindow *parent, wxWindowID id, string file_name, const wxSize& size, const wxPoint &pos)
{
wxWindow::Create(parent, id, pos, size);
SetBackgroundColour(StateColor::darkModeColorFor(*wxWHITE));
m_show_state = true;
m_switcher = ScalableBitmap(this, file_name, 16);
m_file_name = file_name;
SetSize(size);
SetMinSize(size);
SetMaxSize(size);
Bind(wxEVT_PAINT, &SwitcherImage::paintEvent, this);
}
SwitcherImage::~SwitcherImage() {}
//DevAms Extruder
AMSextruder::AMSextruder(wxWindow *parent, wxWindowID id, int nozzle_num, const wxPoint &pos, const wxSize &size)
{
@@ -3577,6 +3634,18 @@ void AmsItem::doRender(wxDC& dc)
}
}
// Orca: toggle the road segment below the item (m_panel_road). Used to drop the external-spool
// road when a Filament Track Switch is installed. No-op (returns false) when already in state.
bool AmsItem::ShowRoad(bool show)
{
if (!m_panel_road) return false;
if (m_panel_road->IsShown() == show) return false;
m_panel_road->Show(show);
Layout();
Refresh();
return true;
}
void AmsItem::RenderLiteRoad(wxDC& dc, wxSize size) {
auto end_top = size.x - FromDIP(3);
if (m_panel_pos == AMSPanelPos::RIGHT_PANEL){
@@ -3706,4 +3775,342 @@ void AmsItem::show_sn_value(bool show)
}
}
DevExtruderImage::DevExtruderImage(wxWindow *parent, wxWindowID id, int extruder_num, const wxPoint &pos, const wxSize &size) : wxWindow(parent, id, pos, wxDefaultSize), m_extruder_num(extruder_num)
{
// wxWindow::Create(parent, id, pos, wxSize(FromDIP(45), FromDIP(112)));
SetBackgroundColour(*wxWHITE);
SetSize(wxSize(FromDIP(48), FromDIP(112)));
SetMinSize(wxSize(FromDIP(48), FromDIP(112)));
SetMaxSize(wxSize(FromDIP(48), FromDIP(112)));
m_left_extruder_active_filled = new ScalableBitmap(this, "left_extruder_active_filled", 62);
m_left_extruder_active_empty = new ScalableBitmap(this, "left_extruder_active_empty", 62);
m_left_extruder_unactive_filled = new ScalableBitmap(this, "left_extruder_unactive_filled", 62);
m_left_extruder_unactive_empty = new ScalableBitmap(this, "left_extruder_unactive_empty", 62);
m_right_extruder_active_filled = new ScalableBitmap(this, "right_extruder_active_filled", 62);
m_right_extruder_active_empty = new ScalableBitmap(this, "right_extruder_active_empty", 62);
m_right_extruder_unactive_filled = new ScalableBitmap(this, "right_extruder_unactive_filled", 62);
m_right_extruder_unactive_empty = new ScalableBitmap(this, "right_extruder_unactive_empty", 62);
m_extruder_single_nozzle_empty_load = new ScalableBitmap(this, "monitor_extruder_empty_load", 106);
m_extruder_single_nozzle_empty_unload = new ScalableBitmap(this, "monitor_extruder_empty_unload", 106);
m_extruder_single_nozzle_filled_load = new ScalableBitmap(this, "monitor_extruder_filled_load", 106);
m_extruder_single_nozzle_filled_unload = new ScalableBitmap(this, "monitor_extruder_filled_unload", 106);
Bind(wxEVT_PAINT, &DevExtruderImage::paintEvent, this);
}
void DevExtruderImage::msw_rescale()
{
m_left_extruder_active_filled->msw_rescale();
m_left_extruder_active_empty->msw_rescale();
m_left_extruder_unactive_filled->msw_rescale();
m_left_extruder_unactive_empty->msw_rescale();
m_right_extruder_active_filled->msw_rescale();
m_right_extruder_active_empty->msw_rescale();
m_right_extruder_unactive_filled->msw_rescale();
m_right_extruder_unactive_empty->msw_rescale();
m_extruder_single_nozzle_empty_load->msw_rescale();
m_extruder_single_nozzle_empty_unload->msw_rescale();
m_extruder_single_nozzle_filled_load->msw_rescale();
m_extruder_single_nozzle_filled_unload->msw_rescale();
Layout();
Refresh();
}
void DevExtruderImage::render(wxDC &dc)
{
#ifdef __WXMSW__
wxSize size = GetSize();
wxMemoryDC memdc;
wxBitmap bmp(size.x, size.y);
memdc.SelectObject(bmp);
memdc.Blit({0, 0}, size, &dc, {0, 0});
{
wxGCDC dc2(memdc);
doRender(dc2);
}
memdc.SelectObject(wxNullBitmap);
dc.DrawBitmap(bmp, 0, 0);
#else
doRender(dc);
#endif
}
void DevExtruderImage::doRender(wxDC &dc)
{
auto size = GetSize();
auto pot = wxPoint(size.x / 2, (size.y - m_left_extruder_active_filled->GetBmpSize().y) / 2);
if (m_extruder_num >= 2)
{
ScalableBitmap *left_extruder_bmp{nullptr};
ScalableBitmap *right_extruder_bmp{nullptr};
switch (m_right_ext_state)
{
case DevExtruderState::FILLED_LOAD:
right_extruder_bmp = current_extruder_loc == "right" ? m_right_extruder_active_filled : m_right_extruder_unactive_filled;
break;
case DevExtruderState::FILLED_UNLOAD:
right_extruder_bmp = current_extruder_loc == "right" ? m_right_extruder_active_filled : m_right_extruder_unactive_filled;
break;
case DevExtruderState::EMPTY_LOAD:
if (current_extruder_loc.empty())
{
right_extruder_bmp = m_right_extruder_active_empty;
}
else
{
right_extruder_bmp = current_extruder_loc == "right" ? m_right_extruder_active_empty : m_right_extruder_unactive_empty;
}
break;
case DevExtruderState::EMPTY_UNLOAD:
right_extruder_bmp = current_extruder_loc == "right" ? m_right_extruder_active_empty : m_right_extruder_unactive_empty;
break;
default: break;
}
switch (m_left_ext_state)
{
case DevExtruderState::FILLED_LOAD:
left_extruder_bmp = current_extruder_loc == "left" ? m_left_extruder_active_filled : m_left_extruder_unactive_filled;
break;
case DevExtruderState::FILLED_UNLOAD:
left_extruder_bmp = current_extruder_loc == "left" ? m_left_extruder_active_filled : m_left_extruder_unactive_filled;
break;
case DevExtruderState::EMPTY_LOAD:
if (current_extruder_loc.empty())
{
left_extruder_bmp = m_left_extruder_active_empty;
}
else
{
left_extruder_bmp = current_extruder_loc == "left" ? m_left_extruder_active_empty : m_left_extruder_unactive_empty;
}
break;
case DevExtruderState::EMPTY_UNLOAD:
left_extruder_bmp = current_extruder_loc == "left" ? m_left_extruder_active_empty : m_left_extruder_unactive_empty;
break;
default: break;
}
if (left_extruder_bmp) { dc.DrawBitmap(left_extruder_bmp->bmp(), pot.x - left_extruder_bmp->GetBmpWidth(), pot.y); }
if (right_extruder_bmp) { dc.DrawBitmap(right_extruder_bmp->bmp(), pot.x, pot.y); }
}
else
{
ScalableBitmap *extruder_bmp = nullptr;
switch (m_single_ext_state)
{
case DevExtruderState::FILLED_LOAD:
extruder_bmp = m_extruder_single_nozzle_filled_load;
break;
case DevExtruderState::FILLED_UNLOAD:
extruder_bmp = m_extruder_single_nozzle_filled_unload;
break;
case DevExtruderState::EMPTY_LOAD:
extruder_bmp = m_extruder_single_nozzle_empty_load;
break;
case DevExtruderState::EMPTY_UNLOAD:
extruder_bmp = m_extruder_single_nozzle_empty_unload;
break;
default: break;
}
if (extruder_bmp) { dc.DrawBitmap(extruder_bmp->bmp(), pot.x - extruder_bmp->GetBmpWidth() / 2, (size.y - extruder_bmp->GetBmpHeight()) / 2); }
}
}
FeedDirectionDialog::FeedDirectionDialog(wxWindow* parent,
const int extruderNum,
const std::string& printer_type)
: wxDialog(parent, wxID_ANY, "", wxDefaultPosition, wxDefaultSize),
m_extruder_num(extruderNum),
m_printer_type(printer_type)
{
SetBackgroundColour(wxColour("#FFFFFF"));
SetMaxSize(wxSize(FromDIP(360), FromDIP(207)));
SetMinSize(wxSize(FromDIP(360), FromDIP(207)));
SetSize(wxSize(FromDIP(360), FromDIP(207)));
wxBoxSizer* mainSizer = new wxBoxSizer(wxVERTICAL);
wxGridSizer* topSizer = new wxGridSizer (1, 3, FromDIP(5), 0);
m_radioHelper = new wxRadioButton(this, wxID_ANY, wxT(""), wxDefaultPosition, wxDefaultSize, wxRB_GROUP);
m_leftRadio = new wxRadioButton(this, wxID_ANY,
_L(DevPrinterConfigUtil::get_toolhead_display_name(m_printer_type, DEPUTY_EXTRUDER_ID, ToolHeadComponent::Extruder, ToolHeadNameCase::TitleCase, true)));
m_rightRadio = new wxRadioButton(this, wxID_ANY,
_L(DevPrinterConfigUtil::get_toolhead_display_name(m_printer_type, MAIN_EXTRUDER_ID, ToolHeadComponent::Extruder, ToolHeadNameCase::TitleCase, true)));
m_radioHelper->Show(false);
m_radioHelper->SetCanFocus(false);
m_leftRadio->SetForegroundColour(*wxBLACK);
m_rightRadio->SetForegroundColour(*wxBLACK);
topSizer->Add(m_leftRadio, 0, wxALIGN_CENTER | wxALL, FromDIP(20));
m_extruderImage = new DevExtruderImage(this, wxID_ANY, m_extruder_num);
topSizer->Add(m_extruderImage, 0, wxALIGN_CENTER | wxALL, FromDIP(5));
topSizer->Add(m_rightRadio, 0, wxALIGN_CENTER | wxALL, FromDIP(20));
mainSizer->AddStretchSpacer(1);
mainSizer->Add(topSizer, 1, wxEXPAND);
mainSizer->AddStretchSpacer(1);
wxBoxSizer* bottomSizer = new wxBoxSizer(wxHORIZONTAL);
m_confirmBtn = new Button(this, _L("Confirm"));
m_confirmBtn->SetSize(wxSize(FromDIP(80), FromDIP(32)));
m_confirmBtn->Enable(false);
m_confirmBtn->SetFont(::Label::Body_14);
bottomSizer->Add(m_confirmBtn, 0, wxALIGN_RIGHT);
mainSizer->Add(bottomSizer, 0, wxALIGN_RIGHT | wxBOTTOM | wxRIGHT, FromDIP(10));
SetSizer(mainSizer);
Layout();
Centre(wxBOTH);
m_lastChecked = m_radioHelper;
m_confirmBtn->Bind(wxEVT_BUTTON, &FeedDirectionDialog::OnConfirm, this);
m_leftRadio->Bind(wxEVT_RADIOBUTTON, &FeedDirectionDialog::OnRadioClicked, this);
m_rightRadio->Bind(wxEVT_RADIOBUTTON, &FeedDirectionDialog::OnRadioClicked, this);
}
void FeedDirectionDialog::OnConfirm(wxCommandEvent& event)
{
EndModal(wxID_OK);
}
void FeedDirectionDialog::OnRadioClicked(wxCommandEvent& evt)
{
auto clicked = static_cast<wxRadioButton*>(evt.GetEventObject());
m_load_extruder_id = std::nullopt;
if (clicked == m_lastChecked)
{
m_radioHelper->SetValue(true);
m_lastChecked = m_radioHelper;
m_confirmBtn->Enable(false);
m_extruderImage->update(DevExtruderState::EMPTY_LOAD, DevExtruderState::EMPTY_LOAD);
m_extruderImage->setExtruderUsed("");
}
else
{
clicked->SetValue(true);
m_lastChecked = clicked;
m_confirmBtn->Enable(true);
if (clicked == m_leftRadio)
{
m_extruderImage->update(DevExtruderState::FILLED_LOAD, DevExtruderState::EMPTY_LOAD);
m_extruderImage->setExtruderUsed("left");
m_load_extruder_id = 1;
{
SetTitle(wxString::Format(_L("Load %s to ") + _L(DevPrinterConfigUtil::get_toolhead_display_name(m_printer_type, DEPUTY_EXTRUDER_ID, ToolHeadComponent::Extruder, ToolHeadNameCase::LowerCase)), m_filament_id));
}
}
else if (clicked == m_rightRadio)
{
m_extruderImage->update(DevExtruderState::EMPTY_LOAD, DevExtruderState::FILLED_LOAD);
m_extruderImage->setExtruderUsed("right");
m_load_extruder_id = 0;
{
SetTitle(wxString::Format(_L("Load %s to ") + _L(DevPrinterConfigUtil::get_toolhead_display_name(m_printer_type, MAIN_EXTRUDER_ID, ToolHeadComponent::Extruder, ToolHeadNameCase::LowerCase)), m_filament_id));
}
}
}
m_leftRadio->Refresh();
m_rightRadio->Refresh();
Fit();
Update();
}
std::optional<int> FeedDirectionDialog::GetExtruderID()
{
if (m_extruderImage)
{
return m_load_extruder_id;
}
return std::nullopt;
}
wxString FeedDirectionDialog::calcTrayName(MachineObject* obj, const std::string& amsID, const std::string& slotID)
{
if (amsID.empty() || slotID.empty() || !obj)
return wxString();
auto filaSys = obj->GetFilaSystem();
if (!filaSys)
return wxString();
DevAms* ams = filaSys->GetAmsById(amsID);
if (!ams)
return wxString();
int ams_id_int = std::stoi(amsID);
int slot_id_int = std::stoi(slotID);
int tray_id = 0;
// Orca: DevAms::AmsType is the local enum (AMS / AMS_LITE / N3F / N3S); it has no EXT_SPOOL
// member, and external/virtual spools are not real DevAms objects (GetAmsById returns nullptr
// above), so the EXT_SPOOL branch from upstream is unnecessary here.
if (ams->GetAmsType() == DevAms::AMS || ams->GetAmsType() == DevAms::AMS_LITE || ams->GetAmsType() == DevAms::N3F) {
tray_id = ams_id_int * 4 + slot_id_int;
} else if (ams->GetAmsType() == DevAms::N3S) {
tray_id = ams_id_int + slot_id_int;
} else {
return wxString();
}
return wxGetApp().transition_tridid(tray_id);
}
void FeedDirectionDialog::SetExtruderMapping(MachineObject* obj,
const std::string& currAmsId,
const std::string& currSlotId,
const std::vector<std::pair<std::string, std::string>>& extruderSlots)
{
wxString filamentID = calcTrayName(obj, currAmsId, currSlotId);
if (filamentID.empty())
return;
m_filament_id = filamentID;
SetTitle(wxString::Format(_L("Load %s to "), filamentID));
std::vector<wxString> extruderMapping(extruderSlots.size());
for (size_t i = 0; i < extruderSlots.size(); ++i) {
if (!extruderSlots[i].first.empty())
extruderMapping[i] = calcTrayName(obj, extruderSlots[i].first, extruderSlots[i].second);
}
if (extruderMapping.size() > 1 && extruderMapping[1] == filamentID) //left extruder
{
m_leftRadio->Enable(false);
m_confirmBtn->Enable(false);
m_extruderImage->update(DevExtruderState::EMPTY_LOAD, DevExtruderState::EMPTY_LOAD);
m_extruderImage->setExtruderUsed("right");
}
else if (!extruderMapping.empty() && extruderMapping[0] == filamentID) //right extruder
{
m_rightRadio->Enable(false);
m_confirmBtn->Enable(false);
m_extruderImage->update(DevExtruderState::EMPTY_LOAD, DevExtruderState::EMPTY_LOAD);
m_extruderImage->setExtruderUsed("left");
}
else
{
m_radioHelper->SetValue(true);
m_lastChecked = m_radioHelper;
m_confirmBtn->Enable(false);
m_extruderImage->update(DevExtruderState::EMPTY_LOAD, DevExtruderState::EMPTY_LOAD);
m_extruderImage->setExtruderUsed("");
}
}
}} // namespace Slic3r::GUI