Add Bambuddy Internal URL for Obico snapshots (#3255)

This commit is contained in:
Chris Bennight
2026-10-06 12:19:38 +02:00
committed by GitHub
parent cf98daa0bb
commit d2ea32ef6f
482 changed files with 339 additions and 6662 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ async def get_status(
"sensitivity": settings["sensitivity"],
"action": settings["action"],
"poll_interval": settings["poll_interval"],
"external_url_configured": bool(settings["external_url"]),
"external_url_configured": bool(settings["snapshot_base_url"]),
}
+33
View File
@@ -1,6 +1,7 @@
import json
import re
from typing import Literal
from urllib.parse import urlparse
from pydantic import BaseModel, Field, ValidationInfo, field_validator
@@ -645,6 +646,9 @@ class AppSettings(BaseModel):
default="",
description="Self-hosted Obico ML API base URL (e.g., http://192.168.1.10:3333)",
)
bambuddy_internal_url: str = Field(
default="", description="Bambuddy Internal URL for Obico; empty uses External URL"
)
obico_ml_token: str = Field(
default="",
description=(
@@ -864,6 +868,7 @@ class AppSettingsUpdate(BaseModel):
ldap_default_group: str | None = None
obico_enabled: bool | None = None
obico_ml_url: str | None = None
bambuddy_internal_url: str | None = None
obico_ml_token: str | None = None
obico_sensitivity: str | None = None
obico_action: str | None = None
@@ -988,6 +993,34 @@ class AppSettingsUpdate(BaseModel):
raise ValueError("obico_enabled_printers must be a JSON array of printer IDs (integers)")
return v
@field_validator("bambuddy_internal_url")
@classmethod
def validate_bambuddy_internal_url(cls, v: str | None) -> str:
"""Require an absolute http(s) URL, or empty to fall back to External URL.
Obico's ML server fetches snapshots from this address, so a value
without a scheme ("bambuddy:8000") would only fail later, mid-print,
as a snapshot error. Rejecting it here surfaces the mistake on save.
An explicit null clears the field. The settings updater stores None
as the literal "None", which would read back as an address.
"""
candidate = (v or "").strip()
if not candidate:
return ""
error = "bambuddy_internal_url must be a full http:// or https:// address"
if any(ch.isspace() for ch in candidate):
raise ValueError(error)
try:
parsed = urlparse(candidate)
hostname = parsed.hostname
_ = parsed.port # raises on a port outside 0-65535
except ValueError:
raise ValueError(error) from None
if parsed.scheme not in ("http", "https") or not hostname:
raise ValueError(error)
return candidate
@staticmethod
def _validate_preset_triple(v: str | None, field_name: str, lo: int, hi: int) -> str | None:
"""Validate a JSON array of exactly 3 ints in [lo, hi]. Empty = defaults."""
+12 -7
View File
@@ -130,6 +130,7 @@ class ObicoDetectionService:
keys = [
"obico_enabled",
"obico_ml_url",
"bambuddy_internal_url",
"obico_ml_token",
"obico_sensitivity",
"obico_action",
@@ -158,7 +159,11 @@ class ObicoDetectionService:
"action": rows.get("obico_action", "notify"),
"poll_interval": int(rows.get("obico_poll_interval", "10")),
"enabled_printers": enabled_printers,
"external_url": (rows.get("external_url") or "").rstrip("/"),
# Where Obico's ML server fetches snapshots: the Internal URL when
# set, otherwise the public External URL.
"snapshot_base_url": (
(rows.get("bambuddy_internal_url") or "").strip() or (rows.get("external_url") or "").strip()
).rstrip("/"),
}
# ---- main loop ----
@@ -297,17 +302,17 @@ class ObicoDetectionService:
self._no_verdict(printer_id, f"Failed to capture snapshot for printer {printer_id}")
return
external_url = settings.get("external_url") or ""
if not external_url:
snapshot_base_url = settings.get("snapshot_base_url") or ""
if not snapshot_base_url:
self._no_verdict(
printer_id,
"external_url setting is empty — Obico's ML API needs a reachable URL to fetch the snapshot from. "
"Set Settings → General → External URL.",
"bambuddy_internal_url and external_url settings are empty — Obico's ML API needs a reachable URL to fetch the snapshot from. "
"Set Settings → Failure Detection → Bambuddy Internal URL or Settings → Network → External URL.",
)
return
nonce = await stash_frame(frame)
snapshot_url = f"{external_url}/api/v1/obico/cached-frame/{nonce}"
snapshot_url = f"{snapshot_base_url}/api/v1/obico/cached-frame/{nonce}"
ml_url = f"{settings['ml_url']}/p/"
try:
@@ -403,7 +408,7 @@ class ObicoDetectionService:
``error`` the most recent poll produced no verdict. ``error`` carries
the reason — a rejected token, an unreachable ML API, a
camera that would not yield a frame, an unset External URL.
camera that would not yield a frame, a missing Bambuddy address.
``unknown`` monitored, but no inference has come back yet. The state
entry is created when the print is first seen, which is
before the first capture, so this is the honest answer for
+106 -2
View File
@@ -5,11 +5,18 @@ pre-captured JPEG frames. This endpoint lets the detection loop sidestep Obico's
hardcoded 5s read timeout by pre-populating a cache before issuing the ML call.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
from httpx import AsyncClient, Request, Response
from backend.app.core.printer_scope import ALL_PRINTERS
from backend.app.services.obico_detection import _frame_cache, obico_detection_service, stash_frame
from backend.app.services.obico_detection import (
ObicoDetectionService,
_frame_cache,
obico_detection_service,
stash_frame,
)
from backend.app.services.obico_smoothing import PrintState
FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
@@ -73,6 +80,103 @@ class TestObicoCachedFrame:
assert "no-store" in response.headers.get("cache-control", "")
class TestBambuddyInternalUrl:
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
("bambuddy_internal_url", "external_url", "expected_base"),
[
(None, "https://bambuddy.example.com", "https://bambuddy.example.com"),
("", "https://bambuddy.example.com/", "https://bambuddy.example.com"),
("http://bambuddy:8000/", "https://bambuddy.example.com", "http://bambuddy:8000"),
("http://192.168.1.20:8000", "", "http://192.168.1.20:8000"),
],
)
async def test_saved_url_controls_snapshot_callback(
self, async_client: AsyncClient, bambuddy_internal_url, external_url, expected_base
):
updates = {"external_url": external_url, "obico_ml_url": "http://obico:3333"}
if bambuddy_internal_url is not None:
updates["bambuddy_internal_url"] = bambuddy_internal_url
response = await async_client.put("/api/v1/settings/", json=updates)
assert response.status_code == 200
saved = (await async_client.get("/api/v1/settings/")).json()
assert saved["external_url"] == external_url
assert saved["bambuddy_internal_url"] == (bambuddy_internal_url or "")
status = (await async_client.get("/api/v1/obico/status")).json()
assert status["external_url_configured"] is True
async def fetch_snapshot(url, *, params, headers):
assert url == "http://obico:3333/p/"
assert params["img"].startswith(f"{expected_base}/api/v1/obico/cached-frame/")
frame = await async_client.get(params["img"])
assert frame.status_code == 200
assert frame.content == FAKE_JPEG
return Response(200, json={"detections": []}, request=Request("GET", url))
svc = ObicoDetectionService()
settings = await svc._load_settings()
mock_client = MagicMock()
mock_client.get = AsyncMock(side_effect=fetch_snapshot)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
):
await svc._check_printer(1, MagicMock(state="RUNNING", task_name="job", subtask_name=""), settings)
mock_client.get.assert_awaited_once()
assert svc.get_per_printer()[1]["class"] == "safe"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_empty_value_clears_the_bambuddy_internal_url(self, async_client: AsyncClient):
response = await async_client.put(
"/api/v1/settings/",
json={"bambuddy_internal_url": "http://bambuddy:8000", "external_url": "https://bambuddy.example.com"},
)
assert response.status_code == 200
response = await async_client.put("/api/v1/settings/", json={"bambuddy_internal_url": ""})
assert response.status_code == 200
assert response.json()["bambuddy_internal_url"] == ""
assert response.json()["external_url"] == "https://bambuddy.example.com"
assert (await ObicoDetectionService()._load_settings())["snapshot_base_url"] == "https://bambuddy.example.com"
@pytest.mark.asyncio
@pytest.mark.integration
@pytest.mark.parametrize(
"value",
["bambuddy:8000", "192.168.1.20:8000", "ftp://bambuddy", "http://", "http://bam buddy:8000", "http://h:99999"],
)
async def test_address_without_http_scheme_is_rejected(self, async_client: AsyncClient, value):
response = await async_client.put("/api/v1/settings/", json={"bambuddy_internal_url": value})
assert response.status_code == 422
saved = (await async_client.get("/api/v1/settings/")).json()
assert saved["bambuddy_internal_url"] == ""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_null_clears_instead_of_storing_none(self, async_client: AsyncClient):
await async_client.put(
"/api/v1/settings/",
json={"bambuddy_internal_url": "http://bambuddy:8000", "external_url": "https://bambuddy.example.com"},
)
response = await async_client.put("/api/v1/settings/", json={"bambuddy_internal_url": None})
assert response.status_code == 200
assert response.json()["bambuddy_internal_url"] == ""
assert (await ObicoDetectionService()._load_settings())["snapshot_base_url"] == "https://bambuddy.example.com"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_surrounding_whitespace_is_stripped(self, async_client: AsyncClient):
response = await async_client.put(
"/api/v1/settings/", json={"bambuddy_internal_url": " http://bambuddy:8000/ "}
)
assert response.status_code == 200
assert response.json()["bambuddy_internal_url"] == "http://bambuddy:8000/"
assert (await ObicoDetectionService()._load_settings())["snapshot_base_url"] == "http://bambuddy:8000"
class TestObicoPrinterStatus:
"""The lightweight /obico/printer-status endpoint for printer-card badges (#1546)."""
+15 -15
View File
@@ -161,7 +161,7 @@ class TestMlApiToken:
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "http://bambuddy:8000",
"snapshot_base_url": "http://bambuddy:8000",
}
base.update(overrides)
return base
@@ -354,7 +354,7 @@ class TestPollOneStateLifecycle:
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "http://bambuddy:8000",
"snapshot_base_url": "http://bambuddy:8000",
}
status = MagicMock(state="RUNNING", task_name="new_task", subtask_name="")
@@ -387,7 +387,7 @@ class TestPollOneStateLifecycle:
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "http://bambuddy:8000",
"snapshot_base_url": "http://bambuddy:8000",
}
status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
@@ -416,7 +416,7 @@ class TestPollOneStateLifecycle:
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "http://bambuddy:8000",
"snapshot_base_url": "http://bambuddy:8000",
}
status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
@@ -451,7 +451,7 @@ class TestPollOneStateLifecycle:
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "http://bambuddy:8000",
"snapshot_base_url": "http://bambuddy:8000",
}
status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
@@ -669,7 +669,7 @@ class TestCheckPrinterUsesCachedFrameUrl:
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "http://bambuddy:8000",
"snapshot_base_url": "http://bambuddy:8000",
}
status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
@@ -709,7 +709,7 @@ class TestCheckPrinterUsesCachedFrameUrl:
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "http://bambuddy:8000",
"snapshot_base_url": "http://bambuddy:8000",
}
status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
@@ -729,8 +729,8 @@ class TestCheckPrinterUsesCachedFrameUrl:
assert "Failed to capture snapshot" in svc._last_error
@pytest.mark.asyncio
async def test_missing_external_url_skips_ml_call(self):
"""Without external_url, Obico can't reach our cached-frame endpoint."""
async def test_missing_snapshot_address_skips_ml_call(self):
"""Without a snapshot address, Obico can't reach our cached-frame endpoint."""
svc = ObicoDetectionService()
settings = {
"enabled": True,
@@ -739,7 +739,7 @@ class TestCheckPrinterUsesCachedFrameUrl:
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "",
"snapshot_base_url": "",
}
status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
@@ -756,7 +756,7 @@ class TestCheckPrinterUsesCachedFrameUrl:
mock_client.get.assert_not_called()
assert svc._last_error is not None
assert "external_url" in svc._last_error
assert "bambuddy_internal_url" in svc._last_error
@pytest.mark.asyncio
async def test_successful_cycle_clears_previous_error(self):
@@ -774,7 +774,7 @@ class TestCheckPrinterUsesCachedFrameUrl:
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "http://bambuddy:8000",
"snapshot_base_url": "http://bambuddy:8000",
}
status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
@@ -822,7 +822,7 @@ class TestNoVerdictIsNotSafe:
"action": "notify",
"poll_interval": 10,
"enabled_printers": None,
"external_url": "http://bambuddy:8000",
"snapshot_base_url": "http://bambuddy:8000",
}
@staticmethod
@@ -882,9 +882,9 @@ class TestNoVerdictIsNotSafe:
assert "capture" in entry["error"].lower()
@pytest.mark.asyncio
async def test_missing_external_url_reports_error(self):
async def test_missing_snapshot_address_reports_error(self):
svc = ObicoDetectionService()
settings = {**self.SETTINGS, "external_url": ""}
settings = {**self.SETTINGS, "snapshot_base_url": ""}
with patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)):
await svc._check_printer(1, self._status(), settings)
@@ -253,13 +253,14 @@ def test_every_url_setting_is_either_guarded_or_explicitly_exempt():
added per-incident rather than to the whole class of fields.
"""
exempt = {
# Bambuddy's own public address, not a destination it requests. It is
# Bambuddy's own address, not a destination it requests. It is
# rendered into notification bodies and OIDC redirect URIs, and handed
# to Obico's ML server as the `img` parameter for that server to fetch
# (obico_detection.py builds `{external_url}/api/v1/obico/cached-frame/
# {nonce}`). Pointing it at a private address only breaks Bambuddy's own
# links; it cannot make Bambuddy request anything it otherwise wouldn't.
"external_url",
"bambuddy_internal_url",
# Guarded by assert_safe_spoolman_url at each consumer (spoolman.py,
# location_service.py, inventory.py, spoolbuddy.py,
# spoolman_inventory.py) rather than in the schema, keeping its
@@ -609,6 +610,7 @@ GUARDED_BODY_URLS = {
# Not a destination Bambuddy requests — no guard applies.
NOT_A_FETCH_TARGET = {
("AppSettingsUpdate", "external_url"), # Bambuddy's own address (see exempt list above)
("AppSettingsUpdate", "bambuddy_internal_url"),
("AppSettingsUpdate", "ldap_server_url"), # ldap://, handed to an LDAP client
("ProjectCreate", "url"), # stored link, rendered in the UI, never fetched
("ProjectUpdate", "url"),