Bläddra i källkod

Add layers, job id, HMS faults and serial to the API-key printer status (#2919)

maziggy 2 dagar sedan
förälder
incheckning
8b5d8e3170

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **The API-key printer status carries layers, the job id, HMS faults and the serial (#2919, requested by @simplytoast1)** — `GET /api/v1/webhook/printer/{id}/status`, the compact status route polled by add-ons such as notify-bambuddy for iOS Live Activities, returned only state, progress and time remaining. It now also returns `layer_num` and `total_layers`, the printer's `subtask_id` for the running job (a new value means a new print, even between two polls; `null` when Bambu gives the job no id), the live `hms_errors` in the same shape as the printer status route (so a filament runout reads differently from a manual pause), and the printer's `serial_number`. `remaining_time` stays in minutes for existing clients; the new `remaining_seconds` gives the same estimate in seconds, the unit notifications use. Nothing existing changed, and the route still needs **Read Status** and honours a key's printer limits.
 - **Choose what goes on a spool label, see it before printing, and save labels as PNG (#2981, requested by @apizz)** — Spool labels always carried the same lines, and you only saw the result once the PDF opened. The label picker now has a checkbox for each line: brand, material and subtype, colour code, name, storage location, material number, nozzle temperature, net weight, note, date added, QR code and spool ID. Material number, temperature, weight, note and date were never printed before, and leaving out the QR code gives the text its space. A preview beside the options shows the first selected spool's label as it will print, and updates as you change them. Lines that don't fit on the chosen size are left out rather than printed over the spool ID. Labels can now also be saved as PNG at 203, 300 or 600 dpi for label printer software that takes images, such as Brother P-touch Editor: one label comes as a PNG, several as a ZIP. The picker remembers the size, the lines chosen for each size, monochrome and the output settings in your browser. Spoolman labels now carry the same data as built-in ones, including the subtype and the colour name you set, where before they showed neither.
 - **The outcome prompt can be answered by reacting to the Telegram message (#3046, requested and contributed by @Thomansky in #3129)** — The Good/Reject buttons in a Telegram outcome prompt open a Bambuddy link, which only works when the phone can reach Bambuddy. A thumbs-up or thumbs-down on the message now records the verdict instead: the phone only talks to Telegram, so it works away from home, behind NAT or with the VPN down, and needs no external URL. A Telegram provider gains **Outcome verdict via**: **Inline buttons (link)** (the default, unchanged), **Reaction (👍 / 👎)**, or **Buttons and reaction**. Bambuddy then polls the bot for reactions (one poll per bot, outbound only) and edits the message to say "marked as good" or "marked as reject". The first verdict wins, whichever way it arrives, and a reaction stops counting exactly when the prompt's links would, so a message from an earlier run can't answer a reprint. Use a bot only Bambuddy uses for reactions: another app polling the same bot, such as Home Assistant's Telegram integration, stops receiving its messages. In a group the bot must be an admin to see reactions. The Archives page's **Unconfirmed** filter now lists completed prints only, like the **outcome?** badge.
 - **SSO logins can keep a user's Bambuddy groups in step with their groups at the identity provider (#3107, requested and contributed by @willuhmjs in #3122)** — Each OIDC provider in Settings → Authentication → SSO/OIDC gains a **Group Claim** (default `groups`) and a **Group Mapping**, a list of rows pairing an identity provider group with a Bambuddy group. On every SSO login, existing accounts included, the mapped Bambuddy groups follow the provider: joining a provider group grants its Bambuddy group, leaving it removes that group at the next login. Groups the mapping doesn't name are left alone, so a manual promotion sticks. This is the same rule the LDAP group mapping uses. Groups are read from the signed ID token, as a list or as a space- or comma-separated string, and matched ignoring case. Claims with a namespace such as `app/roles` work, for Auth0. A missing claim, or a sync that fails, never blocks the login; the user keeps the groups they had. The form offers only existing Bambuddy groups, flags a row pointing at a deleted group, and won't save a half-filled row or a second row for the same provider group. The provider card shows whether group sync is on. With no mapping, a provider behaves exactly as before. Docker and env-var setups use `BAMBUDDY_OIDC_GROUP_CLAIM` and `BAMBUDDY_OIDC_GROUP_MAPPING`, a JSON object whose values are Bambuddy group names. A mapping naming a group that doesn't exist, or one that isn't valid, is refused at startup with the reason in the log, and the app still starts.

+ 2 - 15
backend/app/api/routes/printers.py

@@ -36,7 +36,6 @@ from backend.app.schemas.printer import (
     ExtruderSlotResponse,
     FilaSwitchResponse,
     HmsActionBody,
-    HMSErrorResponse,
     NozzleInfoResponse,
     NozzleRackSlot,
     PrinterCreate,
@@ -48,6 +47,7 @@ from backend.app.schemas.printer import (
     PrinterStatus,
     PrinterUpdate,
     PrintOptionsResponse,
+    hms_error_responses,
 )
 from backend.app.services import drying_preflight
 from backend.app.services.bambu_ftp import (
@@ -508,20 +508,7 @@ async def get_printer_status(
     if state.state in ("RUNNING", "PAUSE") and state.gcode_file:
         cover_url = f"/api/v1/printers/{printer_id}/cover"
 
-    # Convert HMS errors to response format
-    hms_errors = [
-        HMSErrorResponse(
-            code=e.code,
-            attr=e.attr,
-            module=e.module,
-            severity=e.severity,
-            actions=e.actions,
-            job_id=e.job_id,
-            full_code=e.full_code,
-            description=e.description,
-        )
-        for e in (state.hms_errors or [])
-    ]
+    hms_errors = hms_error_responses(state.hms_errors)
 
     # Parse AMS data from raw_data
     ams_units = []

+ 52 - 5
backend/app/api/routes/webhook.py

@@ -11,6 +11,7 @@ from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
+from backend.app.schemas.printer import HMSErrorResponse, hms_error_responses
 from backend.app.services.print_confirmation import confirm_outcome_for_new_queue_item
 from backend.app.services.printer_manager import printer_manager
 
@@ -41,11 +42,28 @@ class QueueAddResponse(BaseModel):
 class PrinterStatusResponse(BaseModel):
     id: int
     name: str
+    # The printer's own serial, so a client can tell printers apart by what
+    # they report rather than by Bambuddy's row id (#2919).
+    serial_number: str
     connected: bool
     state: str | None
     current_print: str | None
     progress: float | None
+    # Minutes, as the printer reports it. Kept for existing clients;
+    # remaining_seconds is the same estimate in seconds, the unit the
+    # notification pipeline uses (#2919).
     remaining_time: int | None
+    remaining_seconds: int | None = None
+    layer_num: int | None = None
+    total_layers: int | None = None
+    # Bambu's id for the running job. A new value marks a new print, even when
+    # two prints of the same file run back to back between two polls. None
+    # when the job has no id: Bambu reports "0" or "" for local prints (for
+    # example one started on the printer), the same reading main.py uses.
+    subtask_id: str | None = None
+    # Live HMS faults, in the same shape as GET /printers/{id}/status. They
+    # tell a filament runout apart from someone pressing pause.
+    hms_errors: list[HMSErrorResponse] = []
 
 
 class QueueStatusResponse(BaseModel):
@@ -56,6 +74,18 @@ class QueueStatusResponse(BaseModel):
     items: list[dict]
 
 
+def _job_id(subtask_id) -> str | None:
+    """The printer's job id as text, or None when the job has none.
+
+    Stored as the printer sent it, so coerce: a numeric id would fail
+    validation and turn a status poll into a 500.
+    """
+    if subtask_id is None:
+        return None
+    value = str(subtask_id).strip()
+    return None if value in ("", "0") else value
+
+
 # Webhook endpoints
 
 
@@ -278,14 +308,31 @@ async def webhook_get_printer_status(
     # attribute access, not dict lookup. The previous `.get(...)` calls raised
     # AttributeError and surfaced as a generic 500 for any printer that
     # actually had a status row (#1584).
+    if status is None:
+        return PrinterStatusResponse(
+            id=printer.id,
+            name=printer.name,
+            serial_number=printer.serial_number,
+            connected=False,
+            state=None,
+            current_print=None,
+            progress=None,
+            remaining_time=None,
+        )
     return PrinterStatusResponse(
         id=printer.id,
         name=printer.name,
-        connected=status.connected if status else False,
-        state=status.state if status else None,
-        current_print=status.current_print if status else None,
-        progress=status.progress if status else None,
-        remaining_time=status.remaining_time if status else None,
+        serial_number=printer.serial_number,
+        connected=status.connected,
+        state=status.state,
+        current_print=status.current_print,
+        progress=status.progress,
+        remaining_time=status.remaining_time,
+        remaining_seconds=status.remaining_time * 60 if status.remaining_time is not None else None,
+        layer_num=status.layer_num,
+        total_layers=status.total_layers,
+        subtask_id=_job_id(status.subtask_id),
+        hms_errors=hms_error_responses(status.hms_errors),
     )
 
 

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

@@ -181,6 +181,27 @@ class HMSErrorResponse(BaseModel):
     description: str | None = None
 
 
+def hms_error_responses(errors) -> list[HMSErrorResponse]:
+    """A printer's live HMS faults (``PrinterState.hms_errors``) as API rows.
+
+    Shared by the printer status route and the webhook status route, so a
+    fault reads the same to the UI and to an API-key client (#2919).
+    """
+    return [
+        HMSErrorResponse(
+            code=e.code,
+            attr=e.attr,
+            module=e.module,
+            severity=e.severity,
+            actions=e.actions,
+            job_id=e.job_id,
+            full_code=e.full_code,
+            description=e.description,
+        )
+        for e in (errors or [])
+    ]
+
+
 class AMSTray(BaseModel):
     id: int
     tray_color: str | None = None

+ 175 - 1
backend/tests/integration/test_webhook_printer_status.py

@@ -12,7 +12,7 @@ from unittest.mock import MagicMock, patch
 import pytest
 from httpx import AsyncClient
 
-from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.services.bambu_mqtt import HMSError, PrinterState
 
 
 @pytest.fixture
@@ -94,6 +94,7 @@ class TestWebhookGetPrinterStatus:
         assert body["current_print"] == "bench.3mf"
         assert body["progress"] == 42.0
         assert body["remaining_time"] == 1234
+        assert body["remaining_seconds"] == 1234 * 60
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -123,6 +124,12 @@ class TestWebhookGetPrinterStatus:
         assert body["current_print"] is None
         assert body["progress"] is None
         assert body["remaining_time"] is None
+        assert body["serial_number"] == "00M00A000000010"
+        assert body["remaining_seconds"] is None
+        assert body["layer_num"] is None
+        assert body["total_layers"] is None
+        assert body["subtask_id"] is None
+        assert body["hms_errors"] == []
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -138,6 +145,173 @@ class TestWebhookGetPrinterStatus:
         assert resp.status_code == 404
 
 
+async def _status(async_client: AsyncClient, key: str, printer_id: int, state: PrinterState | None):
+    with patch(
+        "backend.app.api.routes.webhook.printer_manager.get_status",
+        MagicMock(return_value=state),
+    ):
+        return await async_client.get(
+            f"/api/v1/webhook/printer/{printer_id}/status",
+            headers={"X-API-Key": key},
+        )
+
+
+class TestWebhookPrinterStatusFields:
+    """What an external client such as a phone Live Activity needs from the
+    status route, all of it already on ``PrinterState`` (#2919)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_running_print_reports_layers_job_and_seconds(
+        self, async_client: AsyncClient, api_key_data, printer_row
+    ):
+        state = PrinterState(
+            connected=True,
+            state="RUNNING",
+            current_print="bench.3mf",
+            progress=62.0,
+            remaining_time=107,
+            layer_num=88,
+            total_layers=240,
+            subtask_id="512345678",
+        )
+        body = (await _status(async_client, api_key_data, printer_row.id, state)).json()
+
+        assert body["serial_number"] == "00M00A000000010"
+        assert body["layer_num"] == 88
+        assert body["total_layers"] == 240
+        assert body["subtask_id"] == "512345678"
+        # remaining_time stays in minutes for existing clients.
+        assert body["remaining_time"] == 107
+        assert body["remaining_seconds"] == 6420
+        assert body["hms_errors"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_paused_print_says_why(self, async_client: AsyncClient, api_key_data, printer_row):
+        """A runout pause carries its HMS fault; a user pause carries none."""
+        runout = HMSError(
+            code="0x20008",
+            attr=0x07008000,
+            module=0x07,
+            severity=2,
+            description="Filament has run out.",
+            actions=["RESUME_PRINTING"],
+            job_id="512345678",
+            full_code="0700800000020008",
+        )
+        state = PrinterState(connected=True, state="PAUSE", subtask_id="512345678", hms_errors=[runout])
+        body = (await _status(async_client, api_key_data, printer_row.id, state)).json()
+
+        assert body["hms_errors"] == [
+            {
+                "code": "0x20008",
+                "attr": 0x07008000,
+                "module": 0x07,
+                "severity": 2,
+                "actions": ["RESUME_PRINTING"],
+                "job_id": "512345678",
+                "full_code": "0700800000020008",
+                "description": "Filament has run out.",
+            }
+        ]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hms_errors_match_the_printer_status_route(
+        self, async_client: AsyncClient, api_key_data, printer_row
+    ):
+        """One shape for a fault, whether the UI or an API key asks."""
+        from backend.app.api.routes.printers import hms_error_responses as used_by_printers_route
+
+        fault = HMSError(code="0x1", attr=0x0300_0100, module=0x03, severity=1, full_code="0300010000000001")
+        state = PrinterState(connected=True, state="FAILED", hms_errors=[fault])
+        body = (await _status(async_client, api_key_data, printer_row.id, state)).json()
+
+        assert body["hms_errors"] == [e.model_dump() for e in used_by_printers_route([fault])]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_numeric_subtask_id_is_returned_as_text(self, async_client: AsyncClient, api_key_data, printer_row):
+        """Stored as the printer sent it; a number must not 500 the poll."""
+        state = PrinterState(connected=True, state="RUNNING", subtask_id=512345678)
+        resp = await _status(async_client, api_key_data, printer_row.id, state)
+
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["subtask_id"] == "512345678"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("raw", ["0", "", " ", 0])
+    async def test_a_job_without_an_id_reports_null(self, async_client: AsyncClient, api_key_data, printer_row, raw):
+        """Bambu reports "0" or "" for local prints. Every such print would
+        share the same "id", so it must not be handed out as one."""
+        state = PrinterState(connected=True, state="RUNNING", subtask_id=raw)
+        resp = await _status(async_client, api_key_data, printer_row.id, state)
+
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["subtask_id"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_idle_printer_reports_zero_not_null(self, async_client: AsyncClient, api_key_data, printer_row):
+        """A connected idle printer has real zeros; null is kept for "no status yet"."""
+        state = PrinterState(connected=True, state="IDLE")
+        body = (await _status(async_client, api_key_data, printer_row.id, state)).json()
+
+        assert body["remaining_time"] == 0
+        assert body["remaining_seconds"] == 0
+        assert body["layer_num"] == 0
+        assert body["total_layers"] == 0
+        assert body["subtask_id"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_needs_the_read_status_scope(self, async_client: AsyncClient, db_session, printer_row):
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        db_session.add(
+            APIKey(
+                name="no-status",
+                key_hash=key_hash,
+                key_prefix=key_prefix,
+                can_read_status=False,
+                can_queue=True,
+                enabled=True,
+            )
+        )
+        await db_session.commit()
+
+        resp = await _status(async_client, full_key, printer_row.id, PrinterState(connected=True))
+        assert resp.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_key_for_another_printer_sees_nothing(self, async_client: AsyncClient, db_session, printer_row):
+        """The serial and faults are only for keys allowed on this printer."""
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        db_session.add(
+            APIKey(
+                name="other-printer",
+                key_hash=key_hash,
+                key_prefix=key_prefix,
+                can_read_status=True,
+                printer_ids=[printer_row.id + 1000],
+                enabled=True,
+            )
+        )
+        await db_session.commit()
+
+        resp = await _status(async_client, full_key, printer_row.id, PrinterState(connected=True))
+        assert resp.status_code == 403
+        assert "00M00A000000010" not in resp.text
+
+
 class TestWebhookStopPrint:
     """``POST /api/v1/webhook/printer/{id}/stop`` — same dataclass-shape
     fix applies to the connection / state precondition checks (#1584)."""