nodeslicer/infra/printer_backends.py
2026-05-14 14:47:10 +03:00

337 lines
No EOL
12 KiB
Python

from __future__ import annotations
from functools import wraps
from typing import Any, Callable
def with_api_state(api_state: str) -> Callable[..., Any]:
def decorator(func: Callable[..., Any]) -> Any:
@wraps(func)
def wrapped(self: Any, *args: Any, **kwargs: Any) -> Any:
self.api_state = api_state
try: return func(self, *args, **kwargs)
finally: self.api_state = None
return wrapped
return decorator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol, Any, Optional
import requests
import time
import json
@dataclass(frozen=True)
class PrinterStatus:
progress: float = 0.0
state: str = "OFFLINE"
job_name: str = ""
job_id: str = ""
nozzle_temperature: float = 0.0
bed_temperature: float = 0.0
nozzle_diameter: float = 0.0
@dataclass
class PrinterConfig:
host: str = "localhost"
port: int = 80
prefix: str = ""
label: str = "Printer"
username: str = ""
password: str = ""
host_type: str = "PRUSALINK"
class PrinterBackend(Protocol):
def query_status(self) -> PrinterStatus: ...
def pause(self) -> None: ...
def resume(self) -> None: ...
def stop(self) -> None: ...
def start_print(self, gcode: Path, name: str) -> None: ...
class HttpBackend:
def __init__(self, host: str, port: int, prefix: str, api_key: Optional[str] = None, timeout: float = 30.0):
self.host = host
self.port = port
self.base = f"http://{host}:{port}{prefix}"
self.timeout = timeout
self.session = requests.Session()
self.headers = {}
self.api_state: str | None = None
if api_key:
self.headers["X-Api-Key"] = api_key
def _get(self, path: str, **kw) -> requests.Response:
headers = {**self.headers, **kw.pop('headers', {})}
return self.session.get(self.base + path, headers=headers, timeout=self.timeout, **kw)
def _post(self, path: str, **kw) -> requests.Response:
headers = {**self.headers, **kw.pop('headers', {})}
return self.session.post(self.base + path, headers=headers, timeout=self.timeout, **kw)
def _put(self, path: str, **kw) -> requests.Response:
headers = {**self.headers, **kw.pop('headers', {})}
return self.session.put(self.base + path, headers=headers, timeout=self.timeout, **kw)
def _delete(self, path: str, **kw) -> requests.Response:
headers = {**self.headers, **kw.pop('headers', {})}
return self.session.delete(self.base + path, headers=headers, timeout=self.timeout, **kw)
def _json(self, resp, default: dict = {}, ok=(200, 201, 202, 204)) -> dict:
if resp.status_code not in ok:
resp.raise_for_status()
if resp.status_code == 204 or not resp.content:
return default
return resp.json()
class PrinterHttpBackend(HttpBackend, PrinterBackend):
pass
class PrusaLinkBackend(PrinterHttpBackend):
def query_status(self) -> PrinterStatus:
s = self._get("/api/v1/status").json()
info = self._get("/api/v1/info").json()
job = self._json(self._get("/api/v1/job"))
return PrinterStatus(
progress=float(job.get("progress", 0.0)),
state=str(s.get("printer", {}).get("state", "OFFLINE")),
job_name=str(job.get("file", {}).get("display_name", "")),
job_id=str(s.get("job", {}).get("id", "")),
nozzle_temperature=float(s.get("printer", {}).get("temp_nozzle", 0) or 0),
bed_temperature=float(s.get("printer", {}).get("temp_bed", 0) or 0),
nozzle_diameter=float(info.get('nozzle_diameter', 0.0)),
)
@with_api_state('PAUSING')
def pause(self) -> None:
jid = self.query_status().job_id
if not jid: raise RuntimeError("No job to pause")
self._put(f"/api/v1/job/{jid}/pause").raise_for_status()
@with_api_state('RESUMING')
def resume(self) -> None:
jid = self.query_status().job_id
if not jid: raise RuntimeError("No job to resume")
self._put(f"/api/v1/job/{jid}/resume").raise_for_status()
@with_api_state('STOPPING')
def stop(self) -> None:
jid = self.query_status().job_id
if not jid: raise RuntimeError("No job to stop")
self._delete(f"/api/v1/job/{jid}").raise_for_status()
@with_api_state("STARTING")
def start_print(self, gcode: Path, name: str) -> None:
storage = self._get("/api/v1/storage").json()
writables = [s for s in storage.get("storage_list", []) if s.get("available") and not s.get("read_only")]
if not writables:
raise RuntimeError("No writable storage")
path = writables[0]["path"].lstrip("/")
file_size = gcode.stat().st_size
with open(gcode, "rb") as f:
self._put(
f"/api/v1/files/{path}/{name}",
headers={
"Overwrite": "?1",
"Print-After-Upload": "?1",
"Content-Type": "text/x.gcode" if Path(name).suffix == ".gcode" else "application/octet-stream",
"Content-Length": str(file_size),
},
data=f,
).raise_for_status()
try: # This block will ensure a start on mk3s printers, which don't seem to respond to Print-After-Upload
# wait until /transfer is gone (204)
for _ in range(int(self.timeout)):
r = self._get("/api/v1/transfer")
if r.status_code == 204: break
time.sleep(1)
else: raise TimeoutError("Transfer timed out")
# verify remote metadata size matches
for _ in range(int(self.timeout)):
r = self._get(f"/api/v1/files/{path}/{name}", headers={"Accept": "application/json"})
if r.status_code == 200 and (r.json().get("size") == file_size): break
time.sleep(1)
else: raise TimeoutError("Remote file size did not match expected size")
self._post(f"/api/v1/files/{path}/{name}").raise_for_status()
finally: return
class CrealityBackend(PrinterHttpBackend):
STATUS_EP = "/protocal.csp?fname=Info&opt=main&function=get"
STORAGE_PATH = "/mmcblk0p1/creality/gztemp/"
STORAGE_PATH_PRINT = "/media/mmcblk0p1/creality/gztemp/"
def query_status(self) -> PrinterStatus:
r = self._get(self.STATUS_EP)
r.raise_for_status()
d = r.json()
# Map numeric state to string
state_id = str(d.get("state", "-1"))
state: str = (
"OFFLINE" if state_id == "-1" else
"PAUSED" if state_id == "5" else
"IDLE" if state_id in {"0", "3", "4"} else
"PRINTING" if state_id == "1" else
"FINISHED" if state_id == "2" else
"UNKNOWN"
)
# Defensive casting
def f(x, default=0.0):
try:
return float(x)
except Exception:
return default
def s(x, default=""):
return str(x) if x is not None else default
return PrinterStatus(
progress=round(f(d.get("printProgress"), 0.0), 1),
state=state,
job_name=s(d.get("print")),
job_id="", # Not exposed by this API
nozzle_temperature=f(d.get("nozzleTemp"), 0.0),
bed_temperature=f(d.get("bedTemp"), 0.0),
)
@with_api_state('PAUSING')
def pause(self) -> None:
r = self._get("/protocal.csp?fname=net&opt=iot_conf&function=set&pause=1")
r.raise_for_status()
@with_api_state('RESUMING')
def resume(self) -> None:
r = self._get("/protocal.csp?fname=net&opt=iot_conf&function=set&pause=0")
r.raise_for_status()
@with_api_state('STOPPING')
def stop(self) -> None:
r = self._get("/protocal.csp?fname=net&opt=iot_conf&function=set&stop=1")
r.raise_for_status()
@with_api_state('STARTING')
def start_print(self, gcode: Path, name: str) -> None:
from ..infra.ftp import ftp_upload, ftp_wipe, ftp_get_filesize
file_size = gcode.stat().st_size
# Clean target dir and upload
ftp_wipe(self.host, self.STORAGE_PATH)
ftp_upload(self.host, gcode, self.STORAGE_PATH, filename=name, overwrite=True, timeout=self.timeout)
for _ in range(10):
print(_)
uploaded_size = ftp_get_filesize(self.host, storage_path=self.STORAGE_PATH, filename=name, timeout=self.timeout)
if uploaded_size == file_size:
break
time.sleep(2)
else:
raise TimeoutError("Uploaded file did not reach expected size on printer")
# Trigger print
ep = (
"/protocal.csp?fname=net&opt=iot_conf&function=set"
f"&print={self.STORAGE_PATH_PRINT}{name}"
)
r = self._get(ep)
r.raise_for_status()
class MoonrakerBackend(PrinterHttpBackend):
def query_status(self) -> PrinterStatus:
url = f"{self.base}/printer/objects/query?webhooks&virtual_sdcard&print_stats"
r = self.session.get(url, headers=self.headers, timeout=self.timeout)
r.raise_for_status()
data = r.json().get("result", {}).get("status", {})
sd = data.get("virtual_sdcard", {}) or {}
ps = data.get("print_stats", {}) or {}
state = str(ps.get("state", "OFFLINE")).upper()
if state == "STANDBY":
state = "IDLE"
progress = 0.0
try:
progress = float(sd.get("progress", 0.0)) * 100.0
except Exception:
pass
file_path = sd.get("file_path", "") or ""
return PrinterStatus(
progress=round(progress, 1),
state=state,
job_name=str(file_path),
job_id="",
nozzle_temperature=0.0,
bed_temperature=0.0,
)
@with_api_state('PAUSING')
def pause(self) -> None:
url = f"{self.base}/printer/print/pause"
r = self.session.post(url, headers=self.headers, timeout=self.timeout)
r.raise_for_status()
@with_api_state('RESUMING')
def resume(self) -> None:
url = f"{self.base}/printer/print/resume"
r = self.session.post(url, headers=self.headers, timeout=self.timeout)
r.raise_for_status()
@with_api_state('STOPPING')
def stop(self) -> None:
url = f"{self.base}/printer/print/cancel"
r = self.session.post(url, headers=self.headers, timeout=self.timeout)
r.raise_for_status()
@with_api_state('STARTING')
def start_print(self, gcode: Path, name: str) -> None:
import hashlib
expected_size = gcode.stat().st_size
h = hashlib.sha256()
with open(gcode, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
checksum = h.hexdigest()
upload_url = f"{self.base}/server/files/upload"
with open(gcode, "rb") as f:
files = {"file": (name, f, "application/octet-stream")}
form = {
"root": "gcodes",
"print": "true",
"checksum": checksum,
}
r = self.session.post(
upload_url,
headers=self.headers,
files=files,
data=form,
timeout=self.timeout,
)
r.raise_for_status()
result = r.json()
if not (result.get("print_started") or result.get("print_queued")):
raise RuntimeError(f"Upload succeeded but print did not start: {result}")
@dataclass
class ManagedPrinter:
name: str
backend: PrinterHttpBackend
status: PrinterStatus = field(default_factory=PrinterStatus)
last_error: str | None = None
last_command_time: str | None = None
last_command_response: str = ""
def query_status(self):
self.status = self.backend.query_status()