From e9d9e51a8107e5e0f112e258b4d56edb0e8204a4 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 11 Aug 2026 09:37:03 +0200 Subject: [PATCH] fix(h2c): send physical nozzle IDs for both carriages, not extruder indices (#2800) The first pass at the H2C rack mapping had both of its hardware-derived values wrong, and the reporter's follow-up A/B on real hardware settled them. The rack does not feed extruder 0. A mixed-nozzle plate extracted as slots=[0, -1, -1, 1] with rack position 17 dispatched as [17, -1, -1, 1], and the rack nozzle printed several millimetres above the bed. The rack is extruder 1. Correcting only that is not enough. The fixed hotend answers to physical ID 1, not to its extruder index of 0, and forwarding the index produced [0, -1, -1, 17] -- a command the printer rejected outright rather than mis-printing. Translating both carriages gives [1, -1, -1, 17], and the same sliced file then cleaned, levelled and printed on the correct nozzle at the correct Z through to completion. Both values agree with three native Bambu Studio captures from the same machine, which carry [1, 17, ...] and [17, 1, ...] depending on filament slot order. An extruder index naming neither carriage now omits the field instead of reaching the wire as a physical ID that identifies no nozzle. The fixed-hotend-only path is unchanged but no longer a guess: Bambu Studio sends no nozzle_mapping at all for such a plate, which is what Bambuddy already did. Reported, diagnosed and hardware-verified by @tru3l3gend, who ran the mixed-nozzle A/B on both nozzles and captured what Bambu Studio sends for fixed-only and mixed plates. --- CHANGELOG.md | 2 +- backend/app/services/bambu_mqtt.py | 56 +++++++---- backend/app/utils/printer_models.py | 9 +- .../unit/test_nozzle_rack_mapping_2800.py | 95 ++++++++++++------- 4 files changed, 105 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 161f70f78..6df91f75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ All notable changes to Bambuddy will be documented in this file. - **A large model was refused with "Slicer CLI failed (500): File too large" and no way to find out what was too large (#2802, reported by @zevulos)** — Server-side slicing of a big multi-colour project failed on every attempt, and the message pointed at nothing. The slicer sidecar caps the size of the model it will accept; that cap was fixed at 100 MB, which real MakerWorld projects exceed. Worse than the limit was how it arrived: the sidecar's upload layer reports a size rejection as a kind of error its own handler does not recognise, so it fell through to a generic **HTTP 500** carrying the bare words "File too large". A 500 reads as a crash inside the slicer, and Bambuddy's one good explanation about request size was written for the HTTP 413 that a reverse proxy sends, so it never appeared. The reporter did the only reasonable thing with what they were shown: set `MAX_FILE_SIZE`, `BODY_PARSER_LIMIT` and `EXPRESS_PAYLOAD_LIMIT`, restart everything, stop nginx in case it was interfering, and move the whole installation from Windows to Docker — none of which the sidecar reads, on a proxy that was never in the path. The cap is now **512 MB** by default and settable with `MAX_MODEL_UPLOAD_MB` on the slicer-api service, and the sidecar answers an oversized upload with a 413 that names the limit and where it lives. Bambuddy recognises the rejection by what it says rather than by its status code, so an installation still running an older sidecar image gets the same explanation — including that the fix there is to update the image, since those have no setting to change. Two things followed from the same misreading: the failure was classed as a slicer crash, so every attempt retried the identical oversized upload "with embedded settings", spending a second 25-second conversion on a guaranteed-identical answer; and nothing anywhere recorded the size of what was being sent, so the support package from a slice that died on an upload cap looked exactly like one that died on a bad profile. Both are fixed — the retry is skipped, and each slice logs the model's size. Raising the cap also changed how the sidecar handles the upload: the model is streamed to disk instead of being held whole in memory, so a 512 MB project no longer costs half a gigabyte of RAM per concurrent slice on the small machines most likely to be running it. **This needs a sidecar update to take effect**, and the command has to name the sidecar: `cd slicer-api/ && docker compose pull orca-slicer-api && docker compose up -d orca-slicer-api`, substituting `bambu-studio-api` if that is the one you slice with. A bare `docker compose pull` looks like it works and does not: the Bambu Studio sidecar is declared behind a Compose profile, and Compose skips profile-gated services without saying so, leaving the old container running under `restart: unless-stopped`. The reporter hit exactly that on the first attempt at this fix — pulled, restarted, set `MAX_MODEL_UPLOAD_MB=5000`, and got the same 100 MB rejection, because the image never changed. Bambuddy's message, this changelog, the wiki and the sidecar README all gave the bare command; all four now name the service. - **`/auth/me` described API keys as administrators they were never allowed to be (#1894, reported by @MorganMLGman)** — Asked to identify an API key, Bambuddy answered with a synthetic administrator: user id `0`, role `admin`, `is_admin: true`, and every permission in the system. None of that was true. An API key cannot reach an administrative route at all, whatever its scopes and whoever owns it, so a client that built its interface from this answer — which is exactly what a native app does — offered buttons that failed with a permission error the moment anyone pressed one, and still had no way to learn which user id its own prints were filed under. The endpoint now reports the key's **owner** as its identity, `is_admin: false`, and a permission list containing precisely what the key's scopes admit, so what a client is told matches what it will be allowed to do. Keys created before keys had owners have no identity to report and keep the old `id: 0` placeholder, but they no longer claim to be administrators either. Clients that branched on `is_admin` or `role` should branch on `permissions` instead. - **An unacknowledged plate no longer stops AMS drying once a minute, for ever (#2801, reported by @superflyer11)** — With "require plate clear" on, a finished print left unacknowledged and something pending in that printer's queue put the scheduler into a loop: it stopped drying, auto-drying re-armed on the next tick, and it stopped it again — around 2000 state changes over ten days on the reporter's P2S, with no cycle ever running long enough to remove any moisture. Cycles the user had started by hand on other AMS units of the same printer were torn down with it. Two ideas had become tangled. Plate-clear answers "is the bed ready for the next job", which says nothing about whether the AMS may heat — and the gap between a finished print and the acknowledgment is exactly when drying is most useful, since the printer is free and nobody is waiting on it. Leaving the plate unacknowledged is also how people hold the queue by hand, so the hold was costing them the drying it should have enabled. On top of that, the "print takes priority" stop was reached only on the passes where the print was *not* going to start: drying is not one of the things the idle check looks at, so stopping a cycle could never make a blocked printer dispatchable, and the cycle was spent for nothing. Auto-drying no longer consults plate-clear at all; the stop now happens on dispatches that are actually going to proceed, and only where the model cannot dry through a print — hardware that can, and has been allowed to, keeps drying as #2758 established it should. A stop is also confined to cycles Bambuddy itself started, matching a contract the code already documented but did not honour, so a manual dry on another unit is left alone. Two smaller faults went with it: a printer merely waiting on the plate was being classed as mid-print, which silently applied the mid-print spool-protection cap to a printer that was not printing and logged the cycle as `(mid-print)` in `FINISH`; and a humidity reading that dipped to the threshold as the AMS cooled discarded the unit's whole history, including the 30-minute re-arm cooldown added in #2770 — so a reading oscillating a point either side of the threshold reset the very guard meant to ride it out. **One behaviour change to be aware of:** "Block queue while drying" previously had no effect on dispatch at all, and now does what it says — with it on, a queued print waits for a running cycle to finish. It is off by default. -- **An H2C could clean and level with one hotend and then print with another, several millimetres above the plate (#2800, reported by @tru3l3gend)** — The reporter's H2C ran its startup clean and bed levelling on the wrong nozzle, switched hotends, and then printed in mid-air; the same job sent from Bambu Studio was fine. The H2C is the only printer that mounts its nozzle from a rack of six, and a print command names that nozzle by its *physical* rack position — the firmware reports those as IDs 16 to 21 — rather than by the extruder index, 0 or 1, that every other dual-nozzle printer uses. Bambuddy only ever had a rack position when a job arrived through the Virtual Printer, which captures Bambu Studio's own pick and replays it untouched (#1780). Anything queued from the library, from an archive, through the webhook or from a slicer pipeline carried none, so the field was left off the command entirely and the firmware chose a nozzle for itself — and its choice does not have to agree with the one the file was sliced for. Bambuddy now reads the per-slot extruder assignment out of the file it is about to dispatch and resolves it against the rack position the printer is reporting at that moment, which is the only place it can be known: the mounted hotend can be swapped from the touchscreen between queueing a job and printing it. Nothing about this is guessed. When the rack position cannot be established — mid-swap, or a connection that has not yet reported one — the field is left off and the firmware picks exactly as it did before, because a wrong physical ID is what puts a print in the air and is far worse than no ID at all. For the same reason a job that prints only from the fixed hotend is still left to the firmware: that nozzle's own physical ID has not yet been confirmed against a known-good Bambu Studio capture, and it will not be invented. Confined to the H2C throughout — the dispatch for every other printer, including the H2D and X2D, is unchanged. Diagnosed on real hardware by the reporter, who compared Bambuddy's dispatch against a working Bambu Studio one, established the rack ID range, and supplied a patch. +- **An H2C could clean and level with one hotend and then print with another, several millimetres above the plate (#2800, reported by @tru3l3gend)** — The reporter's H2C ran its startup clean and bed levelling on the wrong nozzle, switched hotends, and then printed in mid-air; the same job sent from Bambu Studio was fine. The H2C is the only printer that mounts its nozzle from a rack of six, and a print command names that nozzle by its *physical* rack position — the firmware reports those as IDs 16 to 21 — rather than by the extruder index, 0 or 1, that every other dual-nozzle printer uses. Bambuddy only ever had a rack position when a job arrived through the Virtual Printer, which captures Bambu Studio's own pick and replays it untouched (#1780). Anything queued from the library, from an archive, through the webhook or from a slicer pipeline carried none, so the field was left off the command entirely and the firmware chose a nozzle for itself — and its choice does not have to agree with the one the file was sliced for. Bambuddy now reads the per-slot extruder assignment out of the file it is about to dispatch and resolves it against the rack position the printer is reporting at that moment, which is the only place it can be known: the mounted hotend can be swapped from the touchscreen between queueing a job and printing it. Nothing about this is guessed. When the rack position cannot be established — mid-swap, or a connection that has not yet reported one — the field is left off and the firmware picks exactly as it did before, because a wrong physical ID is what puts a print in the air and is far worse than no ID at all. A plate sliced for the fixed hotend alone now goes out with no such field at all, which is exactly what Bambu Studio does with one — until this correction those plates were being handed a rack position, since the extruder index they carry was the very one being read as "the rack". Which of the two carriages the rack feeds, and what physical ID the fixed hotend answers to, were both settled afterwards on the reporter's own machine, and both were wrong on the first pass: the rack was resolved onto the other extruder, and the fixed hotend was sent its extruder index in place of a physical ID. A plate using both nozzles printed the rack side in mid-air as a result, and the printer would not start a job at all once only the first of the two was corrected. Both values now agree with three native Bambu Studio captures and with a print that ran correctly on both nozzles from start to finish. Confined to the H2C throughout — the dispatch for every other printer, including the H2D and X2D, is unchanged. Diagnosed on real hardware by the reporter, who compared Bambuddy's dispatch against a working Bambu Studio one, established the rack ID range, supplied a patch, and then ran the A/B on both nozzles that pinned down the last two values. - **Automatic drying no longer loops when the humidity threshold is set below what a warm AMS reports (#2770, reported by @tchavei)** — A reporter's H2D armed five separate 12-hour drying cycles inside four hours, one of them six seconds after the previous ended, and none of them ran for more than a couple of hours. Two things combine to produce that. The firmware ends a cycle whenever it decides the filament is dry, without reporting a fault: across this printer's history the run length tracks how wet the spools were, from nearly the full 12 hours when the AMS started at 32% down to minutes once it sat at 10-13%. That is the AMS doing its job. The loop is Bambuddy's. An AMS reports *higher* relative humidity while it is warm than once it has cooled — the same unit read 10-13% cold and 15-20% throughout every cycle — so with a threshold of 14% the reading at the moment a cycle ended was always still above it, and the next 30-second pass started another 12-hour cycle. Nothing counted, nothing waited, and it only stopped when the box finally cooled enough to read 13%. Auto-drying now waits half an hour after a cycle ends before it will arm another on the same unit, because the humidity reading means nothing until the AMS has cooled; and after two cycles in a row that bring the reading no lower it stops arming that unit altogether, says so in the log, and sends a notification — a new **Auto-drying suspended** event, on by default, since it reports that Bambuddy has *stopped* doing something and silence there reads as "still drying". Progress is judged against the lowest reading any cycle on that unit has ended at, so a genuinely wet spool in a humid room that is coming down slowly -- 40%, 37%, 35% -- keeps drying however far it still is from the threshold, and the suspension lifts by itself the moment the reading falls below it. Neither guard can ever stop a cycle that is running, and a cycle Bambuddy itself cut short for a print, or that you stopped by hand, is not counted against the unit -- so a farm that dries between queue jobs is unaffected. The threshold field in **Settings → Filament → AMS Display Thresholds** now warns when it is set below 20%, and every drying cycle end — early or normal — logs the unit's temperature and humidity, which is what made this diagnosable at all. - **"database is locked" errors when a notification provider is unreachable (#2770)** — A reporter's log showed two unrelated background tasks -- printer sensor history, and the queue's orphaned-dispatch sweep -- failing with `sqlite3.OperationalError: database is locked`, each one landing inside a Discord connect timeout that took exactly 30.000 seconds. It was not contention from writing too much. Bambuddy raises an alarm from inside the loop that records sensor history, at a point where the new history rows have been added to the session but not yet committed; the first database read inside the notification path then flushed those rows to satisfy itself, which opens a write transaction, and the provider was contacted over the network with that transaction still open. SQLite allows exactly one writer, and 30 seconds of waiting for a host that is not answering comfortably outlives the 15-second busy timeout, so every other task that wanted to write during that window failed. The two reads that run before a provider is contacted no longer flush the caller's pending work, so nothing holds the writer while the network is in play, and the connect timeout is now 5 seconds rather than 30 -- reaching a host either works quickly or is not going to. Sending the body keeps the full 30 seconds, so snapshot images on a slow uplink are unaffected. Only SQLite installs were affected; Postgres has no single-writer limit. - **A 3D preview that a proxy refuses to embed now says so, instead of leaving you with the browser's error page (#2787, reported by @trickfilm)** — A reporter uploaded an STL, sliced it in Bambuddy, and found that the sliced file's **3D Preview** showed a frowny icon and "*hostname* refused to connect" — while the STL's own preview worked. The split is exactly where the two previews part company: an STL or a source 3MF is drawn in the page itself, but a sliced file opens the embedded G-code viewer, which lives in an iframe. Bambuddy's own headers allow that frame — it is same-origin, and both the policy and the legacy header say so — which means a refusal comes from something between the browser and Bambuddy, typically a reverse proxy or security add-on sending its own framing header. None of that was visible: the browser drew its error page inside Bambuddy's layout, and nothing said what had been refused, by whom, or that the viewer opens perfectly well in a tab of its own. The page now asks for the viewer directly, reads the framing headers off the reply, and when they refuse the frame it replaces it with an explanation naming the exact header — so an operator can go and find the rule in their proxy configuration — plus a link that opens the viewer in its own tab, which no framing header applies to. A viewer that is missing from the installation is reported the same way rather than as raw JSON inside the frame. When the check cannot reach a verdict the frame is left exactly as it was, because a guess at a cause we cannot see would be worse than the browser's own page. diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index 621a11d45..412a6e28a 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -276,28 +276,34 @@ def apply_tray_exist_bits( # --- H2C nozzle-rack dispatch mapping (#2800) ------------------------------- # -# Physical nozzle IDs the H2C reports for its six rack slots. The two hotend -# carriage positions are 0 and 1 in the same namespace, which is why a rack -# position can never be confused with an extruder index by value. +# Physical nozzle IDs the H2C reports for its six rack slots, verified on +# hardware. They sit well clear of the fixed hotend's own physical ID, so a +# rack position is never mistakable for the nozzle on the other carriage. +# +# Extruder indices are a different namespace that happens to overlap these +# low numbers -- index 1 means the rack, physical ID 1 means the fixed hotend. +# Nothing below may pass a value from one namespace to the other untranslated; +# doing exactly that is what #2800 was. _RACK_NOZZLE_IDS = frozenset(range(16, 22)) # BambuStudio dispatches a fixed-length nozzle_mapping on rack models: one # physical nozzle ID per filament slot, -1 for slots the plate does not print. _RACK_WIRE_SLOTS = 32 -# The extruder the rack feeds. On the H2C the swappable hotend sits on the -# right carriage, which the slicer's physical_extruder_map numbers 0 (left is -# 1) -- so a slot assigned extruder 0 is a slot that prints from whichever -# rack nozzle is currently mounted. -# -# This is the one value here taken from a single hardware observation (#2800) -# rather than from something the printer reports. It is safe to be wrong about -# for a job that prints entirely from one side: if the rack were really on -# extruder 1, no slot would match and the mapping would simply be omitted, -# which is the behaviour that existed before any of this. Only a job that -# prints from both nozzles at once could be actively harmed by a flip, and -# that is what a second hardware capture needs to confirm. -_RACK_EXTRUDER_ID = 0 +# The two carriages, as extruder indices in the form the queue stores (already +# translated through the file's physical_extruder_map). Settled on hardware in +# #2800: the same sliced mixed-nozzle plate dispatched as [17, -1, -1, 1] +# printed the rack nozzle several millimetres above the bed, and as +# [1, -1, -1, 17] printed correctly on both nozzles start to finish. +_FIXED_EXTRUDER_ID = 0 +_RACK_EXTRUDER_ID = 1 + +# The fixed hotend's physical ID, which is *not* its extruder index. The same +# hardware A/B ruled the index out: [0, -1, -1, 17] was rejected by the printer +# outright, which would not start the job at all. Native BambuStudio captures +# of a mixed plate agree -- [1, 17, ...], and [17, 1, ...] once the filament +# slot order is swapped, so the fixed side is 1 whichever slot it lands in. +_FIXED_NOZZLE_ID = 1 def resolve_rack_nozzle_mapping( @@ -323,9 +329,11 @@ def resolve_rack_nozzle_mapping( - a slot needs the rack but the printer has not reported a live rack position (mid-swap, or a stale connection); - - no slot needs the rack at all. The non-rack hotend's own physical ID is - not yet confirmed against a known-good BambuStudio capture, and this - code will not guess one. Such a job dispatches as it does today. + - no slot needs the rack at all. BambuStudio omits nozzle_mapping entirely + for a plate sliced for the fixed hotend only (#2800 capture), so this + matches it rather than naming a nozzle it does not have to name; + - a slot names a carriage that is neither of the two an H2C has, which + means the file was mapped for a machine this translation does not model; - the plate needs more slots than the wire format carries; - the input is not a list of whole numbers. @@ -363,7 +371,15 @@ def resolve_rack_nozzle_mapping( for index, extruder in enumerate(normalised): if extruder < 0: continue - wire[index] = rack_nozzle_id if extruder == _RACK_EXTRUDER_ID else extruder + if extruder == _RACK_EXTRUDER_ID: + wire[index] = rack_nozzle_id + elif extruder == _FIXED_EXTRUDER_ID: + wire[index] = _FIXED_NOZZLE_ID + else: + # An H2C has these two carriages and no others. A third index is a + # file mapped for something else, and forwarding it raw would name + # a physical nozzle by an index that does not identify one. + return None return wire diff --git a/backend/app/utils/printer_models.py b/backend/app/utils/printer_models.py index 7d80b309d..6ef111bed 100644 --- a/backend/app/utils/printer_models.py +++ b/backend/app/utils/printer_models.py @@ -240,9 +240,12 @@ DUAL_NOZZLE_MODELS = frozenset( # Why this needs its own set rather than reusing DUAL_NOZZLE_MODELS: on every # other dual-nozzle printer the dispatch `nozzle_mapping` values ARE the MQTT # extruder indices (0 = right, 1 = left). On a rack model the wire wants the -# *physical* nozzle position, and the rack positions are reported by the -# firmware as IDs 16-21 — see `device.nozzle.info` handling in bambu_mqtt. -# Sending an extruder index where a rack position is expected makes the +# *physical* nozzle position for both carriages: the rack positions the +# firmware reports as IDs 16-21 — see `device.nozzle.info` handling in +# bambu_mqtt — and 1 for the fixed hotend, which is not its extruder index. +# Note the H2C does not follow the 0 = right convention either: extruder +# index 1 is the rack side, confirmed on hardware in #2800. +# Sending an extruder index where a physical position is expected makes the # printer clean and level with one nozzle and then print with another, at the # wrong Z (#2800). NOZZLE_RACK_MODELS = frozenset( diff --git a/backend/tests/unit/test_nozzle_rack_mapping_2800.py b/backend/tests/unit/test_nozzle_rack_mapping_2800.py index 9e953722f..f1ebe0d5c 100644 --- a/backend/tests/unit/test_nozzle_rack_mapping_2800.py +++ b/backend/tests/unit/test_nozzle_rack_mapping_2800.py @@ -37,19 +37,23 @@ class TestIsNozzleRackModel: class TestResolveRackNozzleMapping: def test_rack_slot_takes_the_live_rack_position(self): - mapping = resolve_rack_nozzle_mapping([0], rack_nozzle_id=17) + mapping = resolve_rack_nozzle_mapping([1], rack_nozzle_id=17) assert mapping is not None assert len(mapping) == _RACK_WIRE_SLOTS assert mapping[0] == 17 assert set(mapping[1:]) == {-1} - def test_non_rack_slots_keep_their_extruder_index(self): - """Only the rack extruder is substituted; the fixed hotend is untouched.""" - mapping = resolve_rack_nozzle_mapping([1, 0], rack_nozzle_id=21) + def test_the_fixed_hotend_takes_its_own_physical_id(self): + """Both carriages are translated; neither extruder index reaches the wire. + + Sending the index for the fixed side (0) is what the printer rejected + outright on hardware — it would not start the job at all. + """ + mapping = resolve_rack_nozzle_mapping([0, 1], rack_nozzle_id=21) assert mapping[:2] == [1, 21] def test_unprinted_slots_stay_unset(self): - mapping = resolve_rack_nozzle_mapping([0, -1, 0], rack_nozzle_id=16) + mapping = resolve_rack_nozzle_mapping([1, -1, 1], rack_nozzle_id=16) assert mapping[:3] == [16, -1, 16] @pytest.mark.parametrize("rack_id", [None, 0, 1, 15, 22, 255]) @@ -59,21 +63,35 @@ class TestResolveRackNozzleMapping: Guessing here is what prints in mid-air, so returning None (and omitting nozzle_mapping) is the intended failure mode. """ - assert resolve_rack_nozzle_mapping([0], rack_nozzle_id=rack_id) is None + assert resolve_rack_nozzle_mapping([1], rack_nozzle_id=rack_id) is None def test_job_that_never_uses_the_rack_is_left_alone(self): - """The fixed hotend's own physical ID is not confirmed by a capture yet.""" - assert resolve_rack_nozzle_mapping([1, 1], rack_nozzle_id=17) is None + """BambuStudio omits nozzle_mapping for a fixed-hotend-only plate. + + Captured from the reporter's H2C: a plate sliced for the fixed side + alone carries ams_mapping and no nozzle_mapping field at all, so + naming a nozzle here would depart from what the printer expects. + """ + assert resolve_rack_nozzle_mapping([0, 0], rack_nozzle_id=17) is None + + @pytest.mark.parametrize("unknown", [2, 3, 31]) + def test_a_carriage_the_h2c_does_not_have_omits_the_field(self, unknown): + """A third index means the file was mapped for another machine. + + Forwarding it raw would name a physical nozzle by a number that does + not identify one, which is the class of mistake #2800 was. + """ + assert resolve_rack_nozzle_mapping([unknown, 1], rack_nozzle_id=17) is None @pytest.mark.parametrize( "bad_slots", [ - ["a", 0], # non-numeric - [{}, 0], # nested object - [[0], 0], # nested list - [0.5, 0], # fractional - [True, 0], # bool would reach the wire as JSON `true` - "0", # not a list at all + ["a", 1], # non-numeric + [{}, 1], # nested object + [[0], 1], # nested list + [0.5, 1], # fractional + [True, 1], # bool would reach the wire as JSON `true` + "1", # not a list at all ], ) def test_junk_input_returns_none_and_never_raises(self, bad_slots): @@ -85,23 +103,29 @@ class TestResolveRackNozzleMapping: @pytest.mark.parametrize("bad_rack", [[17], {"id": 17}, "17", 17.0, True]) def test_junk_rack_position_returns_none_and_never_raises(self, bad_rack): - assert resolve_rack_nozzle_mapping([0], rack_nozzle_id=bad_rack) is None + assert resolve_rack_nozzle_mapping([1], rack_nozzle_id=bad_rack) is None def test_none_entries_read_as_unprinted(self): - assert resolve_rack_nozzle_mapping([None, 0], rack_nozzle_id=17)[:2] == [-1, 17] + assert resolve_rack_nozzle_mapping([None, 1], rack_nozzle_id=17)[:2] == [-1, 17] - def test_a_flipped_rack_side_would_omit_rather_than_misfire(self): - """Guards the one assumption taken from a single hardware capture. + def test_hardware_confirmed_mixed_nozzle_plate(self): + """The exact job the reporter ran on an H2C, both ways round. - If the rack turned out to feed the other extruder, a job printing - entirely from one side matches nothing and falls back to the - firmware's own pick — the pre-#2800 behaviour — instead of naming a - nozzle confidently and wrongly. + Dispatched as [17, -1, -1, 1] the rack nozzle printed several + millimetres above the bed; dispatched as [1, -1, -1, 17] the same + sliced file printed correctly on both nozzles and completed. Native + BambuStudio captures of mixed plates on the same machine carry + [1, 17, ...] and [17, 1, ...] depending on filament slot order. """ - assert resolve_rack_nozzle_mapping([1, 1], rack_nozzle_id=17) is None + wire = resolve_rack_nozzle_mapping([0, -1, -1, 1], rack_nozzle_id=17) + assert wire[:4] == [1, -1, -1, 17] + assert set(wire[4:]) == {-1} + + swapped = resolve_rack_nozzle_mapping([1, 0], rack_nozzle_id=17) + assert swapped[:2] == [17, 1] def test_more_slots_than_the_wire_carries(self): - assert resolve_rack_nozzle_mapping([0] * (_RACK_WIRE_SLOTS + 1), rack_nozzle_id=17) is None + assert resolve_rack_nozzle_mapping([1] * (_RACK_WIRE_SLOTS + 1), rack_nozzle_id=17) is None def test_empty_mapping(self): assert resolve_rack_nozzle_mapping([], rack_nozzle_id=17) is None @@ -161,7 +185,7 @@ class TestDispatch: def test_rack_model_resolves_slot_extruders(self): client = self._client("H2C") client.state.nozzle_rack_tar_id = 18 - client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0, -1, 0])) + client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([1, -1, 1])) cmd = self._print_cmd(client) assert cmd["nozzle_mapping"][:3] == [18, -1, 18] @@ -170,12 +194,12 @@ class TestDispatch: client = self._client("H2C") client.state.nozzle_rack_src_id = 20 client.state.nozzle_rack_tar_id = 0 - client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0])) + client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([1])) assert self._print_cmd(client)["nozzle_mapping"][0] == 20 def test_unknown_rack_position_omits_the_field(self): client = self._client("H2C") - client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([0])) + client.start_print("job.3mf", nozzle_slot_extruders=json.dumps([1])) assert "nozzle_mapping" not in self._print_cmd(client) def test_studio_capture_is_never_overridden(self): @@ -185,7 +209,7 @@ class TestDispatch: client.start_print( "job.3mf", nozzle_mapping=json.dumps([16, -1, -1, 1]), - nozzle_slot_extruders=json.dumps([0, -1, 0]), + nozzle_slot_extruders=json.dumps([1, -1, 1]), ) assert self._print_cmd(client)["nozzle_mapping"] == [16, -1, -1, 1] @@ -214,9 +238,10 @@ class TestDispatch: def _write_dual_nozzle_3mf(path, group_by_slot): """Minimal 3MF carrying just what the nozzle extractor reads. - physical_extruder_map is [1, 0] as Bambu ships it: slicer group 0 is the - left extruder (MQTT index 1) and group 1 the right (index 0) — the right - being the one the H2C rack feeds. + physical_extruder_map is [1, 0] as Bambu ships it, so slicer group 0 comes + out as MQTT extruder index 1 and group 1 as index 0. On the H2C index 1 is + the rack carriage — confirmed on hardware in #2800, and the reason the + rack-side fixture below slices its filaments into group 0. """ filaments = "".join(f'' for slot, group in group_by_slot.items()) with zipfile.ZipFile(path, "w") as zf: @@ -235,19 +260,23 @@ def _write_dual_nozzle_3mf(path, group_by_slot): class TestSlotExtrudersFromFile: def test_derives_dense_per_slot_extruders(self, tmp_path): - """Slots 1 and 3 print from the right (rack) extruder; slot 2 is unused.""" + """Slots 1 and 3 print from the fixed hotend; slot 2 is unused.""" source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 1, 3: 1}) assert extract_slot_extruders_from_3mf(source) == [0, -1, 0] def test_end_to_end_reaches_the_rack_position(self, tmp_path): """The reported failure: a two-slot job that must print from the rack.""" - source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 1, 3: 1}) + source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 0, 3: 0}) + assert extract_slot_extruders_from_3mf(source) == [1, -1, 1] wire = resolve_rack_nozzle_mapping(extract_slot_extruders_from_3mf(source), rack_nozzle_id=17) assert wire[:3] == [17, -1, 17] def test_both_extruders(self, tmp_path): + """One slot per carriage — the mixed job that printed in mid-air.""" source = _write_dual_nozzle_3mf(tmp_path / "job.3mf", {1: 0, 2: 1}) assert extract_slot_extruders_from_3mf(source) == [1, 0] + wire = resolve_rack_nozzle_mapping(extract_slot_extruders_from_3mf(source), rack_nozzle_id=17) + assert wire[:2] == [17, 1] def test_single_nozzle_file_yields_nothing(self, tmp_path): path = tmp_path / "single.3mf"