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.
This commit is contained in:
maziggy
2026-08-04 12:38:36 +02:00
parent 48c231d8ce
commit 33ab5f1ead
28 changed files with 1481 additions and 166 deletions
@@ -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
@@ -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):
@@ -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."""