Explorar el Código

Printers: show each H2C nozzle's print time (#3507)

Keybored hace 8 horas
padre
commit
6443e73c45

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **WLED lights show the printer's status (#1528, requested and contributed by @d3nn3s08 in #3124)** — A WLED controller can now show what each printer is doing. Under **Settings > WLED**, pick a printer, enter its WLED address and assign one of the presets saved in WLED to each state: Idle, Preparing, Printing, Paused, Finished, Failed / Error, Awaiting plate clear, Filament problem, HMS error and Offline. Colours, effects and segments stay in WLED; Bambuddy only switches presets. A state without a preset falls back to the next one that has one (a filament runout without its own preset shows Paused). **Awaiting plate clear** applies only while the queue's **Require plate clear** setting holds the printer, and **Filament problem** only when the printer paused because the filament ran out. An optional timeout switches Finished back to Idle. The page can test the connection, load the presets from WLED and try each preset on the strip. If WLED is unreachable, Bambuddy retries every 30 seconds and logs the outage once. Several printers can share one controller when each preset is saved with "Checked segments only". The tab needs the `printers:update` permission.
 - **Queue several selected files as separate jobs (#3462, requested and contributed by @Eifel-Joe in #3471)** — Selecting several sliced files in the File Manager and pressing **Print** always made them printer alternatives: one queue job, run by the first free printer of a matching model. Two files sliced for the same printer model were refused, so putting several parts into one printer's queue meant printing each file on its own. The print dialog now offers **Alternatives** or **Separate jobs**. Separate jobs puts one queue item per file into the queue, in the order of the list, with the printers (or Any [model]), plate per file, print options, schedule and quantity applying to every item; each item gets its filament mapping at dispatch, against its own file. The dialog picks what fits: two files for the same model start on Separate jobs, with Alternatives unavailable; files for different models start on Alternatives, as before. A version group is still printed as alternatives only. If the queue refuses a file, the files before it stay queued and a second **Print**, also after closing and reopening the dialog, continues with the rest without queueing anything twice. The toolbar button now reads **Print (N files)**. Wiki updated.
 - **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light (#3479, sponsor vote Q4 2026)** — With the **Full** status overlay, every connected tile shows its nozzle (both on a dual-nozzle printer), bed and chamber temperatures, and a printing tile adds its finish time beside the time left. Hovering over a tile shows **Pause/Resume** and **Stop**, with the same confirmations as the printer card, and a **chamber light** switch; **Tile controls: Always** in the wall settings keeps them on screen, and a touch screen always shows them. The controls need the `printers:control` permission; a user without it, an offline printer and a Cam Wall token (kiosk) wall get none.
+- **The H2C shows how long each nozzle has printed (#1908, contributed by @Keybored02 in #3507)** — Recent H2C firmware counts the print time of every nozzle, which says more about a nozzle's use than the wear estimate. Hovering over a nozzle on the printer card, in the nozzle rack or on the left and right hotend, now shows its **Print time** next to **Wear**. Firmware that does not report it, and other printers, show no print time. Wiki updated.
 
 ### Changed
 - **Profile defaults is compact and has Apply all** — Settings → Filament → Profile defaults showed every default as a large box with all its presets and K profiles, so a few products already filled the page. Each default is now one line (product, colour, slicer preset, and how many printer presets and K profiles it has), sorted by product, with a search box; click a line to see its presets and K profiles. The list scrolls on its own. **Apply all** applies every default to the spools in stock in one go, and like Apply to existing spools it only fills in what a spool doesn't have yet. Wiki updated.

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

@@ -794,6 +794,7 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
             nozzle_type=n.get("type", ""),
             nozzle_diameter=n.get("diameter", ""),
             wear=n.get("wear"),
+            print_time_s=n.get("print_time_s"),
             stat=n.get("stat"),
             max_temp=n.get("max_temp", 0),
             serial_number=n.get("serial_number", ""),

+ 1 - 0
backend/app/schemas/printer.py

@@ -350,6 +350,7 @@ class NozzleRackSlot(BaseModel):
     nozzle_type: str = ""
     nozzle_diameter: str = ""
     wear: int | None = None
+    print_time_s: int | None = None  # H2C: seconds this nozzle has printed, from "p_t" (#1908)
     stat: int | None = None  # Nozzle status (e.g. mounted/docked)
     max_temp: int = 0  # Max temperature rating °C (0 = not set)
     serial_number: str = ""  # Nozzle serial number

+ 14 - 0
backend/app/services/bambu_mqtt.py

@@ -1065,6 +1065,18 @@ class NozzleInfo:
     nozzle_diameter: str = ""  # e.g., "0.4"
 
 
+def nozzle_print_time_s(value: object) -> int | None:
+    """A nozzle's accumulated print time from its ``nozzle_info`` entry (#1908).
+
+    H2C firmware reports it per nozzle as ``p_t``, in seconds. Older firmware
+    and other models leave it out, which stays None so the UI hides the row
+    instead of showing "0h". A bool is an int to Python but never a duration.
+    """
+    if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
+        return None
+    return int(value)
+
+
 # What await_cali_ack reports when the printer did not answer a K-profile write
 # in time. Not a refusal: firmware that predates the ack never answers.
 CALI_ACK_UNCONFIRMED = "no acknowledgement from printer"
@@ -5931,6 +5943,8 @@ class BambuMQTTClient:
                                 "type": str(n.get("type", "")),
                                 "diameter": str(n.get("diameter", "")),
                                 "wear": n.get("wear"),
+                                # H2C: seconds this nozzle has printed (#1908)
+                                "print_time_s": nozzle_print_time_s(n.get("p_t")),
                                 "stat": n.get("stat"),
                                 # H2C uses "tm", H2D uses "max_temp"
                                 "max_temp": n.get("max_temp") or n.get("tm", 0),

+ 1 - 0
backend/app/services/printer_manager.py

@@ -1843,6 +1843,7 @@ def printer_state_to_dict(
                 "nozzle_type": n.get("type", ""),
                 "nozzle_diameter": n.get("diameter", ""),
                 "wear": n.get("wear"),
+                "print_time_s": n.get("print_time_s"),
                 "stat": n.get("stat"),
                 "max_temp": n.get("max_temp", 0),
                 "serial_number": n.get("serial_number", ""),

+ 85 - 0
backend/tests/unit/test_nozzle_print_time_1908.py

@@ -0,0 +1,85 @@
+"""H2C per-nozzle print time (#1908).
+
+Recent H2C firmware reports how long each nozzle has printed as ``p_t`` (seconds)
+on its ``device.nozzle.info`` entry. It is carried as ``print_time_s`` through
+the MQTT state, the WebSocket status push and the REST status, and stays None
+where the firmware does not report it.
+"""
+
+import pytest
+
+from backend.app.schemas.printer import NozzleRackSlot
+from backend.app.services.bambu_mqtt import BambuMQTTClient, PrinterState, nozzle_print_time_s
+from backend.app.services.printer_manager import printer_state_to_dict
+
+
+def _nozzle(nozzle_id: int, **extra) -> dict:
+    return {"id": nozzle_id, "type": "HS01", "diameter": 0.4, "tm": 350, "wear": 0, "stat": 184, **extra}
+
+
+def _ingested(*nozzles: dict) -> BambuMQTTClient:
+    client = BambuMQTTClient(ip_address="10.0.0.1", serial_number="H2C", access_code="c", model="H2C")
+    client._process_message({"print": {"device": {"nozzle": {"info": list(nozzles)}}}})
+    return client
+
+
+class TestParse:
+    @pytest.mark.parametrize(
+        ("raw", "expected"),
+        [
+            (0, 0),
+            (7265, 7265),
+            (7265.9, 7265),
+            (None, None),
+            (-1, None),
+            ("7265", None),
+            (True, None),
+        ],
+    )
+    def test_only_a_non_negative_number_is_a_print_time(self, raw, expected):
+        assert nozzle_print_time_s(raw) == expected
+
+    def test_each_nozzle_keeps_its_own_print_time(self):
+        rack = _ingested(_nozzle(0, p_t=3600), _nozzle(16, p_t=90061), _nozzle(17)).state.nozzle_rack
+        assert {n["id"]: n["print_time_s"] for n in rack} == {0: 3600, 16: 90061, 17: None}
+
+    def test_firmware_without_p_t_leaves_it_unset(self):
+        (nozzle,) = _ingested(_nozzle(0)).state.nozzle_rack
+        assert nozzle["print_time_s"] is None
+
+
+class TestSerialise:
+    def test_the_websocket_push_carries_it(self):
+        state = PrinterState(connected=True, state="IDLE")
+        state.nozzle_rack = _ingested(_nozzle(16, p_t=7200)).state.nozzle_rack
+        (slot,) = printer_state_to_dict(state)["nozzle_rack"]
+        assert slot["print_time_s"] == 7200
+
+    def test_the_push_and_the_rest_schema_have_the_same_fields(self):
+        # The frontend shallow-merges pushes over the REST status, so a field
+        # the push leaves out goes stale on the first push.
+        state = PrinterState(connected=True, state="IDLE")
+        state.nozzle_rack = _ingested(_nozzle(16, p_t=7200)).state.nozzle_rack
+        (slot,) = printer_state_to_dict(state)["nozzle_rack"]
+        assert set(slot) == set(NozzleRackSlot.model_fields)
+        assert NozzleRackSlot(**slot).print_time_s == 7200
+
+
+class TestRestStatus:
+    @pytest.mark.asyncio
+    async def test_the_status_route_carries_it(self, async_client, printer_factory):
+        # What the page has before the first push arrives.
+        from unittest.mock import MagicMock, patch
+
+        printer = await printer_factory()
+        state = _ingested(_nozzle(0, p_t=3600), _nozzle(16)).state
+        state.connected = True
+        state.state = "IDLE"
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_status = MagicMock(return_value=state)
+            mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
+            response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+        assert response.status_code == 200
+        assert {n["id"]: n["print_time_s"] for n in response.json()["nozzle_rack"]} == {0: 3600, 16: None}

+ 58 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -868,6 +868,64 @@ describe('PrintersPage', () => {
       expect(dashes.length).toBeGreaterThanOrEqual(3); // 3 empty rack positions (IDs 19,20,21)
     });
 
+    const hoverRackDiameter = async (diameter: string) => {
+      await waitFor(() => {
+        expect(screen.getAllByText('Nozzle Rack').length).toBeGreaterThan(0);
+      });
+      const chip = Array.from(document.querySelectorAll('span[data-rack-diameter]')).find(
+        s => s.textContent === diameter,
+      )!;
+      await userEvent.setup().hover(chip);
+    };
+
+    it('shows the nozzle print time from H2C firmware on the rack hover card (#1908)', async () => {
+      const withPrintTime = {
+        ...h2cStatus,
+        nozzle_rack: h2cStatus.nozzle_rack.map(n => (n.id === 17 ? { ...n, print_time_s: 90061 } : n)),
+      };
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(withPrintTime)));
+
+      render(<PrintersPage />);
+      await hoverRackDiameter('0.6');
+
+      expect(await screen.findByText('Print time')).toBeInTheDocument();
+      expect(screen.getByText('25h 1m')).toBeInTheDocument();
+    });
+
+    it('shows the print time of the left and right hotend on their hover card (#1908)', async () => {
+      localStorage.setItem('printerCardSize', '2');
+      const h2c = { ...mockPrinters[0], model: 'H2C', nozzle_count: 2 };
+      const dualWithPrintTime = {
+        ...h2cStatus,
+        temperatures: { ...mockPrinterStatus.temperatures, nozzle_2: 25 },
+        nozzle_rack: h2cStatus.nozzle_rack.map(n =>
+          n.id === 0 ? { ...n, print_time_s: 3660 } : n.id === 1 ? { ...n, print_time_s: 7320 } : n,
+        ),
+      };
+      server.use(
+        http.get('/api/v1/printers/', () => HttpResponse.json([h2c])),
+        http.get('/api/v1/printers/:id/status', () => HttpResponse.json(dualWithPrintTime)),
+      );
+
+      render(<PrintersPage />);
+      const left = await screen.findByText('L 0.4');
+      await userEvent.setup().hover(left);
+
+      expect(await screen.findByText('2h 2m')).toBeInTheDocument(); // left hotend (id 1)
+      expect(screen.getByText('1h 1m')).toBeInTheDocument(); // right hotend (id 0)
+      expect(screen.getAllByText('Print time')).toHaveLength(2);
+    });
+
+    it('leaves the print time row out when the firmware does not report it (#1908)', async () => {
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(h2cStatus)));
+
+      render(<PrintersPage />);
+      await hoverRackDiameter('0.6');
+
+      expect(await screen.findByText('Wear')).toBeInTheDocument();
+      expect(screen.queryByText('Print time')).not.toBeInTheDocument();
+    });
+
     it('keeps empty slot anchored to physical position when its nozzle is mounted (#943)', async () => {
       // H2C with rack slot 16 picked up into the hotend — firmware omits ID 16
       // entirely from nozzle.info. Each rack diameter is unique so we can assert

+ 1 - 0
frontend/src/api/client.ts

@@ -601,6 +601,7 @@ export interface NozzleRackSlot {
   nozzle_type: string;
   nozzle_diameter: string;
   wear: number | null;
+  print_time_s?: number | null;  // H2C: seconds this nozzle has printed (#1908); null when not reported
   stat: number | null;  // Nozzle status (e.g. mounted/docked)
   max_temp: number;
   serial_number: string;

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

@@ -510,6 +510,7 @@ export default {
     nozzleStatus: 'Status',
     nozzleFilament: 'Filament',
     nozzleWear: 'Verschleiß',
+    nozzlePrintTime: 'Druckzeit',
     nozzleMaxTemp: 'Max. Temp',
     nozzleSerial: 'Seriennr.',
     nozzleHardenedSteel: 'Gehärteter Stahl',

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

@@ -515,6 +515,7 @@ export default {
     nozzleStatus: 'Status',
     nozzleFilament: 'Filament',
     nozzleWear: 'Wear',
+    nozzlePrintTime: 'Print time',
     nozzleMaxTemp: 'Max Temp',
     nozzleSerial: 'Serial',
     nozzleHardenedSteel: 'Hardened Steel',

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

@@ -510,6 +510,7 @@ export default {
     nozzleStatus: 'Estado',
     nozzleFilament: 'Filamento',
     nozzleWear: 'Desgaste',
+    nozzlePrintTime: 'Tiempo de impresión',
     nozzleMaxTemp: 'Temp. máx.',
     nozzleSerial: 'Serie',
     nozzleHardenedSteel: 'Acero endurecido',

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

@@ -510,6 +510,7 @@ export default {
     nozzleStatus: 'Statut',
     nozzleFilament: 'Filament',
     nozzleWear: 'Usure',
+    nozzlePrintTime: "Temps d'impression",
     nozzleMaxTemp: 'Temp Max',
     nozzleSerial: 'Série',
     nozzleHardenedSteel: 'Acier Trempé',

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

@@ -510,6 +510,7 @@ export default {
     nozzleStatus: 'Stato',
     nozzleFilament: 'Filamento',
     nozzleWear: 'Usura',
+    nozzlePrintTime: 'Tempo di stampa',
     nozzleMaxTemp: 'Temp Max',
     nozzleSerial: 'Seriale',
     nozzleHardenedSteel: 'Acciaio Temprato',

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

@@ -509,6 +509,7 @@ export default {
     nozzleStatus: 'ステータス',
     nozzleFilament: 'フィラメント',
     nozzleWear: '摩耗',
+    nozzlePrintTime: '印刷時間',
     nozzleMaxTemp: '最高温度',
     nozzleSerial: 'シリアル',
     nozzleHardenedSteel: '焼入れ鋼',

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

@@ -487,6 +487,7 @@ export default {
     nozzleStatus: '상태',
     nozzleFilament: '필라멘트',
     nozzleWear: '마모',
+    nozzlePrintTime: '출력 시간',
     nozzleMaxTemp: '최대 온도',
     nozzleSerial: '일련번호',
     nozzleHardenedSteel: '경화강',

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

@@ -513,6 +513,7 @@ export default {
     nozzleStatus: 'Status',
     nozzleFilament: 'Filament',
     nozzleWear: 'Slijtage',
+    nozzlePrintTime: 'Printtijd',
     nozzleMaxTemp: 'Max. temp.',
     nozzleSerial: 'Serienummer',
     nozzleHardenedSteel: 'Gehard staal',

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

@@ -515,6 +515,7 @@ export default {
     nozzleStatus: 'Status',
     nozzleFilament: 'Filament',
     nozzleWear: 'Zużycie',
+    nozzlePrintTime: 'Czas druku',
     nozzleMaxTemp: 'Maks. temp.',
     nozzleSerial: 'Nr seryjny',
     nozzleHardenedSteel: 'Stal hartowana',

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

@@ -510,6 +510,7 @@ export default {
     nozzleStatus: 'Status',
     nozzleFilament: 'Filamento',
     nozzleWear: 'Desgaste',
+    nozzlePrintTime: 'Tempo de impressão',
     nozzleMaxTemp: 'Temp Máx',
     nozzleSerial: 'Número de série',
     nozzleHardenedSteel: 'Aço Endurecido',

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

@@ -492,6 +492,7 @@ export default {
     nozzleStatus: "Состояние",
     nozzleFilament: "Филамент",
     nozzleWear: "Износ",
+    nozzlePrintTime: "Время печати",
     nozzleMaxTemp: "Макс. температура",
     nozzleSerial: "Серийный номер",
     nozzleHardenedSteel: "Закалённая сталь",

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

@@ -513,6 +513,7 @@ export default {
     nozzleStatus: 'Status',
     nozzleFilament: 'Filament',
     nozzleWear: 'Slitage',
+    nozzlePrintTime: 'Utskriftstid',
     nozzleMaxTemp: 'Max temp',
     nozzleSerial: 'Serienummer',
     nozzleHardenedSteel: 'Härdat stål',

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

@@ -510,6 +510,7 @@ export default {
     nozzleStatus: 'Durum',
     nozzleFilament: 'Filament',
     nozzleWear: 'Aşınma',
+    nozzlePrintTime: 'Baskı süresi',
     nozzleMaxTemp: 'Maks. Sıcaklık',
     nozzleSerial: 'Seri',
     nozzleHardenedSteel: 'Sertleştirilmiş Çelik',

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

@@ -513,6 +513,7 @@ export default {
     nozzleStatus: "Статус",
     nozzleFilament: "Філамент",
     nozzleWear: "Знос",
+    nozzlePrintTime: "Час друку",
     nozzleMaxTemp: "Макс. температура",
     nozzleSerial: "Серійний номер",
     nozzleHardenedSteel: "Загартована сталь",

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

@@ -510,6 +510,7 @@ export default {
     nozzleStatus: '状态',
     nozzleFilament: '耗材',
     nozzleWear: '磨损',
+    nozzlePrintTime: '打印时长',
     nozzleMaxTemp: '最高温度',
     nozzleSerial: '序列号',
     nozzleHardenedSteel: '硬化钢',

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

@@ -510,6 +510,7 @@ export default {
     nozzleStatus: '狀態',
     nozzleFilament: '耗材',
     nozzleWear: '磨損',
+    nozzlePrintTime: '列印時長',
     nozzleMaxTemp: '最高溫度',
     nozzleSerial: '序列號',
     nozzleHardenedSteel: '硬化鋼',

+ 14 - 0
frontend/src/pages/PrintersPage.tsx

@@ -512,6 +512,14 @@ function NozzleSlotHoverCard({ slot, index, activeStatus, filamentName, children
                   </div>
                 )}
 
+                {/* Print time (H2C firmware only, hide if not reported) */}
+                {slot.print_time_s != null && (
+                  <div className="flex items-center justify-between">
+                    <span className="text-[length:var(--pc-t10,10px)] uppercase tracking-wider text-bambu-gray font-medium">{t('printers.nozzlePrintTime')}</span>
+                    <span className="text-xs text-white font-semibold">{formatDuration(slot.print_time_s)}</span>
+                  </div>
+                )}
+
                 {/* Max Temp (hide if 0) */}
                 {slot.max_temp > 0 && (
                   <div className="flex items-center justify-between">
@@ -652,6 +660,12 @@ function DualNozzleHoverCard({ leftSlot, rightSlot, activeNozzle, filamentInfo,
             <span className="text-xs text-white font-semibold">{slot.wear}%</span>
           </div>
         )}
+        {slot.print_time_s != null && (
+          <div className="flex items-center justify-between">
+            <span className="text-[length:var(--pc-t10,10px)] text-bambu-gray">{t('printers.nozzlePrintTime')}</span>
+            <span className="text-xs text-white font-semibold">{formatDuration(slot.print_time_s)}</span>
+          </div>
+        )}
         {/* Serial and max temp only available on the right (removable) nozzle */}
         {side === 'R' && slot.max_temp > 0 && (
           <div className="flex items-center justify-between">