Просмотр исходного кода

Add temperatures to the streaming overlay and a URL builder (#1422)

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.
maziggy 1 месяц назад
Родитель
Сommit
33ab5f1ead

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 5 - 0
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,
     }
 

+ 13 - 1
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"

+ 42 - 0
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.
 

+ 51 - 0
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

+ 37 - 0
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):

+ 51 - 0
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."""
 

+ 156 - 0
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 <code>, 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(<StreamOverlayBuilder />);
+
+    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(<StreamOverlayBuilder />);
+
+    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(<StreamOverlayBuilder />);
+
+    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(<StreamOverlayBuilder />);
+
+    // "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(<StreamOverlayBuilder />);
+
+    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(<StreamOverlayBuilder />);
+
+    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(<StreamOverlayBuilder />);
+
+    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(<StreamOverlayBuilder />);
+
+    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(<StreamOverlayBuilder />);
+
+    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(<StreamOverlayBuilder />);
+
+    // 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'));
+  });
+});

+ 145 - 0
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();
+    });
+  });
 });

+ 4 - 0
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<string, number>;
   time_format: 'system' | '12h' | '24h';
 }
 

+ 301 - 0
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<Printer[]>([]);
+  const [printerId, setPrinterId] = useState<number | null>(null);
+  const [fields, setFields] = useState<string[]>(DEFAULT_FIELDS);
+  const [size, setSize] = useState<OverlaySize>('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 (
+    <div>
+      <p className="text-sm text-bambu-gray mb-4">
+        {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.',
+        )}
+      </p>
+
+      <div className="grid gap-4 md:grid-cols-2">
+        <div>
+          <label
+            htmlFor="overlay-builder-printer"
+            className="block text-sm font-medium text-white mb-1"
+          >
+            {t('streamOverlay.builder.printer', 'Printer')}
+          </label>
+          <select
+            id="overlay-builder-printer"
+            value={printerId ?? ''}
+            onChange={(e) => setPrinterId(Number(e.target.value))}
+            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"
+          >
+            {printers.length === 0 && <option value="">{t('common.loading', 'Loading…')}</option>}
+            {printers.map((p) => (
+              <option key={p.id} value={p.id}>
+                {p.name}
+              </option>
+            ))}
+          </select>
+        </div>
+
+        <div>
+          <label htmlFor="overlay-builder-size" className="block text-sm font-medium text-white mb-1">
+            {t('streamOverlay.builder.size', 'Text size')}
+          </label>
+          <select
+            id="overlay-builder-size"
+            value={size}
+            onChange={(e) => setSize(e.target.value as OverlaySize)}
+            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"
+          >
+            <option value="small">{t('streamOverlay.builder.sizeSmall', 'Small')}</option>
+            <option value="medium">{t('streamOverlay.builder.sizeMedium', 'Medium')}</option>
+            <option value="large">{t('streamOverlay.builder.sizeLarge', 'Large')}</option>
+          </select>
+        </div>
+
+        <div>
+          <label htmlFor="overlay-builder-fps" className="block text-sm font-medium text-white mb-1">
+            {t('streamOverlay.builder.fps', 'Frame rate')}
+          </label>
+          <input
+            id="overlay-builder-fps"
+            type="number"
+            min={1}
+            max={30}
+            value={fps}
+            onChange={(e) => 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"
+          />
+          <p className="text-xs text-bambu-gray mt-1">
+            {t(
+              'streamOverlay.builder.fpsHint',
+              'A1 and P1 cameras top out around 5 fps whatever you ask for.',
+            )}
+          </p>
+        </div>
+
+        <div>
+          <label htmlFor="overlay-builder-token" className="block text-sm font-medium text-white mb-1">
+            {t('streamOverlay.builder.token', 'Streaming Overlay token (optional)')}
+          </label>
+          <input
+            id="overlay-builder-token"
+            type="text"
+            value={token}
+            onChange={(e) => 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"
+          />
+          <p className="text-xs text-bambu-gray mt-1">
+            {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.',
+            )}
+          </p>
+        </div>
+      </div>
+
+      <fieldset className="mt-4">
+        <legend className="text-sm font-medium text-white mb-2">
+          {t('streamOverlay.builder.fields', 'Fields to show')}
+        </legend>
+        <div className="grid gap-2 sm:grid-cols-2 md:grid-cols-3">
+          {FIELDS.map((field) => (
+            <label key={field.key} className="flex items-center gap-2 text-sm text-bambu-gray">
+              <input
+                type="checkbox"
+                checked={fields.includes(field.key)}
+                onChange={() => toggleField(field.key)}
+                className="accent-bambu-green"
+              />
+              {t(field.labelKey, field.fallback)}
+            </label>
+          ))}
+          <label className="flex items-center gap-2 text-sm text-bambu-gray">
+            <input
+              type="checkbox"
+              checked={showCamera}
+              onChange={(e) => setShowCamera(e.target.checked)}
+              className="accent-bambu-green"
+            />
+            {t('streamOverlay.builder.fieldCamera', 'Camera feed')}
+          </label>
+        </div>
+        <p className="text-xs text-bambu-gray mt-2">
+          {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.',
+          )}
+        </p>
+      </fieldset>
+
+      <div className="mt-4">
+        <p className="text-sm font-medium text-white mb-1">
+          {t('streamOverlay.builder.urlTitle', 'Overlay URL')}
+        </p>
+        <div className="flex items-center gap-2">
+          <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
+            {url}
+          </code>
+          <button
+            type="button"
+            onClick={() => void copyUrl()}
+            className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
+          >
+            <Copy className="w-4 h-4" />
+            {t('cameraTokens.created.copy', 'Copy')}
+          </button>
+          <a
+            href={url}
+            target="_blank"
+            rel="noopener noreferrer"
+            className="flex items-center gap-2 px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
+          >
+            <ExternalLink className="w-4 h-4" />
+            {t('streamOverlay.builder.open', 'Open')}
+          </a>
+        </div>
+        {token.trim() && (
+          <p className="text-xs text-bambu-gray mt-2">
+            {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.',
+            )}
+          </p>
+        )}
+      </div>
+
+      {/* 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. */}
+      <div className="mt-4">
+        <button
+          type="button"
+          onClick={() => setPreview((p) => !p)}
+          className="flex items-center gap-2 px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80 text-sm"
+        >
+          {preview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
+          {preview
+            ? t('streamOverlay.builder.hidePreview', 'Hide preview')
+            : t('streamOverlay.builder.showPreview', 'Show preview')}
+        </button>
+        {preview && (
+          <iframe
+            key={url}
+            src={url}
+            title={t('streamOverlay.builder.previewTitle', 'Overlay preview')}
+            className="mt-3 w-full aspect-video rounded-md border border-bambu-dark-tertiary bg-black"
+          />
+        )}
+      </div>
+    </div>
+  );
+}

+ 28 - 0
frontend/src/i18n/locales/de.ts

@@ -3329,6 +3329,34 @@ export default {
     eta: 'ETA',
     printerIdle: 'Drucker ist inaktiv',
     printerOffline: 'Drucker offline',
+    builder: {
+      title: 'Stream-Overlay',
+      description: 'Erstellt die URL für ein Stream-Overlay — eine bildschirmfüllende Kameraansicht mit eingeblendeten Live-Druckdaten, für OBS, ein Wanddisplay oder jede andere Browserquelle. Felder auswählen und URL kopieren.',
+      printer: 'Drucker',
+      size: 'Textgröße',
+      sizeSmall: 'Klein',
+      sizeMedium: 'Mittel',
+      sizeLarge: 'Groß',
+      fps: 'Bildrate',
+      fpsHint: 'A1- und P1-Kameras liefern höchstens etwa 5 Bilder pro Sekunde, unabhängig vom eingestellten Wert.',
+      token: 'Stream-Overlay-Token (optional)',
+      tokenHint: 'Nur nötig, wenn die Anmeldung aktiviert ist: OBS hat keine eigene Sitzung. Oben eines mit dem Bereich Stream-Overlay erstellen.',
+      tokenWarning: 'Diese URL enthält ein Token — wer sie lesen kann, sieht den Stream und den Dateinamen. Token widerrufen, um den Zugriff zu beenden.',
+      fields: 'Anzuzeigende Felder',
+      fieldPrinter: 'Druckername',
+      fieldFilename: 'Dateiname',
+      fieldStatus: 'Status',
+      fieldProgress: 'Fortschrittsbalken',
+      fieldLayers: 'Schichtanzahl',
+      fieldEta: 'Restzeit und ETA',
+      fieldCamera: 'Kamerabild',
+      chamberHint: 'Die Kammertemperatur erscheint nur bei Modellen mit echtem Kammersensor — P1- und A1-Drucker melden einen bedeutungslosen Wert und lassen sie deshalb weg.',
+      urlTitle: 'Overlay-URL',
+      open: 'Öffnen',
+      showPreview: 'Vorschau anzeigen',
+      hidePreview: 'Vorschau ausblenden',
+      previewTitle: 'Overlay-Vorschau',
+    },
     status: {
       printing: 'Druckt',
       paused: 'Pausiert',

+ 28 - 0
frontend/src/i18n/locales/en.ts

@@ -3358,6 +3358,34 @@ export default {
     eta: 'ETA',
     printerIdle: 'Printer is idle',
     printerOffline: 'Printer offline',
+    builder: {
+      title: 'Streaming Overlay',
+      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.',
+      printer: 'Printer',
+      size: 'Text size',
+      sizeSmall: 'Small',
+      sizeMedium: 'Medium',
+      sizeLarge: 'Large',
+      fps: 'Frame rate',
+      fpsHint: 'A1 and P1 cameras top out around 5 fps whatever you ask for.',
+      token: 'Streaming Overlay token (optional)',
+      tokenHint: 'Only needed when login is enabled: OBS has no session of its own. Create one above with the Streaming Overlay scope.',
+      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.',
+      fields: 'Fields to show',
+      fieldPrinter: 'Printer name',
+      fieldFilename: 'File name',
+      fieldStatus: 'Status',
+      fieldProgress: 'Progress bar',
+      fieldLayers: 'Layer count',
+      fieldEta: 'Time remaining and ETA',
+      fieldCamera: 'Camera feed',
+      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.',
+      urlTitle: 'Overlay URL',
+      open: 'Open',
+      showPreview: 'Show preview',
+      hidePreview: 'Hide preview',
+      previewTitle: 'Overlay preview',
+    },
     status: {
       printing: 'Printing',
       paused: 'Paused',

+ 28 - 0
frontend/src/i18n/locales/es.ts

@@ -3332,6 +3332,34 @@ export default {
     eta: 'Tiempo estimado',
     printerIdle: 'La impresora está inactiva',
     printerOffline: 'Impresora desconectada',
+    builder: {
+      title: 'Superposición de emisión',
+      description: 'Crea la URL de una superposición de emisión: una vista de cámara a pantalla completa con los datos de impresión en directo encima, para OBS, una pantalla de pared o cualquier fuente de navegador. Elige los campos y copia la URL.',
+      printer: 'Impresora',
+      size: 'Tamaño del texto',
+      sizeSmall: 'Pequeño',
+      sizeMedium: 'Mediano',
+      sizeLarge: 'Grande',
+      fps: 'Fotogramas por segundo',
+      fpsHint: 'Las cámaras A1 y P1 no pasan de unos 5 fps, sea cual sea el valor solicitado.',
+      token: 'Token de superposición (opcional)',
+      tokenHint: 'Solo hace falta si el inicio de sesión está activado: OBS no tiene sesión propia. Crea uno arriba con el ámbito Superposición de emisión.',
+      tokenWarning: 'Esta URL contiene un token: cualquiera que pueda leerla verá la emisión y el nombre del archivo. Revoca el token para cortar el acceso.',
+      fields: 'Campos que mostrar',
+      fieldPrinter: 'Nombre de la impresora',
+      fieldFilename: 'Nombre del archivo',
+      fieldStatus: 'Estado',
+      fieldProgress: 'Barra de progreso',
+      fieldLayers: 'Número de capas',
+      fieldEta: 'Tiempo restante y hora de fin',
+      fieldCamera: 'Imagen de la cámara',
+      chamberHint: 'La temperatura de la cámara de impresión solo aparece en modelos con sensor real: las P1 y A1 informan un valor sin sentido, así que se omite.',
+      urlTitle: 'URL de la superposición',
+      open: 'Abrir',
+      showPreview: 'Mostrar vista previa',
+      hidePreview: 'Ocultar vista previa',
+      previewTitle: 'Vista previa de la superposición',
+    },
     status: {
       printing: 'Imprimiendo',
       paused: 'En pausa',

+ 28 - 0
frontend/src/i18n/locales/fr.ts

@@ -3318,6 +3318,34 @@ export default {
     eta: 'Fin estimée',
     printerIdle: 'Imprimante inactive',
     printerOffline: 'Imprimante hors ligne',
+    builder: {
+      title: 'Incrustation de diffusion',
+      description: 'Compose l\'URL d\'une incrustation de diffusion — une vue caméra plein écran avec les données d\'impression en direct par-dessus, pour OBS, un écran mural ou toute source navigateur. Choisissez les champs voulus et copiez l\'URL.',
+      printer: 'Imprimante',
+      size: 'Taille du texte',
+      sizeSmall: 'Petite',
+      sizeMedium: 'Moyenne',
+      sizeLarge: 'Grande',
+      fps: 'Fréquence d\'images',
+      fpsHint: 'Les caméras A1 et P1 plafonnent autour de 5 images par seconde, quelle que soit la valeur demandée.',
+      token: 'Jeton d\'incrustation (facultatif)',
+      tokenHint: 'Nécessaire uniquement si la connexion est activée : OBS n\'a pas de session. Créez-en un ci-dessus avec la portée Incrustation de diffusion.',
+      tokenWarning: 'Cette URL contient un jeton — quiconque peut la lire peut voir le flux et le nom du fichier. Révoquez le jeton pour couper l\'accès.',
+      fields: 'Champs à afficher',
+      fieldPrinter: 'Nom de l\'imprimante',
+      fieldFilename: 'Nom du fichier',
+      fieldStatus: 'Statut',
+      fieldProgress: 'Barre de progression',
+      fieldLayers: 'Nombre de couches',
+      fieldEta: 'Temps restant et heure de fin',
+      fieldCamera: 'Flux caméra',
+      chamberHint: 'La température du caisson n\'apparaît que sur les modèles dotés d\'un vrai capteur — les P1 et A1 renvoient une valeur sans signification, elle est donc omise.',
+      urlTitle: 'URL de l\'incrustation',
+      open: 'Ouvrir',
+      showPreview: 'Afficher l\'aperçu',
+      hidePreview: 'Masquer l\'aperçu',
+      previewTitle: 'Aperçu de l\'incrustation',
+    },
     status: {
       printing: 'Impression',
       paused: 'En pause',

+ 28 - 0
frontend/src/i18n/locales/it.ts

@@ -3317,6 +3317,34 @@ export default {
     eta: 'ETA',
     printerIdle: 'Stampante inattiva',
     printerOffline: 'Stampante offline',
+    builder: {
+      title: 'Overlay per streaming',
+      description: 'Compone l\'URL di un overlay per streaming: una vista telecamera a schermo intero con i dati di stampa in tempo reale sovrapposti, per OBS, un display a parete o qualsiasi sorgente browser. Scegli i campi e copia l\'URL.',
+      printer: 'Stampante',
+      size: 'Dimensione del testo',
+      sizeSmall: 'Piccola',
+      sizeMedium: 'Media',
+      sizeLarge: 'Grande',
+      fps: 'Frequenza fotogrammi',
+      fpsHint: 'Le telecamere A1 e P1 si fermano intorno a 5 fps, qualunque valore venga richiesto.',
+      token: 'Token overlay (facoltativo)',
+      tokenHint: 'Serve solo con il login attivo: OBS non ha una sessione propria. Creane uno sopra con ambito Overlay per streaming.',
+      tokenWarning: 'Questo URL contiene un token: chi riesce a leggerlo può vedere lo streaming e il nome del file. Revoca il token per interrompere l\'accesso.',
+      fields: 'Campi da mostrare',
+      fieldPrinter: 'Nome stampante',
+      fieldFilename: 'Nome file',
+      fieldStatus: 'Stato',
+      fieldProgress: 'Barra di avanzamento',
+      fieldLayers: 'Numero di layer',
+      fieldEta: 'Tempo rimanente e orario di fine',
+      fieldCamera: 'Immagine telecamera',
+      chamberHint: 'La temperatura della camera compare solo sui modelli con un vero sensore: P1 e A1 riportano un valore privo di significato, quindi viene omessa.',
+      urlTitle: 'URL overlay',
+      open: 'Apri',
+      showPreview: 'Mostra anteprima',
+      hidePreview: 'Nascondi anteprima',
+      previewTitle: 'Anteprima overlay',
+    },
     status: {
       printing: 'In stampa',
       paused: 'In pausa',

+ 28 - 0
frontend/src/i18n/locales/ja.ts

@@ -3329,6 +3329,34 @@ export default {
     eta: '残り時間',
     printerIdle: 'プリンター待機中',
     printerOffline: 'プリンターオフライン',
+    builder: {
+      title: 'ストリームオーバーレイ',
+      description: 'ストリームオーバーレイのURLを作成します。全画面のカメラ映像に印刷中の情報を重ねて表示するもので、OBSや壁掛けディスプレイなどのブラウザソースで使えます。表示する項目を選んでURLをコピーしてください。',
+      printer: 'プリンター',
+      size: '文字サイズ',
+      sizeSmall: '小',
+      sizeMedium: '中',
+      sizeLarge: '大',
+      fps: 'フレームレート',
+      fpsHint: 'A1およびP1のカメラは、指定した値にかかわらず毎秒5フレーム程度が上限です。',
+      token: 'ストリームオーバーレイトークン(任意)',
+      tokenHint: 'ログインを有効にしている場合のみ必要です。OBS自体はセッションを持ちません。上の欄でストリームオーバーレイのスコープを選んで作成してください。',
+      tokenWarning: 'このURLにはトークンが含まれます。URLを読める人は誰でも映像とファイル名を見られます。アクセスを止めるにはトークンを失効させてください。',
+      fields: '表示する項目',
+      fieldPrinter: 'プリンター名',
+      fieldFilename: 'ファイル名',
+      fieldStatus: 'ステータス',
+      fieldProgress: '進捗バー',
+      fieldLayers: 'レイヤー数',
+      fieldEta: '残り時間と終了予定時刻',
+      fieldCamera: 'カメラ映像',
+      chamberHint: 'チャンバー温度は実際のセンサーを備えたモデルでのみ表示されます。P1およびA1は意味のない値を返すため除外されます。',
+      urlTitle: 'オーバーレイURL',
+      open: '開く',
+      showPreview: 'プレビューを表示',
+      hidePreview: 'プレビューを非表示',
+      previewTitle: 'オーバーレイのプレビュー',
+    },
     status: {
       printing: '印刷中',
       paused: '一時停止',

+ 28 - 0
frontend/src/i18n/locales/ko.ts

@@ -3155,6 +3155,34 @@ export default {
     eta: '예상 완료',
     printerIdle: '프린터 대기 중',
     printerOffline: '프린터 오프라인',
+    builder: {
+      title: '스트림 오버레이',
+      description: '스트림 오버레이 URL을 만듭니다. 전체 화면 카메라 영상 위에 실시간 출력 정보를 겹쳐 보여주며 OBS, 벽걸이 디스플레이 등 모든 브라우저 소스에서 쓸 수 있습니다. 원하는 항목을 고르고 URL을 복사하세요.',
+      printer: '프린터',
+      size: '글자 크기',
+      sizeSmall: '작게',
+      sizeMedium: '보통',
+      sizeLarge: '크게',
+      fps: '프레임 속도',
+      fpsHint: 'A1 및 P1 카메라는 요청한 값과 관계없이 초당 약 5프레임이 한계입니다.',
+      token: '스트림 오버레이 토큰(선택)',
+      tokenHint: '로그인을 사용할 때만 필요합니다. OBS에는 자체 세션이 없습니다. 위에서 스트림 오버레이 범위로 발급하세요.',
+      tokenWarning: '이 URL에는 토큰이 들어 있습니다. URL을 읽을 수 있는 사람은 누구나 영상과 파일 이름을 볼 수 있습니다. 접근을 끊으려면 토큰을 폐기하세요.',
+      fields: '표시할 항목',
+      fieldPrinter: '프린터 이름',
+      fieldFilename: '파일 이름',
+      fieldStatus: '상태',
+      fieldProgress: '진행률 막대',
+      fieldLayers: '레이어 수',
+      fieldEta: '남은 시간과 완료 예정 시각',
+      fieldCamera: '카메라 영상',
+      chamberHint: '챔버 온도는 실제 센서가 있는 모델에서만 표시됩니다. P1과 A1은 의미 없는 값을 보고하므로 제외됩니다.',
+      urlTitle: '오버레이 URL',
+      open: '열기',
+      showPreview: '미리보기 표시',
+      hidePreview: '미리보기 숨기기',
+      previewTitle: '오버레이 미리보기',
+    },
     status: {
       printing: '인쇄 중',
       paused: '일시 중지됨',

+ 28 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3317,6 +3317,34 @@ export default {
     eta: 'ETA',
     printerIdle: 'Impressora ociosa',
     printerOffline: 'Impressora offline',
+    builder: {
+      title: 'Sobreposição de transmissão',
+      description: 'Monta a URL de uma sobreposição de transmissão: a câmera em tela cheia com os dados da impressão sobrepostos, para OBS, um painel de parede ou qualquer fonte de navegador. Escolha os campos e copie a URL.',
+      printer: 'Impressora',
+      size: 'Tamanho do texto',
+      sizeSmall: 'Pequeno',
+      sizeMedium: 'Médio',
+      sizeLarge: 'Grande',
+      fps: 'Taxa de quadros',
+      fpsHint: 'Câmeras A1 e P1 chegam no máximo a cerca de 5 fps, qualquer que seja o valor pedido.',
+      token: 'Token de sobreposição (opcional)',
+      tokenHint: 'Só é necessário com login ativado: o OBS não tem sessão própria. Crie um acima com o escopo Sobreposição de transmissão.',
+      tokenWarning: 'Esta URL contém um token: quem conseguir lê-la pode assistir à transmissão e ver o nome do arquivo. Revogue o token para cortar o acesso.',
+      fields: 'Campos a exibir',
+      fieldPrinter: 'Nome da impressora',
+      fieldFilename: 'Nome do arquivo',
+      fieldStatus: 'Status',
+      fieldProgress: 'Barra de progresso',
+      fieldLayers: 'Contagem de camadas',
+      fieldEta: 'Tempo restante e previsão de término',
+      fieldCamera: 'Imagem da câmera',
+      chamberHint: 'A temperatura da câmara aparece apenas em modelos com sensor real: P1 e A1 informam um valor sem sentido, por isso ela é omitida.',
+      urlTitle: 'URL da sobreposição',
+      open: 'Abrir',
+      showPreview: 'Mostrar prévia',
+      hidePreview: 'Ocultar prévia',
+      previewTitle: 'Prévia da sobreposição',
+    },
     status: {
       printing: 'Imprimindo',
       paused: 'Pausado',

+ 28 - 0
frontend/src/i18n/locales/ru.ts

@@ -3147,6 +3147,34 @@ export default {
     eta: "Осталось",
     printerIdle: "Принтер простаивает",
     printerOffline: "Принтер не в сети",
+    builder: {
+      title: "Оформление трансляции",
+      description: "Собирает адрес оформления трансляции: полноэкранное изображение камеры с наложенными данными о печати — для OBS, настенного экрана или любого источника-браузера. Выберите нужные поля и скопируйте адрес.",
+      printer: "Принтер",
+      size: "Размер текста",
+      sizeSmall: "Мелкий",
+      sizeMedium: "Средний",
+      sizeLarge: "Крупный",
+      fps: "Частота кадров",
+      fpsHint: "Камеры A1 и P1 выдают не более примерно 5 кадров в секунду, какое бы значение вы ни задали.",
+      token: "Токен оформления трансляции (необязательно)",
+      tokenHint: "Нужен только при включённом входе: у OBS нет собственного сеанса. Создайте его выше с областью «Оформление трансляции».",
+      tokenWarning: "Этот адрес содержит токен: любой, кто его прочитает, увидит трансляцию и имя файла. Отзовите токен, чтобы закрыть доступ.",
+      fields: "Показываемые поля",
+      fieldPrinter: "Имя принтера",
+      fieldFilename: "Имя файла",
+      fieldStatus: "Состояние",
+      fieldProgress: "Полоса прогресса",
+      fieldLayers: "Количество слоёв",
+      fieldEta: "Оставшееся время и время окончания",
+      fieldCamera: "Изображение камеры",
+      chamberHint: "Температура камеры показывается только на моделях с настоящим датчиком: P1 и A1 сообщают бессмысленное значение, поэтому она опускается.",
+      urlTitle: "Адрес оформления",
+      open: "Открыть",
+      showPreview: "Показать предпросмотр",
+      hidePreview: "Скрыть предпросмотр",
+      previewTitle: "Предпросмотр оформления",
+    },
     status: {
       printing: "Печать",
       paused: "Приостановлено",

+ 28 - 0
frontend/src/i18n/locales/tr.ts

@@ -3333,6 +3333,34 @@ export default {
     eta: 'ETA',
     printerIdle: 'Yazıcı boşta',
     printerOffline: 'Yazıcı çevrimdışı',
+    builder: {
+      title: 'Yayın Kaplaması',
+      description: 'Yayın kaplaması için URL oluşturur: tam ekran kamera görüntüsünün üzerine canlı baskı bilgileri bindirilir; OBS, duvar ekranı veya herhangi bir tarayıcı kaynağı için. İstediğiniz alanları seçip URL\'yi kopyalayın.',
+      printer: 'Yazıcı',
+      size: 'Yazı boyutu',
+      sizeSmall: 'Küçük',
+      sizeMedium: 'Orta',
+      sizeLarge: 'Büyük',
+      fps: 'Kare hızı',
+      fpsHint: 'A1 ve P1 kameraları, hangi değeri isterseniz isteyin saniyede yaklaşık 5 karede kalır.',
+      token: 'Yayın Kaplaması belirteci (isteğe bağlı)',
+      tokenHint: 'Yalnızca oturum açma etkinken gerekir: OBS\'nin kendi oturumu yoktur. Yukarıdan Yayın Kaplaması kapsamıyla bir tane oluşturun.',
+      tokenWarning: 'Bu URL bir belirteç içerir: okuyabilen herkes yayını izleyebilir ve dosya adını görebilir. Erişimi kesmek için belirteci iptal edin.',
+      fields: 'Gösterilecek alanlar',
+      fieldPrinter: 'Yazıcı adı',
+      fieldFilename: 'Dosya adı',
+      fieldStatus: 'Durum',
+      fieldProgress: 'İlerleme çubuğu',
+      fieldLayers: 'Katman sayısı',
+      fieldEta: 'Kalan süre ve tahmini bitiş',
+      fieldCamera: 'Kamera görüntüsü',
+      chamberHint: 'Hazne sıcaklığı yalnızca gerçek hazne sensörü olan modellerde görünür: P1 ve A1 anlamsız bir değer bildirdiği için orada gösterilmez.',
+      urlTitle: 'Kaplama URL\'si',
+      open: 'Aç',
+      showPreview: 'Önizlemeyi göster',
+      hidePreview: 'Önizlemeyi gizle',
+      previewTitle: 'Kaplama önizlemesi',
+    },
     status: {
       printing: 'Yazdırılıyor',
       paused: 'Duraklatıldı',

+ 28 - 0
frontend/src/i18n/locales/uk.ts

@@ -3358,6 +3358,34 @@ export default {
     eta: "ETA",
     printerIdle: "Принтер неактивний",
     printerOffline: "Принтер не в мережі",
+    builder: {
+      title: "Оформлення трансляції",
+      description: "Складає адресу оформлення трансляції: повноекранне зображення камери з накладеними даними друку — для OBS, настінного екрана або будь-якого джерела-браузера. Виберіть потрібні поля та скопіюйте адресу.",
+      printer: "Принтер",
+      size: "Розмір тексту",
+      sizeSmall: "Малий",
+      sizeMedium: "Середній",
+      sizeLarge: "Великий",
+      fps: "Частота кадрів",
+      fpsHint: "Камери A1 і P1 видають щонайбільше близько 5 кадрів за секунду, хоч би яке значення ви задали.",
+      token: "Токен оформлення трансляції (необов'язково)",
+      tokenHint: "Потрібен лише за увімкненого входу: OBS не має власного сеансу. Створіть його вище з областю «Оформлення трансляції».",
+      tokenWarning: "Ця адреса містить токен: будь-хто, хто її прочитає, побачить трансляцію та назву файлу. Відкличте токен, щоб закрити доступ.",
+      fields: "Поля для показу",
+      fieldPrinter: "Назва принтера",
+      fieldFilename: "Назва файлу",
+      fieldStatus: "Стан",
+      fieldProgress: "Смуга поступу",
+      fieldLayers: "Кількість шарів",
+      fieldEta: "Залишок часу та час завершення",
+      fieldCamera: "Зображення камери",
+      chamberHint: "Температура камери показується лише на моделях зі справжнім датчиком: P1 та A1 повідомляють беззмістовне значення, тому її пропущено.",
+      urlTitle: "Адреса оформлення",
+      open: "Відкрити",
+      showPreview: "Показати попередній перегляд",
+      hidePreview: "Сховати попередній перегляд",
+      previewTitle: "Попередній перегляд оформлення",
+    },
     status: {
       printing: "Друк",
       paused: "Призупинено",

+ 28 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3317,6 +3317,34 @@ export default {
     eta: '预计完成时间',
     printerIdle: '打印机空闲',
     printerOffline: '打印机离线',
+    builder: {
+      title: '直播叠加层',
+      description: '生成直播叠加层的网址:全屏摄像头画面上叠加实时打印信息,可用于 OBS、墙面显示屏或任何浏览器源。选择需要的字段并复制网址。',
+      printer: '打印机',
+      size: '文字大小',
+      sizeSmall: '小',
+      sizeMedium: '中',
+      sizeLarge: '大',
+      fps: '帧率',
+      fpsHint: '无论设置多少,A1 和 P1 的摄像头最高约为每秒 5 帧。',
+      token: '直播叠加层令牌(可选)',
+      tokenHint: '仅在启用登录时需要:OBS 没有自己的登录会话。请在上方以直播叠加层范围创建一个。',
+      tokenWarning: '此网址包含令牌:任何能看到它的人都可以观看画面并看到文件名。撤销令牌即可切断访问。',
+      fields: '要显示的字段',
+      fieldPrinter: '打印机名称',
+      fieldFilename: '文件名',
+      fieldStatus: '状态',
+      fieldProgress: '进度条',
+      fieldLayers: '层数',
+      fieldEta: '剩余时间和预计完成时间',
+      fieldCamera: '摄像头画面',
+      chamberHint: '仅在配有真实腔体传感器的机型上显示腔体温度:P1 和 A1 上报的数值没有意义,因此不显示。',
+      urlTitle: '叠加层网址',
+      open: '打开',
+      showPreview: '显示预览',
+      hidePreview: '隐藏预览',
+      previewTitle: '叠加层预览',
+    },
     status: {
       printing: '打印中',
       paused: '已暂停',

+ 28 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3317,6 +3317,34 @@ export default {
     eta: '預計完成時間',
     printerIdle: '印表機空閒',
     printerOffline: '印表機離線',
+    builder: {
+      title: '直播疊加層',
+      description: '產生直播疊加層的網址:全螢幕攝影機畫面上疊加即時列印資訊,可用於 OBS、牆面顯示器或任何瀏覽器來源。選擇需要的欄位並複製網址。',
+      printer: '印表機',
+      size: '文字大小',
+      sizeSmall: '小',
+      sizeMedium: '中',
+      sizeLarge: '大',
+      fps: '影格率',
+      fpsHint: '無論設定多少,A1 與 P1 的攝影機最高約為每秒 5 影格。',
+      token: '直播疊加層權杖(選填)',
+      tokenHint: '僅在啟用登入時需要:OBS 沒有自己的登入工作階段。請在上方以直播疊加層範圍建立一個。',
+      tokenWarning: '此網址包含權杖:任何能看到它的人都可以觀看畫面並看到檔案名稱。撤銷權杖即可中止存取。',
+      fields: '要顯示的欄位',
+      fieldPrinter: '印表機名稱',
+      fieldFilename: '檔案名稱',
+      fieldStatus: '狀態',
+      fieldProgress: '進度列',
+      fieldLayers: '層數',
+      fieldEta: '剩餘時間與預計完成時間',
+      fieldCamera: '攝影機畫面',
+      chamberHint: '僅在具備真實機箱感測器的機型上顯示機箱溫度:P1 與 A1 回報的數值沒有意義,因此不顯示。',
+      urlTitle: '疊加層網址',
+      open: '開啟',
+      showPreview: '顯示預覽',
+      hidePreview: '隱藏預覽',
+      previewTitle: '疊加層預覽',
+    },
     status: {
       printing: '列印中',
       paused: '已暫停',

+ 17 - 1
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow, UploadCloud } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow, UploadCloud, MonitorPlay } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -16,6 +16,7 @@ import { Card, CardContent, CardDensityProvider, CardHeader } from '../component
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
 import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
 import { CameraTokensSection } from './CameraTokensPage';
+import { StreamOverlayBuilder } from '../components/StreamOverlayBuilder';
 import { Collapsible } from '../components/Collapsible';
 import { CopyButton } from '../components/CopyButton';
 import { Button } from '../components/Button';
@@ -4251,6 +4252,21 @@ export function SettingsPage() {
                 <CameraTokensSection />
               </CardContent>
             </Card>
+
+            {/* Streaming-overlay URL builder (#1422). Sits under the camera
+                tokens it usually needs — an overlay for a login-enabled
+                deployment is a token plus a URL, and both are made here. */}
+            <Card className="mt-6">
+              <CardHeader>
+                <h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-stream-overlay">
+                  <MonitorPlay className="w-4 h-4 text-bambu-green" />
+                  {t('streamOverlay.builder.title', 'Streaming Overlay')}
+                </h3>
+              </CardHeader>
+              <CardContent>
+                <StreamOverlayBuilder />
+              </CardContent>
+            </Card>
           </div>
 
           {/* Right Column - API Browser. Hidden from users without

+ 129 - 1
frontend/src/pages/StreamOverlayPage.tsx

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
 import { useParams, useSearchParams } from 'react-router-dom';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { Layers, Clock, Timer, Printer } from 'lucide-react';
+import { Layers, Clock, Timer, Printer, Flame, Square, Box } from 'lucide-react';
 import { api, ApiError, withStreamToken } from '../api/client';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 
@@ -20,6 +20,9 @@ interface OverlayConfig {
   showFilename: boolean;
   showStatus: boolean;
   showPrinter: boolean;
+  showNozzle: boolean;
+  showBed: boolean;
+  showChamber: boolean;
 }
 
 function formatPrintName(name: string | null, gcodeFile: string | null | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string {
@@ -33,6 +36,9 @@ function formatPrintName(name: string | null, gcodeFile: string | null | undefin
 }
 
 function parseConfig(params: URLSearchParams): OverlayConfig {
+  // The default set is deliberately unchanged by #1422: temperatures are opt-in,
+  // so every overlay URL already pasted into an OBS scene keeps looking the same
+  // after upgrading.
   const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
 
   // Parse FPS (default 15, max 30, min 1)
@@ -53,6 +59,9 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
     showFilename: show.includes('filename'),
     showStatus: show.includes('status'),
     showPrinter: show.includes('printer'),
+    showNozzle: show.includes('nozzle'),
+    showBed: show.includes('bed'),
+    showChamber: show.includes('chamber'),
   };
 }
 
@@ -71,6 +80,46 @@ function getStatusText(status: { state: string | null; stg_cur_name?: string | n
   }
 }
 
+// Reads one reading out of either status shape. The kiosk feed types
+// temperatures as Record<string, number>; the logged-in PrinterStatus types it
+// as a named object that also carries `*_heating` booleans. Narrowing here lets
+// one render path serve both without casting.
+function readTemp(temps: Record<string, unknown>, key: string): number | null {
+  const value = temps[key];
+  return typeof value === 'number' ? value : null;
+}
+
+interface TempReadingProps {
+  icon: React.ReactNode;
+  label: string;
+  current: number;
+  target: number | null;
+  sizes: ReturnType<typeof getSizeClasses>;
+}
+
+// One "Nozzle 220°C" reading. The target is appended only while it is set and
+// still differs from the current value, so a hotend that has reached
+// temperature reads "220°C" for the rest of the print instead of the noisier
+// "220 / 220°C".
+function TempReading({ icon, label, current, target, sizes }: TempReadingProps) {
+  const heating = target != null && target > 0 && Math.round(target) !== Math.round(current);
+  return (
+    <div className={`flex items-center ${sizes.gap} text-white/70`}>
+      {icon}
+      <span className={sizes.text}>
+        <span className="mr-1">{label}</span>
+        <span className="text-white">{Math.round(current)}°C</span>
+        {heating && (
+          <>
+            <span className="mx-1">/</span>
+            <span>{Math.round(target)}°C</span>
+          </>
+        )}
+      </span>
+    </div>
+  );
+}
+
 function getSizeClasses(size: OverlaySize) {
   switch (size) {
     case 'small':
@@ -258,6 +307,64 @@ export function StreamOverlayPage() {
 
   const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
   const progress = status.progress || 0;
+
+  // Temperature readings the URL asked for, in a fixed order, skipping any the
+  // printer doesn't report. Labels reuse printers.heaterHistory.* so the naming
+  // matches the heater chart rather than inventing a second vocabulary.
+  const temps: Record<string, unknown> = status.temperatures ?? {};
+  const tempReadings: {
+    key: string;
+    icon: React.ReactNode;
+    label: string;
+    current: number;
+    target: number | null;
+  }[] = [];
+  if (config.showNozzle) {
+    const nozzle = readTemp(temps, 'nozzle');
+    const nozzle2 = readTemp(temps, 'nozzle_2');
+    if (nozzle != null) {
+      tempReadings.push({
+        key: 'nozzle',
+        icon: <Flame className={sizes.icon} />,
+        label: t('printers.heaterHistory.nozzle', 'Nozzle'),
+        current: nozzle,
+        target: readTemp(temps, 'nozzle_target'),
+      });
+    }
+    if (nozzle2 != null) {
+      tempReadings.push({
+        key: 'nozzle_2',
+        icon: <Flame className={sizes.icon} />,
+        label: t('printers.heaterHistory.nozzle2', 'Nozzle 2'),
+        current: nozzle2,
+        target: readTemp(temps, 'nozzle_2_target'),
+      });
+    }
+  }
+  if (config.showBed) {
+    const bed = readTemp(temps, 'bed');
+    if (bed != null) {
+      tempReadings.push({
+        key: 'bed',
+        icon: <Square className={sizes.icon} />,
+        label: t('printers.heaterHistory.bed', 'Bed'),
+        current: bed,
+        target: readTemp(temps, 'bed_target'),
+      });
+    }
+  }
+  if (config.showChamber) {
+    const chamber = readTemp(temps, 'chamber');
+    if (chamber != null) {
+      tempReadings.push({
+        key: 'chamber',
+        icon: <Box className={sizes.icon} />,
+        label: t('printers.heaterHistory.chamber', 'Chamber'),
+        current: chamber,
+        target: readTemp(temps, 'chamber_target'),
+      });
+    }
+  }
   // Append the kiosk token directly rather than leaning on withStreamToken's
   // module cache — the cache is populated by an effect and would miss the first
   // render (a 401 flash before the retry). The logged-in path keeps the cache.
@@ -377,6 +484,27 @@ export function StreamOverlayPage() {
               {status.connected ? t('streamOverlay.printerIdle') : t('streamOverlay.printerOffline')}
             </div>
           )}
+
+          {/* Temperatures (#1422). Rendered whether or not a print is running —
+              a preheating or cooling printer is exactly when these are worth
+              watching. Each reading appears only when the printer reports it,
+              so a single-nozzle machine shows one nozzle and a model without a
+              chamber sensor shows no chamber row even if `chamber` is in
+              ?show= (the backend omits the reading entirely for those). */}
+          {tempReadings.length > 0 && (
+            <div className={`flex items-center ${sizes.gap} flex-wrap mt-2`}>
+              {tempReadings.map((reading) => (
+                <TempReading
+                  key={reading.key}
+                  icon={reading.icon}
+                  label={reading.label}
+                  current={reading.current}
+                  target={reading.target}
+                  sizes={sizes}
+                />
+              ))}
+            </div>
+          )}
         </div>
       </div>
     </div>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CFRqaod2.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Ds22o6-q.js"></script>
+    <script type="module" crossorigin src="/assets/index-CFRqaod2.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-GBTQ2eaA.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов