Browse Source

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

Adam Spice 1 day ago
parent
commit
858f8c772f

+ 3 - 1
backend/app/api/routes/printers.py

@@ -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,

+ 12 - 0
backend/tests/integration/test_overlay_status_api.py

@@ -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.

+ 20 - 0
frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx

@@ -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 />);

+ 42 - 0
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -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 - 0
frontend/src/api/client.ts

@@ -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;

+ 1 - 0
frontend/src/components/StreamOverlayBuilder.tsx

@@ -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 - 0
frontend/src/i18n/locales/de.ts

@@ -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 - 0
frontend/src/i18n/locales/en.ts

@@ -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 - 0
frontend/src/i18n/locales/es.ts

@@ -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 - 0
frontend/src/i18n/locales/fr.ts

@@ -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 - 0
frontend/src/i18n/locales/it.ts

@@ -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 - 0
frontend/src/i18n/locales/ja.ts

@@ -3560,6 +3560,7 @@ export default {
       tokenWarning: 'このURLにはトークンが含まれます。URLを読める人は誰でも映像とファイル名を見られます。アクセスを止めるにはトークンを失効させてください。',
       fields: '表示する項目',
       fieldPrinter: 'プリンター名',
+      fieldModel: 'プリンターの機種',
       fieldFilename: 'ファイル名',
       fieldStatus: 'ステータス',
       fieldProgress: '進捗バー',

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

@@ -3384,6 +3384,7 @@ export default {
       tokenWarning: '이 URL에는 토큰이 들어 있습니다. URL을 읽을 수 있는 사람은 누구나 영상과 파일 이름을 볼 수 있습니다. 접근을 끊으려면 토큰을 폐기하세요.',
       fields: '표시할 항목',
       fieldPrinter: '프린터 이름',
+      fieldModel: '프린터 모델',
       fieldFilename: '파일 이름',
       fieldStatus: '상태',
       fieldProgress: '진행률 막대',

+ 1 - 0
frontend/src/i18n/locales/nl.ts

@@ -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 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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 - 0
frontend/src/i18n/locales/ru.ts

@@ -3376,6 +3376,7 @@ export default {
       tokenWarning: "Этот адрес содержит токен: любой, кто его прочитает, увидит трансляцию и имя файла. Отзовите токен, чтобы закрыть доступ.",
       fields: "Показываемые поля",
       fieldPrinter: "Имя принтера",
+      fieldModel: "Модель принтера",
       fieldFilename: "Имя файла",
       fieldStatus: "Состояние",
       fieldProgress: "Полоса прогресса",

+ 1 - 0
frontend/src/i18n/locales/sv.ts

@@ -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 - 0
frontend/src/i18n/locales/tr.ts

@@ -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 - 0
frontend/src/i18n/locales/uk.ts

@@ -3588,6 +3588,7 @@ export default {
       tokenWarning: "Ця адреса містить токен: будь-хто, хто її прочитає, побачить трансляцію та назву файлу. Відкличте токен, щоб закрити доступ.",
       fields: "Поля для показу",
       fieldPrinter: "Назва принтера",
+      fieldModel: "Модель принтера",
       fieldFilename: "Назва файлу",
       fieldStatus: "Стан",
       fieldProgress: "Смуга поступу",

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

@@ -3548,6 +3548,7 @@ export default {
       tokenWarning: '此网址包含令牌:任何能看到它的人都可以观看画面并看到文件名。撤销令牌即可切断访问。',
       fields: '要显示的字段',
       fieldPrinter: '打印机名称',
+      fieldModel: '打印机型号',
       fieldFilename: '文件名',
       fieldStatus: '状态',
       fieldProgress: '进度条',

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

@@ -3548,6 +3548,7 @@ export default {
       tokenWarning: '此網址包含權杖:任何能看到它的人都可以觀看畫面並看到檔案名稱。撤銷權杖即可中止存取。',
       fields: '要顯示的欄位',
       fieldPrinter: '印表機名稱',
+      fieldModel: '印表機型號',
       fieldFilename: '檔案名稱',
       fieldStatus: '狀態',
       fieldProgress: '進度列',

+ 11 - 5
frontend/src/pages/StreamOverlayPage.tsx

@@ -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 it is too large
+ 0 - 1
static/assets/PdfPreviewModal-DDobgSqb.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DjjKs6_1.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/index-DwLYwYOk.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-D4N_XcKU.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-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>

Some files were not shown because too many files changed in this diff