From 33ab5f1ead03bebb9bbaf2df832ae21c8aff1bdb Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 4 Aug 2026 12:38:36 +0200 Subject: [PATCH] Add temperatures to the streaming overlay and a URL builder (#1422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay at /overlay/{printer} draws live print data over a full-screen camera view for OBS, a wall display or any browser source. It has been tunable since it shipped -- which fields, what size, what frame rate -- but only through query parameters documented in the wiki, and temperatures were not among the fields on offer. The request asked for temperatures first and for the field set to be selectable in the web UI; this addresses both. Nozzle, bed and chamber readings join the list. The target is drawn only while the heater is still climbing, so a settled hotend reads "220°C" for the rest of the print instead of the noisier "220 / 220°C" -- 219.6 against a target of 220 rounds to the same number, and repeating it says nothing. Both nozzles appear on a dual-nozzle machine. They are drawn whether or not a print is running, because a preheating printer is exactly when they are worth watching, and each reading appears only when the printer genuinely reports one: chamber temperature stays absent on P1 and A1 models, which publish a chamber_temper with no sensor behind it, so the overlay never puts a measurement on screen that does not exist. Labels reuse the heater chart's strings rather than inventing a second vocabulary for the same three things. The feed sends an allow-list rather than the temperatures dict. That dict doubles as the MQTT client's working memory -- derived heater flags and private target-set timestamps live alongside the readings -- and an overlay token is a narrower grant than a login, so it gets exactly what the overlay draws and does not pick up fields as the dict grows. The same chamber-sensor gate the full status payload already applies is applied here. The integration test that asserts the payload's exact key set, which exists to catch that surface widening silently, is updated deliberately. Temperatures are not in the default field set, so an overlay URL already pasted into a scene renders identically after upgrading. Settings -> API Keys -> Streaming Overlay now builds the URL: printer, field checkboxes, size, frame rate, camera toggle, an optional token, and a copy button. It persists nothing and calls nothing new -- the URL is the configuration, which keeps a scene reproducible by copy-paste and lets two displays show different fields off one token. Fields are emitted in the overlay's own top-to-bottom order rather than click order, and parameters left at their default are omitted, so the same selection always produces the same URL. The preview alongside it stays off until asked for: an always-live iframe would hold a subscriber on the printer's single camera connection for as long as the settings tab stayed open. The preview needed one narrow security-header change. Every SPA route sent frame-ancestors 'none', which is stricter than the SAMEORIGIN in X-Frame-Options beside it and refuses even a same-origin frame, so the preview showed Firefox's "another site has embedded it" page instead of the overlay. The overlay path now sends 'self', mirroring /gcode-viewer, which admits a framer only on this origin -- Bambuddy's own UI. Every other path keeps 'none', and embedding the overlay from another host still requires TRUSTED_FRAME_ORIGINS. --- CHANGELOG.md | 3 + backend/app/api/routes/printers.py | 5 + backend/app/main.py | 14 +- backend/app/services/printer_manager.py | 42 +++ .../integration/test_overlay_status_api.py | 51 +++ .../integration/test_security_headers.py | 37 ++ .../unit/services/test_printer_manager.py | 51 +++ .../components/StreamOverlayBuilder.test.tsx | 156 +++++++++ .../pages/StreamOverlayPage.test.tsx | 145 ++++++++ frontend/src/api/client.ts | 4 + .../src/components/StreamOverlayBuilder.tsx | 301 ++++++++++++++++ frontend/src/i18n/locales/de.ts | 28 ++ frontend/src/i18n/locales/en.ts | 28 ++ frontend/src/i18n/locales/es.ts | 28 ++ frontend/src/i18n/locales/fr.ts | 28 ++ frontend/src/i18n/locales/it.ts | 28 ++ frontend/src/i18n/locales/ja.ts | 28 ++ frontend/src/i18n/locales/ko.ts | 28 ++ frontend/src/i18n/locales/pt-BR.ts | 28 ++ frontend/src/i18n/locales/ru.ts | 28 ++ frontend/src/i18n/locales/tr.ts | 28 ++ frontend/src/i18n/locales/uk.ts | 28 ++ frontend/src/i18n/locales/zh-CN.ts | 28 ++ frontend/src/i18n/locales/zh-TW.ts | 28 ++ frontend/src/pages/SettingsPage.tsx | 18 +- frontend/src/pages/StreamOverlayPage.tsx | 130 ++++++- .../{index-Ds22o6-q.js => index-CFRqaod2.js} | 324 +++++++++--------- static/index.html | 2 +- 28 files changed, 1481 insertions(+), 166 deletions(-) create mode 100644 frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx create mode 100644 frontend/src/components/StreamOverlayBuilder.tsx rename static/assets/{index-Ds22o6-q.js => index-CFRqaod2.js} (75%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d36dfaa..0fe69b42b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased +### Added +- **Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW)** — The overlay at `/overlay/{printer}` draws live print data over a full-screen camera view for OBS, a wall display or any browser source. It could already be tuned — which fields, what size, what frame rate — but only through query parameters documented in the wiki, and temperatures were not among the fields on offer. Both are now addressed. Nozzle, bed and chamber readings join the list, shown with the target while the heater is still climbing and with the target dropped once it is reached, so a settled hotend reads "220°C" rather than "220 / 220°C" for the rest of the print. Both nozzles appear on a dual-nozzle printer. They are drawn whether or not a print is running, since a preheating machine is exactly when they are worth watching, and each reading appears only when the printer genuinely reports it — chamber temperature stays absent on P1 and A1 models, which publish a value with no sensor behind it. And **Settings → API Keys → Streaming Overlay** now builds the URL for you: pick the printer, tick the fields, set size and frame rate, paste in a token if login is enabled, and copy the result, with an optional preview alongside it. The preview stays off until you ask for it so that leaving the settings page open does not hold a viewer on the printer's single camera connection. Making that preview possible needed one narrow change to the security headers: the overlay path now sends `frame-ancestors 'self'` instead of `'none'`, so Bambuddy's own UI can embed it. Every other page still refuses to be framed at all, `'self'` permits a framer only on this same origin, and embedding the overlay from another host — Home Assistant on a different port, say — is unchanged and still requires `TRUSTED_FRAME_ORIGINS`. Temperatures are not in the default field set, so an overlay URL already pasted into a scene looks exactly the same after upgrading. Translated in all locales, wiki updated, covered by backend and frontend tests. + ### Fixed - **A drying cycle no longer reports itself finished a minute after it starts (#2759)** — Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools and picking PLA showed "PLA @ 45°C" for about a minute, then switched to "PETG @ 65°C" for the remaining twelve hours. Bambu never echoes back which filament or temperature a cycle is running, so the badge reads the target Bambuddy cached when it sent the command — and that cache had been thrown away. Between accepting the command and settling its countdown the firmware publishes one update with the remaining time at zero while the unit is still in its Checking phase; the reporter's log caught 720 minutes, then 0, then 719. Bambuddy read the zero as the cycle ending. Losing the cached target left the badge to guess the filament from the first loaded slot, which happened to be PETG, and its RFID-recommended 65°C — a confident wrong answer for a cycle running PLA at 45. The same false ending also armed smart-plug auto-off-after-drying, so anyone with that switched on had power scheduled to cut one minute into a twelve-hour dry. A remaining time of zero is now only treated as the end of a cycle when the AMS also reports an idle phase, which the firmware already publishes alongside it; stopping a dry early still ends it immediately, and a unit that reports no phase at all still ends its cycles as before. The fallback guess has been tightened to match: it now answers only when every loaded spool is the same filament, and on a mixed unit the badge shows the countdown alone rather than naming a temperature the cycle isn't using. Covered by backend tests. - **A print that never starts now says AMS drying was running, instead of blaming the SD card (#2758)** — Sending a job to an X2D with two AMS units mid-drying failed silently: the file uploaded, the printer accepted it and then simply stayed idle. Bambuddy waited out the start watchdog, re-uploaded the whole 3MF, waited again, and after three attempts gave up with advice to check the printer's screen and the SD card — while Bambu Studio, asked directly, said it could not start the job because of the drying. Bambuddy now watches the AMS drying telemetry it already receives across the dispatch window and, when a job never starts while a unit was drying, names the units in the failure message and records the correlation in the log from the first attempt rather than only after the retries are spent. This is deliberately a diagnosis and not a rule: the printers concerned support drying *continuing* through a print, so drying and printing are not in conflict as such, and the report also involved one AMS drying without its external power supply — which would make the start-of-print calibration a power problem rather than a drying one. Stopping the cycle automatically would therefore be acting on a guess, and could tear down drying the hardware was happy to continue. Until it is known which of the two is the real obstacle, Bambuddy tells you what it saw and leaves the call to you. The message for a stalled dispatch with no drying involved is unchanged. Wiki updated. Covered by backend tests. diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index d26ec973e..3608bbe6c 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -52,6 +52,7 @@ from backend.app.services.bambu_ftp import ( ) from backend.app.services.printer_diagnostic import run_connection_diagnostic from backend.app.services.printer_manager import ( + display_temperatures, drying_screen_only, get_derived_status_name, printer_manager, @@ -868,6 +869,7 @@ async def get_overlay_status( "layer_num": None, "total_layers": None, "stg_cur_name": None, + "temperatures": {}, "time_format": time_format, } @@ -884,6 +886,9 @@ async def get_overlay_status( "layer_num": state.layer_num, "total_layers": state.total_layers, "stg_cur_name": get_derived_status_name(state, printer.model), + # Nozzle / bed / chamber readings for the overlay's temperature fields + # (#1422). Filtered rather than passed through: see display_temperatures. + "temperatures": display_temperatures(state.temperatures, printer.model), "time_format": time_format, } diff --git a/backend/app/main.py b/backend/app/main.py index 300ca7487..4e5de2a52 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7699,6 +7699,18 @@ async def security_headers_middleware(request, call_next): "base-uri 'self'; " + _frame_ancestors("'none'") ) else: + # The streaming overlay is embedded same-origin by the URL builder's + # preview in Settings (#1422) — the same reason /gcode-viewer allows + # 'self' above. Embedding from anywhere else is still refused: 'self' + # only permits a framer on this origin, which is Bambuddy's own UI, so + # a clickjacking page on another host is blocked exactly as before. + # (The overlay draws status over a camera feed and its only interactive + # element is the logo link, so there is nothing to bait a click into + # even from a same-origin framer.) Cross-origin embedding of the + # overlay — Home Assistant on another port — remains what + # TRUSTED_FRAME_ORIGINS is for, and _frame_ancestors already folds that + # allowlist in. + embeddable_same_origin = request.url.path.startswith("/overlay/") response.headers["Content-Security-Policy"] = ( "default-src 'self'; " f"script-src 'self' 'nonce-{csp_nonce}'; " @@ -7709,7 +7721,7 @@ async def security_headers_middleware(request, call_next): "font-src 'self' data:; " "object-src 'none'; " "base-uri 'self'; " - "frame-src 'self' http: https:; " + _frame_ancestors("'none'") + "frame-src 'self' http: https:; " + _frame_ancestors("'self'" if embeddable_same_origin else "'none'") ) if request.url.scheme == "https": response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index 5fa898191..076bfed3f 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -238,6 +238,48 @@ def drying_screen_only(model: str | None) -> bool: return model.strip().upper() in _DRYING_SCREEN_ONLY_MODELS +# Temperature keys the UI actually draws. `state.temperatures` is also working +# memory: it carries private bookkeeping (`_nozzle_target_set_time`) and derived +# flags (`nozzle_heating`) that no consumer outside this module should see. The +# full-status path hands out the whole dict to logged-in callers; the streaming +# overlay gets only this list, because an overlay token is a narrower grant than +# a login and should not pick up fields by accident as the dict grows. +DISPLAY_TEMPERATURE_KEYS = ( + "nozzle", + "nozzle_target", + "nozzle_2", + "nozzle_2_target", + "bed", + "bed_target", + "chamber", + "chamber_target", +) + + +def display_temperatures(temperatures: dict | None, model: str | None) -> dict[str, float]: + """Filter `state.temperatures` down to the readings a viewer is shown. + + Drops chamber readings on models without a real chamber sensor — P1P, P1S, + A1 and A1 mini all report a meaningless `chamber_temper` — matching what + ``printer_state_to_dict`` already does for the full status payload. + """ + if not temperatures: + return {} + allow_chamber = supports_chamber_temp(model) + out: dict[str, float] = {} + for key in DISPLAY_TEMPERATURE_KEYS: + if key.startswith("chamber") and not allow_chamber: + continue + value = temperatures.get(key) + if value is None: + continue + try: + out[key] = float(value) + except (TypeError, ValueError): + continue + return out + + def uniform_tray_drying_hint(loaded_trays: list[tuple[str, object]]) -> tuple[str | None, int | None]: """Guess an active cycle's filament + target temperature from the loaded trays. diff --git a/backend/tests/integration/test_overlay_status_api.py b/backend/tests/integration/test_overlay_status_api.py index 06e469977..0130409a6 100644 --- a/backend/tests/integration/test_overlay_status_api.py +++ b/backend/tests/integration/test_overlay_status_api.py @@ -156,6 +156,7 @@ class TestOverlayFeedPayload: "layer_num", "total_layers", "stg_cur_name", + "temperatures", "time_format", } @@ -171,6 +172,56 @@ class TestOverlayFeedPayload: assert entry["connected"] is False assert entry["state"] is None assert entry["current_print"] is None + # Present but empty rather than absent (#1422): the overlay reads the + # key unconditionally, and an offline printer simply has no readings. + assert entry["temperatures"] == {} + + async def test_temperatures_are_filtered_not_passed_through( + self, async_client: AsyncClient, printer_row, monkeypatch + ): + """#1422 — the overlay can draw nozzle/bed/chamber, so the feed carries + them. It sends only the readings it draws: `state.temperatures` is also + the MQTT client's working memory and holds private bookkeeping and + derived heater flags that an overlay token has no business seeing. + """ + from backend.app.services import printer_manager as pm + + class _FakeState: + connected = True + state = "RUNNING" + current_print = "bracket.3mf" + gcode_file = "/data/Metadata/plate_1.gcode" + progress = 42.0 + remaining_time = 30 + layer_num = 10 + total_layers = 100 + stg_cur = -1 + temperatures = { + "nozzle": 219.7, + "nozzle_target": 220.0, + "bed": 60.0, + "bed_target": 60.0, + "chamber": 38.0, + "nozzle_heating": True, + "_nozzle_target_set_time": 1754300000.0, + } + + monkeypatch.setattr(pm.printer_manager, "get_status", lambda _pid: _FakeState()) + + jwt = await _setup_admin(async_client, suffix="_temps") + overlay_token = await _mint(async_client, jwt, scope="overlay") + + response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}") + temps = response.json()["temperatures"] + + assert temps["nozzle"] == 219.7 + assert temps["nozzle_target"] == 220.0 + assert temps["bed"] == 60.0 + # The fixture printer is a P1S — no real chamber sensor, so the + # meaningless reading is dropped rather than drawn on a live stream. + assert "chamber" not in temps + assert "nozzle_heating" not in temps + assert "_nozzle_target_set_time" not in temps async def test_unknown_printer_is_404_not_401(self, async_client: AsyncClient): """A valid token for a printer id that doesn't exist is a 404 — the token diff --git a/backend/tests/integration/test_security_headers.py b/backend/tests/integration/test_security_headers.py index afa1c5f5f..0cc9980b0 100644 --- a/backend/tests/integration/test_security_headers.py +++ b/backend/tests/integration/test_security_headers.py @@ -112,6 +112,43 @@ async def test_default_headers_strict(async_client: AsyncClient, monkeypatch): assert "frame-ancestors 'none'" in resp.headers.get("Content-Security-Policy", "") +@pytest.mark.asyncio +@pytest.mark.integration +async def test_overlay_route_allows_same_origin_framing(async_client: AsyncClient, monkeypatch): + """#1422 — the overlay is framed same-origin by the URL builder's preview. + + 'none' blocks that too, which is why the preview showed Firefox's "will not + allow Firefox to display the page if another site has embedded it". 'self' + permits only a framer on this origin — Bambuddy's own UI — so a + clickjacking page on another host is refused exactly as before. + """ + from backend.app import main as main_module + + monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ()) + + resp = await async_client.get("/overlay/1") + csp = resp.headers.get("Content-Security-Policy", "") + assert "frame-ancestors 'self';" in csp + # The legacy header already permitted same-origin framing; only the CSP was + # blocking it. Assert it still says so rather than being dropped. + assert resp.headers.get("X-Frame-Options") == "SAMEORIGIN" + + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_other_spa_routes_still_refuse_all_framing(async_client: AsyncClient, monkeypatch): + """The #1422 carve-out is the overlay path only — everything else keeps + 'none', including paths that merely start with something similar.""" + from backend.app import main as main_module + + monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ()) + + for path in ("/", "/settings", "/printers", "/overlays", "/camwall"): + resp = await async_client.get(path) + csp = resp.headers.get("Content-Security-Policy", "") + assert "frame-ancestors 'none'" in csp, f"{path} must not be framable" + + @pytest.mark.asyncio @pytest.mark.integration async def test_trusted_origins_relaxes_csp_and_drops_xfo(async_client: AsyncClient, monkeypatch): diff --git a/backend/tests/unit/services/test_printer_manager.py b/backend/tests/unit/services/test_printer_manager.py index 9d4984517..48ae70fd9 100644 --- a/backend/tests/unit/services/test_printer_manager.py +++ b/backend/tests/unit/services/test_printer_manager.py @@ -10,6 +10,7 @@ import pytest from backend.app.services.printer_manager import ( PrinterManager, + display_temperatures, drying_screen_only, get_derived_status_name, has_stg_cur_idle_bug, @@ -1478,6 +1479,56 @@ class TestDryingTargetExposure: assert result["ams"][0]["dry_target_temp"] == 45 +class TestDisplayTemperatures: + """#1422 — the readings handed to the streaming overlay. + + `state.temperatures` doubles as the MQTT client's working memory: alongside + the readings it carries derived heater flags and private timestamps. The + overlay feed is reached by a token rather than a login, so it gets an + allow-list rather than the dict. + """ + + def test_keeps_the_readings_the_overlay_draws(self): + result = display_temperatures({"nozzle": 219.5, "nozzle_target": 220.0, "bed": 60.0, "bed_target": 60.0}, "X1C") + assert result == {"nozzle": 219.5, "nozzle_target": 220.0, "bed": 60.0, "bed_target": 60.0} + + def test_drops_heater_flags_and_private_bookkeeping(self): + result = display_temperatures( + { + "nozzle": 219.5, + "nozzle_heating": True, + "bed_heating": False, + "_nozzle_target_set_time": 1754300000.0, + "_chamber_target_set_time": 1754300000.0, + }, + "X1C", + ) + assert result == {"nozzle": 219.5} + + def test_chamber_kept_on_models_with_a_real_sensor(self): + result = display_temperatures({"chamber": 38.0, "chamber_target": 40.0}, "X1C") + assert result == {"chamber": 38.0, "chamber_target": 40.0} + + def test_chamber_dropped_on_models_without_one(self): + """P1P, P1S, A1 and A1 mini publish a meaningless chamber_temper. Drawing + it on a live stream would state a measurement that doesn't exist.""" + for model in ("P1S", "P1P", "A1", "A1MINI"): + assert display_temperatures({"nozzle": 200.0, "chamber": 38.0}, model) == {"nozzle": 200.0} + + def test_second_nozzle_is_included(self): + result = display_temperatures({"nozzle": 220.0, "nozzle_2": 240.0, "nozzle_2_target": 250.0}, "H2D") + assert result == {"nozzle": 220.0, "nozzle_2": 240.0, "nozzle_2_target": 250.0} + + def test_unparseable_and_missing_values_are_skipped(self): + """A reading that isn't a number is dropped rather than crashing the + feed or reaching the page as a string.""" + assert display_temperatures({"nozzle": None, "bed": "warm", "chamber": 38.0}, "X1C") == {"chamber": 38.0} + + def test_empty_and_none_are_empty(self): + assert display_temperatures(None, "X1C") == {} + assert display_temperatures({}, "X1C") == {} + + class TestSupportsChamberTemp: """Tests for supports_chamber_temp helper function.""" diff --git a/frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx b/frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx new file mode 100644 index 000000000..c1ad94b93 --- /dev/null +++ b/frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx @@ -0,0 +1,156 @@ +/** + * Tests for the streaming-overlay URL builder (#1422). + * + * The builder's whole output is a URL, so that is what these assert: the field + * order, what is omitted at its default, and that the preview does not open a + * camera stream until it is asked to. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render } from '../utils'; +import { server } from '../mocks/server'; +import { StreamOverlayBuilder } from '../../components/StreamOverlayBuilder'; + +const printers = [ + { id: 1, name: 'X1 Carbon', ip_address: '192.168.1.100', serial_number: '00M09A350100001', model: 'X1C' }, + { id: 2, name: 'P1S', ip_address: '192.168.1.101', serial_number: '01P00A000000002', model: 'P1S' }, +]; + +// The URL is rendered inside a , so read it back the way a user would. +function shownUrl(): string { + const code = document.querySelector('code'); + return code?.textContent ?? ''; +} + +describe('StreamOverlayBuilder', () => { + beforeEach(() => { + server.use(http.get('/api/v1/printers', () => HttpResponse.json(printers))); + }); + + it('starts on the first printer with the overlay defaults', async () => { + render(); + + await waitFor(() => { + expect(shownUrl()).toContain('/overlay/1'); + }); + // The same set parseConfig() defaults to, so the builder's starting point + // and a bare /overlay/1 render the same overlay. Emitted in the overlay's + // own top-to-bottom field order rather than parseConfig's listing order — + // ?show= is read with includes(), so order is free to be the stable one. + expect(shownUrl()).toContain('show=filename%2Cstatus%2Cprogress%2Clayers%2Ceta'); + // Defaults are omitted rather than spelled out — a shorter URL to paste. + expect(shownUrl()).not.toContain('size='); + expect(shownUrl()).not.toContain('fps='); + expect(shownUrl()).not.toContain('camera='); + expect(shownUrl()).not.toContain('token='); + }); + + it('switches printer', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getByLabelText('Printer')).toBeInTheDocument()); + await user.selectOptions(screen.getByLabelText('Printer'), '2'); + + await waitFor(() => expect(shownUrl()).toContain('/overlay/2')); + }); + + it('adds a temperature field the URL did not have', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getByLabelText('Nozzle')).toBeInTheDocument()); + await user.click(screen.getByLabelText('Nozzle')); + + await waitFor(() => + expect(shownUrl()).toContain('show=filename%2Cstatus%2Cprogress%2Clayers%2Ceta%2Cnozzle'), + ); + }); + + it('emits fields in the overlay order, not the order they were clicked', async () => { + const user = userEvent.setup(); + render(); + + // "Printer name" is first in the overlay's own top-to-bottom order, so + // ticking it last must still put it at the front. Otherwise the same + // selection would produce a different URL depending on click order, and a + // scene file would stop being comparable to the one next to it. + await waitFor(() => expect(screen.getByLabelText('Printer name')).toBeInTheDocument()); + await user.click(screen.getByLabelText('Printer name')); + + await waitFor(() => expect(shownUrl()).toContain('show=printer%2Cfilename')); + }); + + it('drops a field when its box is cleared', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getByLabelText('Layer count')).toBeInTheDocument()); + await user.click(screen.getByLabelText('Layer count')); + + await waitFor(() => expect(shownUrl()).not.toContain('layers')); + expect(shownUrl()).toContain('progress'); + }); + + it('emits camera=false when the camera feed is switched off', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getByLabelText('Camera feed')).toBeInTheDocument()); + await user.click(screen.getByLabelText('Camera feed')); + + await waitFor(() => expect(shownUrl()).toContain('camera=false')); + }); + + it('emits size and fps only when they differ from the defaults', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getByLabelText('Text size')).toBeInTheDocument()); + await user.selectOptions(screen.getByLabelText('Text size'), 'large'); + await waitFor(() => expect(shownUrl()).toContain('size=large')); + + await user.selectOptions(screen.getByLabelText('Text size'), 'medium'); + await waitFor(() => expect(shownUrl()).not.toContain('size=')); + }); + + it('appends a token and warns that the URL is now a key', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getByLabelText(/token/i)).toBeInTheDocument()); + expect(screen.queryByText(/This URL contains a token/)).not.toBeInTheDocument(); + + await user.type(screen.getByLabelText(/token/i), 'bblt_abc'); + + await waitFor(() => expect(shownUrl()).toContain('token=bblt_abc')); + expect(screen.getByText(/This URL contains a token/)).toBeInTheDocument(); + }); + + it('opens no camera stream until the preview is asked for', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getByText('Show preview')).toBeInTheDocument()); + // An always-on preview would hold a subscriber on the printer's single + // camera connection for as long as the settings tab stays open. + expect(document.querySelector('iframe')).toBeNull(); + + await user.click(screen.getByText('Show preview')); + + await waitFor(() => expect(document.querySelector('iframe')).not.toBeNull()); + expect(document.querySelector('iframe')?.getAttribute('src')).toContain('/overlay/1'); + }); + + it('still builds a URL when the printer list cannot be loaded', async () => { + server.use(http.get('/api/v1/printers', () => HttpResponse.json({ detail: 'nope' }, { status: 500 }))); + render(); + + // Falls back to printer 1 rather than rendering /overlay/null — the number + // is the one thing the user can fix by hand in the URL. + await waitFor(() => expect(shownUrl()).toContain('/overlay/1')); + }); +}); diff --git a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx index 639fa5a53..5c9d5ddf3 100644 --- a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx +++ b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx @@ -430,4 +430,149 @@ describe('StreamOverlayPage', () => { expect(WebSocket).not.toHaveBeenCalled(); }); }); + + describe('temperatures (#1422)', () => { + const withTemps = { + ...mockStatusPrinting, + temperatures: { + nozzle: 219.6, + nozzle_target: 220, + bed: 60, + bed_target: 60, + chamber: 38.4, + }, + }; + + beforeEach(() => { + server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(withTemps))); + }); + + it('draws no temperatures unless the URL asks for them', async () => { + renderOverlayPage(1); + + await waitFor(() => { + expect(screen.getByText('45%')).toBeInTheDocument(); + }); + // Default ?show= is unchanged by #1422, so overlays already running in an + // OBS scene look identical after the upgrade. + expect(screen.queryByText('Nozzle')).not.toBeInTheDocument(); + expect(screen.queryByText('Bed')).not.toBeInTheDocument(); + }); + + it('draws only the readings named in ?show=', async () => { + renderOverlayPage(1, '?show=progress,nozzle'); + + await waitFor(() => { + expect(screen.getByText('Nozzle')).toBeInTheDocument(); + }); + expect(screen.queryByText('Bed')).not.toBeInTheDocument(); + expect(screen.queryByText('Chamber')).not.toBeInTheDocument(); + }); + + it('rounds the reading and hides a target it has already reached', async () => { + renderOverlayPage(1, '?show=nozzle,bed'); + + await waitFor(() => { + expect(screen.getByText('220°C')).toBeInTheDocument(); + }); + // Nozzle is 219.6 against a target of 220: both round to 220, so the + // "/ 220°C" half is dropped rather than reading "220 / 220°C" all print. + expect(screen.queryByText('/')).not.toBeInTheDocument(); + expect(screen.getByText('60°C')).toBeInTheDocument(); + }); + + it('shows the target while the heater is still climbing', async () => { + server.use( + http.get('/api/v1/printers/:id/status', () => + HttpResponse.json({ ...withTemps, temperatures: { nozzle: 140, nozzle_target: 220 } }), + ), + ); + renderOverlayPage(1, '?show=nozzle'); + + await waitFor(() => { + expect(screen.getByText('140°C')).toBeInTheDocument(); + }); + expect(screen.getByText('220°C')).toBeInTheDocument(); + }); + + it('skips a reading the printer does not report', async () => { + server.use( + http.get('/api/v1/printers/:id/status', () => + // A P1S: the backend drops chamber for models without a real sensor, + // so asking for it in ?show= must not produce an empty row. + HttpResponse.json({ ...withTemps, temperatures: { nozzle: 200, bed: 55 } }), + ), + ); + renderOverlayPage(1, '?show=nozzle,bed,chamber'); + + await waitFor(() => { + expect(screen.getByText('Nozzle')).toBeInTheDocument(); + }); + expect(screen.queryByText('Chamber')).not.toBeInTheDocument(); + }); + + it('draws both nozzles on a dual-nozzle printer', async () => { + server.use( + http.get('/api/v1/printers/:id/status', () => + HttpResponse.json({ + ...withTemps, + temperatures: { nozzle: 220, nozzle_2: 250, nozzle_2_target: 250 }, + }), + ), + ); + renderOverlayPage(1, '?show=nozzle'); + + await waitFor(() => { + expect(screen.getByText('Nozzle')).toBeInTheDocument(); + }); + expect(screen.getByText('Nozzle 2')).toBeInTheDocument(); + expect(screen.getByText('250°C')).toBeInTheDocument(); + }); + + it('draws temperatures while the printer is idle', async () => { + server.use( + http.get('/api/v1/printers/:id/status', () => + HttpResponse.json({ ...mockStatusIdle, temperatures: { bed: 45, bed_target: 60 } }), + ), + ); + renderOverlayPage(1, '?show=bed'); + + await waitFor(() => { + expect(screen.getByText('Printer is idle')).toBeInTheDocument(); + }); + // A preheating printer is exactly when the readings are worth watching, + // so they are not gated behind a running print. + expect(screen.getByText('45°C')).toBeInTheDocument(); + expect(screen.getByText('60°C')).toBeInTheDocument(); + }); + + it('reads temperatures from the token-authed feed in kiosk mode', async () => { + server.use( + http.get('/api/v1/printers/:id/overlay-status', () => + HttpResponse.json({ + id: 1, + name: 'X1 Carbon', + camera_rotation: 0, + connected: true, + state: 'RUNNING', + current_print: 'KioskBenchy.gcode.3mf', + gcode_file: 'plate_1.gcode', + progress: 67, + remaining_time: 40, + layer_num: 10, + total_layers: 20, + stg_cur_name: null, + temperatures: { chamber: 38, chamber_target: 40 }, + time_format: 'system', + }), + ), + ); + renderOverlayPage(1, '?token=obs-tok&show=chamber'); + + await waitFor(() => { + expect(screen.getByText('Chamber')).toBeInTheDocument(); + }); + expect(screen.getByText('38°C')).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f500ea6c4..0bdf7b3b9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -341,6 +341,10 @@ export interface OverlayStatus { layer_num: number | null; total_layers: number | null; stg_cur_name: string | null; + // Nozzle / bed / chamber readings for the overlay's temperature fields + // (#1422). Only the keys a viewer is shown; chamber is absent on models + // without a real sensor. + temperatures: Record; time_format: 'system' | '12h' | '24h'; } diff --git a/frontend/src/components/StreamOverlayBuilder.tsx b/frontend/src/components/StreamOverlayBuilder.tsx new file mode 100644 index 000000000..e8b742453 --- /dev/null +++ b/frontend/src/components/StreamOverlayBuilder.tsx @@ -0,0 +1,301 @@ +/** + * Streaming-overlay URL builder (#1422). + * + * The overlay at /overlay/{printerId} has been configurable by query string + * since #2613, but only for people who found the parameters in the wiki. The + * issue asked for the field set to be selectable "through the web UI"; this is + * that surface. It composes a URL, it does not persist anything — the URL *is* + * the configuration, which keeps a scene in OBS reproducible by copy-paste and + * means two displays can show different fields off one token. + */ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Copy, ExternalLink, Eye, EyeOff } from 'lucide-react'; +import { api, type Printer } from '../api/client'; +import { useToast } from '../contexts/ToastContext'; + +type OverlaySize = 'small' | 'medium' | 'large'; + +// Order matters: it is the order the fields appear in the overlay, so the +// checkbox list reads as a preview of the result. +const FIELDS = [ + { key: 'printer', labelKey: 'streamOverlay.builder.fieldPrinter', fallback: 'Printer name' }, + { key: 'filename', labelKey: 'streamOverlay.builder.fieldFilename', fallback: 'File name' }, + { key: 'status', labelKey: 'streamOverlay.builder.fieldStatus', fallback: 'Status' }, + { key: 'progress', labelKey: 'streamOverlay.builder.fieldProgress', fallback: 'Progress bar' }, + { key: 'layers', labelKey: 'streamOverlay.builder.fieldLayers', fallback: 'Layer count' }, + { key: 'eta', labelKey: 'streamOverlay.builder.fieldEta', fallback: 'Time remaining and ETA' }, + { key: 'nozzle', labelKey: 'printers.heaterHistory.nozzle', fallback: 'Nozzle' }, + { key: 'bed', labelKey: 'printers.heaterHistory.bed', fallback: 'Bed' }, + { key: 'chamber', labelKey: 'printers.heaterHistory.chamber', fallback: 'Chamber' }, +] as const; + +// Matches parseConfig() in StreamOverlayPage: the fields an overlay shows when +// the URL carries no ?show= at all. +const DEFAULT_FIELDS = ['progress', 'layers', 'eta', 'filename', 'status']; + +const DEFAULT_FPS = 15; + +export function StreamOverlayBuilder() { + const { t } = useTranslation(); + const { showToast } = useToast(); + + const [printers, setPrinters] = useState([]); + const [printerId, setPrinterId] = useState(null); + const [fields, setFields] = useState(DEFAULT_FIELDS); + const [size, setSize] = useState('medium'); + const [fps, setFps] = useState(DEFAULT_FPS); + const [showCamera, setShowCamera] = useState(true); + const [token, setToken] = useState(''); + const [preview, setPreview] = useState(false); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const list = await api.getPrinters(); + if (cancelled) return; + setPrinters(list); + if (list.length > 0) setPrinterId(list[0].id); + } catch { + // A failed printer list only costs the picker its options — the builder + // still works if the user types a printer number into the URL by hand, + // so this is not worth a toast on a settings page they may just be + // scrolling past. + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const url = useMemo(() => { + const id = printerId ?? 1; + const params = new URLSearchParams(); + // Emit ?show= in the canonical field order rather than click order, so the + // same selection always produces the same URL. + const selected = FIELDS.filter((f) => fields.includes(f.key)).map((f) => f.key); + params.set('show', selected.join(',')); + if (size !== 'medium') params.set('size', size); + if (fps !== DEFAULT_FPS) params.set('fps', String(fps)); + if (!showCamera) params.set('camera', 'false'); + if (token.trim()) params.set('token', token.trim()); + return `${window.location.origin}/overlay/${id}?${params.toString()}`; + }, [printerId, fields, size, fps, showCamera, token]); + + const toggleField = (key: string) => { + setFields((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key])); + }; + + const copyUrl = async () => { + try { + // Same fallback as the token dialog: the clipboard API needs a secure + // context, and plenty of Bambuddy installs are plain HTTP on a LAN. + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(url); + } else { + const ta = document.createElement('textarea'); + ta.value = url; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + try { + ta.select(); + document.execCommand('copy'); + } finally { + document.body.removeChild(ta); + } + } + showToast(t('cameraTokens.toast.copied', 'Copied to clipboard')); + } catch { + showToast(t('cameraTokens.toast.copyFailed', 'Copy failed — select and copy manually'), 'error'); + } + }; + + return ( +
+

+ {t( + 'streamOverlay.builder.description', + 'Build the URL for a streaming overlay — a full-screen camera view with live print data drawn over it, for OBS, a wall display, or any browser source. Pick the fields you want and copy the URL.', + )} +

+ +
+
+ + +
+ +
+ + +
+ +
+ + setFps(Math.min(Math.max(Number(e.target.value) || 1, 1), 30))} + className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none" + /> +

