feat: add optional printer model to stream overlay (#3099)

This commit is contained in:
Adam Spice
2026-09-27 12:04:05 +02:00
committed by GitHub
parent bdc1fc68d0
commit 858f8c772f
27 changed files with 128 additions and 29 deletions
+3 -1
View File
@@ -883,7 +883,7 @@ async def get_overlay_status(
A token-authenticated sibling of ``get_printer_status`` for embeds with no
login session — OBS loads ``/overlay/{id}?token=...`` and this feeds it.
Deliberately flat and minimal (name, camera rotation, live print state, and
Deliberately flat and minimal (name, model, camera rotation, live print state, and
the one setting the overlay reads) rather than the full ``PrinterStatus``:
a token holder gets exactly the fields the overlay renders, nothing more.
@@ -907,6 +907,7 @@ async def get_overlay_status(
return {
"id": printer_id,
"name": printer.name,
"model": printer.model,
"camera_rotation": printer.camera_rotation or 0,
"connected": False,
"state": None,
@@ -924,6 +925,7 @@ async def get_overlay_status(
return {
"id": printer_id,
"name": printer.name,
"model": printer.model,
"camera_rotation": printer.camera_rotation or 0,
"connected": state.connected,
"state": state.state,
@@ -146,6 +146,7 @@ class TestOverlayFeedPayload:
assert set(entry) == {
"id",
"name",
"model",
"camera_rotation",
"connected",
"state",
@@ -169,6 +170,7 @@ class TestOverlayFeedPayload:
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
entry = response.json()
assert entry["model"] == "P1S"
assert entry["connected"] is False
assert entry["state"] is None
assert entry["current_print"] is None
@@ -212,6 +214,7 @@ class TestOverlayFeedPayload:
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}")
assert response.json()["model"] == "P1S"
temps = response.json()["temperatures"]
assert temps["nozzle"] == 219.7
@@ -223,6 +226,15 @@ class TestOverlayFeedPayload:
assert "nozzle_heating" not in temps
assert "_nozzle_target_set_time" not in temps
async def test_unknown_model_is_null(self, async_client: AsyncClient, printer_row, db_session):
printer_row.model = None
await db_session.commit()
jwt = await _setup_admin(async_client, suffix="_nomodel")
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}")
assert response.status_code == 200
assert response.json()["model"] is None
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
passed the gate, the resource simply isn't there.
@@ -48,6 +48,26 @@ describe('StreamOverlayBuilder', () => {
expect(shownUrl()).not.toContain('token=');
});
it('keeps the model opt-in and updates the URL and preview when toggled', async () => {
const user = userEvent.setup();
render(<StreamOverlayBuilder />);
const model = await screen.findByLabelText('Printer model');
expect(model).not.toBeChecked();
const originalUrl = shownUrl();
await user.click(model);
expect(new URL(shownUrl()).searchParams.get('show')).toBe('model,filename,status,progress,layers,eta');
await user.click(screen.getByLabelText('Printer name'));
expect(new URL(shownUrl()).searchParams.get('show')).toBe('printer,model,filename,status,progress,layers,eta');
await user.click(screen.getByRole('button', { name: 'Show preview' }));
expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl());
await user.click(model);
await user.click(screen.getByLabelText('Printer name'));
expect(shownUrl()).toBe(originalUrl);
expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', originalUrl);
});
it('switches printer', async () => {
const user = userEvent.setup();
render(<StreamOverlayBuilder />);
@@ -134,6 +134,48 @@ describe('StreamOverlayPage', () => {
});
});
describe('printer model', () => {
it.each(['', '?show=printer', '?show='])('hides the model unless selected (%s)', async (query) => {
renderOverlayPage(1, query);
await screen.findByText('Printer is idle');
expect(screen.queryByText(/X1C/)).not.toBeInTheDocument();
});
it.each([
['?show=model', 'X1C'],
['?show=printer,model', 'X1 Carbon · X1C'],
])('shows the selected printer identity (%s)', async (query, identity) => {
renderOverlayPage(1, query);
expect(await screen.findByText(identity)).toBeInTheDocument();
if (query === '?show=model') expect(screen.queryByText(/X1 Carbon/)).not.toBeInTheDocument();
});
it.each([null, ''])('omits a missing model without adding a separator (%s)', async (model) => {
server.use(http.get('/api/v1/printers/:id', () => HttpResponse.json({ ...mockPrinter, model })));
renderOverlayPage(1, '?show=printer,model');
expect(await screen.findByText('X1 Carbon')).toBeInTheDocument();
expect(screen.queryByText(/·/)).not.toBeInTheDocument();
});
it.each([['H2D', 'Workshop · H2D'], [null, 'Workshop'], ['', 'Workshop']] as const)('reads the model (%s) from the OBS token feed without requesting printer details', async (model, identity) => {
let printerHit = false;
server.use(
http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({
...mockStatusIdle, name: 'Workshop', model, camera_rotation: 0,
gcode_file: null, temperatures: {}, time_format: 'system',
})),
http.get('/api/v1/printers/:id', () => {
printerHit = true;
return new HttpResponse(null, { status: 401 });
}),
);
renderOverlayPage(1, '?token=obs-tok&show=printer,model');
expect(await screen.findByText(identity)).toBeInTheDocument();
expect(printerHit).toBe(false);
if (!model) expect(screen.queryByText(/·/)).not.toBeInTheDocument();
});
});
describe('printing state', () => {
beforeEach(() => {
server.use(
+1
View File
@@ -377,6 +377,7 @@ export interface CamWallPrinter {
export interface OverlayStatus {
id: number;
name: string;
model: string | null;
camera_rotation: number;
connected: boolean;
state: string | null;
@@ -20,6 +20,7 @@ type OverlaySize = 'small' | 'medium' | 'large';
// checkbox list reads as a preview of the result.
const FIELDS = [
{ key: 'printer', labelKey: 'streamOverlay.builder.fieldPrinter', fallback: 'Printer name' },
{ key: 'model', labelKey: 'streamOverlay.builder.fieldModel', fallback: 'Printer model' },
{ 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' },
+1
View File
@@ -3560,6 +3560,7 @@ export default {
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',
fieldModel: 'Druckermodell',
fieldFilename: 'Dateiname',
fieldStatus: 'Status',
fieldProgress: 'Fortschrittsbalken',
+1
View File
@@ -3590,6 +3590,7 @@ export default {
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',
fieldModel: 'Printer model',
fieldFilename: 'File name',
fieldStatus: 'Status',
fieldProgress: 'Progress bar',
+1
View File
@@ -3562,6 +3562,7 @@ export default {
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',
fieldModel: 'Modelo de impresora',
fieldFilename: 'Nombre del archivo',
fieldStatus: 'Estado',
fieldProgress: 'Barra de progreso',
+1
View File
@@ -3549,6 +3549,7 @@ export default {
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',
fieldModel: 'Modèle d\'imprimante',
fieldFilename: 'Nom du fichier',
fieldStatus: 'Statut',
fieldProgress: 'Barre de progression',
+1
View File
@@ -3548,6 +3548,7 @@ export default {
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',
fieldModel: 'Modello stampante',
fieldFilename: 'Nome file',
fieldStatus: 'Stato',
fieldProgress: 'Barra di avanzamento',
+1
View File
@@ -3560,6 +3560,7 @@ export default {
tokenWarning: 'このURLにはトークンが含まれます。URLを読める人は誰でも映像とファイル名を見られます。アクセスを止めるにはトークンを失効させてください。',
fields: '表示する項目',
fieldPrinter: 'プリンター名',
fieldModel: 'プリンターの機種',
fieldFilename: 'ファイル名',
fieldStatus: 'ステータス',
fieldProgress: '進捗バー',
+1
View File
@@ -3384,6 +3384,7 @@ export default {
tokenWarning: '이 URL에는 토큰이 들어 있습니다. URL을 읽을 수 있는 사람은 누구나 영상과 파일 이름을 볼 수 있습니다. 접근을 끊으려면 토큰을 폐기하세요.',
fields: '표시할 항목',
fieldPrinter: '프린터 이름',
fieldModel: '프린터 모델',
fieldFilename: '파일 이름',
fieldStatus: '상태',
fieldProgress: '진행률 막대',
+1
View File
@@ -3590,6 +3590,7 @@ export default {
tokenWarning: 'Deze URL bevat een token — iedereen die hem kan lezen kan de stream bekijken en de bestandsnaam zien. Trek het token in om de toegang te stoppen.',
fields: 'Velden om te tonen',
fieldPrinter: 'Printernaam',
fieldModel: 'Printermodel',
fieldFilename: 'Bestandsnaam',
fieldStatus: 'Status',
fieldProgress: 'Voortgangsbalk',
+1
View File
@@ -3548,6 +3548,7 @@ export default {
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',
fieldModel: 'Modelo da impressora',
fieldFilename: 'Nome do arquivo',
fieldStatus: 'Status',
fieldProgress: 'Barra de progresso',
+1
View File
@@ -3376,6 +3376,7 @@ export default {
tokenWarning: "Этот адрес содержит токен: любой, кто его прочитает, увидит трансляцию и имя файла. Отзовите токен, чтобы закрыть доступ.",
fields: "Показываемые поля",
fieldPrinter: "Имя принтера",
fieldModel: "Модель принтера",
fieldFilename: "Имя файла",
fieldStatus: "Состояние",
fieldProgress: "Полоса прогресса",
+1
View File
@@ -3590,6 +3590,7 @@ errors: {
tokenWarning: 'Denna URL innehåller en token — vem som helst som kan läsa den kan titta på strömmen och se filnamnet. Återkalla token för att blockera den.',
fields: 'Fält att visa',
fieldPrinter: 'Skrivarnamn',
fieldModel: 'Skrivarmodell',
fieldFilename: 'Filnamn',
fieldStatus: 'Status',
fieldProgress: 'Förloppsindikator',
+1
View File
@@ -3563,6 +3563,7 @@ export default {
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ı',
fieldModel: 'Yazıcı modeli',
fieldFilename: 'Dosya adı',
fieldStatus: 'Durum',
fieldProgress: 'İlerleme çubuğu',
+1
View File
@@ -3588,6 +3588,7 @@ export default {
tokenWarning: "Ця адреса містить токен: будь-хто, хто її прочитає, побачить трансляцію та назву файлу. Відкличте токен, щоб закрити доступ.",
fields: "Поля для показу",
fieldPrinter: "Назва принтера",
fieldModel: "Модель принтера",
fieldFilename: "Назва файлу",
fieldStatus: "Стан",
fieldProgress: "Смуга поступу",
+1
View File
@@ -3548,6 +3548,7 @@ export default {
tokenWarning: '此网址包含令牌:任何能看到它的人都可以观看画面并看到文件名。撤销令牌即可切断访问。',
fields: '要显示的字段',
fieldPrinter: '打印机名称',
fieldModel: '打印机型号',
fieldFilename: '文件名',
fieldStatus: '状态',
fieldProgress: '进度条',
+1
View File
@@ -3548,6 +3548,7 @@ export default {
tokenWarning: '此網址包含權杖:任何能看到它的人都可以觀看畫面並看到檔案名稱。撤銷權杖即可中止存取。',
fields: '要顯示的欄位',
fieldPrinter: '印表機名稱',
fieldModel: '印表機型號',
fieldFilename: '檔案名稱',
fieldStatus: '狀態',
fieldProgress: '進度列',
+11 -5
View File
@@ -20,6 +20,7 @@ interface OverlayConfig {
showFilename: boolean;
showStatus: boolean;
showPrinter: boolean;
showModel: boolean;
showNozzle: boolean;
showBed: boolean;
showChamber: boolean;
@@ -59,6 +60,7 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
showFilename: show.includes('filename'),
showStatus: show.includes('status'),
showPrinter: show.includes('printer'),
showModel: show.includes('model'),
showNozzle: show.includes('nozzle'),
showBed: show.includes('bed'),
showChamber: show.includes('chamber'),
@@ -209,10 +211,14 @@ export function StreamOverlayPage() {
const printer = useMemo(
() =>
kiosk
? overlay && { name: overlay.name, camera_rotation: overlay.camera_rotation }
? overlay && { name: overlay.name, model: overlay.model, camera_rotation: overlay.camera_rotation }
: printerData,
[kiosk, overlay, printerData],
);
const printerIdentity = [
config.showPrinter ? printer?.name : null,
config.showModel ? printer?.model : null,
].filter(Boolean).join(' · ');
const status = kiosk ? overlay : statusData;
const timeFormat: TimeFormat = (kiosk ? overlay?.time_format : settings?.time_format) || 'system';
@@ -404,11 +410,11 @@ export function StreamOverlayPage() {
{/* Status overlay - bottom */}
<div className="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent">
<div className={`${sizes.container}`}>
{/* Printer name */}
{config.showPrinter && printer && (
{/* Printer name and model can each be selected independently. */}
{printerIdentity && (
<div className={`flex items-center ${sizes.gap} mb-2`}>
<Printer className={`${sizes.icon} text-white/70`} />
<span className={`${sizes.text} text-white font-medium`}>{printer.name}</span>
<Printer className={`${sizes.icon} shrink-0 text-white/70`} />
<span className={`${sizes.text} min-w-0 truncate text-white font-medium`}>{printerIdentity}</span>
</div>
)}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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-CwRA56yz.js"></script>
<script type="module" crossorigin src="/assets/index-DwLYwYOk.js"></script>
<link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
<link rel="stylesheet" crossorigin href="/assets/index-DDT9pHR2.css">
</head>