Files
bambuddy/frontend/src/components/FeedDirectionModal.tsx
T
maziggy 9500c046c0 Ask which nozzle to feed when a Filament Track Switch is fitted
Load and Unload in the AMS slot menu did nothing on an H2C with the switch
fitted. The ams_change_filament command carries an optional extruder_id and
Bambuddy never sent it. That is correct on every printer without the switch,
and is what BambuStudio does there too -- each AMS is wired to one hotend, so
the firmware works the target out for itself and an explicit value would only
be a guess at something it already knows. Fit the switch and every AMS is
bound to one of its two inlets instead, either hotend is reachable from any
slot, and a command naming neither leaves the firmware nothing to act on. It
was discarded in silence.

Load now asks which hotend to feed, on the same terms as Bambu Studio: no
preselection, so a stray Enter cannot feed the wrong one, and the hotend
already fed from that very slot greyed out. Printers without a switch send a
byte-identical command and still load in one click. A switch fitted but not
yet set up -- any AMS still unassigned to an inlet -- refuses the load up
front rather than publishing one the firmware will drop, mirroring
DevFilaSwitch::IsReady, which likewise demands a switcher position on every
AMS.

Unload was addressed at the same time. It was aimed with tray_now, a single
value for the whole printer, so on any dual-nozzle machine with both hotends
loaded it unloaded whichever that field happened to name regardless of which
slot's menu was used. It now names the slot and resolves the holding hotend
from device.extruder.info, previously read for temperatures only. That
resolution is gated on the printer having reported two extruders:
single-nozzle machines do send the block, but nobody has read a single-nozzle
snow value off the wire, and staking every X1C, P1S and A1 unload on an
unverified encoding buys nothing where tray_now is already unambiguous.

Both new state fields ride the WebSocket and are in the broadcast key, and
both are computed in the REST status route as well -- that response is what
the page has before any push arrives, and leaving them at their defaults
would have told a correctly set-up machine that its switch was not set up.

Verified on H2C-1, AMS-A slot 3: loaded and unloaded from each hotend in
turn, all four correct. Covered by 18 MQTT unit tests, 4 status-dict tests,
7 integration tests and 6 component tests.

Two known stragglers, both deliberately left alone. Load on an AMS-HT slot
has never worked -- an HT unit is addressed by its unit id rather than
ams*4+slot, which these endpoints do not accept -- so unload there keeps the
printer-wide form it always used instead of gaining a slot it cannot name.
And a slot's K-profile still follows the AMS's plumbing rather than the
nozzle just loaded, so loading to the far hotend leaves the other one's
calibration bound; that is the same per-nozzle problem the filament and
K-profile redesign is scoped to fix.
2026-08-26 12:04:42 +02:00

140 lines
5.3 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import type { ExtruderSlot } from '../api/client';
// Hotend ids as the firmware numbers them. Mirrors BambuStudio's
// MAIN_EXTRUDER_ID / DEPUTY_EXTRUDER_ID and `fts_routing.py` on the backend.
const RIGHT_EXTRUDER = 0;
const LEFT_EXTRUDER = 1;
interface FeedDirectionModalProps {
// Human-readable name of the slot being loaded, e.g. "AMS-A 3".
slotLabel: string;
// The slot's own coordinates, used to spot the hotend already holding it.
amsId: number;
slotId: number;
extruderSlots: Record<string, ExtruderSlot>;
isLoading?: boolean;
onConfirm: (extruderId: number) => void;
onCancel: () => void;
}
/**
* Asks which hotend to feed a slot into, for printers with a Filament Track
* Switch fitted.
*
* Without a switch each AMS is wired to one hotend and the firmware works the
* target out for itself, so the load command carries no hotend at all. With one
* fitted, every AMS is bound to a switch *inlet* instead and either hotend is
* reachable — the firmware then has nothing to infer from and drops a command
* that does not name one. BambuStudio asks the same question in the same place
* (`FeedDirectionDialog`), including leaving Confirm disabled until a side is
* picked, so there is no default to accidentally act on.
*/
export function FeedDirectionModal({
slotLabel,
amsId,
slotId,
extruderSlots,
isLoading = false,
onConfirm,
onCancel,
}: FeedDirectionModalProps) {
const { t } = useTranslation();
const [selected, setSelected] = useState<number | null>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isLoading) onCancel();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCancel, isLoading]);
// A hotend already fed from this exact slot cannot be loaded from it again.
const holdsThisSlot = (extruderId: number) => {
const slot = extruderSlots[String(extruderId)];
return slot?.ams_id === amsId && slot?.slot_id === slotId;
};
const options = [
{ extruderId: LEFT_EXTRUDER, label: t('printers.ams.feedLeft'), taken: holdsThisSlot(LEFT_EXTRUDER) },
{ extruderId: RIGHT_EXTRUDER, label: t('printers.ams.feedRight'), taken: holdsThisSlot(RIGHT_EXTRUDER) },
];
const selectedIsTaken = options.some(o => o.extruderId === selected && o.taken);
// Status keeps arriving while the dialog is open, so a hotend can become the
// one holding this slot after it was picked — someone loading it from the
// printer's own screen. Drop the selection rather than leave Confirm armed on
// an option that has since been disabled.
useEffect(() => {
if (selectedIsTaken) setSelected(null);
}, [selectedIsTaken]);
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
onClick={isLoading ? undefined : onCancel}
>
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<CardContent className="p-6">
<h3 className="text-lg font-semibold text-white mb-2">
{t('printers.ams.feedTitle', { slot: slotLabel })}
</h3>
<p className="text-bambu-gray text-sm">{t('printers.ams.feedPrompt')}</p>
<div className="grid grid-cols-2 gap-3 mt-4">
{options.map(({ extruderId, label, taken }) => {
const isSelected = selected === extruderId;
return (
<button
key={extruderId}
type="button"
onClick={() => setSelected(extruderId)}
disabled={taken || isLoading}
title={taken ? t('printers.ams.feedAlreadyLoaded') : undefined}
className={`p-3 rounded-lg border text-sm transition-colors ${
taken
? 'border-transparent bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
: isSelected
? 'border-bambu-green/50 bg-bambu-green/10 text-white'
: 'border-transparent bg-bambu-dark text-white hover:bg-bambu-dark-tertiary'
}`}
>
<div className="font-medium">{label}</div>
{taken && (
<div className="text-xs mt-1">{t('printers.ams.feedAlreadyLoaded')}</div>
)}
</button>
);
})}
</div>
<div className="flex gap-3 mt-6">
<Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
{t('common.cancel')}
</Button>
<Button
onClick={() => selected !== null && onConfirm(selected)}
className="flex-1"
disabled={selected === null || isLoading}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('common.loading')}
</>
) : (
t('common.confirm')
)}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}