+ {t( + 'streamOverlay.builder.fpsHint', + 'A1 and P1 cameras top out around 5 fps whatever you ask for.', + )} +

+
+ +
+ + setToken(e.target.value)} + placeholder="bblt_…" + className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none font-mono text-xs" + /> +

+ {t( + 'streamOverlay.builder.tokenHint', + 'Only needed when login is enabled: OBS has no session of its own. Create one above with the Streaming Overlay scope.', + )} +

+
+
+ +
+ + {t('streamOverlay.builder.fields', 'Fields to show')} + +
+ {FIELDS.map((field) => ( + + ))} + +
+

+ {t( + 'streamOverlay.builder.chamberHint', + 'Chamber temperature only appears on models with a real chamber sensor — P1 and A1 printers report a meaningless value, so it is left out there.', + )} +

+
+ +
+

+ {t('streamOverlay.builder.urlTitle', 'Overlay URL')} +

+
+ + {url} + + + + + {t('streamOverlay.builder.open', 'Open')} + +
+ {token.trim() && ( +

+ {t( + 'streamOverlay.builder.tokenWarning', + 'This URL contains a token — anyone who can read it can watch the stream and see the file name. Revoke the token to cut it off.', + )} +

+ )} +
+ + {/* The preview opens a real camera stream, so it stays off until asked + for. Leaving one running behind a settings tab would hold a subscriber + on the printer's single camera connection for as long as the tab is + open. */} +
+ + {preview && ( +