Repository navigation
feat(printers): onboarding wizard + Moonraker probe + S1 camera-only lock [H3D-CLAUDE-PRINTERS] #71
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,10 @@ | |
| REMOTE_SUBDIR_RE = re.compile(r"^[A-Za-z0-9._/-]+$") | ||
| ONBOARD_ID_RE = re.compile(r"^[A-Za-z0-9._-]{2,64}$") | ||
|
|
||
| # S1 is camera/read-only ONLY — it must never be added as a printable target. | ||
| # Any attempt to add a printer whose IP is in this set returns 403. | ||
| CAMERA_ONLY_IPS: frozenset[str] = frozenset({"192.168.0.12"}) | ||
|
|
||
|
|
||
| class UrlUpdate(BaseModel): | ||
| url: str | ||
|
|
@@ -63,6 +67,14 @@ class PrinterOnboardRequest(BaseModel): | |
| write_enabled: bool = False | ||
|
|
||
|
|
||
| class PrinterProbeRequest(BaseModel): | ||
| ip: str | ||
|
|
||
|
|
||
| class CameraValidateRequest(BaseModel): | ||
| camera_url: str | ||
|
|
||
|
|
||
| def _printer(printer_id: str) -> dict[str, Any]: | ||
| printer = local_printer(printer_id) | ||
| if not printer: | ||
|
|
@@ -75,6 +87,146 @@ def list_printers() -> list[dict[str, Any]]: | |
| return local_printers() | ||
|
|
||
|
|
||
| @router.get("/api/printers/probe") | ||
| def probe_printer_by_ip(ip: str) -> dict[str, Any]: | ||
| """Read-only Moonraker probe for the onboarding wizard. | ||
|
|
||
| Returns Moonraker server info (version, firmware, bed size) for the given | ||
| IP. NEVER sends GCode or any printer command — only GET /server/info and | ||
| GET /printer/objects/query (read-only). S1 (CAMERA_ONLY_IPS) is blocked. | ||
| """ | ||
| try: | ||
| address = ipaddress.ip_address(ip.strip()) | ||
| except ValueError as exc: | ||
| raise HTTPException(status_code=400, detail="ip must be a valid IP address.") from exc | ||
|
|
||
| if str(address) in CAMERA_ONLY_IPS or is_s1_target(str(address)): | ||
| raise HTTPException( | ||
| status_code=403, | ||
| detail={ | ||
| "error": "CAMERA_ONLY_IP", | ||
| "ip": str(address), | ||
| "reason": "This IP is camera-only and cannot be added as a print target.", | ||
| }, | ||
| ) | ||
|
|
||
| if address.is_loopback or address.is_multicast or address.is_unspecified or address.is_reserved: | ||
| raise HTTPException(status_code=400, detail="IP address is not allowed.") | ||
|
|
||
| moonraker_url = f"http://{address}:7125" | ||
| client = MoonrakerClient(moonraker_url, timeout_s=4.0) | ||
|
|
||
| # Read-only: GET /server/info only — no GCode, no commands. | ||
| try: | ||
| info = client.server_info() | ||
| except (MoonrakerError, OSError, ValueError) as exc: | ||
| raise HTTPException( | ||
| status_code=502, | ||
| detail={ | ||
| "error": "MOONRAKER_PROBE_FAILED", | ||
| "ip": str(address), | ||
| "moonraker_url": moonraker_url, | ||
| "reason": str(exc), | ||
| }, | ||
| ) from exc | ||
|
|
||
| # Optionally read printer objects (read-only). | ||
| bed_info: dict[str, Any] = {} | ||
| try: | ||
| state = client.printer_state(("configfile", "toolhead")) | ||
| bed_info = { | ||
| "print_state": state.state, | ||
| "filename": state.filename or None, | ||
| "progress": state.progress, | ||
| } | ||
| except (MoonrakerError, OSError, ValueError): | ||
| pass # optional — don't fail the probe if objects query fails | ||
|
|
||
| # Look up static fleet profile for bed size (read-only, no network call). | ||
| profile_info: dict[str, Any] = {} | ||
| try: | ||
| profile = get_profile(f"moonraker_{str(address).replace('.', '_')}") | ||
| profile_info = { | ||
| "bed_kind": profile.bed.kind, | ||
| "bed_diameter_mm": getattr(profile.bed, "diameter_mm", None), | ||
| "bed_x_mm": getattr(profile.bed, "x_mm", None), | ||
| "bed_y_mm": getattr(profile.bed, "y_mm", None), | ||
| "z_height_mm": profile.z_height_mm, | ||
| } | ||
| except KeyError: | ||
| pass # unknown profile — not an error | ||
|
|
||
| return { | ||
| "ok": True, | ||
| "ip": str(address), | ||
| "moonraker_url": moonraker_url, | ||
| "klippy_connected": info.klippy_connected, | ||
| "klippy_state": info.klippy_state, | ||
| "moonraker_version": info.moonraker_version, | ||
| "api_version": info.api_version, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is a type mismatch for |
||
| **bed_info, | ||
| **profile_info, | ||
| } | ||
|
|
||
|
|
||
| @router.post("/api/printers/validate-camera") | ||
| def validate_camera_url(body: CameraValidateRequest) -> dict[str, Any]: | ||
| """Read-only camera URL validation. | ||
|
|
||
| Sends a HEAD request to the camera URL and checks that the Content-Type | ||
| looks like an MJPEG stream. Never sends print commands or writes. | ||
| """ | ||
| raw_url = body.camera_url.strip() | ||
| if not raw_url: | ||
| raise HTTPException(status_code=400, detail="camera_url is required.") | ||
|
|
||
| parsed = urlparse(raw_url) | ||
| if parsed.scheme not in {"http", "https"} or not parsed.hostname: | ||
| raise HTTPException(status_code=400, detail="Camera URL must be http(s) with a host.") | ||
| if parsed.username or parsed.password: | ||
| raise HTTPException(status_code=400, detail="Camera URL must not include credentials.") | ||
|
|
||
| try: | ||
| address = ipaddress.ip_address(parsed.hostname) | ||
| if address.is_loopback or address.is_multicast or address.is_unspecified or address.is_reserved: | ||
| raise HTTPException(status_code=400, detail="Camera URL host is not allowed.") | ||
| except ValueError: | ||
| pass # hostname — allowed for camera URLs | ||
|
Comment on lines
+189
to
+194
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The SSRF protection in |
||
|
|
||
| # Read-only HEAD request only — no GET body, no commands. | ||
| try: | ||
| request = urllib.request.Request(raw_url, method="HEAD", headers={"Accept": "*/*"}) | ||
| with urllib.request.urlopen(request, timeout=3.0) as response: | ||
| content_type: str = response.headers.get("Content-Type", "") | ||
| http_status = int(response.status) | ||
| except urllib.error.HTTPError as exc: | ||
| return { | ||
| "ok": False, | ||
| "camera_url": raw_url, | ||
| "http_status": int(exc.code), | ||
| "content_type": None, | ||
| "reason": f"Camera returned HTTP {exc.code}.", | ||
| } | ||
| except OSError as exc: | ||
| return { | ||
| "ok": False, | ||
| "camera_url": raw_url, | ||
| "http_status": None, | ||
| "content_type": None, | ||
| "reason": str(exc), | ||
| } | ||
|
|
||
| is_mjpeg = "multipart/x-mixed-replace" in content_type or "image/jpeg" in content_type | ||
| return { | ||
| "ok": is_mjpeg or (200 <= http_status < 400), | ||
| "camera_url": raw_url, | ||
| "http_status": http_status, | ||
| "content_type": content_type or None, | ||
| "is_mjpeg": is_mjpeg, | ||
| "reason": None if (is_mjpeg or (200 <= http_status < 400)) else f"Unexpected content-type: {content_type!r}", | ||
| } | ||
|
|
||
|
|
||
| @router.post("/api/printers/onboard", status_code=201) | ||
| def onboard_printer(body: PrinterOnboardRequest) -> dict[str, Any]: | ||
| moonraker_url, host = _validate_onboard_moonraker_url(body.moonraker_url) | ||
|
|
@@ -360,14 +512,14 @@ def _validate_onboard_moonraker_url(raw_url: str) -> tuple[str, str]: | |
| address = ipaddress.ip_address(host) | ||
| except ValueError as exc: | ||
| raise HTTPException(status_code=400, detail="Moonraker URL host must be a printer IP address.") from exc | ||
| if str(address) == "192.168.0.12" or is_s1_target(str(address)): | ||
| if str(address) in CAMERA_ONLY_IPS or is_s1_target(str(address)): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| raise HTTPException( | ||
| status_code=423, | ||
| status_code=403, | ||
| detail={ | ||
| "error": "S1_POLICY_LOCKED", | ||
| "error": "CAMERA_ONLY_IP", | ||
| "printer_id": "flsun_s1", | ||
| "ip": str(address), | ||
| "reason": "FLSUN S1 remains offline/locked/no-test by operator policy; onboarding cannot convert it into a write-enabled printer.", | ||
| "reason": "This IP is camera-only and cannot be added as a print target.", | ||
| }, | ||
| ) | ||
| if address.is_loopback or address.is_multicast or address.is_unspecified or address.is_reserved: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1087,6 +1087,104 @@ export function emitProofEventLive(type: string, payload: Record<string, unknown | |
| return postVoid("/api/proof/events", { type, payload }); | ||
| } | ||
|
|
||
| export interface PrinterProbeResult { | ||
| ok: boolean; | ||
| ip: string; | ||
| moonraker_url: string; | ||
| klippy_connected: boolean; | ||
| klippy_state: string; | ||
| moonraker_version: string | null; | ||
| api_version: string[] | null; | ||
| print_state?: string; | ||
| filename?: string | null; | ||
| progress?: number; | ||
| bed_kind?: string; | ||
| bed_diameter_mm?: number | null; | ||
| bed_x_mm?: number | null; | ||
| bed_y_mm?: number | null; | ||
| z_height_mm?: number; | ||
| error?: string; | ||
| reason?: string; | ||
| } | ||
|
|
||
| export interface CameraValidateResult { | ||
| ok: boolean; | ||
| camera_url: string; | ||
| http_status: number | null; | ||
| content_type: string | null; | ||
| is_mjpeg?: boolean; | ||
| reason?: string | null; | ||
| } | ||
|
|
||
| /** Read-only probe of a Moonraker printer by IP — GET /server/info only, never sends GCode. */ | ||
| export async function probePrinterLive(ip: string): Promise<PrinterProbeResult> { | ||
| try { | ||
| const response = await fetch(`${LIVE_BASE_URL}/api/printers/probe?ip=${encodeURIComponent(ip)}`, { | ||
| method: "GET", | ||
| headers: { Accept: "application/json" }, | ||
| cache: "no-store", | ||
| }); | ||
| const payload: unknown = await response.json().catch(() => null); | ||
| if (!response.ok) { | ||
| const detail = isRecord(payload) ? payload.detail : null; | ||
| const reason = isRecord(detail) && isString(detail.reason) | ||
| ? detail.reason | ||
| : isRecord(payload) && isString(payload.detail) | ||
| ? payload.detail | ||
| : `Probe failed with HTTP ${response.status}.`; | ||
| return { ok: false, ip, moonraker_url: `http://${ip}:7125`, klippy_connected: false, klippy_state: "unknown", moonraker_version: null, api_version: null, error: `HTTP ${response.status}`, reason }; | ||
| } | ||
| if (!isRecord(payload)) { | ||
| return { ok: false, ip, moonraker_url: `http://${ip}:7125`, klippy_connected: false, klippy_state: "unknown", moonraker_version: null, api_version: null, reason: "Invalid probe response." }; | ||
| } | ||
| return { | ||
| ok: payload.ok === true, | ||
| ip: isString(payload.ip) ? payload.ip : ip, | ||
| moonraker_url: isString(payload.moonraker_url) ? payload.moonraker_url : `http://${ip}:7125`, | ||
| klippy_connected: payload.klippy_connected === true, | ||
| klippy_state: isString(payload.klippy_state) ? payload.klippy_state : "unknown", | ||
| moonraker_version: isNullableString(payload.moonraker_version) ? payload.moonraker_version : null, | ||
| api_version: Array.isArray(payload.api_version) ? payload.api_version as string[] : null, | ||
| print_state: isString(payload.print_state) ? payload.print_state : undefined, | ||
| filename: isNullableString(payload.filename) ? payload.filename : undefined, | ||
| progress: isNumber(payload.progress) ? payload.progress : undefined, | ||
| bed_kind: isString(payload.bed_kind) ? payload.bed_kind : undefined, | ||
| bed_diameter_mm: isNullableNumber(payload.bed_diameter_mm) ? payload.bed_diameter_mm : undefined, | ||
| bed_x_mm: isNullableNumber(payload.bed_x_mm) ? payload.bed_x_mm : undefined, | ||
| bed_y_mm: isNullableNumber(payload.bed_y_mm) ? payload.bed_y_mm : undefined, | ||
| z_height_mm: isNumber(payload.z_height_mm) ? payload.z_height_mm : undefined, | ||
| }; | ||
| } catch (error) { | ||
| return { ok: false, ip, moonraker_url: `http://${ip}:7125`, klippy_connected: false, klippy_state: "unknown", moonraker_version: null, api_version: null, reason: errorMessage(error) }; | ||
| } | ||
| } | ||
|
|
||
| /** Read-only camera URL validation — HEAD request only, never sends print commands. */ | ||
| export async function validateCameraUrlLive(cameraUrl: string): Promise<CameraValidateResult> { | ||
| try { | ||
| const response = await fetch(`${LIVE_BASE_URL}/api/printers/validate-camera`, { | ||
| method: "POST", | ||
| headers: { Accept: "application/json", "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ camera_url: cameraUrl }), | ||
| cache: "no-store", | ||
| }); | ||
| const payload: unknown = await response.json().catch(() => null); | ||
| if (!response.ok || !isRecord(payload)) { | ||
| return { ok: false, camera_url: cameraUrl, http_status: null, content_type: null, reason: `Camera validation failed with HTTP ${response.status}.` }; | ||
| } | ||
|
Comment on lines
+1172
to
+1174
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
if (!response.ok || !isRecord(payload)) {
const detail = isRecord(payload) ? payload.detail : null;
const reason = isRecord(detail) && isString(detail.reason)
? detail.reason
: isRecord(payload) && isString(payload.detail)
? payload.detail
: `Camera validation failed with HTTP ${response.status}.`;
return { ok: false, camera_url: cameraUrl, http_status: null, content_type: null, reason };
} |
||
| return { | ||
| ok: payload.ok === true, | ||
| camera_url: isString(payload.camera_url) ? payload.camera_url : cameraUrl, | ||
| http_status: isNullableNumber(payload.http_status) ? payload.http_status : null, | ||
| content_type: isNullableString(payload.content_type) ? payload.content_type : null, | ||
| is_mjpeg: payload.is_mjpeg === true, | ||
| reason: isNullableString(payload.reason) ? payload.reason : null, | ||
| }; | ||
| } catch (error) { | ||
| return { ok: false, camera_url: cameraUrl, http_status: null, content_type: null, reason: errorMessage(error) }; | ||
| } | ||
| } | ||
|
|
||
| export function getCameraObserverStatusLive(): Promise<{ status: string; reason: string }> { | ||
| return fetchJson<{ state?: string; status?: string; reason?: string | null }>("/api/plugins/camera-observer/status").then((status) => { | ||
| if (!status) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This check is redundant.
is_s1_target(str(address))already checks if the IP is inS1_ALT_IDS(which includes192.168.0.12). Additionally, havingCAMERA_ONLY_IPSdefined here whileS1_ALT_IDSexists insafety.pycreates a split source of truth for the S1 policy. Consider consolidating these intosafety.py.