mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Add Bambuddy Internal URL for Obico snapshots (#3255)
This commit is contained in:
@@ -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"]),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user