Drive Klipper, OctoPrint and PrusaLink for real
Windows discovery tool / windows (push) Successful in 1m2s
Windows discovery tool / windows (push) Successful in 1m2s
The three were println stubs. Each is now an HTTP client (Moonraker, OctoPrint's REST API, PrusaLink's v1 API) behind a small shared helper: status polled in the background, pause/resume/cancel, G-code lines, fan speed, and upload-and-start of a sliced file. A slicer project (.3mf) is refused for these, and so is an upload while a print is running. Telemetry and the completion watcher now read a vendor-neutral PrinterStatus instead of Bambu's PrintReport, so these printers report progress, temperatures and job state to the cloud. Written from the public API docs and tested against mock servers only; no real Klipper, OctoPrint or PrusaLink machine has been on the bench.
This commit is contained in:
+1
-1
@@ -105,7 +105,7 @@ pub async fn run(fleet: SharedFleet) {
|
|||||||
if let Err(err) = printer.refresh_status().await {
|
if let Err(err) = printer.refresh_status().await {
|
||||||
warn!(printer = %printer.base().id, %err, "could not request a status refresh");
|
warn!(printer = %printer.base().id, %err, "could not request a status refresh");
|
||||||
}
|
}
|
||||||
if let Some(state) = printer.print_status().await.and_then(|r| r.gcode_state) {
|
if let Some(state) = printer.status().await.and_then(|s| s.state) {
|
||||||
states.push((printer.base().id.clone(), state));
|
states.push((printer.base().id.clone(), state));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! The HTTP plumbing the REST-speaking vendors (Klipper's Moonraker,
|
||||||
|
//! OctoPrint, PrusaLink) share, so each driver is only its own endpoints.
|
||||||
|
//!
|
||||||
|
//! reqwest is built without its `json` and `multipart` features (see
|
||||||
|
//! Cargo.toml), so bodies are made by hand here: JSON through `serde_json`
|
||||||
|
//! and the one multipart upload as a few lines of bytes.
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use reqwest::Method;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::PrinterError;
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
/// A project file over a Pi's Wi-Fi can be tens of megabytes.
|
||||||
|
const UPLOAD_TIMEOUT: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Http {
|
||||||
|
client: reqwest::Client,
|
||||||
|
base: String,
|
||||||
|
api_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `host` as the operator wrote it: a bare `192.168.0.9`, `host:port`, or a
|
||||||
|
/// full `http(s)://` URL. A bare host gets `default_port`.
|
||||||
|
fn base_url(host: &str, default_port: u16) -> String {
|
||||||
|
let host = host.trim().trim_end_matches('/');
|
||||||
|
if host.starts_with("http://") || host.starts_with("https://") {
|
||||||
|
host.to_string()
|
||||||
|
} else if host.contains(':') {
|
||||||
|
format!("http://{host}")
|
||||||
|
} else {
|
||||||
|
format!("http://{host}:{default_port}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Http {
|
||||||
|
pub fn new(host: &str, default_port: u16, api_key: Option<&str>) -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
base: base_url(host, default_port),
|
||||||
|
api_key: api_key.filter(|k| !k.is_empty()).map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
content_type: Option<&str>,
|
||||||
|
extra_headers: &[(&str, &str)],
|
||||||
|
body: Option<Vec<u8>>,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<Value, PrinterError> {
|
||||||
|
let url = format!("{}{path}", self.base);
|
||||||
|
let mut request = self.client.request(method.clone(), &url).timeout(timeout);
|
||||||
|
if let Some(key) = &self.api_key {
|
||||||
|
request = request.header("X-Api-Key", key);
|
||||||
|
}
|
||||||
|
if let Some(content_type) = content_type {
|
||||||
|
request = request.header("Content-Type", content_type);
|
||||||
|
}
|
||||||
|
for (name, value) in extra_headers {
|
||||||
|
request = request.header(*name, *value);
|
||||||
|
}
|
||||||
|
if let Some(body) = body {
|
||||||
|
request = request.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The URL is left out of errors on purpose: it is the printer's
|
||||||
|
// address, which the caller already names.
|
||||||
|
let response = request.send().await.map_err(|err| PrinterError(format!("{method} {path}: {err}")))?;
|
||||||
|
let status = response.status();
|
||||||
|
let bytes = response.bytes().await.map_err(|err| PrinterError(format!("{method} {path}: {err}")))?;
|
||||||
|
if !status.is_success() {
|
||||||
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
let text: String = text.trim().chars().take(200).collect();
|
||||||
|
return Err(PrinterError(format!("{method} {path}: HTTP {} {text}", status.as_u16())));
|
||||||
|
}
|
||||||
|
// An empty body (204, or a 201 with nothing) is a success, not a
|
||||||
|
// parse error; so is a non-JSON one, which only the callers that
|
||||||
|
// read the answer care about.
|
||||||
|
Ok(serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_json(&self, path: &str) -> Result<Value, PrinterError> {
|
||||||
|
self.send(Method::GET, path, None, &[], None, REQUEST_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_json(&self, path: &str, body: &Value) -> Result<Value, PrinterError> {
|
||||||
|
let body = body.to_string().into_bytes();
|
||||||
|
self.send(Method::POST, path, Some("application/json"), &[], Some(body), REQUEST_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_empty(&self, path: &str) -> Result<Value, PrinterError> {
|
||||||
|
self.send(Method::POST, path, None, &[], None, REQUEST_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_empty(&self, path: &str) -> Result<Value, PrinterError> {
|
||||||
|
self.send(Method::PUT, path, None, &[], None, REQUEST_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete(&self, path: &str) -> Result<Value, PrinterError> {
|
||||||
|
self.send(Method::DELETE, path, None, &[], None, REQUEST_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_bytes(&self, path: &str, headers: &[(&str, &str)], data: Vec<u8>) -> Result<Value, PrinterError> {
|
||||||
|
self.send(Method::PUT, path, Some("application/octet-stream"), headers, Some(data), UPLOAD_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `multipart/form-data` POST: the text `fields`, then `data` as the
|
||||||
|
/// part named `file_field`.
|
||||||
|
pub async fn post_file(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
fields: &[(&str, &str)],
|
||||||
|
file_field: &str,
|
||||||
|
file_name: &str,
|
||||||
|
data: &[u8],
|
||||||
|
) -> Result<Value, PrinterError> {
|
||||||
|
let boundary = format!(
|
||||||
|
"continuum-{:x}{:x}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
|
||||||
|
);
|
||||||
|
let body = multipart_body(&boundary, fields, file_field, file_name, data);
|
||||||
|
let content_type = format!("multipart/form-data; boundary={boundary}");
|
||||||
|
self.send(Method::POST, path, Some(&content_type), &[], Some(body), UPLOAD_TIMEOUT).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn multipart_body(boundary: &str, fields: &[(&str, &str)], file_field: &str, file_name: &str, data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut body = Vec::with_capacity(data.len() + 512);
|
||||||
|
for (name, value) in fields {
|
||||||
|
body.extend_from_slice(
|
||||||
|
format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n").as_bytes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
body.extend_from_slice(
|
||||||
|
format!(
|
||||||
|
"--{boundary}\r\nContent-Disposition: form-data; name=\"{file_field}\"; filename=\"{file_name}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
body.extend_from_slice(data);
|
||||||
|
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file name to give the printer: the last path component, and nothing
|
||||||
|
/// that could break out of a multipart header or a URL path.
|
||||||
|
pub fn safe_file_name(file_name: &str) -> Result<String, PrinterError> {
|
||||||
|
let name = file_name.rsplit(['/', '\\']).next().unwrap_or("").trim();
|
||||||
|
if name.is_empty() || name.starts_with('.') || name.chars().any(|c| c.is_control() || c == '"') {
|
||||||
|
return Err(PrinterError(format!("'{file_name}' is not a usable file name")));
|
||||||
|
}
|
||||||
|
Ok(name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Percent-encodes `name` for use as one URL path segment.
|
||||||
|
pub fn encode_segment(name: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for byte in name.bytes() {
|
||||||
|
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
||||||
|
out.push(byte as char);
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("%{byte:02X}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bare_host_gets_the_default_port() {
|
||||||
|
assert_eq!(base_url("192.168.0.9", 7125), "http://192.168.0.9:7125");
|
||||||
|
assert_eq!(base_url("printer.lan:80", 7125), "http://printer.lan:80");
|
||||||
|
assert_eq!(base_url("https://p.example/", 7125), "https://p.example");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_names_are_reduced_to_a_safe_last_component() {
|
||||||
|
assert_eq!(safe_file_name("jobs/../a b.gcode").unwrap(), "a b.gcode");
|
||||||
|
assert!(safe_file_name("..").is_err());
|
||||||
|
assert!(safe_file_name("x\".gcode").is_err());
|
||||||
|
assert!(safe_file_name("").is_err());
|
||||||
|
assert_eq!(encode_segment("a b+c.gcode"), "a%20b%2Bc.gcode");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_multipart_body_has_fields_then_the_file() {
|
||||||
|
let body = multipart_body("B", &[("print", "true")], "file", "x.gcode", b"G1");
|
||||||
|
let text = String::from_utf8(body).unwrap();
|
||||||
|
assert!(text.starts_with("--B\r\nContent-Disposition: form-data; name=\"print\"\r\n\r\ntrue\r\n"));
|
||||||
|
assert!(text.contains("name=\"file\"; filename=\"x.gcode\""));
|
||||||
|
assert!(text.ends_with("G1\r\n--B--\r\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+263
-34
@@ -1,75 +1,304 @@
|
|||||||
use super::{GenericPrinter, PrinterBase, PrinterError};
|
//! Klipper, through Moonraker's HTTP API (default port 7125).
|
||||||
|
//!
|
||||||
|
//! Written from Moonraker's published API and exercised against a mock
|
||||||
|
//! server in the tests below - no real Klipper machine has been on the
|
||||||
|
//! bench, so treat the field names as unconfirmed until one has.
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::http::{safe_file_name, Http};
|
||||||
|
use super::polled::StatusFeed;
|
||||||
|
use super::{GenericPrinter, PrintControl, PrinterBase, PrinterError, PrinterStatus};
|
||||||
|
|
||||||
|
const MOONRAKER_PORT: u16 = 7125;
|
||||||
|
const POLL_EVERY: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// What one `objects/query` asks for - everything `parse_status` reads.
|
||||||
|
const STATUS_QUERY: &str =
|
||||||
|
"/printer/objects/query?print_stats&display_status&extruder&heater_bed&virtual_sdcard";
|
||||||
|
|
||||||
pub struct KlipperPrinter {
|
pub struct KlipperPrinter {
|
||||||
base: PrinterBase,
|
base: PrinterBase,
|
||||||
|
http: Http,
|
||||||
|
feed: Option<StatusFeed>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KlipperPrinter {
|
impl KlipperPrinter {
|
||||||
pub fn new(id: &str, name: &str, host: &str) -> Self {
|
pub fn new(id: &str, name: &str, host: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
|
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
|
||||||
|
http: Http::new(host, MOONRAKER_PORT, None),
|
||||||
|
feed: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> Option<PrinterStatus> {
|
||||||
|
self.feed.as_ref().and_then(StatusFeed::latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moonraker's `print_stats.state` in the vocabulary the cloud already
|
||||||
|
/// understands (the strings Bambu reports; see `mapStatus` in the backend).
|
||||||
|
fn map_state(state: &str) -> Option<&'static str> {
|
||||||
|
match state {
|
||||||
|
"standby" => Some("IDLE"),
|
||||||
|
"printing" => Some("RUNNING"),
|
||||||
|
"paused" => Some("PAUSE"),
|
||||||
|
"complete" => Some("FINISH"),
|
||||||
|
// A cancelled print leaves nothing running; the machine is free.
|
||||||
|
"cancelled" => Some("IDLE"),
|
||||||
|
"error" => Some("FAILED"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `result.status` object of an `objects/query` answer.
|
||||||
|
fn parse_status(answer: &Value) -> Option<PrinterStatus> {
|
||||||
|
let status = answer.get("result")?.get("status")?;
|
||||||
|
let stats = status.get("print_stats")?;
|
||||||
|
let state = stats.get("state")?.as_str()?;
|
||||||
|
let number = |v: Option<&Value>| v.and_then(Value::as_f64).map(|n| n as f32);
|
||||||
|
|
||||||
|
let progress = number(status.get("virtual_sdcard").and_then(|s| s.get("progress"))).unwrap_or(0.0);
|
||||||
|
let percent = match state {
|
||||||
|
"complete" => 100,
|
||||||
|
"printing" | "paused" => (progress * 100.0).round() as i32,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let duration = number(stats.get("print_duration")).unwrap_or(0.0);
|
||||||
|
// Klipper has no estimate of its own; this is elapsed time scaled by
|
||||||
|
// progress, which is wrong early in a print and settles as it goes.
|
||||||
|
let remaining_minutes = (matches!(state, "printing" | "paused") && progress > 0.01)
|
||||||
|
.then(|| ((duration / progress - duration) / 60.0).round() as i32);
|
||||||
|
let layer_info = stats.get("info");
|
||||||
|
let int = |v: Option<&Value>| v.and_then(Value::as_i64).map(|n| n as i32);
|
||||||
|
|
||||||
|
Some(PrinterStatus {
|
||||||
|
state: map_state(state).map(str::to_string),
|
||||||
|
percent: Some(percent),
|
||||||
|
layer: int(layer_info.and_then(|i| i.get("current_layer"))),
|
||||||
|
total_layers: int(layer_info.and_then(|i| i.get("total_layer"))),
|
||||||
|
remaining_minutes,
|
||||||
|
job_name: stats
|
||||||
|
.get("filename")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|f| !f.is_empty())
|
||||||
|
.map(str::to_string),
|
||||||
|
nozzle_temp: number(status.get("extruder").and_then(|e| e.get("temperature"))),
|
||||||
|
nozzle_target: number(status.get("extruder").and_then(|e| e.get("target"))),
|
||||||
|
bed_temp: number(status.get("heater_bed").and_then(|e| e.get("temperature"))),
|
||||||
|
bed_target: number(status.get("heater_bed").and_then(|e| e.get("target"))),
|
||||||
|
print_error: (state == "error").then_some(1),
|
||||||
|
..PrinterStatus::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn poll(http: &Http) -> Option<PrinterStatus> {
|
||||||
|
match http.get_json(STATUS_QUERY).await {
|
||||||
|
Ok(answer) => parse_status(&answer),
|
||||||
|
// Moonraker answers 503 to object queries while Klipper itself is
|
||||||
|
// down, but still answers `/printer/info` - which is the
|
||||||
|
// difference between "off" and "faulted".
|
||||||
|
Err(_) => {
|
||||||
|
let info = http.get_json("/printer/info").await.ok()?;
|
||||||
|
let state = info.get("result")?.get("state")?.as_str()?;
|
||||||
|
matches!(state, "error" | "shutdown").then(|| PrinterStatus {
|
||||||
|
state: Some("FAILED".into()),
|
||||||
|
print_error: Some(1),
|
||||||
|
..PrinterStatus::default()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GenericPrinter for KlipperPrinter {
|
impl GenericPrinter for KlipperPrinter {
|
||||||
/// OVERRIDE: real version is `POST /printer/print/pause`, `/resume` or
|
/// Note Klipper calls it "cancel" where Bambu calls it "stop".
|
||||||
/// `/cancel` on Moonraker - note Klipper calls it "cancel" where Bambu
|
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> {
|
||||||
/// calls it "stop".
|
|
||||||
async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> {
|
|
||||||
let endpoint = match action {
|
let endpoint = match action {
|
||||||
super::PrintControl::Pause => "pause",
|
PrintControl::Pause => "pause",
|
||||||
super::PrintControl::Resume => "resume",
|
PrintControl::Resume => "resume",
|
||||||
super::PrintControl::Stop => "cancel",
|
PrintControl::Stop => "cancel",
|
||||||
};
|
};
|
||||||
println!("[{}] POST /printer/print/{endpoint}", self.base.name);
|
self.http.post_empty(&format!("/printer/print/{endpoint}")).await.map(|_| ())
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn base(&self) -> &PrinterBase {
|
fn base(&self) -> &PrinterBase {
|
||||||
&self.base
|
&self.base
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OVERRIDE: a third, again-different body - Moonraker's real feed is a
|
/// Checks Moonraker answers, then starts the status poller. Klipper
|
||||||
/// WebSocket (see `adapters::moonraker`). This also shows the `Err`
|
/// itself being down is not a connect failure: Moonraker is up and
|
||||||
/// path: an empty `host` fails immediately, same as any other vendor
|
/// will report it.
|
||||||
/// would if it couldn't reach its printer.
|
|
||||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
async fn connect(&mut self) -> Result<(), PrinterError> {
|
||||||
if self.base.host.is_empty() {
|
if self.base.host.trim().is_empty() {
|
||||||
return Err(PrinterError(format!("{} has no host configured", self.base.name)));
|
return Err(PrinterError(format!("{} has no host configured", self.base.name)));
|
||||||
}
|
}
|
||||||
println!("[{}] connecting over WebSocket (Moonraker)", self.base.name);
|
self.http
|
||||||
|
.get_json("/printer/info")
|
||||||
|
.await
|
||||||
|
.map_err(|err| PrinterError(format!("{} did not answer as Moonraker: {err}", self.base.name)))?;
|
||||||
|
let http = self.http.clone();
|
||||||
|
self.feed = Some(StatusFeed::start(POLL_EVERY, move || {
|
||||||
|
let http = http.clone();
|
||||||
|
async move { poll(&http).await }
|
||||||
|
}));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nothing to publish to: Moonraker is request/response.
|
||||||
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
|
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
|
||||||
// TODO: add publishing via HTTP
|
Err(PrinterError("Klipper has no publish channel".into()))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OVERRIDE: Moonraker's own **hypothetical** native API, not gcode injection - no
|
/// `M106` works on Klipper for the part-cooling fan, same as elsewhere.
|
||||||
/// percent-to-PWM conversion needed since it already takes a
|
|
||||||
/// percentage directly. Real version:
|
|
||||||
/// `POST /printer/gcode/script?script=SET_FAN_SPEED SPEED=0.NN`.
|
|
||||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
||||||
println!("[{}] Moonraker: SET_FAN_SPEED SPEED={:.2}", self.base.name, percent as f32 / 100.0);
|
self.send_gcode_line(format!("M106 S{}", super::percent_to_pwm(percent))).await
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OVERRIDE: real version is a multipart `POST /server/files/upload`
|
/// Uploads to the printer's `gcodes` root and starts it (`print=true`),
|
||||||
/// with `data` as the file part - Moonraker stores it on the printer's
|
/// like Bambu's upload-then-`project_file`. Klipper runs plain G-code,
|
||||||
/// virtual SD card, same upload-then-start model as Bambu/PrusaLink.
|
/// so a slicer project (`.3mf`) is refused here rather than uploaded
|
||||||
|
/// and left to fail on the machine.
|
||||||
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
||||||
println!("[{}] POST /server/files/upload {file_name} ({} bytes)", self.base.name, data.len());
|
let name = safe_file_name(&file_name)?;
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
if !(lower.ends_with(".gcode") || lower.ends_with(".gco") || lower.ends_with(".g")) {
|
||||||
|
return Err(PrinterError(format!(
|
||||||
|
"{name} is not G-code - Klipper needs a sliced .gcode file, not a project"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(status) = self.status()
|
||||||
|
&& matches!(status.state.as_deref(), Some("RUNNING" | "PAUSE"))
|
||||||
|
{
|
||||||
|
return Err(PrinterError(format!("{} is already printing", self.base.name)));
|
||||||
|
}
|
||||||
|
let answer = self
|
||||||
|
.http
|
||||||
|
.post_file("/server/files/upload", &[("root", "gcodes"), ("print", "true")], "file", &name, &data)
|
||||||
|
.await?;
|
||||||
|
if answer.get("print_started").and_then(Value::as_bool) == Some(false) {
|
||||||
|
return Err(PrinterError(format!("{name} was uploaded but Klipper did not start it")));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OVERRIDE: Moonraker's gcode-script endpoint, one line per call - same
|
|
||||||
/// idea as `set_fan_speed`'s `SET_FAN_SPEED` call above, just with an
|
|
||||||
/// arbitrary line instead of a hardcoded one.
|
|
||||||
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||||
println!("[{}] POST /printer/gcode/script?script={gcode_line}", self.base.name);
|
self.http.post_json("/printer/gcode/script", &json!({ "script": gcode_line })).await.map(|_| ())
|
||||||
Ok(())
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::printer::mock_http::MockServer;
|
||||||
|
|
||||||
|
const PRINTING: &str = r#"{"result":{"status":{
|
||||||
|
"print_stats":{"state":"printing","filename":"cube.gcode","print_duration":600.0,"info":{"current_layer":12,"total_layer":60}},
|
||||||
|
"virtual_sdcard":{"progress":0.25},
|
||||||
|
"extruder":{"temperature":214.8,"target":215.0},
|
||||||
|
"heater_bed":{"temperature":59.9,"target":60.0}}}}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_printing_machine_maps_to_the_shared_vocabulary() {
|
||||||
|
let status = parse_status(&serde_json::from_str(PRINTING).unwrap()).unwrap();
|
||||||
|
assert_eq!(status.state.as_deref(), Some("RUNNING"));
|
||||||
|
assert_eq!(status.percent, Some(25));
|
||||||
|
assert_eq!((status.layer, status.total_layers), (Some(12), Some(60)));
|
||||||
|
// 600 s for a quarter: 1800 s more.
|
||||||
|
assert_eq!(status.remaining_minutes, Some(30));
|
||||||
|
assert_eq!(status.job_name.as_deref(), Some("cube.gcode"));
|
||||||
|
assert_eq!(status.nozzle_temp, Some(214.8));
|
||||||
|
assert_eq!(status.bed_target, Some(60.0));
|
||||||
|
assert_eq!(status.print_error, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn complete_is_100_percent_and_error_carries_a_code() {
|
||||||
|
let done = r#"{"result":{"status":{"print_stats":{"state":"complete","filename":"a.gcode"},"virtual_sdcard":{"progress":1.0}}}}"#;
|
||||||
|
let status = parse_status(&serde_json::from_str(done).unwrap()).unwrap();
|
||||||
|
assert_eq!((status.state.as_deref(), status.percent), (Some("FINISH"), Some(100)));
|
||||||
|
|
||||||
|
let failed = r#"{"result":{"status":{"print_stats":{"state":"error"}}}}"#;
|
||||||
|
let status = parse_status(&serde_json::from_str(failed).unwrap()).unwrap();
|
||||||
|
assert_eq!((status.state.as_deref(), status.print_error), (Some("FAILED"), Some(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn control_gcode_and_fan_go_to_the_right_endpoints() {
|
||||||
|
let mock = MockServer::start().await;
|
||||||
|
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
|
||||||
|
mock.answer("POST /printer/print/cancel", 200, r#"{"result":"ok"}"#);
|
||||||
|
mock.answer("POST /printer/gcode/script", 200, r#"{"result":"ok"}"#);
|
||||||
|
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
||||||
|
printer.connect().await.unwrap();
|
||||||
|
|
||||||
|
printer.control_print(PrintControl::Stop).await.unwrap();
|
||||||
|
assert_eq!(mock.sent("POST", "/printer/print/cancel").len(), 1);
|
||||||
|
|
||||||
|
printer.set_fan_speed(50).await.unwrap();
|
||||||
|
let sent = mock.sent("POST", "/printer/gcode/script");
|
||||||
|
assert_eq!(sent[0].text(), r#"{"script":"M106 S127"}"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_fails_when_nothing_answers_as_moonraker() {
|
||||||
|
let mock = MockServer::start().await; // every route is a 404
|
||||||
|
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
||||||
|
assert!(printer.connect().await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_poller_feeds_status_and_clears_it_when_the_printer_goes() {
|
||||||
|
let mock = MockServer::start().await;
|
||||||
|
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
|
||||||
|
mock.answer("GET /printer/objects/query", 200, PRINTING);
|
||||||
|
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
||||||
|
printer.connect().await.unwrap();
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
assert_eq!(printer.status().unwrap().percent, Some(25));
|
||||||
|
|
||||||
|
// Klipper shuts down: queries 503, /printer/info says why.
|
||||||
|
mock.answer("GET /printer/objects/query", 503, "{}");
|
||||||
|
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"shutdown"}}"#);
|
||||||
|
tokio::time::sleep(Duration::from_millis(2500)).await;
|
||||||
|
assert_eq!(printer.status().unwrap().state.as_deref(), Some("FAILED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upload_sends_the_file_with_print_true_and_refuses_a_project() {
|
||||||
|
let mock = MockServer::start().await;
|
||||||
|
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
|
||||||
|
mock.answer("POST /server/files/upload", 201, r#"{"print_started":true}"#);
|
||||||
|
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
||||||
|
printer.connect().await.unwrap();
|
||||||
|
|
||||||
|
printer.upload_project_file("jobs/cube.gcode".into(), b"G28\nG1 X1\n".to_vec()).await.unwrap();
|
||||||
|
let sent = mock.sent("POST", "/server/files/upload");
|
||||||
|
assert_eq!(sent.len(), 1);
|
||||||
|
let body = sent[0].text();
|
||||||
|
assert!(body.contains("name=\"print\"\r\n\r\ntrue"));
|
||||||
|
assert!(body.contains("filename=\"cube.gcode\""));
|
||||||
|
assert!(body.contains("G28\nG1 X1\n"));
|
||||||
|
assert!(sent[0].header("content-type").unwrap().starts_with("multipart/form-data; boundary="));
|
||||||
|
|
||||||
|
let err = printer.upload_project_file("x.3mf".into(), vec![1]).await.unwrap_err();
|
||||||
|
assert!(err.0.contains("not G-code"), "{err}");
|
||||||
|
assert_eq!(mock.sent("POST", "/server/files/upload").len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_upload_is_refused_while_a_print_runs() {
|
||||||
|
let mock = MockServer::start().await;
|
||||||
|
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
|
||||||
|
mock.answer("GET /printer/objects/query", 200, PRINTING);
|
||||||
|
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
||||||
|
printer.connect().await.unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
let err = printer.upload_project_file("b.gcode".into(), vec![1]).await.unwrap_err();
|
||||||
|
assert!(err.0.contains("already printing"), "{err}");
|
||||||
|
assert!(mock.sent("POST", "/server/files/upload").is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! A tiny HTTP server for the REST drivers' tests: canned answers per
|
||||||
|
//! `METHOD /path` (query string ignored), and a record of what was sent.
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Recorded {
|
||||||
|
pub method: String,
|
||||||
|
pub path: String,
|
||||||
|
pub headers: Vec<(String, String)>,
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Recorded {
|
||||||
|
pub fn header(&self, name: &str) -> Option<&str> {
|
||||||
|
self.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
|
||||||
|
}
|
||||||
|
pub fn text(&self) -> String {
|
||||||
|
String::from_utf8_lossy(&self.body).into_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MockServer {
|
||||||
|
pub addr: SocketAddr,
|
||||||
|
routes: Arc<Mutex<HashMap<String, (u16, String)>>>,
|
||||||
|
requests: Arc<Mutex<Vec<Recorded>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockServer {
|
||||||
|
pub async fn start() -> Self {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let server = Self {
|
||||||
|
addr: listener.local_addr().unwrap(),
|
||||||
|
routes: Arc::default(),
|
||||||
|
requests: Arc::default(),
|
||||||
|
};
|
||||||
|
let shared = server.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((stream, _)) = listener.accept().await else { return };
|
||||||
|
let shared = shared.clone();
|
||||||
|
tokio::spawn(async move { shared.handle(stream).await });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn host(&self) -> String {
|
||||||
|
self.addr.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `route` is e.g. `"GET /printer/info"`.
|
||||||
|
pub fn answer(&self, route: &str, status: u16, body: &str) {
|
||||||
|
self.routes.lock().unwrap().insert(route.to_string(), (status, body.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn requests(&self) -> Vec<Recorded> {
|
||||||
|
self.requests.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sent(&self, method: &str, path: &str) -> Vec<Recorded> {
|
||||||
|
self.requests().into_iter().filter(|r| r.method == method && r.path == path).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(&self, mut stream: tokio::net::TcpStream) {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0u8; 8192];
|
||||||
|
let head_end = loop {
|
||||||
|
if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
|
||||||
|
break i + 4;
|
||||||
|
}
|
||||||
|
match stream.read(&mut chunk).await {
|
||||||
|
Ok(0) | Err(_) => return,
|
||||||
|
Ok(n) => buf.extend_from_slice(&chunk[..n]),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let head = String::from_utf8_lossy(&buf[..head_end]).into_owned();
|
||||||
|
let mut lines = head.split("\r\n");
|
||||||
|
let mut first = lines.next().unwrap_or("").split(' ');
|
||||||
|
let method = first.next().unwrap_or("").to_string();
|
||||||
|
let target = first.next().unwrap_or("").to_string();
|
||||||
|
let headers: Vec<(String, String)> = lines
|
||||||
|
.filter_map(|l| l.split_once(':').map(|(k, v)| (k.trim().to_string(), v.trim().to_string())))
|
||||||
|
.collect();
|
||||||
|
let length: usize = headers
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
|
||||||
|
.and_then(|(_, v)| v.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
while buf.len() < head_end + length {
|
||||||
|
match stream.read(&mut chunk).await {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => buf.extend_from_slice(&chunk[..n]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path = target.split('?').next().unwrap_or("").to_string();
|
||||||
|
let body = buf[head_end..].to_vec();
|
||||||
|
let (status, answer) = self
|
||||||
|
.routes
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&format!("{method} {path}"))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or((404, "{}".to_string()));
|
||||||
|
self.requests.lock().unwrap().push(Recorded { method, path, headers, body });
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{answer}",
|
||||||
|
answer.len()
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(response.as_bytes()).await;
|
||||||
|
let _ = stream.shutdown().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
-3
@@ -24,16 +24,21 @@
|
|||||||
//! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's
|
//! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's
|
||||||
//! protocol, `bambu_ftps.rs` for getting files onto a Bambu printer,
|
//! protocol, `bambu_ftps.rs` for getting files onto a Bambu printer,
|
||||||
//! `marlin_serial.rs` for a real Marlin serial connection - see each
|
//! `marlin_serial.rs` for a real Marlin serial connection - see each
|
||||||
//! module's own doc comment. PrusaLink/Klipper's HTTP clients are still
|
//! module's own doc comment. Klipper (`klipper.rs`) and OctoPrint/PrusaLink
|
||||||
//! stubs; that's the next piece to build past this module.
|
//! (`octoprint.rs`) are real HTTP clients, tested against mock servers only.
|
||||||
|
|
||||||
mod bambu;
|
mod bambu;
|
||||||
mod bambu_camera;
|
mod bambu_camera;
|
||||||
mod bambu_commands;
|
mod bambu_commands;
|
||||||
mod bambu_ftps;
|
mod bambu_ftps;
|
||||||
mod bambu_mqtt;
|
mod bambu_mqtt;
|
||||||
|
mod http;
|
||||||
mod klipper;
|
mod klipper;
|
||||||
mod marlin_serial;
|
mod marlin_serial;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod mock_http;
|
||||||
|
mod octoprint;
|
||||||
|
mod polled;
|
||||||
mod prusa;
|
mod prusa;
|
||||||
|
|
||||||
// Reading a `.3mf` well enough to know which filament a plate wants. Kept
|
// Reading a `.3mf` well enough to know which filament a plate wants. Kept
|
||||||
@@ -54,7 +59,54 @@ pub use bambu_commands::{
|
|||||||
XcamSettings,
|
XcamSettings,
|
||||||
};
|
};
|
||||||
pub use klipper::KlipperPrinter;
|
pub use klipper::KlipperPrinter;
|
||||||
pub use prusa::{OctoPrintFlavour, OctoPrintPrinter, PrusaSerialPrinter};
|
pub use octoprint::{OctoPrintFlavour, OctoPrintPrinter};
|
||||||
|
pub use polled::StatusFeed;
|
||||||
|
pub use prusa::PrusaSerialPrinter;
|
||||||
|
|
||||||
|
/// What the cloud is told about a printer's job, whatever the vendor.
|
||||||
|
///
|
||||||
|
/// `state` uses the strings Bambu reports (`IDLE`, `RUNNING`, `PAUSE`,
|
||||||
|
/// `FINISH`, `FAILED`) because the cloud's `mapStatus` and the completion
|
||||||
|
/// watcher already speak them; the other vendors translate into them.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct PrinterStatus {
|
||||||
|
pub state: Option<String>,
|
||||||
|
pub percent: Option<i32>,
|
||||||
|
pub stage: Option<i32>,
|
||||||
|
pub layer: Option<i32>,
|
||||||
|
pub total_layers: Option<i32>,
|
||||||
|
pub remaining_minutes: Option<i32>,
|
||||||
|
pub job_name: Option<String>,
|
||||||
|
pub nozzle_temp: Option<f32>,
|
||||||
|
pub nozzle_target: Option<f32>,
|
||||||
|
pub bed_temp: Option<f32>,
|
||||||
|
pub bed_target: Option<f32>,
|
||||||
|
pub print_error: Option<i32>,
|
||||||
|
pub active_alerts: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&PrintReport> for PrinterStatus {
|
||||||
|
fn from(report: &PrintReport) -> Self {
|
||||||
|
Self {
|
||||||
|
state: report.gcode_state.clone(),
|
||||||
|
percent: report.mc_percent,
|
||||||
|
stage: report.stg_cur,
|
||||||
|
layer: report.layer_num,
|
||||||
|
total_layers: report.total_layer_num,
|
||||||
|
remaining_minutes: report.mc_remaining_time,
|
||||||
|
// An idle printer reports `""` rather than omitting the field;
|
||||||
|
// empty-string-as-absent is worth normalising here so the cloud
|
||||||
|
// doesn't have to know that.
|
||||||
|
job_name: report.subtask_name.clone().filter(|s| !s.is_empty()),
|
||||||
|
nozzle_temp: report.nozzle_temper,
|
||||||
|
nozzle_target: report.nozzle_target_temper,
|
||||||
|
bed_temp: report.bed_temper,
|
||||||
|
bed_target: report.bed_target_temper,
|
||||||
|
print_error: report.print_error,
|
||||||
|
active_alerts: report.hms.as_ref().map(|h| h.len()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A tiny error type: just a message. `String` would work too - this exists
|
/// A tiny error type: just a message. `String` would work too - this exists
|
||||||
/// mainly so `?` has something concrete to convert into everywhere.
|
/// mainly so `?` has something concrete to convert into everywhere.
|
||||||
@@ -204,6 +256,20 @@ impl PrinterHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The latest vendor-neutral status, if the printer has one. Bambu's
|
||||||
|
/// comes from its MQTT cache, the HTTP vendors' from their pollers; the
|
||||||
|
/// serial printer has none.
|
||||||
|
pub async fn status(&self) -> Option<PrinterStatus> {
|
||||||
|
match self {
|
||||||
|
PrinterHandle::Bambu(p) => p.print_status().await.as_ref().map(PrinterStatus::from),
|
||||||
|
PrinterHandle::OctoPrint(p) => p.status(),
|
||||||
|
PrinterHandle::Klipper(p) => p.status(),
|
||||||
|
PrinterHandle::PrusaSerial(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bambu's full report, for what is Bambu-specific (camera, AMS).
|
||||||
|
///
|
||||||
/// The latest status this process has for the printer, if it has one.
|
/// The latest status this process has for the printer, if it has one.
|
||||||
///
|
///
|
||||||
/// An inherent method rather than a `GenericPrinter` trait method,
|
/// An inherent method rather than a `GenericPrinter` trait method,
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
//! OctoPrint and PrusaLink over HTTP (`X-Api-Key`).
|
||||||
|
//!
|
||||||
|
//! Written from the two projects' published REST APIs and exercised against
|
||||||
|
//! a mock server in the tests below - no real OctoPrint or PrusaLink machine
|
||||||
|
//! has been on the bench, so treat the field names as unconfirmed until one
|
||||||
|
//! has.
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use super::http::{encode_segment, safe_file_name, Http};
|
||||||
|
use super::polled::StatusFeed;
|
||||||
|
use super::{GenericPrinter, PrintControl, PrinterBase, PrinterError, PrinterStatus};
|
||||||
|
|
||||||
|
const POLL_EVERY: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// Which implementation of the OctoPrint REST API is on the other end.
|
||||||
|
///
|
||||||
|
/// PrusaLink deliberately implements OctoPrint's API, so one client serves
|
||||||
|
/// both - but not identically. PrusaLink adds its own `/api/v1/*` (`info`,
|
||||||
|
/// `status`, `job`) which is where the useful detail lives, while OctoPrint
|
||||||
|
/// proper has the older `/api/printer` and `/api/job` and a plugin surface
|
||||||
|
/// PrusaLink does not have.
|
||||||
|
///
|
||||||
|
/// This is recorded from config now, while the client is still a stub,
|
||||||
|
/// because **no probe can settle it**: both answer `/api/version`, and every
|
||||||
|
/// PrusaLink identity endpoint needs a credential, so from outside the two
|
||||||
|
/// are indistinguishable (see `discovery::net::classify_octoprint_api`). It
|
||||||
|
/// is the operator's answer to a question only they can answer, so it needs
|
||||||
|
/// somewhere to live the moment they are asked - otherwise the real client
|
||||||
|
/// would have to ask them a second time.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OctoPrintFlavour {
|
||||||
|
/// Prusa's own firmware-hosted implementation, on an MK4/XL/MINI.
|
||||||
|
PrusaLink,
|
||||||
|
/// OctoPrint itself, usually on a Raspberry Pi in front of any printer.
|
||||||
|
Generic,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OctoPrintFlavour {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
OctoPrintFlavour::PrusaLink => "PrusaLink",
|
||||||
|
OctoPrintFlavour::Generic => "OctoPrint",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub struct OctoPrintPrinter {
|
||||||
|
base: PrinterBase,
|
||||||
|
pub flavour: OctoPrintFlavour,
|
||||||
|
http: Http,
|
||||||
|
feed: Option<StatusFeed>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OctoPrintPrinter {
|
||||||
|
pub fn new(id: &str, name: &str, host: &str, api_key: &str, flavour: OctoPrintFlavour) -> Self {
|
||||||
|
Self {
|
||||||
|
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
|
||||||
|
flavour,
|
||||||
|
// Port 80: OctoPrint's usual front (haproxy) and PrusaLink both
|
||||||
|
// answer there. An unusual port goes in `host`.
|
||||||
|
http: Http::new(host, 80, Some(api_key)),
|
||||||
|
feed: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> Option<PrinterStatus> {
|
||||||
|
self.feed.as_ref().and_then(StatusFeed::latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number(v: Option<&Value>) -> Option<f32> {
|
||||||
|
v.and_then(Value::as_f64).map(|n| n as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OctoPrint's `GET /api/job`, plus `GET /api/printer` for temperatures
|
||||||
|
/// (which is a 409 whenever the printer is not operational, hence `Option`).
|
||||||
|
fn octoprint_status(job: &Value, printer: Option<&Value>) -> Option<PrinterStatus> {
|
||||||
|
let state = job.get("state")?.as_str()?;
|
||||||
|
let completion = number(job.get("progress").and_then(|p| p.get("completion")));
|
||||||
|
let left = number(job.get("progress").and_then(|p| p.get("printTimeLeft")));
|
||||||
|
|
||||||
|
let (mapped, percent) = if state.starts_with("Printing") || state == "Cancelling" {
|
||||||
|
("RUNNING", completion.unwrap_or(0.0))
|
||||||
|
} else if state == "Pausing" || state == "Paused" {
|
||||||
|
("PAUSE", completion.unwrap_or(0.0))
|
||||||
|
} else if state == "Operational" {
|
||||||
|
// OctoPrint has no "finished" state: the job simply stays at 100%.
|
||||||
|
match completion {
|
||||||
|
Some(c) if c >= 100.0 => ("FINISH", 100.0),
|
||||||
|
_ => ("IDLE", 0.0),
|
||||||
|
}
|
||||||
|
} else if state.contains("rror") && !state.starts_with("Offline") {
|
||||||
|
("FAILED", 0.0)
|
||||||
|
} else {
|
||||||
|
// Offline, still opening the serial port, ...: not a state worth
|
||||||
|
// reporting as the printer's.
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let tool = printer.and_then(|p| p.get("temperature")).and_then(|t| t.get("tool0"));
|
||||||
|
let bed = printer.and_then(|p| p.get("temperature")).and_then(|t| t.get("bed"));
|
||||||
|
Some(PrinterStatus {
|
||||||
|
state: Some(mapped.into()),
|
||||||
|
percent: Some(percent.round() as i32),
|
||||||
|
remaining_minutes: (mapped == "RUNNING" || mapped == "PAUSE").then_some(left).flatten().map(|s| (s / 60.0).round() as i32),
|
||||||
|
job_name: job
|
||||||
|
.get("job")
|
||||||
|
.and_then(|j| j.get("file"))
|
||||||
|
.and_then(|f| f.get("display").or_else(|| f.get("name")))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string),
|
||||||
|
nozzle_temp: number(tool.and_then(|t| t.get("actual"))),
|
||||||
|
nozzle_target: number(tool.and_then(|t| t.get("target"))),
|
||||||
|
bed_temp: number(bed.and_then(|t| t.get("actual"))),
|
||||||
|
bed_target: number(bed.and_then(|t| t.get("target"))),
|
||||||
|
print_error: (mapped == "FAILED").then_some(1),
|
||||||
|
..PrinterStatus::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PrusaLink's `GET /api/v1/status`, with `GET /api/v1/job` for the file name
|
||||||
|
/// when a job exists.
|
||||||
|
fn prusalink_status(status: &Value, job: Option<&Value>) -> Option<PrinterStatus> {
|
||||||
|
let printer = status.get("printer")?;
|
||||||
|
let state = printer.get("state")?.as_str()?;
|
||||||
|
let mapped = match state {
|
||||||
|
"IDLE" | "STOPPED" => "IDLE",
|
||||||
|
"BUSY" => "PREPARE",
|
||||||
|
"PRINTING" => "RUNNING",
|
||||||
|
"PAUSED" | "ATTENTION" => "PAUSE",
|
||||||
|
"FINISHED" => "FINISH",
|
||||||
|
"ERROR" => "FAILED",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let running = matches!(mapped, "RUNNING" | "PAUSE");
|
||||||
|
let progress = number(status.get("job").and_then(|j| j.get("progress")));
|
||||||
|
let left = number(status.get("job").and_then(|j| j.get("time_remaining")));
|
||||||
|
Some(PrinterStatus {
|
||||||
|
state: Some(mapped.into()),
|
||||||
|
percent: Some(match mapped {
|
||||||
|
"FINISH" => 100,
|
||||||
|
_ if running => progress.unwrap_or(0.0).round() as i32,
|
||||||
|
_ => 0,
|
||||||
|
}),
|
||||||
|
remaining_minutes: running.then_some(left).flatten().map(|s| (s / 60.0).round() as i32),
|
||||||
|
job_name: job
|
||||||
|
.and_then(|j| j.get("file"))
|
||||||
|
.and_then(|f| f.get("display_name").or_else(|| f.get("name")))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string),
|
||||||
|
nozzle_temp: number(printer.get("temp_nozzle")),
|
||||||
|
nozzle_target: number(printer.get("target_nozzle")),
|
||||||
|
bed_temp: number(printer.get("temp_bed")),
|
||||||
|
bed_target: number(printer.get("target_bed")),
|
||||||
|
print_error: (mapped == "FAILED").then_some(1),
|
||||||
|
..PrinterStatus::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn poll(http: &Http, flavour: OctoPrintFlavour) -> Option<PrinterStatus> {
|
||||||
|
match flavour {
|
||||||
|
OctoPrintFlavour::Generic => {
|
||||||
|
let job = http.get_json("/api/job").await.ok()?;
|
||||||
|
let printer = http.get_json("/api/printer").await.ok();
|
||||||
|
octoprint_status(&job, printer.as_ref())
|
||||||
|
}
|
||||||
|
OctoPrintFlavour::PrusaLink => {
|
||||||
|
let status = http.get_json("/api/v1/status").await.ok()?;
|
||||||
|
let has_job = status.get("job").is_some_and(|j| !j.is_null());
|
||||||
|
let job = if has_job { http.get_json("/api/v1/job").await.ok() } else { None };
|
||||||
|
prusalink_status(&status, job.as_ref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GenericPrinter for OctoPrintPrinter {
|
||||||
|
/// OctoPrint: `POST /api/job` with a command. PrusaLink addresses the
|
||||||
|
/// running job by id, so the current job is fetched first.
|
||||||
|
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> {
|
||||||
|
match self.flavour {
|
||||||
|
OctoPrintFlavour::Generic => {
|
||||||
|
let body = match action {
|
||||||
|
PrintControl::Pause => json!({ "command": "pause", "action": "pause" }),
|
||||||
|
PrintControl::Resume => json!({ "command": "pause", "action": "resume" }),
|
||||||
|
PrintControl::Stop => json!({ "command": "cancel" }),
|
||||||
|
};
|
||||||
|
self.http.post_json("/api/job", &body).await.map(|_| ())
|
||||||
|
}
|
||||||
|
OctoPrintFlavour::PrusaLink => {
|
||||||
|
let status = self.http.get_json("/api/v1/status").await?;
|
||||||
|
let id = status
|
||||||
|
.get("job")
|
||||||
|
.and_then(|j| j.get("id"))
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.ok_or_else(|| PrinterError(format!("{} has no job to {}", self.base.name, action.as_str())))?;
|
||||||
|
match action {
|
||||||
|
PrintControl::Pause => self.http.put_empty(&format!("/api/v1/job/{id}/pause")).await,
|
||||||
|
PrintControl::Resume => self.http.put_empty(&format!("/api/v1/job/{id}/resume")).await,
|
||||||
|
PrintControl::Stop => self.http.delete(&format!("/api/v1/job/{id}")).await,
|
||||||
|
}
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base(&self) -> &PrinterBase {
|
||||||
|
&self.base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks the printer answers with this key, then starts the poller.
|
||||||
|
async fn connect(&mut self) -> Result<(), PrinterError> {
|
||||||
|
if self.base.host.trim().is_empty() {
|
||||||
|
return Err(PrinterError(format!("{} has no host configured", self.base.name)));
|
||||||
|
}
|
||||||
|
// `/api/version` needs no permissions on either, so it would pass
|
||||||
|
// with a wrong key; these two are the first that check it.
|
||||||
|
let probe = match self.flavour {
|
||||||
|
OctoPrintFlavour::Generic => "/api/job",
|
||||||
|
OctoPrintFlavour::PrusaLink => "/api/v1/status",
|
||||||
|
};
|
||||||
|
self.http
|
||||||
|
.get_json(probe)
|
||||||
|
.await
|
||||||
|
.map_err(|err| PrinterError(format!("{} did not answer as {}: {err}", self.base.name, self.flavour.as_str())))?;
|
||||||
|
let (http, flavour) = (self.http.clone(), self.flavour);
|
||||||
|
self.feed = Some(StatusFeed::start(POLL_EVERY, move || {
|
||||||
|
let http = http.clone();
|
||||||
|
async move { poll(&http, flavour).await }
|
||||||
|
}));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
|
||||||
|
Err(PrinterError(format!("{} has no publish channel", self.flavour.as_str())))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
||||||
|
self.send_gcode_line(format!("M106 S{}", super::percent_to_pwm(percent))).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploads and starts the print. OctoPrint takes a multipart POST to
|
||||||
|
/// `/api/files/local`; PrusaLink a raw `PUT /api/v1/files/local/{name}`
|
||||||
|
/// with `Print-After-Upload`. Both run sliced G-code, not a project.
|
||||||
|
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
||||||
|
let name = safe_file_name(&file_name)?;
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
let printable = [".gcode", ".gco", ".g", ".bgcode"].iter().any(|ext| lower.ends_with(ext));
|
||||||
|
if !printable {
|
||||||
|
return Err(PrinterError(format!(
|
||||||
|
"{name} is not G-code - {} needs a sliced file, not a project",
|
||||||
|
self.flavour.as_str()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(status) = self.status()
|
||||||
|
&& matches!(status.state.as_deref(), Some("RUNNING" | "PAUSE" | "PREPARE"))
|
||||||
|
{
|
||||||
|
return Err(PrinterError(format!("{} is already printing", self.base.name)));
|
||||||
|
}
|
||||||
|
match self.flavour {
|
||||||
|
OctoPrintFlavour::Generic => self
|
||||||
|
.http
|
||||||
|
.post_file("/api/files/local", &[("select", "true"), ("print", "true")], "file", &name, &data)
|
||||||
|
.await
|
||||||
|
.map(|_| ()),
|
||||||
|
OctoPrintFlavour::PrusaLink => self
|
||||||
|
.http
|
||||||
|
.put_bytes(
|
||||||
|
&format!("/api/v1/files/local/{}", encode_segment(&name)),
|
||||||
|
&[("Print-After-Upload", "?1"), ("Overwrite", "?1")],
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| ()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||||
|
match self.flavour {
|
||||||
|
OctoPrintFlavour::Generic => self
|
||||||
|
.http
|
||||||
|
.post_json("/api/printer/command", &json!({ "commands": [gcode_line] }))
|
||||||
|
.await
|
||||||
|
.map(|_| ()),
|
||||||
|
// PrusaLink's API has no G-code endpoint.
|
||||||
|
OctoPrintFlavour::PrusaLink => {
|
||||||
|
Err(PrinterError("PrusaLink cannot be sent G-code lines".into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::printer::mock_http::MockServer;
|
||||||
|
|
||||||
|
const JOB: &str = r#"{"job":{"file":{"name":"cube.gcode","display":"cube.gcode"}},
|
||||||
|
"progress":{"completion":40.0,"printTime":600,"printTimeLeft":900},"state":"Printing"}"#;
|
||||||
|
const TEMPS: &str =
|
||||||
|
r#"{"temperature":{"tool0":{"actual":209.5,"target":210.0},"bed":{"actual":60.0,"target":60.0}}}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn octoprint_states_map_to_the_shared_vocabulary() {
|
||||||
|
let status = octoprint_status(&serde_json::from_str(JOB).unwrap(), serde_json::from_str(TEMPS).ok().as_ref()).unwrap();
|
||||||
|
assert_eq!(status.state.as_deref(), Some("RUNNING"));
|
||||||
|
assert_eq!(status.percent, Some(40));
|
||||||
|
assert_eq!(status.remaining_minutes, Some(15));
|
||||||
|
assert_eq!(status.job_name.as_deref(), Some("cube.gcode"));
|
||||||
|
assert_eq!(status.nozzle_target, Some(210.0));
|
||||||
|
|
||||||
|
// Finished: still "Operational", job stays at 100%. No temperatures
|
||||||
|
// (the printer endpoint was a 409) is fine.
|
||||||
|
let done = r#"{"state":"Operational","progress":{"completion":100.0}}"#;
|
||||||
|
let status = octoprint_status(&serde_json::from_str(done).unwrap(), None).unwrap();
|
||||||
|
assert_eq!((status.state.as_deref(), status.percent, status.nozzle_temp), (Some("FINISH"), Some(100), None));
|
||||||
|
|
||||||
|
let idle = r#"{"state":"Operational","progress":{"completion":null}}"#;
|
||||||
|
assert_eq!(octoprint_status(&serde_json::from_str(idle).unwrap(), None).unwrap().state.as_deref(), Some("IDLE"));
|
||||||
|
|
||||||
|
let offline = r#"{"state":"Offline","progress":{}}"#;
|
||||||
|
assert!(octoprint_status(&serde_json::from_str(offline).unwrap(), None).is_none());
|
||||||
|
let broken = r#"{"state":"Error","progress":{}}"#;
|
||||||
|
assert_eq!(octoprint_status(&serde_json::from_str(broken).unwrap(), None).unwrap().print_error, Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prusalink_states_map_to_the_shared_vocabulary() {
|
||||||
|
let status = r#"{"printer":{"state":"PRINTING","temp_nozzle":215.1,"target_nozzle":215.0,"temp_bed":60.2,"target_bed":60.0},
|
||||||
|
"job":{"id":7,"progress":33.0,"time_remaining":1200,"time_printing":300}}"#;
|
||||||
|
let job = r#"{"id":7,"file":{"name":"CUBE~1.GCO","display_name":"cube.gcode"}}"#;
|
||||||
|
let parsed = prusalink_status(&serde_json::from_str(status).unwrap(), serde_json::from_str(job).ok().as_ref()).unwrap();
|
||||||
|
assert_eq!(parsed.state.as_deref(), Some("RUNNING"));
|
||||||
|
assert_eq!((parsed.percent, parsed.remaining_minutes), (Some(33), Some(20)));
|
||||||
|
assert_eq!(parsed.job_name.as_deref(), Some("cube.gcode"));
|
||||||
|
assert_eq!(parsed.bed_temp, Some(60.2));
|
||||||
|
|
||||||
|
let idle = r#"{"printer":{"state":"IDLE","temp_nozzle":25.0,"temp_bed":24.0}}"#;
|
||||||
|
let parsed = prusalink_status(&serde_json::from_str(idle).unwrap(), None).unwrap();
|
||||||
|
assert_eq!((parsed.state.as_deref(), parsed.percent), (Some("IDLE"), Some(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn octoprint_controls_upload_and_send_the_key() {
|
||||||
|
let mock = MockServer::start().await;
|
||||||
|
mock.answer("GET /api/job", 200, r#"{"state":"Operational","progress":{}}"#);
|
||||||
|
mock.answer("POST /api/job", 204, "");
|
||||||
|
mock.answer("POST /api/printer/command", 204, "");
|
||||||
|
mock.answer("POST /api/files/local", 201, r#"{"done":true}"#);
|
||||||
|
let mut printer = OctoPrintPrinter::new("o1", "Ender", &mock.host(), "KEY123", OctoPrintFlavour::Generic);
|
||||||
|
printer.connect().await.unwrap();
|
||||||
|
|
||||||
|
printer.control_print(PrintControl::Resume).await.unwrap();
|
||||||
|
let sent = mock.sent("POST", "/api/job");
|
||||||
|
assert_eq!(sent[0].text(), r#"{"action":"resume","command":"pause"}"#);
|
||||||
|
assert_eq!(sent[0].header("x-api-key"), Some("KEY123"));
|
||||||
|
printer.control_print(PrintControl::Stop).await.unwrap();
|
||||||
|
assert_eq!(mock.sent("POST", "/api/job")[1].text(), r#"{"command":"cancel"}"#);
|
||||||
|
|
||||||
|
printer.set_fan_speed(100).await.unwrap();
|
||||||
|
assert_eq!(mock.sent("POST", "/api/printer/command")[0].text(), r#"{"commands":["M106 S255"]}"#);
|
||||||
|
|
||||||
|
printer.upload_project_file("a.gcode".into(), b"G28".to_vec()).await.unwrap();
|
||||||
|
let body = mock.sent("POST", "/api/files/local")[0].text();
|
||||||
|
assert!(body.contains("name=\"print\"\r\n\r\ntrue") && body.contains("filename=\"a.gcode\""));
|
||||||
|
assert!(printer.upload_project_file("a.3mf".into(), vec![1]).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_wrong_key_fails_connect() {
|
||||||
|
let mock = MockServer::start().await;
|
||||||
|
mock.answer("GET /api/job", 403, r#"{"error":"forbidden"}"#);
|
||||||
|
let mut printer = OctoPrintPrinter::new("o1", "Ender", &mock.host(), "bad", OctoPrintFlavour::Generic);
|
||||||
|
assert!(printer.connect().await.unwrap_err().0.contains("403"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prusalink_addresses_the_running_job_by_id() {
|
||||||
|
let mock = MockServer::start().await;
|
||||||
|
mock.answer("GET /api/v1/status", 200, r#"{"printer":{"state":"PRINTING"},"job":{"id":7,"progress":10.0}}"#);
|
||||||
|
mock.answer("GET /api/v1/job", 200, r#"{"id":7,"file":{"display_name":"c.gcode"}}"#);
|
||||||
|
mock.answer("PUT /api/v1/job/7/pause", 204, "");
|
||||||
|
mock.answer("DELETE /api/v1/job/7", 204, "");
|
||||||
|
let mut printer = OctoPrintPrinter::new("p1", "MK4", &mock.host(), "K", OctoPrintFlavour::PrusaLink);
|
||||||
|
printer.connect().await.unwrap();
|
||||||
|
|
||||||
|
printer.control_print(PrintControl::Pause).await.unwrap();
|
||||||
|
printer.control_print(PrintControl::Stop).await.unwrap();
|
||||||
|
assert_eq!(mock.sent("PUT", "/api/v1/job/7/pause").len(), 1);
|
||||||
|
assert_eq!(mock.sent("DELETE", "/api/v1/job/7").len(), 1);
|
||||||
|
assert!(printer.send_gcode_line("M106".into()).await.is_err());
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
assert_eq!(printer.status().unwrap().job_name.as_deref(), Some("c.gcode"));
|
||||||
|
// A job is running, so an upload is refused before anything is sent.
|
||||||
|
let err = printer.upload_project_file("n.bgcode".into(), vec![1]).await.unwrap_err();
|
||||||
|
assert!(err.0.contains("already printing"), "{err}");
|
||||||
|
assert!(mock.sent("PUT", "/api/v1/files/local/n.bgcode").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn prusalink_upload_is_a_put_with_print_after_upload() {
|
||||||
|
let mock = MockServer::start().await;
|
||||||
|
mock.answer("GET /api/v1/status", 200, r#"{"printer":{"state":"IDLE"}}"#);
|
||||||
|
mock.answer("PUT /api/v1/files/local/my%20part.gcode", 201, "");
|
||||||
|
let mut printer = OctoPrintPrinter::new("p1", "MK4", &mock.host(), "K", OctoPrintFlavour::PrusaLink);
|
||||||
|
printer.connect().await.unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
printer.upload_project_file("my part.gcode".into(), b"G28".to_vec()).await.unwrap();
|
||||||
|
let sent = mock.sent("PUT", "/api/v1/files/local/my%20part.gcode");
|
||||||
|
assert_eq!(sent[0].header("print-after-upload"), Some("?1"));
|
||||||
|
assert_eq!(sent[0].body, b"G28");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//! Status for vendors that have no push feed: a background task asks the
|
||||||
|
//! printer on an interval and keeps the last answer for `status()` to read.
|
||||||
|
//! (Bambu pushes over MQTT and keeps its own cache.)
|
||||||
|
use std::future::Future;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use super::PrinterStatus;
|
||||||
|
|
||||||
|
pub struct StatusFeed {
|
||||||
|
latest: Arc<Mutex<Option<PrinterStatus>>>,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatusFeed {
|
||||||
|
/// `fetch` returning `None` means "no usable answer" (printer
|
||||||
|
/// unreachable, or not ready), and clears the cache: a stale status
|
||||||
|
/// shown as current is worse than none.
|
||||||
|
pub fn start<F, Fut>(interval: Duration, fetch: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn() -> Fut + Send + 'static,
|
||||||
|
Fut: Future<Output = Option<PrinterStatus>> + Send,
|
||||||
|
{
|
||||||
|
let latest = Arc::new(Mutex::new(None));
|
||||||
|
let shared = latest.clone();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let status = fetch().await;
|
||||||
|
*shared.lock().unwrap() = status;
|
||||||
|
tokio::time::sleep(interval).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self { latest, task }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn latest(&self) -> Option<PrinterStatus> {
|
||||||
|
self.latest.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StatusFeed {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,110 +1,4 @@
|
|||||||
use super::{GenericPrinter, PrinterBase, PrinterError};
|
use super::{GenericPrinter, PrinterBase, PrinterError};
|
||||||
|
|
||||||
/****************** OctoPrint API family ******************/
|
|
||||||
|
|
||||||
/// Which implementation of the OctoPrint REST API is on the other end.
|
|
||||||
///
|
|
||||||
/// PrusaLink deliberately implements OctoPrint's API, so one client serves
|
|
||||||
/// both - but not identically. PrusaLink adds its own `/api/v1/*` (`info`,
|
|
||||||
/// `status`, `job`) which is where the useful detail lives, while OctoPrint
|
|
||||||
/// proper has the older `/api/printer` and `/api/job` and a plugin surface
|
|
||||||
/// PrusaLink does not have.
|
|
||||||
///
|
|
||||||
/// This is recorded from config now, while the client is still a stub,
|
|
||||||
/// because **no probe can settle it**: both answer `/api/version`, and every
|
|
||||||
/// PrusaLink identity endpoint needs a credential, so from outside the two
|
|
||||||
/// are indistinguishable (see `discovery::net::classify_octoprint_api`). It
|
|
||||||
/// is the operator's answer to a question only they can answer, so it needs
|
|
||||||
/// somewhere to live the moment they are asked - otherwise the real client
|
|
||||||
/// would have to ask them a second time.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum OctoPrintFlavour {
|
|
||||||
/// Prusa's own firmware-hosted implementation, on an MK4/XL/MINI.
|
|
||||||
PrusaLink,
|
|
||||||
/// OctoPrint itself, usually on a Raspberry Pi in front of any printer.
|
|
||||||
Generic,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OctoPrintFlavour {
|
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
OctoPrintFlavour::PrusaLink => "PrusaLink",
|
|
||||||
OctoPrintFlavour::Generic => "OctoPrint",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct OctoPrintPrinter {
|
|
||||||
base: PrinterBase,
|
|
||||||
pub api_key: String,
|
|
||||||
pub flavour: OctoPrintFlavour,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OctoPrintPrinter {
|
|
||||||
pub fn new(id: &str, name: &str, host: &str, api_key: &str, flavour: OctoPrintFlavour) -> Self {
|
|
||||||
Self {
|
|
||||||
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
|
|
||||||
api_key: api_key.into(),
|
|
||||||
flavour,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GenericPrinter for OctoPrintPrinter {
|
|
||||||
/// OVERRIDE: real version is `PUT /api/v1/job/{id}/pause`, `/resume`, or
|
|
||||||
/// `DELETE /api/v1/job/{id}` to cancel - PrusaLink addresses the running
|
|
||||||
/// job by id rather than the printer, so a real implementation has to
|
|
||||||
/// fetch the current job first.
|
|
||||||
async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> {
|
|
||||||
println!("[{}] {} job control: {}", self.base.name, self.flavour.as_str(), action.as_str());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fn base(&self) -> &PrinterBase {
|
|
||||||
&self.base
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OVERRIDE: a completely different body from Bambu's - PrusaLink is a
|
|
||||||
/// plain REST API (real version: `GET /api/v1/status` with the API
|
|
||||||
/// key, see `adapters::prusalink`), no persistent session to hold.
|
|
||||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
|
||||||
println!("[{}] connecting over HTTP ({})", self.base.name, self.flavour.as_str());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
|
|
||||||
// TODO: add publishing via HTTP
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OVERRIDE: gcode via PrusaLink's command-injection endpoint - same
|
|
||||||
/// underlying M106 as Bambu, reusing the shared percent->PWM
|
|
||||||
/// conversion, but sent over HTTP instead of MQTT.
|
|
||||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
|
||||||
let pwm = super::percent_to_pwm(percent);
|
|
||||||
println!("[{}] POST /api/printer/command {{\"commands\":[\"M106 S{pwm}\"]}}", self.base.name);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OVERRIDE: real version is `PUT /api/v1/files/local/{file_name}` with
|
|
||||||
/// `data` as the request body - PrusaLink stores it on the printer's own
|
|
||||||
/// SD card, same "upload to storage the firmware owns" model as Bambu.
|
|
||||||
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
|
||||||
println!("[{}] PUT /api/v1/files/local/{file_name} ({} bytes)", self.base.name, data.len());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OVERRIDE: PrusaLink's command-injection endpoint, one line per call -
|
|
||||||
/// same endpoint `set_fan_speed` above already uses for M106, just with
|
|
||||||
/// an arbitrary line instead of a hardcoded one.
|
|
||||||
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
|
||||||
println!("[{}] POST /api/printer/command {{\"commands\":[\"{gcode_line}\"]}}", self.base.name);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/****************** PrusaSerial ******************/
|
/****************** PrusaSerial ******************/
|
||||||
|
|
||||||
pub struct PrusaSerialPrinter {
|
pub struct PrusaSerialPrinter {
|
||||||
|
|||||||
+5
-2
@@ -201,8 +201,8 @@ mod behaviour {
|
|||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The success path, using the OctoPrint client because its `connect` is
|
/// The success path, using the OctoPrint client against a mock server,
|
||||||
/// the one that succeeds without a real machine (the driver is a stub).
|
/// because no real machine is needed to connect to one.
|
||||||
/// What this proves is the plumbing, not a vendor: a connected printer is
|
/// What this proves is the plumbing, not a vendor: a connected printer is
|
||||||
/// saved, joins the running fleet, and is still there after a "restart"
|
/// saved, joins the running fleet, and is still there after a "restart"
|
||||||
/// that reopens the store and rebuilds the fleet from it.
|
/// that reopens the store and rebuilds the fleet from it.
|
||||||
@@ -210,7 +210,10 @@ mod behaviour {
|
|||||||
async fn an_added_printer_joins_the_fleet_and_survives_a_restart() {
|
async fn an_added_printer_joins_the_fleet_and_survives_a_restart() {
|
||||||
let (store, path) = store();
|
let (store, path) = store();
|
||||||
let fleet = empty_fleet();
|
let fleet = empty_fleet();
|
||||||
|
let mock = crate::printer::mock_http::MockServer::start().await;
|
||||||
|
mock.answer("GET /api/job", 200, r#"{"state":"Operational","progress":{}}"#);
|
||||||
let mut req = request("octo1");
|
let mut req = request("octo1");
|
||||||
|
req.host = Some(mock.host());
|
||||||
req.vendor = "octoprint".into();
|
req.vendor = "octoprint".into();
|
||||||
req.sn = None;
|
req.sn = None;
|
||||||
req.access_code = None;
|
req.access_code = None;
|
||||||
|
|||||||
+19
-20
@@ -374,29 +374,28 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
|
|||||||
|
|
||||||
for printer in fleet.iter() {
|
for printer in fleet.iter() {
|
||||||
let base = printer.base();
|
let base = printer.base();
|
||||||
let Some(report) = printer.print_status().await else {
|
let Some(status) = printer.status().await else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
// Only Bambu has a camera report to derive these from.
|
||||||
|
let report = printer.print_status().await;
|
||||||
|
|
||||||
out.push(PrinterTelemetry {
|
out.push(PrinterTelemetry {
|
||||||
printer_id: base.id.clone(),
|
printer_id: base.id.clone(),
|
||||||
name: base.name.clone(),
|
name: base.name.clone(),
|
||||||
state: report.gcode_state.clone(),
|
state: status.state,
|
||||||
percent: report.mc_percent,
|
percent: status.percent,
|
||||||
stage: report.stg_cur,
|
stage: status.stage,
|
||||||
layer: report.layer_num,
|
layer: status.layer,
|
||||||
total_layers: report.total_layer_num,
|
total_layers: status.total_layers,
|
||||||
remaining_minutes: report.mc_remaining_time,
|
remaining_minutes: status.remaining_minutes,
|
||||||
// An idle printer reports `""` rather than omitting the field;
|
job_name: status.job_name,
|
||||||
// empty-string-as-absent is worth normalising here so the cloud
|
nozzle_temp: status.nozzle_temp,
|
||||||
// doesn't have to know that.
|
nozzle_target: status.nozzle_target,
|
||||||
job_name: report.subtask_name.clone().filter(|s| !s.is_empty()),
|
bed_temp: status.bed_temp,
|
||||||
nozzle_temp: report.nozzle_temper,
|
bed_target: status.bed_target,
|
||||||
nozzle_target: report.nozzle_target_temper,
|
print_error: status.print_error,
|
||||||
bed_temp: report.bed_temper,
|
active_alerts: status.active_alerts,
|
||||||
bed_target: report.bed_target_temper,
|
|
||||||
print_error: report.print_error,
|
|
||||||
active_alerts: report.hms.as_ref().map(|h| h.len()),
|
|
||||||
// Derived from the same reported URL `camera::sources` builds
|
// Derived from the same reported URL `camera::sources` builds
|
||||||
// the go2rtc config from, so a printer is advertised as
|
// the go2rtc config from, so a printer is advertised as
|
||||||
// streamable here only if it is also actually being served
|
// streamable here only if it is also actually being served
|
||||||
@@ -404,13 +403,13 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
|
|||||||
// the rule means the two cannot drift apart.
|
// the rule means the two cannot drift apart.
|
||||||
camera_stream: crate::camera::source_for(
|
camera_stream: crate::camera::source_for(
|
||||||
printer,
|
printer,
|
||||||
report.ipcam.as_ref().and_then(|c| c.rtsp_url.as_deref()),
|
report.as_ref().and_then(|r| r.ipcam.as_ref()).and_then(|c| c.rtsp_url.as_deref()),
|
||||||
)
|
)
|
||||||
.map(|source| source.stream_name),
|
.map(|source| source.stream_name),
|
||||||
camera_snapshot: crate::camera::wants_snapshot(
|
camera_snapshot: crate::camera::wants_snapshot(
|
||||||
printer,
|
printer,
|
||||||
report.ipcam.is_some(),
|
report.as_ref().is_some_and(|r| r.ipcam.is_some()),
|
||||||
report.ipcam.as_ref().and_then(|c| c.rtsp_url.as_deref()),
|
report.as_ref().and_then(|r| r.ipcam.as_ref()).and_then(|c| c.rtsp_url.as_deref()),
|
||||||
)
|
)
|
||||||
.then_some(true),
|
.then_some(true),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user