diff --git a/CHANGELOG.md b/CHANGELOG.md
index 757acb5c1..aad2f8b70 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,6 +45,7 @@ All notable changes to Bambuddy will be documented in this file.
- **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
### Fixed
+- **SpoolBuddy showed a multi-colour or effect spool as one flat colour (#3033, reported by @Sawtaytoes)** — The Filament page paints a spool's extra colours and effect: a blue-and-yellow split, a marble swirl, a galaxy sheen. SpoolBuddy drew every spool from its main colour alone, so a blue-and-yellow Silk spool was a blue disc, and three Galaxy Black spools were three identical black discs, on the screen used at the printer to pick the spool to load. The spool discs on the Inventory cards, the spool details, the tag-scanned and link-tag screens, and the small colour dots on the Inventory cards, the AMS slot menu, the Assign to AMS screen and the Write Tag screen now use the same painter as the Filament page whenever a spool has extra colours or an effect. A plain spool looks as before. A scanned tag now also sends the spool's extra colours and effect to SpoolBuddy, which it did not before. Spoolman spools show their extra colours; Spoolman has no effect field, so they show no effect.
- **HMS faults show Bambu's own description and the right level, and faults Bambu publishes no text for no longer vanish (#2728, reported by @gzimbric)** — Two problems in how printer faults were shown, both in the same path. **No description for most faults.** A fault from the printer's `hms[]` list is identified by a 16-digit code, but Bambuddy's table of descriptions only had the 8-digit codes of the other kind of fault (`print_error`), and the lookup cut the 16-digit code down to a shape that could never match a real one. So those faults never got text, and the printer card only showed them if they happened to offer action buttons. On the reporter's P2S, three faults the printer was holding showed nothing at all. Descriptions now come from Bambu Studio's own HMS files: about 6,400 codes, with the text for your printer model where models differ (`0300_8001` means "paused by the user" on some models and "paused by a pause command in the file" on others). The 24 texts Bambuddy had that Bambu Studio doesn't are kept. `scripts/generate_hms_catalog.py` regenerates the table from a Bambu Studio checkout. **Wrong level.** The level was read from the part of the code that names the printer part, not from the level the printer sends, so a fault that paused the print could show as blue "Info" and a mere notice as red "Serious". It now uses the printer's level, the way Bambu Studio reads it: **Error** (print stopped), **Warning** (print paused), **Notice** (print carries on). The printer card turns red for Error and Warning, amber for Notice only. A `print_error` has no level of its own, so it takes one from its error number (4xxx Error, 8xxx Warning, Cxxx Notice) instead of always showing Warning. **What counts.** A fault counts toward the printer card, the problem badge and the Camera Wall when it offers action buttons, or when Bambu publishes text for it, except an `hms[]` Notice with no buttons: those are things like "The top cover is open" or "the chamber is hot, fan speed increased" that a printer can hold through a whole print. A `print_error` prompt at the Notice level still counts, as before. Faults that don't count are no longer hidden: the error modal lists them, with their text where Bambu has one, collapsed under **Also reported, not counted**, and **Clear Errors** shows whenever the printer holds any fault. **Notifications and the MQTT relay** follow the same rule. Printer-error notifications now also go out for `hms[]` faults that count and have a description, which before this almost never happened, and the relay's error topic carries the faults that count; the H2S cancel echo, which the old check dropped only by accident, stays out. The old check that was meant to skip minor messages read the wrong field, and on the real level it would have skipped the faults that stop a print (spotted by @ojimpo). A fault already notified was recognised by the printer part alone, so a second fault on the same part, such as the two #1840's H2C held at once (`0500-0600-0002-0005` and `-0006`), was never notified; each fault is now recognised by its full code. **Other changes.** The queue's failure message labels an `hms[]` fault with its full code, as the printer screen shows it, such as `0500-0300-0002-000E`, instead of a short code nobody can look up. The status response, WebSocket and Camera Wall carry `description` and the new `severity` values; anything that reads `severity` through the API or the MQTT relay will see different numbers for the same fault. The level labels and the new modal text are translated in all 15 locales.
- **A print the printer's AI camera stopped for spaghetti is archived with a failure reason (#2946, reported and contributed by @ojimpo in #2954)** — When the onboard AI print monitor halts a print for spaghetti or a model coming off the plate, the printer raises HMS code `0300_8003`. That code was missing from the list Bambuddy uses to name a failed print's reason, so the archive was saved with no reason at all, even though its neighbour `0300_8004` (filament runout) was on the list. `0300_8003` and `0C00_8042`, the same AI detection reported from the motion-controller module, now archive as **Spaghetti / Detached**, the option the archive editor already offers, shown in your language. Two nearby codes are left out on purpose: `0C00_C004` ("Possible spaghetti failure was detected") is a warning while the print keeps running, and `0300_800A` is a filament pile-up in the waste chute, not a failed print. Archives saved before this keep their empty reason and can be set by hand. New tests also check that the failure reasons the backend accepts match the ones the archive editor offers, and that every one has English text.
- **A drying command the AMS never started no longer shows as an active cycle, or holds the queue forever (#2896, reported and contributed by @M2ABRAMSTANK in #3096)** — A printer can accept an AMS drying command and set its timer without ever starting the cycle. On the reporter's H2D, mid-print, two AMS-HT units were counting down and heating while a third sat at 720 minutes, cold, and its card still showed the amber drying badge. Bambuddy now notices when a unit's countdown hasn't moved for 150 seconds while the AMS reports no drying phase. A live countdown moves once a minute, so a real cycle is never mistaken for one. The card then shows a grey **Drying not running** badge instead, without the countdown and with Stop still available. It says "not running" rather than "not started" because a cycle paused partway looks the same. If the AMS does report checking, drying or cooling, it always counts as drying. The same timer also never reaches zero, so everything that waited for drying to end waited for good. With **Block queue until drying finishes** on, the queue held that printer indefinitely. A scheduled drying run stayed "running" forever, and the AMS temperature alarm stayed muted on a unit that wasn't heating. Now a printer whose only timers are parked doesn't hold the queue, and doesn't make a scheduled run wait. A scheduled run whose timer parks goes back to the queue if a print is running. On an idle printer it fails with the reason "the printer accepted the command, but the AMS did not start drying". In both cases the timer is left on the printer, since it may still start once power frees up. The temperature alarm treats a parked unit as not drying, and the printer card no longer counts one when deciding whether a dropped file prints now or queues. A failed scheduled drying run now shows its reason in your language instead of in English. Translated in all locales.
diff --git a/backend/app/api/routes/spoolbuddy.py b/backend/app/api/routes/spoolbuddy.py
index ce4f454eb..3319f7149 100644
--- a/backend/app/api/routes/spoolbuddy.py
+++ b/backend/app/api/routes/spoolbuddy.py
@@ -427,6 +427,10 @@ async def nfc_tag_scanned(
# the colour catalog over "Silk+" (#3090).
"color_name_is_synthesized": mapped["color_name_is_synthesized"],
"rgba": mapped["rgba"],
+ # The kiosk paints the disc from these, as the
+ # Filament page does (#3033).
+ "extra_colors": mapped["extra_colors"],
+ "effect_type": mapped["effect_type"],
"brand": mapped["brand"],
"label_weight": mapped["label_weight"],
"core_weight": mapped["core_weight"],
@@ -494,6 +498,8 @@ async def nfc_tag_scanned(
# set, and nothing else — never a stand-in (#3090).
"color_name_is_synthesized": False,
"rgba": spool.rgba,
+ "extra_colors": spool.extra_colors,
+ "effect_type": spool.effect_type,
"brand": spool.brand,
"label_weight": spool.label_weight,
"core_weight": spool.core_weight,
diff --git a/backend/tests/integration/test_spoolbuddy_spool_colours_3033.py b/backend/tests/integration/test_spoolbuddy_spool_colours_3033.py
new file mode 100644
index 000000000..4478cc56e
--- /dev/null
+++ b/backend/tests/integration/test_spoolbuddy_spool_colours_3033.py
@@ -0,0 +1,101 @@
+"""The kiosk paints a scanned spool with its extra colours and effect (#3033).
+
+The tag-matched broadcast is the only place the kiosk learns about a scanned
+spool, and it carried `rgba` alone, so a dual-colour or marble spool arrived as
+one flat colour however the frontend drew it.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.settings import Settings
+
+SPOOLBUDDY_API = "/api/v1/spoolbuddy"
+
+
+@pytest.fixture
+async def spoolman_enabled(db_session: AsyncSession):
+ db_session.add(Settings(key="spoolman_enabled", value="true"))
+ db_session.add(Settings(key="spoolman_url", value="http://spoolman.local:7912"))
+ await db_session.commit()
+
+
+@pytest.fixture
+async def spoolman_disabled(db_session: AsyncSession):
+ db_session.add(Settings(key="spoolman_enabled", value="false"))
+ await db_session.commit()
+
+
+async def _scan(async_client: AsyncClient) -> dict:
+ with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+ mock_ws.broadcast = AsyncMock()
+ resp = await async_client.post(
+ f"{SPOOLBUDDY_API}/nfc/tag-scanned",
+ json={"device_id": "sb-test", "tag_uid": "AABB1122334455FF", "tray_uuid": None},
+ )
+ assert resp.status_code == 200
+ mock_ws.broadcast.assert_called_once()
+ return mock_ws.broadcast.call_args[0][0]
+
+
+class TestTheScanBroadcastCarriesTheSpoolsColours:
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_a_local_dual_colour_spool(self, async_client: AsyncClient, spoolman_disabled):
+ """The reporter's spool #23, Inland PLA Silk Blue-Yellow."""
+ spool = MagicMock()
+ spool.id = 23
+ spool.material = "PLA"
+ spool.subtype = "Silk"
+ spool.color_name = "Blue-Yellow"
+ spool.rgba = "044482FF"
+ spool.extra_colors = "044482,f8d008"
+ spool.effect_type = "dual-color"
+ spool.brand = "Inland"
+ spool.label_weight = 1000
+ spool.core_weight = 250
+ spool.weight_used = 0
+
+ with patch("backend.app.api.routes.spoolbuddy.get_spool_by_tag", new_callable=AsyncMock, return_value=spool):
+ msg = await _scan(async_client)
+
+ assert msg["type"] == "spoolbuddy_tag_matched"
+ assert msg["spool"]["extra_colors"] == "044482,f8d008"
+ assert msg["spool"]["effect_type"] == "dual-color"
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_a_spoolman_spool_sends_its_stops_and_no_effect(self, async_client: AsyncClient, spoolman_enabled):
+ """Spoolman keeps the stops in `multi_color_hexes` and has no effect
+ field, so the effect stays None rather than being guessed."""
+ sm_spool = {
+ "id": 23,
+ "filament": {
+ "material": "PLA",
+ "name": "PLA Silk",
+ "color_hex": "044482",
+ "multi_color_hexes": "044482,F8D008",
+ "weight": 1000.0,
+ "vendor": {"name": "Inland"},
+ },
+ "used_weight": 0.0,
+ "archived": False,
+ "registered": "2024-01-01T00:00:00Z",
+ }
+ client = MagicMock()
+ client.base_url = "http://spoolman.local:7912"
+ client.get_spools = AsyncMock(return_value=[sm_spool])
+ client.find_spool_by_tag = AsyncMock(return_value=sm_spool)
+ client.merge_spool_extra = AsyncMock(return_value={})
+ with (
+ patch("backend.app.services.spoolman.get_spoolman_client", AsyncMock(return_value=client)),
+ patch("backend.app.services.spoolman.init_spoolman_client", AsyncMock(return_value=client)),
+ ):
+ msg = await _scan(async_client)
+
+ assert msg["type"] == "spoolbuddy_tag_matched"
+ assert msg["spool"]["extra_colors"] == "044482,F8D008"
+ assert msg["spool"]["effect_type"] is None
diff --git a/frontend/src/__tests__/components/spoolbuddy/SpoolIcon.test.tsx b/frontend/src/__tests__/components/spoolbuddy/SpoolIcon.test.tsx
index b48c03690..59f6a5fa7 100644
--- a/frontend/src/__tests__/components/spoolbuddy/SpoolIcon.test.tsx
+++ b/frontend/src/__tests__/components/spoolbuddy/SpoolIcon.test.tsx
@@ -53,4 +53,50 @@ describe('SpoolIcon', () => {
expect(div.style.width).toBe('48px');
expect(div.style.height).toBe('48px');
});
+
+ describe('a spool with extra colours or an effect (#3033)', () => {
+ const dualColour = { rgba: '044482FF', extra_colors: '044482,f8d008', effect_type: 'dual-color' };
+
+ it('paints the disc with the Filament page background instead of a flat fill', () => {
+ const { container, getAllByTestId, getByTestId } = render(
+