Compare commits
10
Commits
6c60bd1f70
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f027fe7947
|
||
|
|
f21ad0d497
|
||
|
|
9359add6aa
|
||
|
|
b1e733a5fe
|
||
|
|
47290175aa
|
||
|
|
0c449c47bc
|
||
|
|
0c8ad9cc3d
|
||
|
|
b4948076a0
|
||
|
|
5fc09aef04
|
||
|
|
9dff600160
|
+6
-4
@@ -1,10 +1,12 @@
|
||||
# Identity
|
||||
CONTINUUM_GATEWAY_ID=gw-farm01-rpi01
|
||||
CONTINUUM_FARM_ID=replace-with-farm-uuid
|
||||
# The edge JWT from POST /auth/edge-token. Sent as an Authorization: Bearer
|
||||
# header on the uplink's WebSocket upgrade - not in a message and not in the
|
||||
# URL - so the backend verifies it before any socket exists.
|
||||
CONTINUUM_GATEWAY_TOKEN=replace-with-provisioning-token
|
||||
# The farm's edge proxy token, shown once by the console's Setup page (or
|
||||
# POST /farms/:id/edge-proxy-token). The gateway trades it for a one-hour edge
|
||||
# JWT (POST /auth/edge-token) on every connection, and the JWT - sent as an
|
||||
# Authorization: Bearer header on the WebSocket upgrade, never in a message
|
||||
# or the URL - is what the backend verifies before any socket exists.
|
||||
CONTINUUM_GATEWAY_TOKEN=replace-with-the-farms-edge-proxy-token
|
||||
|
||||
# Cloud uplink
|
||||
CONTINUUM_UPLINK_URL=wss://api.continuum.local/ws/edge/v1
|
||||
|
||||
@@ -14,3 +14,7 @@ go2rtc.yaml
|
||||
go2rtc.yaml.tmp
|
||||
continuum.toml
|
||||
go2rtc.yaml.pid
|
||||
|
||||
# The local store holds printer access codes.
|
||||
continuum.db
|
||||
continuum.db-*
|
||||
|
||||
Generated
+76
@@ -208,6 +208,7 @@ dependencies = [
|
||||
"openssl",
|
||||
"reqwest",
|
||||
"rumqttc",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"suppaftp",
|
||||
@@ -375,6 +376,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -415,6 +428,12 @@ dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
@@ -533,6 +552,18 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
@@ -875,6 +906,17 @@ version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -1369,6 +1411,15 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rumqttc"
|
||||
version = "0.25.1"
|
||||
@@ -1388,6 +1439,21 @@ dependencies = [
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.41.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -1670,6 +1736,16 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"rsqlite-vfs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -92,3 +92,9 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-
|
||||
# `bind_ssdp`). Windows PCs routinely have VPN/Hyper-V/WSL adapters.
|
||||
# MIT OR BSD-3-Clause - checked against docs/LICENSES.md before adding.
|
||||
if-addrs = "0.15"
|
||||
# The proxy's local store (src/store.rs): printers added from the console live
|
||||
# here, so the gateway keeps its fleet with the API unreachable. `bundled`
|
||||
# compiles SQLite from source so the appliance image needs no system library
|
||||
# (SQLite itself is public domain).
|
||||
# MIT - checked with `cargo deny check licenses` (docs/LICENSES.md policy).
|
||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
gateway_id = "REPLACE_ME"
|
||||
farm_id = "REPLACE_ME"
|
||||
# The farm's edge proxy token from the console (Setup page). Traded for a
|
||||
# short-lived edge JWT on every connection - see src/uplink/auth.rs.
|
||||
gateway_token = "REPLACE_ME"
|
||||
|
||||
# uplink_url = "wss://api.continuum.local/ws/edge/v1"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Run with: cargo run --example bambu_mapping_check -- <printer-id> <file-on-card>
|
||||
//!
|
||||
//! **Read-only dry run of what a print would do.** Connects, reads the
|
||||
//! printer's state and loaded filament, downloads one project from the SD
|
||||
//! card root over FTPS, and prints the AMS mapping the production path would
|
||||
//! send - without sending it. Starts nothing, uploads nothing, deletes
|
||||
//! nothing. An optional third argument saves the downloaded copy locally.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use continuum_proxy::fleet;
|
||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let (Some(target), Some(file)) = (args.next(), args.next()) else {
|
||||
anyhow::bail!("usage: cargo run --example bambu_mapping_check -- <printer-id> <file-on-card> [save-to]");
|
||||
};
|
||||
let save_to = args.next();
|
||||
|
||||
let mut printers = fleet::load(std::path::Path::new("printers.toml"))?;
|
||||
let printer = printers
|
||||
.iter_mut()
|
||||
.find(|p| p.base().id == target)
|
||||
.ok_or_else(|| anyhow::anyhow!("no printer with id '{target}' in printers.toml"))?;
|
||||
let PrinterHandle::Bambu(bambu) = printer else {
|
||||
anyhow::bail!("'{target}' is not a Bambu printer");
|
||||
};
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(20), bambu.connect())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("connect timed out"))??;
|
||||
bambu.pushall().await.ok();
|
||||
tokio::time::sleep(Duration::from_secs(4)).await;
|
||||
|
||||
let report = bambu.print_status().await.ok_or_else(|| anyhow::anyhow!("no status yet"))?;
|
||||
println!("gcode_state: {:?}", report.gcode_state);
|
||||
println!("{:#?}", report.ams);
|
||||
|
||||
let bytes = bambu.download_file(&format!("/{file}")).await?;
|
||||
println!("downloaded {} bytes", bytes.len());
|
||||
if let Some(path) = save_to {
|
||||
std::fs::write(&path, &bytes)?;
|
||||
println!("saved a copy to {path}");
|
||||
}
|
||||
for f in continuum_proxy::printer::plate_filaments(&bytes, 1)? {
|
||||
println!("project filament {}: {} {} #{}", f.index, f.filament_type, f.tray_info_idx, f.color);
|
||||
}
|
||||
if let Some(slot) = std::env::var("AMS_SLOT").ok().and_then(|v| v.parse::<u8>().ok()) {
|
||||
println!("(with AMS_SLOT={slot} chosen explicitly)");
|
||||
bambu.set_ams_slot_override(Some(slot));
|
||||
}
|
||||
match bambu.filament_source_for(&bytes, 1).await {
|
||||
Ok(source) => println!("mapping the production path would use: {source:?}"),
|
||||
Err(err) => println!("the production path would REFUSE: {err}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+12
-3
@@ -18,9 +18,10 @@
|
||||
//! Only the X1/H2 class publishes an `rtsp_url`. Confirmed against two real
|
||||
//! machines: an H2C reports `rtsps://<ip>:322/streaming/live/1`, and a P1P
|
||||
//! reports an `ipcam` block with no URL in it at all. A P1/A1 camera is
|
||||
//! reachable only over Bambu's proprietary port-6000 JPEG stream, which this
|
||||
//! crate does not speak, so those printers get no stream here and say so
|
||||
//! rather than advertising one that will not play.
|
||||
//! reachable only over Bambu's proprietary port-6000 JPEG stream. That is
|
||||
//! not restreamable here (go2rtc cannot make WebRTC of MJPEG without ffmpeg),
|
||||
//! so those printers get no go2rtc stream; they are served single frames by
|
||||
//! `printer::bambu_camera` instead and advertise `camera_snapshot`.
|
||||
//!
|
||||
//! That is not just an inference from the missing field - it is confirmed at
|
||||
//! the network layer (2026-10-15). The H2C accepts TCP on 322; the P1P
|
||||
@@ -131,6 +132,14 @@ pub fn source_for(printer: &PrinterHandle, report_url: Option<&str>) -> Option<C
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this printer's camera is the P1/A1 kind: it reports a camera
|
||||
/// block but no RTSP URL, so the only way to see it is single JPEG frames
|
||||
/// from port 6000 (`printer::bambu_camera`). The one rule for "snapshot
|
||||
/// camera", used by telemetry, so what is advertised is what is served.
|
||||
pub fn wants_snapshot(printer: &PrinterHandle, has_ipcam: bool, report_url: Option<&str>) -> bool {
|
||||
matches!(printer, PrinterHandle::Bambu(_)) && has_ipcam && report_url.is_none_or(str::is_empty)
|
||||
}
|
||||
|
||||
/// Renders a go2rtc configuration.
|
||||
///
|
||||
/// Hand-written YAML rather than a serializer, for the same reason the
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ pub async fn run(fleet: SharedFleet) {
|
||||
if let Err(err) = printer.refresh_status().await {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -45,7 +45,7 @@ use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub gateway_id: String,
|
||||
pub farm_id: String,
|
||||
@@ -76,6 +76,9 @@ pub struct Config {
|
||||
/// `BambuPrinter::ensure_trusted`) lives entirely in that file now,
|
||||
/// not as a separate fleet-wide env-configured policy here.
|
||||
pub printers_path: String,
|
||||
/// The gateway's own database (src/store.rs): printers added from the
|
||||
/// console, credentials included, so the file is created owner-only.
|
||||
pub store_path: String,
|
||||
/// How, and whether, to look for printers nobody has configured.
|
||||
///
|
||||
/// Off unless switched on - see [`DiscoverySettings`] for why that is
|
||||
@@ -104,6 +107,7 @@ pub struct ProxyFile {
|
||||
pub go2rtc_api_listen: Option<String>,
|
||||
pub stream_base_url: Option<String>,
|
||||
pub printers_path: Option<String>,
|
||||
pub store_path: Option<String>,
|
||||
/// Only meaningful on a Proxy Box; see the module docs for why it is
|
||||
/// exposed and not applied.
|
||||
pub network: Option<NetworkSettings>,
|
||||
@@ -283,6 +287,22 @@ impl fmt::Debug for WifiSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// By hand: the derived `Debug` would print `gateway_token`, the credential
|
||||
/// that opens this farm's gateway socket, into any log line that formats the
|
||||
/// config.
|
||||
impl fmt::Debug for Config {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Config")
|
||||
.field("gateway_id", &self.gateway_id)
|
||||
.field("farm_id", &self.farm_id)
|
||||
.field("gateway_token", &"<redacted>")
|
||||
.field("uplink_url", &self.uplink_url)
|
||||
.field("printers_path", &self.printers_path)
|
||||
.field("store_path", &self.store_path)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Environment only - the behaviour from before there was a file, kept
|
||||
/// so existing callers and tests do not change.
|
||||
@@ -326,6 +346,7 @@ impl Config {
|
||||
.or_else(|| file.stream_base_url.clone())
|
||||
.filter(|v| !v.is_empty()),
|
||||
printers_path: or_default("CONTINUUM_PRINTERS_PATH", &file.printers_path, "printers.toml"),
|
||||
store_path: or_default("CONTINUUM_STORE_PATH", &file.store_path, "continuum.db"),
|
||||
discovery: discovery_settings(file.discovery.as_ref(), env)?,
|
||||
})
|
||||
}
|
||||
|
||||
+20
-2
@@ -32,7 +32,12 @@ use crate::uplink::ClientMessage;
|
||||
/// long-running tasks. With discovery off it parks on a future that never
|
||||
/// completes, which costs nothing and keeps the "every arm runs forever"
|
||||
/// property of that `select!` true.
|
||||
pub async fn run(settings: DiscoverySettings, printers_path: String, outbound: Sender<ClientMessage>) {
|
||||
pub async fn run(
|
||||
settings: DiscoverySettings,
|
||||
printers_path: String,
|
||||
store: Option<std::sync::Arc<crate::store::Store>>,
|
||||
outbound: Sender<ClientMessage>,
|
||||
) {
|
||||
if !settings.enabled {
|
||||
tracing::info!("printer discovery is off (discovery.enabled); not scanning this network");
|
||||
std::future::pending::<()>().await;
|
||||
@@ -64,7 +69,20 @@ pub async fn run(settings: DiscoverySettings, printers_path: String, outbound: S
|
||||
// restart - but someone who has *edited* the file and not restarted
|
||||
// should not keep being offered the printer they just configured.
|
||||
let configured = match crate::fleet::configured(std::path::Path::new(&printers_path)) {
|
||||
Ok(configured) => configured,
|
||||
Ok(mut configured) => {
|
||||
// Printers added from the console live in the store, not the
|
||||
// file, and are just as "already configured".
|
||||
if let Some(store) = &store {
|
||||
match store.printers() {
|
||||
Ok(stored) => configured.add_entries(stored),
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "cannot read the local store; skipping this discovery pass");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
configured
|
||||
}
|
||||
// Fail the pass rather than report against an empty set. The
|
||||
// alternative offers every printer the farm already runs as a
|
||||
// fresh find, which is worse than no report: it is a list of
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
//! `continuum-proxy enrol <cloud-url> <code> [name]`: connect this gateway to
|
||||
//! a farm with the one-time code the console shows, instead of copying a
|
||||
//! farm id and token between machines.
|
||||
//!
|
||||
//! The code is traded at `POST /auth/enrol` for the farm id and the gateway
|
||||
//! token, which are written into the proxy's config file (owner-only, since
|
||||
//! the token is a credential) next to whatever else it already holds. The
|
||||
//! next start reads them like any other setting.
|
||||
//!
|
||||
//! PC mode only. A Proxy Box is configured by a signed bundle, and writing
|
||||
//! its installed config from here would bypass exactly the check that exists
|
||||
//! to stop that.
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context};
|
||||
use serde_json::Value;
|
||||
|
||||
pub struct Enrolment {
|
||||
pub farm_id: String,
|
||||
pub gateway_token: String,
|
||||
}
|
||||
|
||||
pub const USAGE: &str = "usage: continuum-proxy enrol <cloud-url> <code> [gateway-name]\n\
|
||||
\n\
|
||||
Connects this gateway to a farm using the one-time code shown in the console\n\
|
||||
(valid for 15 minutes, usable once). <cloud-url> is the API's address, e.g.\n\
|
||||
https://api.example.com. Settings are written to the proxy's config file.";
|
||||
|
||||
/// The websocket address of the uplink for an API at `cloud`.
|
||||
pub fn uplink_url(cloud: &str) -> anyhow::Result<String> {
|
||||
let cloud = cloud.trim().trim_end_matches('/');
|
||||
let rest = if let Some(rest) = cloud.strip_prefix("https://") {
|
||||
format!("wss://{rest}")
|
||||
} else if let Some(rest) = cloud.strip_prefix("http://") {
|
||||
format!("ws://{rest}")
|
||||
} else {
|
||||
bail!("the cloud address must start with http:// or https:// (got '{cloud}')");
|
||||
};
|
||||
Ok(format!("{rest}/ws/edge/v1"))
|
||||
}
|
||||
|
||||
pub fn parse_response(body: &str) -> anyhow::Result<Enrolment> {
|
||||
let value: Value = serde_json::from_str(body).context("the cloud's answer was not JSON")?;
|
||||
let field = |name: &str| {
|
||||
value
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(str::to_string)
|
||||
.with_context(|| format!("the cloud's answer has no '{name}'"))
|
||||
};
|
||||
Ok(Enrolment { farm_id: field("farmId")?, gateway_token: field("gatewayToken")? })
|
||||
}
|
||||
|
||||
/// The config file's new text: the four enrolment settings set, everything
|
||||
/// else the file already said kept. (Comments are not preserved - the file is
|
||||
/// parsed and written back.)
|
||||
pub fn merge_config(existing: Option<&str>, gateway_id: &str, enrolment: &Enrolment, uplink_url: &str) -> anyhow::Result<String> {
|
||||
let mut table: toml::Table = match existing {
|
||||
Some(text) => toml::from_str(text).context("the existing config file is not valid TOML; not touching it")?,
|
||||
None => toml::Table::new(),
|
||||
};
|
||||
table.insert("gateway_id".into(), gateway_id.into());
|
||||
table.insert("farm_id".into(), enrolment.farm_id.clone().into());
|
||||
table.insert("gateway_token".into(), enrolment.gateway_token.clone().into());
|
||||
table.insert("uplink_url".into(), uplink_url.into());
|
||||
Ok(toml::to_string(&table)?)
|
||||
}
|
||||
|
||||
/// Trades `code` for the farm's credentials.
|
||||
pub async fn exchange(cloud: &str, code: &str) -> anyhow::Result<Enrolment> {
|
||||
let url = format!("{}/auth/enrol", cloud.trim().trim_end_matches('/'));
|
||||
let body = serde_json::json!({ "code": code }).to_string();
|
||||
let response = reqwest::Client::new()
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("could not reach {cloud}"))?;
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
let message = serde_json::from_str::<Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| v.pointer("/error/message").and_then(Value::as_str).map(str::to_string))
|
||||
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||||
bail!("the cloud refused the code: {message}");
|
||||
}
|
||||
parse_response(&text)
|
||||
}
|
||||
|
||||
/// Owner-only, and replaced atomically so a crash cannot leave half a file.
|
||||
fn write_private(path: &Path, text: &str) -> anyhow::Result<()> {
|
||||
use std::io::Write;
|
||||
let tmp = path.with_extension("tmp");
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(&tmp).with_context(|| format!("writing {tmp:?}"))?;
|
||||
file.write_all(text.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
std::fs::rename(&tmp, path).with_context(|| format!("replacing {path:?}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_gateway_name() -> String {
|
||||
std::env::var("HOSTNAME")
|
||||
.ok()
|
||||
.or_else(|| std::fs::read_to_string("/etc/hostname").ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "gateway".to_string())
|
||||
}
|
||||
|
||||
/// The `enrol` subcommand. `args` are what follows the word `enrol`.
|
||||
pub async fn run(args: &[String], bootstrap: &crate::bootstrap::Bootstrap) -> anyhow::Result<()> {
|
||||
if args.iter().any(|a| a == "--help" || a == "-h") {
|
||||
println!("{USAGE}");
|
||||
return Ok(());
|
||||
}
|
||||
let [cloud, code, rest @ ..] = args else {
|
||||
bail!("{USAGE}");
|
||||
};
|
||||
if bootstrap.mode == crate::bootstrap::Mode::ProxyBox {
|
||||
bail!("this is a Proxy Box: it is configured by a signed config bundle on a pendrive, not by enrol");
|
||||
}
|
||||
let uplink = uplink_url(cloud)?;
|
||||
let enrolment = exchange(cloud, code).await?;
|
||||
|
||||
let path = &bootstrap.config_path;
|
||||
let existing = match std::fs::read_to_string(path) {
|
||||
Ok(text) => Some(text),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(err) => bail!("could not read {path:?}: {err}"),
|
||||
};
|
||||
let name = rest.first().cloned().unwrap_or_else(default_gateway_name);
|
||||
let text = merge_config(existing.as_deref(), &name, &enrolment, &uplink)?;
|
||||
write_private(path, &text)?;
|
||||
println!("Enrolled gateway '{name}' in farm {}. Settings written to {path:?}.", enrolment.farm_id);
|
||||
println!("Start the proxy to connect it.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_uplink_follows_the_api_scheme() {
|
||||
assert_eq!(uplink_url("https://api.example.com/").unwrap(), "wss://api.example.com/ws/edge/v1");
|
||||
assert_eq!(uplink_url("http://localhost:3060").unwrap(), "ws://localhost:3060/ws/edge/v1");
|
||||
assert!(uplink_url("api.example.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_answer_needs_both_fields() {
|
||||
let ok = parse_response(r#"{"farmId":"f","gatewayToken":"t"}"#).unwrap();
|
||||
assert_eq!((ok.farm_id.as_str(), ok.gateway_token.as_str()), ("f", "t"));
|
||||
assert!(parse_response(r#"{"farmId":"f"}"#).is_err());
|
||||
assert!(parse_response(r#"{"farmId":"f","gatewayToken":""}"#).is_err());
|
||||
assert!(parse_response("not json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enrolling_keeps_the_rest_of_the_file_and_the_result_parses_as_a_config() {
|
||||
let enrolment = Enrolment { farm_id: "farm-1".into(), gateway_token: "secret".into() };
|
||||
let existing = "go2rtc_bin = \"/opt/go2rtc\"\ngateway_token = \"old\"\n[discovery]\nenabled = false\n";
|
||||
let text = merge_config(Some(existing), "bench", &enrolment, "wss://x/ws/edge/v1").unwrap();
|
||||
let parsed = crate::config::ProxyFile::parse(&text).unwrap();
|
||||
assert_eq!(parsed.gateway_token.as_deref(), Some("secret"), "the new token replaces the old");
|
||||
assert_eq!(parsed.farm_id.as_deref(), Some("farm-1"));
|
||||
assert_eq!(parsed.gateway_id.as_deref(), Some("bench"));
|
||||
assert_eq!(parsed.go2rtc_bin.as_deref(), Some("/opt/go2rtc"));
|
||||
assert!(parsed.discovery.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_toml_is_left_alone() {
|
||||
let enrolment = Enrolment { farm_id: "f".into(), gateway_token: "t".into() };
|
||||
assert!(merge_config(Some("= nonsense ["), "g", &enrolment, "ws://x").is_err());
|
||||
}
|
||||
}
|
||||
+65
-19
@@ -6,7 +6,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::printer::{
|
||||
BambuPrinter, BambuProtocolPreference, BambuTls, KlipperPrinter, OctoPrintFlavour, OctoPrintPrinter,
|
||||
@@ -19,10 +19,14 @@ struct FleetFile {
|
||||
printers: Vec<PrinterEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PrinterEntry {
|
||||
id: String,
|
||||
name: String,
|
||||
/// One printer's connection details, as written in `printers.toml` or stored
|
||||
/// in the local store (src/store.rs). `Debug` is written by hand below: the
|
||||
/// derived one would print `access_code` and `api_key` into any log line that
|
||||
/// formats an entry.
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct PrinterEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// One of: "bambu" (auto-detects MQTT v5-vs-v3.1.1/BambuMQTT v2-vs-v1 -
|
||||
/// the default, since a firmware update can move the same physical
|
||||
/// printer between them), "bambu_v1"/"bambu_v2" (force a specific one),
|
||||
@@ -32,23 +36,38 @@ struct PrinterEntry {
|
||||
/// flavour - see `OctoPrintFlavour`. Pick `octoprint` if it is OctoPrint
|
||||
/// on a Pi and `prusa_link` if it is an MK4/XL/MINI serving its own API;
|
||||
/// discovery cannot tell, so it suggests `octoprint` and says so.
|
||||
vendor: String,
|
||||
host: Option<String>,
|
||||
access_code: Option<String>,
|
||||
pub vendor: String,
|
||||
pub host: Option<String>,
|
||||
pub access_code: Option<String>,
|
||||
/// The printer's serial number - Bambu's real MQTT topics are addressed
|
||||
/// by serial (`device/{sn}/report`), not by our own `id`.
|
||||
sn: Option<String>,
|
||||
api_key: Option<String>,
|
||||
pub sn: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
/// Serial device path, e.g. `/dev/ttyACM0` on Linux.
|
||||
port: Option<String>,
|
||||
pub port: Option<String>,
|
||||
#[serde(default = "default_baud_rate")]
|
||||
baud_rate: u32,
|
||||
pub baud_rate: u32,
|
||||
/// Path to a printer-specific CA certificate. Leave unset to use the
|
||||
/// bundled Bambu CA - only needed for a printer that doesn't chain to
|
||||
/// it (P1P, at least; see certs/README.md).
|
||||
ca_cert_path: Option<String>,
|
||||
pub ca_cert_path: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
require_valid_cert: bool,
|
||||
pub require_valid_cert: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PrinterEntry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let secret = |value: &Option<String>| value.as_ref().map(|_| "<redacted>");
|
||||
f.debug_struct("PrinterEntry")
|
||||
.field("id", &self.id)
|
||||
.field("name", &self.name)
|
||||
.field("vendor", &self.vendor)
|
||||
.field("host", &self.host)
|
||||
.field("access_code", &secret(&self.access_code))
|
||||
.field("sn", &self.sn)
|
||||
.field("api_key", &secret(&self.api_key))
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -72,6 +91,26 @@ pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
|
||||
file.printers.into_iter().map(build_printer).collect()
|
||||
}
|
||||
|
||||
/// The entries in a printers file, or none if there is no file.
|
||||
///
|
||||
/// A missing file is normal now: a gateway whose printers were all added from
|
||||
/// the console has only the store. A file that exists but does not parse is
|
||||
/// still an error - that is a typo to surface, not a fleet to ignore.
|
||||
pub fn entries(path: &Path) -> anyhow::Result<Vec<PrinterEntry>> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(err) => return Err(anyhow::anyhow!("failed to read {path:?}: {err}")),
|
||||
};
|
||||
Ok(toml::from_str::<FleetFile>(&text)?.printers)
|
||||
}
|
||||
|
||||
/// Builds the client for one entry. Public so a printer added while the
|
||||
/// gateway is running goes through exactly the checks a startup one does.
|
||||
pub fn build(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
|
||||
build_printer(entry)
|
||||
}
|
||||
|
||||
/// Everything the fleet file already names, for telling a printer the
|
||||
/// operator has dealt with from one they have never seen.
|
||||
///
|
||||
@@ -119,7 +158,13 @@ pub fn configured(path: &Path) -> anyhow::Result<Configured> {
|
||||
};
|
||||
let file: FleetFile = toml::from_str(&text)?;
|
||||
let mut out = Configured::default();
|
||||
for entry in file.printers {
|
||||
out.add_entries(file.printers);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Configured {
|
||||
/// Folds more entries in - the file's, and the local store's.
|
||||
pub fn add_entries(&mut self, entries: impl IntoIterator<Item = PrinterEntry>) {
|
||||
let add = |set: &mut HashSet<String>, value: Option<String>| {
|
||||
if let Some(value) = value {
|
||||
let value = value.trim().to_lowercase();
|
||||
@@ -128,11 +173,12 @@ pub fn configured(path: &Path) -> anyhow::Result<Configured> {
|
||||
}
|
||||
}
|
||||
};
|
||||
add(&mut out.hosts, entry.host);
|
||||
add(&mut out.serials, entry.sn);
|
||||
add(&mut out.ports, entry.port);
|
||||
for entry in entries {
|
||||
add(&mut self.hosts, entry.host);
|
||||
add(&mut self.serials, entry.sn);
|
||||
add(&mut self.ports, entry.port);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
//! Following the jobs the cloud has handed this gateway, so their outcomes
|
||||
//! reach the cloud even if it was unreachable when they happened.
|
||||
//!
|
||||
//! The cloud owns the queue; this only reports. When a `start_print` is
|
||||
//! accepted the job is recorded against its printer, and each tick the
|
||||
//! printer's state is compared with it. What the cloud should hear -
|
||||
//! started, finished, failed - goes into the store's outbox, and the uplink
|
||||
//! re-sends the outbox on every connection until the cloud acknowledges each
|
||||
//! event. An outcome reached during an outage, or before a restart, is
|
||||
//! therefore still delivered, instead of the cloud guessing from whatever
|
||||
//! telemetry it happens to see next.
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::printer::GenericPrinter;
|
||||
use crate::store::{ActiveJob, Store};
|
||||
use crate::uplink::SharedFleet;
|
||||
|
||||
const TICK: Duration = Duration::from_secs(5);
|
||||
/// A job whose printer never reported printing within this long is given up on.
|
||||
const NEVER_STARTED_AFTER_MS: i64 = 30 * 60 * 1000;
|
||||
|
||||
pub fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Step {
|
||||
Nothing,
|
||||
Started,
|
||||
Finished,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// What a printer's reported state means for the job it was handed.
|
||||
///
|
||||
/// Nothing is concluded from a state until the job has been seen running: a
|
||||
/// printer that has just been told to start still reports the *previous*
|
||||
/// job's `FINISH` (or `FAILED`) for a while, and reading that as this job's
|
||||
/// outcome would report a print that has not begun as done.
|
||||
pub fn next_step(job: &ActiveJob, state: Option<&str>, now_ms: i64) -> Step {
|
||||
let state = state.map(str::to_ascii_uppercase);
|
||||
let running = matches!(state.as_deref(), Some("RUNNING" | "PAUSE" | "PREPARE" | "SLICING"));
|
||||
|
||||
if !job.started {
|
||||
if running {
|
||||
return Step::Started;
|
||||
}
|
||||
if now_ms - job.since_ms > NEVER_STARTED_AFTER_MS {
|
||||
return Step::Failed("the printer never started printing it".into());
|
||||
}
|
||||
return Step::Nothing;
|
||||
}
|
||||
|
||||
match state.as_deref() {
|
||||
Some("FINISH") => Step::Finished,
|
||||
Some("FAILED") => Step::Failed("the printer reported a fault".into()),
|
||||
// Not a finish, not a fault, and no longer printing: stopped on the
|
||||
// machine itself. Klipper and OctoPrint report a cancelled print as
|
||||
// idle.
|
||||
Some("IDLE") => Step::Failed("the printer stopped without finishing it".into()),
|
||||
_ => Step::Nothing,
|
||||
}
|
||||
}
|
||||
|
||||
/// The gateway accepted a `start_print` for `job_id`.
|
||||
pub fn accepted(store: &Store, printer_id: &str, job_id: &str) {
|
||||
if let Err(err) = store.active_job_set(printer_id, job_id, now_ms()) {
|
||||
warn!(%err, %printer_id, %job_id, "could not record the job against its printer");
|
||||
}
|
||||
}
|
||||
|
||||
/// The accepted start then failed (download, transfer, start).
|
||||
pub fn start_failed(store: &Store, printer_id: &str, job_id: &str, reason: &str) {
|
||||
if let Err(err) = store.outbox_push(job_id, "failed", Some(reason), now_ms()) {
|
||||
warn!(%err, %job_id, "could not queue the failure for the cloud");
|
||||
}
|
||||
let _ = store.active_job_clear(printer_id);
|
||||
}
|
||||
|
||||
pub async fn run(fleet: SharedFleet, store: std::sync::Arc<Store>) {
|
||||
let mut ticker = tokio::time::interval(TICK);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let Ok(active) = store.active_jobs() else { continue };
|
||||
if active.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let states: Vec<(ActiveJob, Option<String>)> = {
|
||||
let fleet = fleet.lock().await;
|
||||
let mut out = Vec::new();
|
||||
for job in active {
|
||||
let state = match fleet.iter().find(|p| p.base().id == job.printer_id) {
|
||||
Some(printer) => printer.status().await.and_then(|s| s.state),
|
||||
None => None,
|
||||
};
|
||||
out.push((job, state));
|
||||
}
|
||||
out
|
||||
};
|
||||
for (job, state) in states {
|
||||
apply(&store, &job, next_step(&job, state.as_deref(), now_ms()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(store: &Store, job: &ActiveJob, step: Step) {
|
||||
let result = match &step {
|
||||
Step::Nothing => return,
|
||||
Step::Started => store
|
||||
.outbox_push(&job.job_id, "started", None, now_ms())
|
||||
.and_then(|_| store.active_job_started(&job.printer_id)),
|
||||
Step::Finished => store
|
||||
.outbox_push(&job.job_id, "finished", None, now_ms())
|
||||
.and_then(|_| store.active_job_clear(&job.printer_id)),
|
||||
Step::Failed(reason) => store
|
||||
.outbox_push(&job.job_id, "failed", Some(reason), now_ms())
|
||||
.and_then(|_| store.active_job_clear(&job.printer_id)),
|
||||
};
|
||||
match result {
|
||||
Ok(_) => info!(printer = %job.printer_id, job = %job.job_id, ?step, "job event queued for the cloud"),
|
||||
Err(err) => warn!(%err, job = %job.job_id, "could not queue a job event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn job(started: bool, since_ms: i64) -> ActiveJob {
|
||||
ActiveJob { printer_id: "p1".into(), job_id: "j1".into(), since_ms, started }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_finish_from_the_previous_job_is_not_this_jobs_outcome() {
|
||||
assert_eq!(next_step(&job(false, 0), Some("FINISH"), 10_000), Step::Nothing);
|
||||
assert_eq!(next_step(&job(false, 0), Some("FAILED"), 10_000), Step::Nothing);
|
||||
assert_eq!(next_step(&job(false, 0), Some("IDLE"), 10_000), Step::Nothing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_marks_it_started_and_then_finish_finishes_it() {
|
||||
assert_eq!(next_step(&job(false, 0), Some("RUNNING"), 10_000), Step::Started);
|
||||
assert_eq!(next_step(&job(true, 0), Some("RUNNING"), 10_000), Step::Nothing);
|
||||
assert_eq!(next_step(&job(true, 0), Some("PAUSE"), 10_000), Step::Nothing);
|
||||
assert_eq!(next_step(&job(true, 0), Some("FINISH"), 10_000), Step::Finished);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fault_or_a_stop_after_starting_is_a_failure() {
|
||||
assert!(matches!(next_step(&job(true, 0), Some("FAILED"), 1), Step::Failed(_)));
|
||||
assert!(matches!(next_step(&job(true, 0), Some("IDLE"), 1), Step::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_printer_that_goes_quiet_leaves_the_job_alone() {
|
||||
assert_eq!(next_step(&job(true, 0), None, 1), Step::Nothing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_that_never_starts_is_eventually_given_up_on() {
|
||||
assert_eq!(next_step(&job(false, 0), Some("IDLE"), NEVER_STARTED_AFTER_MS), Step::Nothing);
|
||||
assert!(matches!(next_step(&job(false, 0), Some("IDLE"), NEVER_STARTED_AFTER_MS + 1), Step::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_steps_fills_the_outbox_and_clears_the_active_job() {
|
||||
let dir = std::env::temp_dir().join(format!("continuum-jobs-test-{}", std::process::id()));
|
||||
let store = Store::open(&dir.join("s.db")).unwrap();
|
||||
accepted(&store, "p1", "j1");
|
||||
let active = store.active_jobs().unwrap().remove(0);
|
||||
apply(&store, &active, Step::Started);
|
||||
let active = store.active_jobs().unwrap().remove(0);
|
||||
assert!(active.started);
|
||||
apply(&store, &active, Step::Finished);
|
||||
assert!(store.active_jobs().unwrap().is_empty());
|
||||
let events: Vec<_> = store.outbox_pending().unwrap().into_iter().map(|e| e.event).collect();
|
||||
assert_eq!(events, ["started", "finished"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@ pub mod camera;
|
||||
pub mod completion;
|
||||
pub mod config;
|
||||
pub mod config_import;
|
||||
pub mod enrol;
|
||||
pub mod discovery;
|
||||
pub mod fleet;
|
||||
pub mod jobs;
|
||||
pub mod printer;
|
||||
pub mod provision;
|
||||
pub mod store;
|
||||
pub mod uplink;
|
||||
|
||||
+35
-3
@@ -13,7 +13,7 @@ use continuum_proxy::bootstrap::{self, Bootstrap};
|
||||
use continuum_proxy::config::Config;
|
||||
use continuum_proxy::config_import;
|
||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||
use continuum_proxy::{camera, completion, fleet, uplink};
|
||||
use continuum_proxy::{camera, completion, fleet, jobs, uplink};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@@ -23,6 +23,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
// `continuum-proxy enrol ...` connects this gateway to a farm and exits;
|
||||
// it needs the bootstrap (for where the config file is) but no settings.
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.first().map(String::as_str) == Some("enrol") {
|
||||
return continuum_proxy::enrol::run(&args[1..], &Bootstrap::discover()?).await;
|
||||
}
|
||||
|
||||
// Order matters: import first, so a stick present at boot is already
|
||||
// the installed config by the time it is read. A rejected stick is
|
||||
// logged inside and does not stop startup - the box runs on what it has.
|
||||
@@ -68,7 +75,30 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Loaded and connected here, once, at startup - nothing yet re-reads
|
||||
// printers.toml on a running daemon, so adding or removing a printer
|
||||
// still means a restart.
|
||||
let mut fleet = fleet::load(std::path::Path::new(&config.printers_path))?;
|
||||
// Two sources, one fleet: `printers.toml` (a hand-written file - strict,
|
||||
// a typo stops startup) and the local store (printers added from the
|
||||
// console, which were checked before they were saved). The store is
|
||||
// opened first and is what lets the gateway come up with no file at all.
|
||||
let store = std::sync::Arc::new(continuum_proxy::store::Store::open(std::path::Path::new(&config.store_path))?);
|
||||
let mut fleet = Vec::new();
|
||||
for entry in fleet::entries(std::path::Path::new(&config.printers_path))? {
|
||||
fleet.push(fleet::build(entry)?);
|
||||
}
|
||||
for entry in store.printers()? {
|
||||
if fleet.iter().any(|printer| printer.base().id == entry.id) {
|
||||
tracing::warn!(printer = %entry.id, "in both printers.toml and the local store; using printers.toml");
|
||||
continue;
|
||||
}
|
||||
// Skipped, not fatal: one entry the build no longer accepts should
|
||||
// not keep the rest of a farm offline.
|
||||
match fleet::build(entry) {
|
||||
Ok(printer) => fleet.push(printer),
|
||||
Err(err) => tracing::error!(%err, "a printer in the local store could not be built; skipping it"),
|
||||
}
|
||||
}
|
||||
if fleet.is_empty() {
|
||||
tracing::warn!("no printers yet - add one from the console, or list some in printers.toml");
|
||||
}
|
||||
connect_fleet(&mut fleet).await;
|
||||
|
||||
// The fleet is now shared with the uplink rather than just parked here
|
||||
@@ -134,10 +164,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
_ = shutdown_requested() => {
|
||||
info!("stop requested; exiting so go2rtc is stopped with us");
|
||||
}
|
||||
_ = uplink::run(config.clone(), fleet.clone(), outbound.clone(), inbox) => {}
|
||||
_ = uplink::run(config.clone(), fleet.clone(), Some(store.clone()), outbound.clone(), inbox) => {}
|
||||
_ = continuum_proxy::discovery::report::run(
|
||||
config.discovery.clone(),
|
||||
config.printers_path.clone(),
|
||||
Some(store.clone()),
|
||||
outbound.clone(),
|
||||
) => {}
|
||||
_ = go2rtc::watchdog(
|
||||
@@ -153,6 +184,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
restart_go2rtc.clone(),
|
||||
) => {}
|
||||
_ = completion::run(fleet.clone()) => {}
|
||||
_ = jobs::run(fleet.clone(), store.clone()) => {}
|
||||
// Returns only after installing a new config from a stick inserted
|
||||
// while running. Settings are read once at startup, so the process
|
||||
// ends here and relies on its supervisor (systemd `Restart=always`
|
||||
|
||||
+61
-2
@@ -207,6 +207,28 @@ pub enum BambuProtocolPreference {
|
||||
}
|
||||
// endregion: BambuMQTTProtocol
|
||||
|
||||
/// One pending camera read for a P1/A1; see `bambu_camera`. Holds the access
|
||||
/// code, so it has no `Debug`.
|
||||
pub struct SnapshotJob {
|
||||
name: String,
|
||||
host: String,
|
||||
access_code: String,
|
||||
connector: native_tls::TlsConnector,
|
||||
}
|
||||
|
||||
impl SnapshotJob {
|
||||
/// One JPEG frame from the printer's port-6000 camera.
|
||||
pub async fn run(self) -> Result<Vec<u8>, PrinterError> {
|
||||
let name = self.name;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
super::bambu_camera::snapshot(&self.host, &self.access_code, self.connector, std::time::Duration::from_secs(8))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| PrinterError(format!("{name}: camera thread failed: {err}")))?
|
||||
.map_err(|err| PrinterError(format!("{name}: camera: {err}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// A Bambu printer. Only one struct now, not two: the old
|
||||
/// `BambuGenericPrinter` (shared fields/behavior) + `BambuV1Printer`/
|
||||
/// `BambuV2Printer` (thin per-version wrappers) split existed to let two
|
||||
@@ -224,6 +246,8 @@ pub struct BambuPrinter {
|
||||
/// alongside the protocol version it settled on, since the two are
|
||||
/// decided together and always used together - see `bambu_mqtt.rs`.
|
||||
connection: Option<(super::bambu_mqtt::BambuMqttSession, BambuProtocolVersion)>,
|
||||
/// See `set_ams_slot_override`.
|
||||
ams_slot_override: Option<u8>,
|
||||
}
|
||||
|
||||
/// Which plate of a multi-plate project gets printed.
|
||||
@@ -469,9 +493,17 @@ impl BambuPrinter {
|
||||
tls,
|
||||
preference,
|
||||
connection: None,
|
||||
ams_slot_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator's chosen AMS slot for the next print (0-based across all
|
||||
/// units), or `None` to map by the project's own filament. Set by the
|
||||
/// uplink around one `start_print` and cleared after it.
|
||||
pub fn set_ams_slot_override(&mut self, slot: Option<u8>) {
|
||||
self.ams_slot_override = slot;
|
||||
}
|
||||
|
||||
pub fn base(&self) -> &PrinterBase {
|
||||
&self.base
|
||||
}
|
||||
@@ -736,7 +768,8 @@ impl BambuPrinter {
|
||||
.map_err(|err| PrinterError(format!("failed to build pushall request: {err}")))?;
|
||||
body["push_target"] = serde_json::Value::from(1);
|
||||
|
||||
session.publish("pushing", &body).await
|
||||
// Unacked: nothing waits on a reply to this, and see `publish_unacked`.
|
||||
session.publish_unacked("pushing", &body).await
|
||||
}
|
||||
|
||||
/// Just the FTPS transfer: puts `data` in the printer's `cache/`
|
||||
@@ -852,7 +885,18 @@ impl BambuPrinter {
|
||||
// naming a filament that also happens to be on the spool should
|
||||
// still use the AMS, because that is what it was sliced for.
|
||||
if !trays.is_empty() {
|
||||
match super::map_to_trays(&filaments, &trays) {
|
||||
let mapped = match self.ams_slot_override {
|
||||
Some(slot) => super::map_to_slot(&filaments, &trays, i32::from(slot)),
|
||||
None => super::map_to_trays(&filaments, &trays),
|
||||
};
|
||||
// An explicit slot that cannot be honoured is an error, never a
|
||||
// fallback to some other spool.
|
||||
if self.ams_slot_override.is_some() {
|
||||
if let Err(err) = &mapped {
|
||||
return Err(PrinterError(format!("{}: {err}", self.base.name)));
|
||||
}
|
||||
}
|
||||
match mapped {
|
||||
Ok(mapping) => {
|
||||
tracing::info!(
|
||||
printer = %self.base.name,
|
||||
@@ -934,6 +978,21 @@ impl BambuPrinter {
|
||||
.map_err(|err| PrinterError(format!("{}: FTPS download failed: {err}", self.base.name)))
|
||||
}
|
||||
|
||||
/// Everything needed to fetch one camera frame, owned, so the caller can
|
||||
/// drop the fleet lock before the network round trip.
|
||||
pub fn snapshot_job(&self) -> Result<SnapshotJob, PrinterError> {
|
||||
let connector = self
|
||||
.tls
|
||||
.build_connector()
|
||||
.map_err(|err| PrinterError(format!("{}: TLS setup failed: {err}", self.base.name)))?;
|
||||
Ok(SnapshotJob {
|
||||
name: self.base.name.clone(),
|
||||
host: self.base.host.clone(),
|
||||
access_code: self.access_code.clone(),
|
||||
connector,
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes one file from the printer's SD card.
|
||||
///
|
||||
/// Takes one explicit path, because the convenient version of this is
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! The camera on a Bambu P1 or A1, which is not RTSP.
|
||||
//!
|
||||
//! Those models publish no `rtsp_url` and refuse port 322 (confirmed on a
|
||||
//! real P1P, 2026-10-15). What they have instead is a proprietary stream on
|
||||
//! TCP 6000: TLS, a single 80-byte login packet, then a continuous run of
|
||||
//! JPEG frames, each behind a 16-byte header.
|
||||
//!
|
||||
//! This reads **one frame** and hangs up. A browser cannot play that stream
|
||||
//! (it is MJPEG over a custom framing, and go2rtc cannot turn it into WebRTC
|
||||
//! without ffmpeg, which a gateway does not have), so the console shows a
|
||||
//! refreshing picture instead and a one-shot read is all it needs.
|
||||
//!
|
||||
//! Same blocking-TLS shape as `bambu_ftps`: the caller hands in the printer's
|
||||
//! own `native_tls` connector, so every trust decision stays in
|
||||
//! `BambuTls::build_connector`.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const PORT: u16 = 6000;
|
||||
|
||||
/// Same username as MQTT and FTPS.
|
||||
const USER: &str = "bblp";
|
||||
|
||||
/// A frame this large is a corrupt header, not a picture. Real frames from a
|
||||
/// P1P are tens of kilobytes.
|
||||
const MAX_FRAME: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// The 80-byte login: four little-endian u32s (0x40, 0x3000, 0, 0), then the
|
||||
/// username and the access code, each zero-padded to 32 bytes.
|
||||
fn auth_packet(access_code: &str) -> [u8; 80] {
|
||||
let mut packet = [0u8; 80];
|
||||
packet[0..4].copy_from_slice(&0x40u32.to_le_bytes());
|
||||
packet[4..8].copy_from_slice(&0x3000u32.to_le_bytes());
|
||||
let user = USER.as_bytes();
|
||||
packet[16..16 + user.len()].copy_from_slice(user);
|
||||
// An access code is 8 characters; truncating anything longer keeps the
|
||||
// packet shape rather than overrunning into nothing.
|
||||
let code = access_code.as_bytes();
|
||||
let n = code.len().min(32);
|
||||
packet[48..48 + n].copy_from_slice(&code[..n]);
|
||||
packet
|
||||
}
|
||||
|
||||
/// Reads one frame: a 16-byte header whose first u32 is the payload length,
|
||||
/// then that many bytes, which must be a JPEG.
|
||||
fn read_frame<R: Read>(stream: &mut R) -> anyhow::Result<Vec<u8>> {
|
||||
let mut header = [0u8; 16];
|
||||
stream
|
||||
.read_exact(&mut header)
|
||||
.map_err(|err| anyhow::anyhow!("the camera sent no frame (wrong access code, or the stream is in use?): {err}"))?;
|
||||
let len = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize;
|
||||
if !(4..=MAX_FRAME).contains(&len) {
|
||||
anyhow::bail!("the camera announced a {len}-byte frame, which is not a picture");
|
||||
}
|
||||
let mut jpeg = vec![0u8; len];
|
||||
stream.read_exact(&mut jpeg)?;
|
||||
if jpeg[..2] != [0xFF, 0xD8] || jpeg[len - 2..] != [0xFF, 0xD9] {
|
||||
anyhow::bail!("the camera's frame is not a complete JPEG");
|
||||
}
|
||||
Ok(jpeg)
|
||||
}
|
||||
|
||||
/// Connects, logs in and returns the first frame. Blocking; call it from
|
||||
/// `spawn_blocking`.
|
||||
pub fn snapshot(
|
||||
host: &str,
|
||||
access_code: &str,
|
||||
connector: native_tls::TlsConnector,
|
||||
timeout: Duration,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let addr = (host, PORT)
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("{host} did not resolve"))?;
|
||||
let tcp = std::net::TcpStream::connect_timeout(&addr, timeout)
|
||||
.map_err(|err| anyhow::anyhow!("{host}:{PORT} did not accept a connection: {err}"))?;
|
||||
tcp.set_read_timeout(Some(timeout))?;
|
||||
tcp.set_write_timeout(Some(timeout))?;
|
||||
let mut tls = connector
|
||||
.connect(host, tcp)
|
||||
.map_err(|err| anyhow::anyhow!("TLS handshake with the camera failed: {err}"))?;
|
||||
tls.write_all(&auth_packet(access_code))?;
|
||||
tls.flush()?;
|
||||
read_frame(&mut tls)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = (payload.len() as u32).to_le_bytes().to_vec();
|
||||
out.extend_from_slice(&[0; 12]);
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_login_packet_has_the_documented_layout() {
|
||||
let p = auth_packet("12345678");
|
||||
assert_eq!(&p[0..8], &[0x40, 0, 0, 0, 0, 0x30, 0, 0]);
|
||||
assert_eq!(&p[8..16], &[0; 8]);
|
||||
assert_eq!(&p[16..20], b"bblp");
|
||||
assert!(p[20..48].iter().all(|b| *b == 0));
|
||||
assert_eq!(&p[48..56], b"12345678");
|
||||
assert!(p[56..].iter().all(|b| *b == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_jpeg_frame_is_returned() {
|
||||
let jpeg = [0xFF, 0xD8, 1, 2, 3, 0xFF, 0xD9];
|
||||
let mut input = frame(&jpeg).into_iter().chain([9, 9, 9]).collect::<Vec<_>>();
|
||||
assert_eq!(read_frame(&mut input.as_slice()).unwrap(), jpeg);
|
||||
input.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonsense_is_refused_not_returned() {
|
||||
// not a JPEG
|
||||
assert!(read_frame(&mut frame(&[1, 2, 3, 4]).as_slice()).is_err());
|
||||
// length beyond the cap
|
||||
let mut huge = (u32::MAX).to_le_bytes().to_vec();
|
||||
huge.extend_from_slice(&[0; 12]);
|
||||
assert!(read_frame(&mut huge.as_slice()).is_err());
|
||||
// truncated
|
||||
let mut cut = frame(&[0xFF, 0xD8, 0, 0xFF, 0xD9]);
|
||||
cut.truncate(cut.len() - 2);
|
||||
assert!(read_frame(&mut cut.as_slice()).is_err());
|
||||
// nothing at all (what a wrong access code looks like)
|
||||
assert!(read_frame(&mut [].as_slice()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,11 @@ impl PrintReport {
|
||||
self.envelope = newer.envelope;
|
||||
self.msg = newer.msg;
|
||||
|
||||
self.ams = newer.ams.or(self.ams.take());
|
||||
// A partial `ams` object (no unit list) must not wipe the units already known.
|
||||
self.ams = match newer.ams {
|
||||
Some(partial) if partial.ams.is_empty() => self.ams.take(),
|
||||
other => other.or(self.ams.take()),
|
||||
};
|
||||
self.ams_exist_bits = newer.ams_exist_bits.or(self.ams_exist_bits.take());
|
||||
self.insert_flag = newer.insert_flag.or(self.insert_flag.take());
|
||||
self.power_on_flag = newer.power_on_flag.or(self.power_on_flag.take());
|
||||
@@ -244,6 +248,10 @@ pub struct LightReport {
|
||||
/// actually sends.
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct AmsState {
|
||||
/// Absent from the partial `ams` objects a P1P pushes mid-print (found on
|
||||
/// the first real dispatch, 2026-10-29: every update was dropped as
|
||||
/// "missing field `ams`" and the job could never complete).
|
||||
#[serde(default)]
|
||||
pub ams: Vec<AmsUnit>,
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ use super::PrinterError;
|
||||
type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>;
|
||||
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long a publish may wait for room in the MQTT request queue.
|
||||
const PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Both rumqttc clients default to a 10KB max packet size in *both*
|
||||
/// directions - fine for a request, nowhere near enough for a reply:
|
||||
/// confirmed live against a real H2C, whose `get_version` reply alone
|
||||
@@ -359,18 +362,38 @@ impl BambuMqttSession {
|
||||
/// `publish` above and `GenericPrinter::publish` (a raw pass-through,
|
||||
/// no envelope opinion at all) are built on.
|
||||
pub async fn publish_raw(&self, payload: String) -> Result<(), PrinterError> {
|
||||
self.publish_with_qos(payload, true).await
|
||||
}
|
||||
|
||||
/// Like `publish`, but QoS 0: for requests that need no acknowledgement
|
||||
/// (a status refresh). QoS 1 publishes the printer never acknowledges
|
||||
/// fill rumqttc's in-flight window, after which every publish blocks
|
||||
/// forever - found on the first real dispatch, where that froze the whole
|
||||
/// proxy about 4.5 minutes in, because callers hold the fleet lock.
|
||||
pub async fn publish_unacked(&self, category: &str, body: &Value) -> Result<(), PrinterError> {
|
||||
self.publish_with_qos(serde_json::json!({ category: body }).to_string(), false).await
|
||||
}
|
||||
|
||||
async fn publish_with_qos(&self, payload: String, acked: bool) -> Result<(), PrinterError> {
|
||||
let topic = format!("device/{}/request", self.sn);
|
||||
|
||||
// Bounded: a publish waits for room in rumqttc's request queue, and an
|
||||
// unbounded wait here wedges every caller that holds the fleet lock.
|
||||
let publishing = async {
|
||||
match &self.client {
|
||||
BambuMqttClient::V1(client) => client
|
||||
.publish(topic, QoS::AtLeastOnce, false, payload)
|
||||
.publish(topic, if acked { QoS::AtLeastOnce } else { QoS::AtMostOnce }, false, payload)
|
||||
.await
|
||||
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
|
||||
BambuMqttClient::V2(client) => client
|
||||
.publish(topic, QoSV5::AtLeastOnce, false, payload)
|
||||
.publish(topic, if acked { QoSV5::AtLeastOnce } else { QoSV5::AtMostOnce }, false, payload)
|
||||
.await
|
||||
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(PUBLISH_TIMEOUT, publishing)
|
||||
.await
|
||||
.map_err(|_| PrinterError(format!("{} publish timed out after {PUBLISH_TIMEOUT:?} - the MQTT queue is not draining", self.sn)))?
|
||||
}
|
||||
|
||||
/// The current in-memory `print.push_status` object, if at least one
|
||||
|
||||
@@ -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 {
|
||||
base: PrinterBase,
|
||||
http: Http,
|
||||
feed: Option<StatusFeed>,
|
||||
}
|
||||
|
||||
impl KlipperPrinter {
|
||||
pub fn new(id: &str, name: &str, host: &str) -> Self {
|
||||
Self {
|
||||
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 {
|
||||
/// OVERRIDE: real version is `POST /printer/print/pause`, `/resume` or
|
||||
/// `/cancel` on Moonraker - note Klipper calls it "cancel" where Bambu
|
||||
/// calls it "stop".
|
||||
async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> {
|
||||
/// Note Klipper calls it "cancel" where Bambu calls it "stop".
|
||||
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> {
|
||||
let endpoint = match action {
|
||||
super::PrintControl::Pause => "pause",
|
||||
super::PrintControl::Resume => "resume",
|
||||
super::PrintControl::Stop => "cancel",
|
||||
PrintControl::Pause => "pause",
|
||||
PrintControl::Resume => "resume",
|
||||
PrintControl::Stop => "cancel",
|
||||
};
|
||||
println!("[{}] POST /printer/print/{endpoint}", self.base.name);
|
||||
Ok(())
|
||||
self.http.post_empty(&format!("/printer/print/{endpoint}")).await.map(|_| ())
|
||||
}
|
||||
|
||||
|
||||
fn base(&self) -> &PrinterBase {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// OVERRIDE: a third, again-different body - Moonraker's real feed is a
|
||||
/// WebSocket (see `adapters::moonraker`). This also shows the `Err`
|
||||
/// path: an empty `host` fails immediately, same as any other vendor
|
||||
/// would if it couldn't reach its printer.
|
||||
/// Checks Moonraker answers, then starts the status poller. Klipper
|
||||
/// itself being down is not a connect failure: Moonraker is up and
|
||||
/// will report it.
|
||||
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)));
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Nothing to publish to: Moonraker is request/response.
|
||||
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
|
||||
// TODO: add publishing via HTTP
|
||||
Ok(())
|
||||
Err(PrinterError("Klipper has no publish channel".into()))
|
||||
}
|
||||
|
||||
/// OVERRIDE: Moonraker's own **hypothetical** native API, not gcode injection - no
|
||||
/// 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`.
|
||||
/// `M106` works on Klipper for the part-cooling fan, same as elsewhere.
|
||||
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);
|
||||
Ok(())
|
||||
self.send_gcode_line(format!("M106 S{}", super::percent_to_pwm(percent))).await
|
||||
}
|
||||
|
||||
/// OVERRIDE: real version is a multipart `POST /server/files/upload`
|
||||
/// with `data` as the file part - Moonraker stores it on the printer's
|
||||
/// virtual SD card, same upload-then-start model as Bambu/PrusaLink.
|
||||
/// Uploads to the printer's `gcodes` root and starts it (`print=true`),
|
||||
/// like Bambu's upload-then-`project_file`. Klipper runs plain G-code,
|
||||
/// 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> {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
println!("[{}] POST /printer/gcode/script?script={gcode_line}", self.base.name);
|
||||
Ok(())
|
||||
self.http.post_json("/printer/gcode/script", &json!({ "script": gcode_line })).await.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
}
|
||||
}
|
||||
+81
-5
@@ -24,26 +24,32 @@
|
||||
//! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's
|
||||
//! protocol, `bambu_ftps.rs` for getting files onto a Bambu printer,
|
||||
//! `marlin_serial.rs` for a real Marlin serial connection - see each
|
||||
//! module's own doc comment. PrusaLink/Klipper's HTTP clients are still
|
||||
//! stubs; that's the next piece to build past this module.
|
||||
//! module's own doc comment. Klipper (`klipper.rs`) and OctoPrint/PrusaLink
|
||||
//! (`octoprint.rs`) are real HTTP clients, tested against mock servers only.
|
||||
|
||||
mod bambu;
|
||||
mod bambu_camera;
|
||||
mod bambu_commands;
|
||||
mod bambu_ftps;
|
||||
mod bambu_mqtt;
|
||||
mod http;
|
||||
mod klipper;
|
||||
mod marlin_serial;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod mock_http;
|
||||
mod octoprint;
|
||||
mod polled;
|
||||
mod prusa;
|
||||
|
||||
// Reading a `.3mf` well enough to know which filament a plate wants. Kept
|
||||
// free of printer types so it is testable without a printer.
|
||||
mod project_file;
|
||||
pub use project_file::{
|
||||
map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament,
|
||||
map_to_slot, map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament,
|
||||
ProjectFileError,
|
||||
};
|
||||
|
||||
pub use bambu::{
|
||||
pub use bambu::{SnapshotJob,
|
||||
BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource,
|
||||
LightState, CHAMBER_LIGHT,
|
||||
};
|
||||
@@ -53,7 +59,54 @@ pub use bambu_commands::{
|
||||
XcamSettings,
|
||||
};
|
||||
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
|
||||
/// mainly so `?` has something concrete to convert into everywhere.
|
||||
@@ -194,6 +247,29 @@ pub enum PrinterHandle {
|
||||
}
|
||||
|
||||
impl PrinterHandle {
|
||||
/// A pending camera-frame read, for printers whose camera is a JPEG
|
||||
/// stream rather than RTSP (Bambu P1/A1).
|
||||
pub fn snapshot_job(&self) -> Result<bambu::SnapshotJob, PrinterError> {
|
||||
match self {
|
||||
PrinterHandle::Bambu(p) => p.snapshot_job(),
|
||||
_ => Err(PrinterError("this printer has no snapshot camera".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,8 @@ pub enum ProjectFileError {
|
||||
/// material or none.
|
||||
#[error("no loaded tray matches {wanted} (filament index {index}); loaded: {loaded}")]
|
||||
NoMatchingTray { index: usize, wanted: String, loaded: String },
|
||||
#[error("cannot use AMS slot {0}: {1}")]
|
||||
BadSlot(i32, String),
|
||||
}
|
||||
|
||||
/// Normalises a colour to six uppercase hex digits.
|
||||
@@ -271,6 +273,36 @@ pub fn map_to_trays(
|
||||
}
|
||||
|
||||
/// Match quality, lower is better; `None` means no match.
|
||||
/// An explicit choice of slot instead of the colour-based pick: the operator
|
||||
/// said which spool to use. Deliberately narrow - a single-filament project
|
||||
/// only (which filament goes where is ambiguous otherwise), onto a slot that
|
||||
/// is loaded and holds the same *material type*, because the type decides
|
||||
/// the temperatures. A different colour is allowed: that is the point.
|
||||
pub fn map_to_slot(
|
||||
filaments: &[ProjectFilament],
|
||||
trays: &[AvailableTray],
|
||||
slot: i32,
|
||||
) -> Result<Vec<i32>, ProjectFileError> {
|
||||
let [filament] = filaments else {
|
||||
return Err(ProjectFileError::BadSlot(
|
||||
slot,
|
||||
format!("the project uses {} filaments; a chosen slot is only for single-filament projects", filaments.len()),
|
||||
));
|
||||
};
|
||||
let tray = trays.iter().find(|t| t.slot == slot).ok_or_else(|| {
|
||||
ProjectFileError::BadSlot(slot, "no filament is loaded in that slot".into())
|
||||
})?;
|
||||
if !tray.filament_type.eq_ignore_ascii_case(&filament.filament_type) {
|
||||
return Err(ProjectFileError::BadSlot(
|
||||
slot,
|
||||
format!("it holds {} but the project was sliced for {}", tray.filament_type, filament.filament_type),
|
||||
));
|
||||
}
|
||||
let mut mapping = vec![-1; filament.index + 1];
|
||||
mapping[filament.index] = slot;
|
||||
Ok(mapping)
|
||||
}
|
||||
|
||||
fn tier(filament: &ProjectFilament, tray: &AvailableTray) -> Option<u8> {
|
||||
let same_product = filament.tray_info_idx.eq_ignore_ascii_case(&tray.tray_info_idx);
|
||||
let same_color = filament.color == tray.color;
|
||||
@@ -288,6 +320,18 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn a_chosen_slot_overrides_the_colour_pick_but_not_the_material() {
|
||||
let f = [filament(0, "GFG99", "PETG", "161616")];
|
||||
let t = [tray(2, "GFG99", "PETG", "161616"), tray(3, "GFG99", "PETG", "2850E0"), tray(1, "GFL99", "PLA", "D3C5A3")];
|
||||
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![2]);
|
||||
assert_eq!(map_to_slot(&f, &t, 3).unwrap(), vec![3]);
|
||||
assert!(map_to_slot(&f, &t, 1).is_err(), "PLA slot for a PETG project");
|
||||
assert!(map_to_slot(&f, &t, 4).is_err(), "empty slot");
|
||||
let two = [filament(0, "GFG99", "PETG", "161616"), filament(1, "GFG99", "PETG", "2850E0")];
|
||||
assert!(map_to_slot(&two, &t, 3).is_err(), "multi-filament project");
|
||||
}
|
||||
|
||||
const REAL: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<header>
|
||||
|
||||
@@ -1,110 +1,4 @@
|
||||
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 ******************/
|
||||
|
||||
pub struct PrusaSerialPrinter {
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Adding and removing printers on a running gateway, on the console's say-so.
|
||||
//!
|
||||
//! The access code arrives in a `provision_printer` message, is checked by
|
||||
//! actually connecting, and is then written to the local store (src/store.rs).
|
||||
//! It is never logged and never sent anywhere. The cloud does not keep it -
|
||||
//! it relayed it - so this is the only place it exists.
|
||||
//!
|
||||
//! Connect first, store second: a printer that cannot be reached with the
|
||||
//! code the operator typed is a typo to report now, not a row to discover
|
||||
//! later in a fleet that quietly never comes up.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::info;
|
||||
|
||||
use crate::fleet;
|
||||
use crate::printer::GenericPrinter;
|
||||
use crate::store::Store;
|
||||
use crate::uplink::SharedFleet;
|
||||
use crate::uplink::protocol::ProvisionPrinter;
|
||||
|
||||
/// Bambu's protocol auto-detection can spend several seconds on a first
|
||||
/// attempt, and an operator is watching a spinner.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Ids end up in MQTT topics, file names (pinned certificates) and log
|
||||
/// lines, so they are held to a small safe alphabet.
|
||||
fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
pub async fn add(fleet: &SharedFleet, store: &Store, request: ProvisionPrinter) -> Result<(), String> {
|
||||
if !valid_id(&request.id) {
|
||||
return Err("printer id must be 1-64 letters, digits, '-' or '_'".into());
|
||||
}
|
||||
if fleet.lock().await.iter().any(|p| p.base().id == request.id) {
|
||||
return Err(format!("this gateway already has a printer with id '{}'", request.id));
|
||||
}
|
||||
|
||||
let entry = fleet::PrinterEntry {
|
||||
id: request.id,
|
||||
name: request.name,
|
||||
vendor: request.vendor,
|
||||
host: request.host,
|
||||
access_code: request.access_code,
|
||||
sn: request.sn,
|
||||
api_key: request.api_key,
|
||||
port: None,
|
||||
baud_rate: 115_200,
|
||||
ca_cert_path: None,
|
||||
require_valid_cert: true,
|
||||
};
|
||||
|
||||
// Built (which checks the required fields are present and non-blank) and
|
||||
// connected before the fleet lock is taken: connecting can take seconds,
|
||||
// and the lock is what telemetry waits on.
|
||||
let mut printer = fleet::build(entry.clone()).map_err(|err| err.to_string())?;
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, printer.connect()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => return Err(format!("could not connect to the printer: {err}")),
|
||||
Err(_) => return Err("the printer did not answer in time - check the address and that it is on".into()),
|
||||
}
|
||||
|
||||
store.save_printer(&entry).map_err(|err| format!("connected, but could not save it locally: {err}"))?;
|
||||
info!(printer = %entry.id, "printer added from the console");
|
||||
|
||||
let mut fleet = fleet.lock().await;
|
||||
// Re-checked under the lock: two requests for one id can both pass the
|
||||
// check above while the first is still connecting.
|
||||
if fleet.iter().any(|p| p.base().id == entry.id) {
|
||||
return Err(format!("this gateway already has a printer with id '{}'", entry.id));
|
||||
}
|
||||
fleet.push(printer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove(fleet: &SharedFleet, store: &Store, printer_id: &str) -> Result<(), String> {
|
||||
let stored = store.remove_printer(printer_id).map_err(|err| err.to_string())?;
|
||||
let mut fleet = fleet.lock().await;
|
||||
let before = fleet.len();
|
||||
fleet.retain(|p| p.base().id != printer_id);
|
||||
if !stored && fleet.len() == before {
|
||||
return Err(format!("no printer with id '{printer_id}' on this gateway"));
|
||||
}
|
||||
info!(printer = %printer_id, "printer removed from the console");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ids_are_a_small_safe_alphabet() {
|
||||
assert!(valid_id("p1p"));
|
||||
assert!(valid_id("Printer_2-b"));
|
||||
assert!(!valid_id(""));
|
||||
assert!(!valid_id("../etc/passwd"));
|
||||
assert!(!valid_id("has space"));
|
||||
assert!(!valid_id(&"a".repeat(65)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod behaviour {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn request(id: &str) -> ProvisionPrinter {
|
||||
ProvisionPrinter {
|
||||
id: id.into(),
|
||||
name: "Bench P1".into(),
|
||||
vendor: "bambu".into(),
|
||||
// Nothing listens here, so a connect attempt is refused at once.
|
||||
host: Some("127.0.0.1".into()),
|
||||
access_code: Some("12345678".into()),
|
||||
sn: Some("01P00A000000001".into()),
|
||||
api_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn store() -> (Store, std::path::PathBuf) {
|
||||
let path = std::env::temp_dir().join(format!("continuum-provision-test-{}-{}.db", std::process::id(), rand_suffix()));
|
||||
(Store::open(&path).unwrap(), path)
|
||||
}
|
||||
|
||||
fn rand_suffix() -> u128 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
||||
}
|
||||
|
||||
fn empty_fleet() -> SharedFleet {
|
||||
Arc::new(tokio::sync::Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bad_id_is_refused_before_anything_happens() {
|
||||
let (store, path) = store();
|
||||
let err = add(&empty_fleet(), &store, request("../x")).await.unwrap_err();
|
||||
assert!(err.contains("printer id"), "{err}");
|
||||
assert!(store.printers().unwrap().is_empty());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_blank_credential_is_named_and_nothing_is_saved() {
|
||||
let (store, path) = store();
|
||||
let mut req = request("p1");
|
||||
req.access_code = Some(" ".into());
|
||||
let err = add(&empty_fleet(), &store, req).await.unwrap_err();
|
||||
assert!(err.contains("access_code"), "{err}");
|
||||
assert!(store.printers().unwrap().is_empty());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_printer_that_cannot_be_reached_is_not_saved() {
|
||||
let (store, path) = store();
|
||||
let fleet = empty_fleet();
|
||||
let err = add(&fleet, &store, request("p1")).await.unwrap_err();
|
||||
assert!(err.contains("could not connect") || err.contains("did not answer"), "{err}");
|
||||
assert!(store.printers().unwrap().is_empty(), "a failed add must not leave a stored printer");
|
||||
assert!(fleet.lock().await.is_empty());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_error_never_contains_the_access_code() {
|
||||
let (store, path) = store();
|
||||
let err = add(&empty_fleet(), &store, request("p1")).await.unwrap_err();
|
||||
assert!(!err.contains("12345678"), "{err}");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_forgets_a_printer_in_both_places() {
|
||||
let (store, path) = store();
|
||||
let entry = crate::fleet::PrinterEntry {
|
||||
id: "p1".into(),
|
||||
name: "Bench".into(),
|
||||
vendor: "bambu".into(),
|
||||
host: Some("127.0.0.1".into()),
|
||||
access_code: Some("12345678".into()),
|
||||
sn: Some("01P00A000000001".into()),
|
||||
api_key: None,
|
||||
port: None,
|
||||
baud_rate: 115_200,
|
||||
ca_cert_path: None,
|
||||
require_valid_cert: true,
|
||||
};
|
||||
store.save_printer(&entry).unwrap();
|
||||
let fleet = empty_fleet();
|
||||
fleet.lock().await.push(crate::fleet::build(entry).unwrap());
|
||||
|
||||
remove(&fleet, &store, "p1").await.unwrap();
|
||||
assert!(fleet.lock().await.is_empty());
|
||||
assert!(store.printers().unwrap().is_empty());
|
||||
|
||||
let err = remove(&fleet, &store, "p1").await.unwrap_err();
|
||||
assert!(err.contains("no printer"), "{err}");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
/// The success path, using the OctoPrint client against a mock server,
|
||||
/// because no real machine is needed to connect to one.
|
||||
/// 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"
|
||||
/// that reopens the store and rebuilds the fleet from it.
|
||||
#[tokio::test]
|
||||
async fn an_added_printer_joins_the_fleet_and_survives_a_restart() {
|
||||
let (store, path) = store();
|
||||
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");
|
||||
req.host = Some(mock.host());
|
||||
req.vendor = "octoprint".into();
|
||||
req.sn = None;
|
||||
req.access_code = None;
|
||||
req.api_key = Some("key-123".into());
|
||||
|
||||
add(&fleet, &store, req.clone()).await.unwrap();
|
||||
assert!(fleet.lock().await.iter().any(|p| p.base().id == "octo1"));
|
||||
|
||||
// Same id again: refused, and the first is untouched.
|
||||
let again = add(&fleet, &store, req).await.unwrap_err();
|
||||
assert!(again.contains("already has"), "{again}");
|
||||
assert_eq!(fleet.lock().await.len(), 1);
|
||||
|
||||
// "Restart": reopen the store, rebuild what main would.
|
||||
drop(store);
|
||||
let reopened = Store::open(&path).unwrap();
|
||||
let rebuilt: Vec<_> = reopened.printers().unwrap().into_iter().map(|e| crate::fleet::build(e).unwrap()).collect();
|
||||
assert_eq!(rebuilt.len(), 1);
|
||||
assert_eq!(rebuilt[0].base().id, "octo1");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wire_message_parses_and_its_debug_hides_the_credential() {
|
||||
let json = r#"{"type":"provision_printer","request_id":"r1","printer":{"id":"p1","name":"Bench","vendor":"bambu","host":"10.0.0.5","access_code":"98765432","sn":"SN1"}}"#;
|
||||
let message: crate::uplink::ServerMessage = serde_json::from_str(json).unwrap();
|
||||
let shown = format!("{message:?}");
|
||||
assert!(shown.contains("p1") && shown.contains("<redacted>"), "{shown}");
|
||||
assert!(!shown.contains("98765432"), "the access code reached a Debug string: {shown}");
|
||||
}
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
//! The gateway's own database: printers an operator added from the console.
|
||||
//!
|
||||
//! Why the gateway keeps these itself and not the cloud: a printer's access
|
||||
//! code is a credential for a machine on someone's LAN, and the platform's
|
||||
//! rule is that those never rest in the cloud. The console sends the code
|
||||
//! once, over the uplink, and this is where it lands. It also means the
|
||||
//! fleet survives the API being unreachable - the gateway starts, connects
|
||||
//! its printers and keeps reporting locally from this alone.
|
||||
//!
|
||||
//! SQLite, one file. The file holds credentials, so on Unix it is created
|
||||
//! owner-only (0600) before SQLite touches it; SQLite gives its journal files
|
||||
//! the same mode as the main one.
|
||||
//!
|
||||
//! `printers.toml` still works and is read alongside this; see `main.rs`.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::Context;
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::fleet::PrinterEntry;
|
||||
|
||||
pub struct Store {
|
||||
// rusqlite's `Connection` is not `Sync`, so it cannot be shared between
|
||||
// tasks directly. Wrapping it in a `Mutex` is the plain fix: one caller
|
||||
// at a time, which is plenty for a few writes a day.
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("creating {dir:?} for the local store"))?;
|
||||
}
|
||||
create_private(path)?;
|
||||
let conn = Connection::open(path).with_context(|| format!("opening the local store at {path:?}"))?;
|
||||
// Each printer is a JSON document keyed by its id. The id is the
|
||||
// gateway's own printer id - the one telemetry is reported under -
|
||||
// so "which printer" has a single answer everywhere.
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS printers (
|
||||
id TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL
|
||||
);
|
||||
-- What happened to jobs the cloud handed this gateway, until the
|
||||
-- cloud has acknowledged it (see `jobs`). Survives a restart.
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
at_ms INTEGER NOT NULL
|
||||
);
|
||||
-- The job each printer is working on, as far as this gateway knows.
|
||||
CREATE TABLE IF NOT EXISTS active_jobs (
|
||||
printer_id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL,
|
||||
since_ms INTEGER NOT NULL,
|
||||
started INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)?;
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
|
||||
/// Adds a printer, or replaces the one with the same id.
|
||||
pub fn save_printer(&self, entry: &PrinterEntry) -> anyhow::Result<()> {
|
||||
let json = serde_json::to_string(entry)?;
|
||||
let conn = self.conn.lock().expect("store lock poisoned");
|
||||
conn.execute(
|
||||
"INSERT INTO printers (id, json) VALUES (?1, ?2)
|
||||
ON CONFLICT(id) DO UPDATE SET json = excluded.json",
|
||||
params![entry.id, json],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forgets a printer. True if there was one.
|
||||
pub fn remove_printer(&self, id: &str) -> anyhow::Result<bool> {
|
||||
let conn = self.conn.lock().expect("store lock poisoned");
|
||||
Ok(conn.execute("DELETE FROM printers WHERE id = ?1", params![id])? > 0)
|
||||
}
|
||||
|
||||
/// Every stored printer, oldest first.
|
||||
pub fn printers(&self) -> anyhow::Result<Vec<PrinterEntry>> {
|
||||
let conn = self.conn.lock().expect("store lock poisoned");
|
||||
let mut statement = conn.prepare("SELECT json FROM printers ORDER BY rowid")?;
|
||||
let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for json in rows {
|
||||
out.push(serde_json::from_str(&json?).context("a stored printer no longer parses")?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// A job outcome waiting for the cloud's acknowledgement.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OutboxEvent {
|
||||
pub id: i64,
|
||||
pub job_id: String,
|
||||
pub event: String,
|
||||
pub detail: Option<String>,
|
||||
pub at_ms: i64,
|
||||
}
|
||||
|
||||
/// A job a printer was handed and has not yet finished with.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ActiveJob {
|
||||
pub printer_id: String,
|
||||
pub job_id: String,
|
||||
pub since_ms: i64,
|
||||
pub started: bool,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Queues an outcome to tell the cloud. Returns its id.
|
||||
pub fn outbox_push(&self, job_id: &str, event: &str, detail: Option<&str>, at_ms: i64) -> anyhow::Result<i64> {
|
||||
let conn = self.conn.lock().expect("store lock poisoned");
|
||||
conn.execute(
|
||||
"INSERT INTO outbox (job_id, event, detail, at_ms) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![job_id, event, detail, at_ms],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Everything the cloud has not acknowledged, oldest first.
|
||||
pub fn outbox_pending(&self) -> anyhow::Result<Vec<OutboxEvent>> {
|
||||
let conn = self.conn.lock().expect("store lock poisoned");
|
||||
let mut statement =
|
||||
conn.prepare("SELECT id, job_id, event, detail, at_ms FROM outbox ORDER BY id LIMIT 500")?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok(OutboxEvent {
|
||||
id: row.get(0)?,
|
||||
job_id: row.get(1)?,
|
||||
event: row.get(2)?,
|
||||
detail: row.get(3)?,
|
||||
at_ms: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<Result<_, _>>()?)
|
||||
}
|
||||
|
||||
pub fn outbox_ack(&self, id: i64) -> anyhow::Result<()> {
|
||||
let conn = self.conn.lock().expect("store lock poisoned");
|
||||
conn.execute("DELETE FROM outbox WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Records that `printer_id` was handed `job_id`, replacing any earlier job.
|
||||
pub fn active_job_set(&self, printer_id: &str, job_id: &str, now_ms: i64) -> anyhow::Result<()> {
|
||||
let conn = self.conn.lock().expect("store lock poisoned");
|
||||
conn.execute(
|
||||
"INSERT INTO active_jobs (printer_id, job_id, since_ms, started) VALUES (?1, ?2, ?3, 0)
|
||||
ON CONFLICT(printer_id) DO UPDATE SET job_id = excluded.job_id, since_ms = excluded.since_ms, started = 0",
|
||||
params![printer_id, job_id, now_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn active_job_started(&self, printer_id: &str) -> anyhow::Result<()> {
|
||||
let conn = self.conn.lock().expect("store lock poisoned");
|
||||
conn.execute("UPDATE active_jobs SET started = 1 WHERE printer_id = ?1", params![printer_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn active_job_clear(&self, printer_id: &str) -> anyhow::Result<()> {
|
||||
let conn = self.conn.lock().expect("store lock poisoned");
|
||||
conn.execute("DELETE FROM active_jobs WHERE printer_id = ?1", params![printer_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn active_jobs(&self) -> anyhow::Result<Vec<ActiveJob>> {
|
||||
let conn = self.conn.lock().expect("store lock poisoned");
|
||||
let mut statement = conn.prepare("SELECT printer_id, job_id, since_ms, started FROM active_jobs")?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok(ActiveJob {
|
||||
printer_id: row.get(0)?,
|
||||
job_id: row.get(1)?,
|
||||
since_ms: row.get(2)?,
|
||||
started: row.get::<_, i64>(3)? != 0,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<Result<_, _>>()?)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the file empty and owner-only if it does not exist yet.
|
||||
#[cfg(unix)]
|
||||
fn create_private(path: &Path) -> anyhow::Result<()> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("creating the local store {path:?}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Windows: the file inherits its folder's permissions, which for the
|
||||
/// per-user data directory is already that user's. Nothing to set here.
|
||||
#[cfg(not(unix))]
|
||||
fn create_private(_path: &Path) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(id: &str, access_code: &str) -> PrinterEntry {
|
||||
PrinterEntry {
|
||||
id: id.into(),
|
||||
name: format!("Printer {id}"),
|
||||
vendor: "bambu".into(),
|
||||
host: Some("192.168.0.50".into()),
|
||||
access_code: Some(access_code.into()),
|
||||
sn: Some("01P00A000000000".into()),
|
||||
api_key: None,
|
||||
port: None,
|
||||
baud_rate: 115_200,
|
||||
ca_cert_path: None,
|
||||
require_valid_cert: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// A throwaway directory, removed when dropped. Hand-rolled rather than
|
||||
/// pulling in a crate for four tests.
|
||||
struct TempDir(std::path::PathBuf);
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_db() -> (TempDir, std::path::PathBuf) {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"continuum-store-test-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let path = dir.join("nested").join("continuum.db");
|
||||
(TempDir(dir), path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_was_saved_comes_back_after_reopening() {
|
||||
let (_dir, path) = temp_db();
|
||||
Store::open(&path).unwrap().save_printer(&entry("a", "11111111")).unwrap();
|
||||
|
||||
let reopened = Store::open(&path).unwrap();
|
||||
let printers = reopened.printers().unwrap();
|
||||
assert_eq!(printers.len(), 1);
|
||||
assert_eq!(printers[0].id, "a");
|
||||
assert_eq!(printers[0].access_code.as_deref(), Some("11111111"));
|
||||
assert_eq!(printers[0].sn.as_deref(), Some("01P00A000000000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_the_same_id_replaces_it_and_order_is_oldest_first() {
|
||||
let (_dir, path) = temp_db();
|
||||
let store = Store::open(&path).unwrap();
|
||||
store.save_printer(&entry("a", "old")).unwrap();
|
||||
store.save_printer(&entry("b", "bbb")).unwrap();
|
||||
store.save_printer(&entry("a", "new")).unwrap();
|
||||
|
||||
let printers = store.printers().unwrap();
|
||||
let ids: Vec<_> = printers.iter().map(|p| p.id.as_str()).collect();
|
||||
assert_eq!(ids, ["a", "b"]);
|
||||
assert_eq!(printers[0].access_code.as_deref(), Some("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_reports_whether_anything_was_there() {
|
||||
let (_dir, path) = temp_db();
|
||||
let store = Store::open(&path).unwrap();
|
||||
store.save_printer(&entry("a", "x")).unwrap();
|
||||
assert!(store.remove_printer("a").unwrap());
|
||||
assert!(!store.remove_printer("a").unwrap());
|
||||
assert!(store.printers().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_file_holding_credentials_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let (_dir, path) = temp_db();
|
||||
let store = Store::open(&path).unwrap();
|
||||
store.save_printer(&entry("a", "secret")).unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_file_is_an_error_not_an_empty_fleet() {
|
||||
let (_dir, path) = temp_db();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, b"this is not a sqlite database at all, just text").unwrap();
|
||||
assert!(Store::open(&path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unacknowledged_event_survives_a_restart_and_an_acknowledged_one_does_not() {
|
||||
let (_dir, path) = temp_db();
|
||||
let store = Store::open(&path).unwrap();
|
||||
let first = store.outbox_push("job-1", "finished", None, 10).unwrap();
|
||||
let second = store.outbox_push("job-2", "failed", Some("download failed"), 20).unwrap();
|
||||
store.outbox_ack(first).unwrap();
|
||||
drop(store);
|
||||
|
||||
let reopened = Store::open(&path).unwrap();
|
||||
let pending = reopened.outbox_pending().unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].id, second);
|
||||
assert_eq!(pending[0].detail.as_deref(), Some("download failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_printer_has_one_active_job_and_a_new_one_replaces_it() {
|
||||
let (_dir, path) = temp_db();
|
||||
let store = Store::open(&path).unwrap();
|
||||
store.active_job_set("p1", "job-1", 100).unwrap();
|
||||
store.active_job_started("p1").unwrap();
|
||||
assert!(store.active_jobs().unwrap()[0].started);
|
||||
|
||||
store.active_job_set("p1", "job-2", 200).unwrap();
|
||||
let jobs = store.active_jobs().unwrap();
|
||||
assert_eq!(jobs, vec![ActiveJob { printer_id: "p1".into(), job_id: "job-2".into(), since_ms: 200, started: false }]);
|
||||
|
||||
store.active_job_clear("p1").unwrap();
|
||||
assert!(store.active_jobs().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Getting from the gateway's long-lived credential to the short-lived one
|
||||
//! the socket wants.
|
||||
//!
|
||||
//! The cloud hands a farm's owner an *edge proxy token*, which is what goes in
|
||||
//! this gateway's settings as `gateway_token`. It does not open the socket
|
||||
//! directly: the upgrade wants an *edge JWT*, which the cloud signs for an
|
||||
//! hour in exchange for the proxy token (`POST /auth/edge-token`). So every
|
||||
//! connection attempt starts by trading, which is also why a gateway that
|
||||
//! has been up for days is not locked out when the first JWT expires - each
|
||||
//! reconnect gets a fresh one.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const SOCKET_PATH: &str = "/ws/edge/v1";
|
||||
|
||||
/// Where to trade the token, derived from the socket URL so there is one
|
||||
/// address to configure, not two that can disagree.
|
||||
pub fn exchange_url(uplink_url: &str) -> anyhow::Result<String> {
|
||||
let (scheme, rest) = if let Some(rest) = uplink_url.strip_prefix("wss://") {
|
||||
("https://", rest)
|
||||
} else if let Some(rest) = uplink_url.strip_prefix("ws://") {
|
||||
("http://", rest)
|
||||
} else {
|
||||
anyhow::bail!("the uplink URL must start with ws:// or wss://: {uplink_url}");
|
||||
};
|
||||
let base = rest
|
||||
.strip_suffix(SOCKET_PATH)
|
||||
.with_context(|| format!("the uplink URL must end with {SOCKET_PATH}: {uplink_url}"))?;
|
||||
Ok(format!("{scheme}{base}/auth/edge-token"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Exchanged {
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// Trades `gateway_token` for an edge JWT.
|
||||
///
|
||||
/// A 401 is reported as what it means - the cloud does not accept this
|
||||
/// farm/token pair - because retrying will not fix it, and the usual cause is
|
||||
/// a token that was rotated in the console while this gateway kept the old
|
||||
/// one.
|
||||
pub async fn fetch_edge_token(config: &Config) -> anyhow::Result<String> {
|
||||
let url = exchange_url(&config.uplink_url)?;
|
||||
let response = reqwest::Client::builder()
|
||||
.timeout(EXCHANGE_TIMEOUT)
|
||||
.build()?
|
||||
.post(&url)
|
||||
// Built by hand: the `json` feature of reqwest is off on purpose (see
|
||||
// Cargo.toml), and this is one small body.
|
||||
.header("Content-Type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"farmId": config.farm_id,
|
||||
"edgeProxyToken": config.gateway_token,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("could not reach {url} to get an edge token"))?;
|
||||
|
||||
match response.status() {
|
||||
status if status.is_success() => {
|
||||
let body = response.bytes().await?;
|
||||
Ok(serde_json::from_slice::<Exchanged>(&body).context("the cloud's edge token reply was not understood")?.token)
|
||||
}
|
||||
reqwest::StatusCode::UNAUTHORIZED => {
|
||||
anyhow::bail!("the cloud does not accept this farm_id / gateway_token - was the token rotated in the console?")
|
||||
}
|
||||
status => anyhow::bail!("the cloud answered {status} when asked for an edge token"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_exchange_address_follows_the_socket_address() {
|
||||
assert_eq!(
|
||||
exchange_url("wss://api.continuum.local/ws/edge/v1").unwrap(),
|
||||
"https://api.continuum.local/auth/edge-token"
|
||||
);
|
||||
assert_eq!(
|
||||
exchange_url("ws://localhost:3040/ws/edge/v1").unwrap(),
|
||||
"http://localhost:3040/auth/edge-token"
|
||||
);
|
||||
// A prefix in front of the API survives.
|
||||
assert_eq!(
|
||||
exchange_url("wss://example.com/api/ws/edge/v1").unwrap(),
|
||||
"https://example.com/api/auth/edge-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_it_cannot_follow_is_an_error_not_a_guess() {
|
||||
assert!(exchange_url("https://api/ws/edge/v1").is_err());
|
||||
assert!(exchange_url("wss://api/somewhere/else").is_err());
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -26,6 +26,26 @@ pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
/// up the whole budget above.
|
||||
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Dev-only escape hatch for a local object store (MinIO) that speaks plain
|
||||
/// HTTP: needs `CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true` AND a loopback
|
||||
/// or private-network (RFC 1918) address literal, so a presigned URL can never
|
||||
/// cross a public network in the clear.
|
||||
fn insecure_local_allowed(url: &reqwest::Url) -> bool {
|
||||
use std::net::IpAddr;
|
||||
if url.scheme() != "http"
|
||||
|| !std::env::var("CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS").is_ok_and(|v| v == "true")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(ip) = url.host_str().and_then(|h| h.trim_matches(['[', ']']).parse::<IpAddr>().ok()) else {
|
||||
return false;
|
||||
};
|
||||
match ip {
|
||||
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private(),
|
||||
IpAddr::V6(v6) => v6.is_loopback(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads `url` fully into memory, or says why not.
|
||||
///
|
||||
/// The error is a `String` naming the failure; the caller prefixes the stage.
|
||||
@@ -39,7 +59,7 @@ pub async fn fetch_job_file_capped(url: &str, max_bytes: u64) -> Result<Vec<u8>,
|
||||
let parsed = reqwest::Url::parse(url).map_err(|err| format!("not a valid URL: {err}"))?;
|
||||
// A presigned URL carries its authority in the query string; sending it
|
||||
// over plain HTTP would expose it to anyone on the path.
|
||||
if parsed.scheme() != "https" {
|
||||
if parsed.scheme() != "https" && !insecure_local_allowed(&parsed) {
|
||||
return Err(format!("refusing non-HTTPS URL (scheme '{}')", parsed.scheme()));
|
||||
}
|
||||
|
||||
|
||||
+215
-25
@@ -26,12 +26,14 @@
|
||||
//! readings for no consumer. See PLAN.md for what would actually be worth
|
||||
//! buffering.
|
||||
|
||||
mod auth;
|
||||
mod download;
|
||||
mod webrtc;
|
||||
mod protocol;
|
||||
pub mod protocol;
|
||||
|
||||
pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage};
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -42,6 +44,9 @@ use tokio_tungstenite::tungstenite::handshake::client::{Request, generate_key};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::provision;
|
||||
use crate::store::Store;
|
||||
use protocol::ProvisionPrinter;
|
||||
use crate::printer::{GenericPrinter, PrinterHandle};
|
||||
|
||||
/// The websocket, once connected. Named because `connect` has to hand it to
|
||||
@@ -78,6 +83,8 @@ const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
|
||||
/// a lot of traffic to describe a machine whose state changes on the scale of
|
||||
/// a layer, so this samples the cached report instead of chasing every push.
|
||||
const TELEMETRY_INTERVAL: Duration = Duration::from_secs(5);
|
||||
/// How often the job-event outbox is looked at for anything new to send.
|
||||
const OUTBOX_INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// The printer fleet, shared between `main` (which owns it and keeps every
|
||||
/// connection alive) and this module (which reads status and dispatches
|
||||
@@ -139,6 +146,7 @@ pub fn channel() -> (
|
||||
pub async fn run(
|
||||
config: Config,
|
||||
fleet: SharedFleet,
|
||||
store: Option<Arc<Store>>,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
mut inbox: tokio::sync::mpsc::Receiver<ClientMessage>,
|
||||
) {
|
||||
@@ -156,7 +164,7 @@ pub async fn run(
|
||||
// would not distinguish a connection that lived for an hour
|
||||
// from one that failed to open.
|
||||
delay = RETRY_DELAY;
|
||||
if let Err(err) = serve(ws_stream, &config, &fleet, &outbound, &mut inbox).await {
|
||||
if let Err(err) = serve(ws_stream, &config, &fleet, store.as_ref(), &outbound, &mut inbox).await {
|
||||
warn!(?err, "uplink connection dropped, reconnecting in {delay:?}");
|
||||
}
|
||||
}
|
||||
@@ -172,6 +180,10 @@ pub async fn run(
|
||||
/// handshake from a connection that opened and later dropped; they want
|
||||
/// different retry behaviour.
|
||||
async fn connect(config: &Config) -> anyhow::Result<WsStream> {
|
||||
// A fresh edge JWT per attempt: it lives an hour, so one fetched at
|
||||
// startup would lock a long-running gateway out on its first reconnect.
|
||||
let edge_token = auth::fetch_edge_token(config).await?;
|
||||
|
||||
// The edge token goes on the upgrade request, not in the first message.
|
||||
// The backend verifies it before the socket exists, so a bad token is an
|
||||
// HTTP 401 here rather than a connection that opens and is then closed -
|
||||
@@ -180,7 +192,7 @@ async fn connect(config: &Config) -> anyhow::Result<WsStream> {
|
||||
// those tools already know to redact.
|
||||
let request = Request::builder()
|
||||
.uri(&config.uplink_url)
|
||||
.header("Authorization", format!("Bearer {}", config.gateway_token))
|
||||
.header("Authorization", format!("Bearer {edge_token}"))
|
||||
// tokio-tungstenite fills these in for a plain URL, but not once we
|
||||
// hand it a request we built ourselves - and a handshake missing any
|
||||
// of them is rejected by the server rather than ignored.
|
||||
@@ -200,6 +212,7 @@ async fn serve(
|
||||
ws_stream: WsStream,
|
||||
config: &Config,
|
||||
fleet: &SharedFleet,
|
||||
store: Option<&Arc<Store>>,
|
||||
outbound: &tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
inbox: &mut tokio::sync::mpsc::Receiver<ClientMessage>,
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -215,9 +228,34 @@ async fn serve(
|
||||
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
|
||||
let mut telemetry = tokio::time::interval(TELEMETRY_INTERVAL);
|
||||
let mut seq: u64 = 0;
|
||||
// Outbox events already written to THIS socket. Per connection on
|
||||
// purpose: after a reconnect everything the cloud has not acknowledged
|
||||
// goes out again, which is the point of keeping them.
|
||||
let mut outbox_tick = tokio::time::interval(OUTBOX_INTERVAL);
|
||||
let mut sent: HashSet<i64> = HashSet::new();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = outbox_tick.tick() => {
|
||||
let Some(store) = store else { continue };
|
||||
let pending = match store.outbox_pending() {
|
||||
Ok(pending) => pending,
|
||||
Err(err) => { warn!(%err, "could not read the job-event outbox"); continue }
|
||||
};
|
||||
let unsent: Vec<_> = pending.into_iter().filter(|e| !sent.contains(&e.id)).collect();
|
||||
for event in unsent {
|
||||
let msg = ClientMessage::JobEvent {
|
||||
event_id: event.id,
|
||||
job_id: event.job_id,
|
||||
event: event.event,
|
||||
detail: event.detail,
|
||||
at_ms: event.at_ms,
|
||||
};
|
||||
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
|
||||
sent.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
_ = heartbeat.tick() => {
|
||||
seq += 1;
|
||||
let msg = ClientMessage::Heartbeat { seq };
|
||||
@@ -265,6 +303,7 @@ async fn serve(
|
||||
command,
|
||||
command_id.clone(),
|
||||
outbound.clone(),
|
||||
store.cloned(),
|
||||
);
|
||||
ClientMessage::CommandResult {
|
||||
command_id, ok: true, error: None,
|
||||
@@ -306,13 +345,39 @@ async fn serve(
|
||||
outbound.clone(),
|
||||
);
|
||||
}
|
||||
// Spawned: a connect, a TLS handshake and a frame
|
||||
// from the printer take seconds, and awaiting that
|
||||
// here would stall heartbeats and telemetry.
|
||||
Ok(ServerMessage::CameraSnapshotRequest { request_id, printer_id }) => {
|
||||
spawn_snapshot(fleet.clone(), request_id, printer_id, outbound.clone());
|
||||
}
|
||||
// Spawned: connecting to the new printer can take
|
||||
// many seconds, and awaiting it here would stall
|
||||
// heartbeats and telemetry for the whole farm.
|
||||
Ok(ServerMessage::ProvisionPrinter { request_id, printer }) => {
|
||||
spawn_provision(fleet.clone(), store.cloned(), request_id, Some(printer), outbound.clone());
|
||||
}
|
||||
Ok(ServerMessage::JobEventAck { event_id }) => {
|
||||
sent.remove(&event_id);
|
||||
if let Some(store) = store
|
||||
&& let Err(err) = store.outbox_ack(event_id)
|
||||
{
|
||||
warn!(%err, event_id, "could not clear an acknowledged job event");
|
||||
}
|
||||
}
|
||||
Ok(ServerMessage::RemovePrinter { request_id, printer_id }) => {
|
||||
spawn_remove(fleet.clone(), store.cloned(), request_id, printer_id, outbound.clone());
|
||||
}
|
||||
Ok(other) => info!(?other, "received from cloud"),
|
||||
// Loudly. This used to be an `if let Ok(..)` with
|
||||
// no else, which is the same silent-drop bug that
|
||||
// hid a real `print.push_status` failure for
|
||||
// weeks - and here it would mean commands
|
||||
// vanishing with the cloud never being told.
|
||||
Err(err) => warn!(%err, payload = %text, "unparseable message from cloud, ignoring"),
|
||||
// The payload is NOT logged. A `provision_printer` message
|
||||
// carries an access code, and one that failed to parse is
|
||||
// exactly the message that would end up here.
|
||||
Err(err) => warn!(%err, bytes = text.len(), "unparseable message from cloud, ignoring"),
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => return Ok(()),
|
||||
@@ -346,29 +411,28 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
|
||||
|
||||
for printer in fleet.iter() {
|
||||
let base = printer.base();
|
||||
let Some(report) = printer.print_status().await else {
|
||||
let Some(status) = printer.status().await else {
|
||||
continue;
|
||||
};
|
||||
// Only Bambu has a camera report to derive these from.
|
||||
let report = printer.print_status().await;
|
||||
|
||||
out.push(PrinterTelemetry {
|
||||
printer_id: base.id.clone(),
|
||||
name: base.name.clone(),
|
||||
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()),
|
||||
state: status.state,
|
||||
percent: status.percent,
|
||||
stage: status.stage,
|
||||
layer: status.layer,
|
||||
total_layers: status.total_layers,
|
||||
remaining_minutes: status.remaining_minutes,
|
||||
job_name: status.job_name,
|
||||
nozzle_temp: status.nozzle_temp,
|
||||
nozzle_target: status.nozzle_target,
|
||||
bed_temp: status.bed_temp,
|
||||
bed_target: status.bed_target,
|
||||
print_error: status.print_error,
|
||||
active_alerts: status.active_alerts,
|
||||
// Derived from the same reported URL `camera::sources` builds
|
||||
// the go2rtc config from, so a printer is advertised as
|
||||
// streamable here only if it is also actually being served
|
||||
@@ -376,9 +440,15 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
|
||||
// the rule means the two cannot drift apart.
|
||||
camera_stream: crate::camera::source_for(
|
||||
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),
|
||||
camera_snapshot: crate::camera::wants_snapshot(
|
||||
printer,
|
||||
report.as_ref().is_some_and(|r| r.ipcam.is_some()),
|
||||
report.as_ref().and_then(|r| r.ipcam.as_ref()).and_then(|c| c.rtsp_url.as_deref()),
|
||||
)
|
||||
.then_some(true),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -427,10 +497,31 @@ fn spawn_long_command(
|
||||
command: PrinterCommand,
|
||||
command_id: String,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
store: Option<Arc<Store>>,
|
||||
) {
|
||||
// The job is followed from here on, so its outcome is reported even if
|
||||
// the cloud is unreachable when it happens (see `jobs`).
|
||||
let job_id = match (&command, &store) {
|
||||
(PrinterCommand::StartPrint { job_id, .. }, Some(store)) => {
|
||||
crate::jobs::accepted(store, &printer_id, job_id);
|
||||
Some(job_id.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
match dispatch(&fleet, &printer_id, command).await {
|
||||
Ok(()) => info!(%printer_id, "long-running command finished"),
|
||||
Err(err) if job_id.is_some() => {
|
||||
// Still logged locally; the cloud hears of it from the
|
||||
// outbox, which survives it being down right now.
|
||||
tracing::error!(
|
||||
%printer_id, %err,
|
||||
"long-running command failed after the cloud was already told it was accepted"
|
||||
);
|
||||
if let (Some(store), Some(job_id)) = (&store, &job_id) {
|
||||
crate::jobs::start_failed(store, &printer_id, job_id, &err);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Still logged locally, and still at error level: the queue
|
||||
// only reaches a cloud that is up, and this is the copy that
|
||||
@@ -456,6 +547,54 @@ fn spawn_long_command(
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds a printer off the connection's task and always reports how it went:
|
||||
/// the console is holding a form open waiting for exactly this.
|
||||
fn spawn_provision(
|
||||
fleet: SharedFleet,
|
||||
store: Option<Arc<Store>>,
|
||||
request_id: String,
|
||||
request: Option<ProvisionPrinter>,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let result = match (store, request) {
|
||||
(Some(store), Some(request)) => provision::add(&fleet, &store, request).await,
|
||||
(None, _) => Err("this gateway has no local store, so it cannot add printers".to_string()),
|
||||
(_, None) => Err("no printer in the request".to_string()),
|
||||
};
|
||||
report(request_id, result, &outbound).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_remove(
|
||||
fleet: SharedFleet,
|
||||
store: Option<Arc<Store>>,
|
||||
request_id: String,
|
||||
printer_id: String,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let result = match store {
|
||||
Some(store) => provision::remove(&fleet, &store, &printer_id).await,
|
||||
None => Err("this gateway has no local store".to_string()),
|
||||
};
|
||||
report(request_id, result, &outbound).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn report(request_id: String, result: Result<(), String>, outbound: &tokio::sync::mpsc::Sender<ClientMessage>) {
|
||||
let msg = match result {
|
||||
Ok(()) => ClientMessage::CommandResult { command_id: request_id, ok: true, error: None },
|
||||
Err(error) => {
|
||||
warn!(%error, "printer request failed");
|
||||
ClientMessage::CommandResult { command_id: request_id, ok: false, error: Some(error) }
|
||||
}
|
||||
};
|
||||
if outbound.send(msg).await.is_err() {
|
||||
warn!("uplink is shutting down; the result was not reported");
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers one WebRTC offer, off this connection's task.
|
||||
///
|
||||
/// Always answers, success or failure: the cloud is holding a browser's
|
||||
@@ -485,6 +624,48 @@ fn spawn_webrtc_negotiation(
|
||||
});
|
||||
}
|
||||
|
||||
/// Reads one camera frame off this connection's task and reports it.
|
||||
///
|
||||
/// The fleet lock is held only to copy out what the read needs (the access
|
||||
/// code never leaves this process in the message), never across the network.
|
||||
fn spawn_snapshot(
|
||||
fleet: SharedFleet,
|
||||
request_id: String,
|
||||
printer_id: String,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let job = {
|
||||
let fleet = fleet.lock().await;
|
||||
match fleet.iter().find(|p| p.base().id == printer_id) {
|
||||
Some(printer) => printer.snapshot_job().map_err(|err| err.to_string()),
|
||||
None => Err(format!("no printer '{printer_id}' in this gateway's fleet")),
|
||||
}
|
||||
};
|
||||
let result = match job {
|
||||
Ok(job) => job.run().await.map_err(|err| err.to_string()),
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
let reply = match result {
|
||||
Ok(jpeg) => {
|
||||
use base64::Engine;
|
||||
ClientMessage::CameraSnapshot {
|
||||
request_id,
|
||||
jpeg_base64: Some(base64::engine::general_purpose::STANDARD.encode(jpeg)),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(%printer_id, %err, "could not read a camera frame");
|
||||
ClientMessage::CameraSnapshot { request_id, jpeg_base64: None, error: Some(err) }
|
||||
}
|
||||
};
|
||||
if outbound.send(reply).await.is_err() {
|
||||
warn!(%printer_id, "uplink is shutting down; the viewer gets no picture");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Finds the printer by our own id and runs the command. The error is a
|
||||
/// `String` rather than a typed error because its only destination is a
|
||||
/// `CommandResult` going back to the cloud as text.
|
||||
@@ -528,7 +709,7 @@ async fn dispatch(fleet: &SharedFleet, printer_id: &str, command: PrinterCommand
|
||||
PrinterCommand::ControlPrint { control } => {
|
||||
printer.control_print(control).await.map_err(|err| err.to_string())
|
||||
}
|
||||
PrinterCommand::StartPrint { job_id, file_name, .. } => {
|
||||
PrinterCommand::StartPrint { job_id, file_name, ams_slot, .. } => {
|
||||
let bytes = downloaded.ok_or_else(|| "internal error: start_print reached dispatch with no downloaded file".to_string())?;
|
||||
let byte_count = bytes.len();
|
||||
info!(%printer_id, %job_id, %file_name, byte_count, "job file downloaded, sending to printer");
|
||||
@@ -537,10 +718,19 @@ async fn dispatch(fleet: &SharedFleet, printer_id: &str, command: PrinterCommand
|
||||
// all as one error - so the stage here is "printer", not finer.
|
||||
// The printer's own message says which part (mapping, upload of
|
||||
// the file, or start) it was.
|
||||
printer
|
||||
if let PrinterHandle::Bambu(bambu) = &mut *printer {
|
||||
bambu.set_ams_slot_override(ams_slot);
|
||||
} else if ams_slot.is_some() {
|
||||
return Err("this printer cannot be told which AMS slot to use".into());
|
||||
}
|
||||
let result = printer
|
||||
.upload_project_file(file_name, bytes)
|
||||
.await
|
||||
.map_err(|err| format!("printer upload/start failed (download of {byte_count} bytes succeeded): {err}"))
|
||||
.map_err(|err| format!("printer upload/start failed (download of {byte_count} bytes succeeded): {err}"));
|
||||
if let PrinterHandle::Bambu(bambu) = &mut *printer {
|
||||
bambu.set_ams_slot_override(None);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+78
-1
@@ -32,6 +32,17 @@ pub enum ClientMessage {
|
||||
stream_base_url: Option<String>,
|
||||
},
|
||||
Heartbeat { seq: u64 },
|
||||
/// What happened to a job the cloud handed this gateway. Kept in the
|
||||
/// store and re-sent on every connection until a `JobEventAck`.
|
||||
JobEvent {
|
||||
event_id: i64,
|
||||
job_id: String,
|
||||
/// `started`, `finished` or `failed`.
|
||||
event: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
at_ms: i64,
|
||||
},
|
||||
/// One snapshot per printer, on a timer. Sent as a batch rather than a
|
||||
/// message per printer: a farm has one uplink and many printers, and the
|
||||
/// cloud wants a coherent picture of the farm at an instant more than it
|
||||
@@ -56,6 +67,16 @@ pub enum ClientMessage {
|
||||
/// waiting on this and a viewer otherwise watches a video element that
|
||||
/// never loads with nothing anywhere saying why.
|
||||
WebrtcError { session_id: String, message: String },
|
||||
/// The answer to a `CameraSnapshotRequest`: one JPEG frame, base64, or
|
||||
/// the reason there is none. Always sent, success or failure - the cloud
|
||||
/// is holding a viewer's request open for it.
|
||||
CameraSnapshot {
|
||||
request_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
jpeg_base64: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
/// Printers found on this gateway's own network and USB ports that are
|
||||
/// *not* in its fleet file, so the console can offer them instead of
|
||||
/// making an operator walk to a machine and read its IP address off the
|
||||
@@ -191,6 +212,11 @@ pub struct PrinterTelemetry {
|
||||
/// `crate::camera`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub camera_stream: Option<String>,
|
||||
/// True when the camera can only be read as single JPEG frames (a P1/A1)
|
||||
/// and this gateway will answer a `CameraSnapshotRequest` for it. See
|
||||
/// `crate::camera::wants_snapshot`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub camera_snapshot: Option<bool>,
|
||||
}
|
||||
|
||||
/// What `continuum-backend` sends back over the same connection.
|
||||
@@ -198,6 +224,8 @@ pub struct PrinterTelemetry {
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerMessage {
|
||||
HelloAck { session_id: String },
|
||||
/// The cloud has dealt with a `JobEvent`; the gateway can forget it.
|
||||
JobEventAck { event_id: i64 },
|
||||
/// The handshake was rejected - a bad, expired or wrong-farm token. The
|
||||
/// backend closes the connection right after sending this.
|
||||
///
|
||||
@@ -225,6 +253,48 @@ pub enum ServerMessage {
|
||||
/// and checks its password before CORS, so the unauthenticated preflight
|
||||
/// a browser always sends is refused - measured, not assumed.
|
||||
WebrtcOffer { session_id: String, camera_stream: String, sdp: String },
|
||||
/// Read one JPEG frame from a P1/A1-style camera and answer with a
|
||||
/// `CameraSnapshot`. `printer_id` is our own id.
|
||||
CameraSnapshotRequest { request_id: String, printer_id: String },
|
||||
/// Add a printer to this gateway. Carries the printer's credential, sent
|
||||
/// once by the console and kept only here - see `provision`. Answered
|
||||
/// with a `CommandResult` carrying the same id.
|
||||
ProvisionPrinter { request_id: String, printer: ProvisionPrinter },
|
||||
/// Forget a printer. Answered like `ProvisionPrinter`.
|
||||
RemovePrinter { request_id: String, printer_id: String },
|
||||
}
|
||||
|
||||
/// What the console may tell a gateway about a printer to add.
|
||||
///
|
||||
/// Deliberately narrower than `fleet::PrinterEntry`: no `ca_cert_path` and no
|
||||
/// serial `port`, because both name something on the gateway's own disk or
|
||||
/// USB bus, and the cloud has no business choosing those.
|
||||
///
|
||||
/// `Debug` is by hand so the credential cannot reach a log line - the
|
||||
/// `Ok(other) => info!(?other, ...)` arm in the uplink formats whole messages.
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct ProvisionPrinter {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub vendor: String,
|
||||
pub host: Option<String>,
|
||||
pub access_code: Option<String>,
|
||||
pub sn: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProvisionPrinter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ProvisionPrinter")
|
||||
.field("id", &self.id)
|
||||
.field("name", &self.name)
|
||||
.field("vendor", &self.vendor)
|
||||
.field("host", &self.host)
|
||||
.field("access_code", &self.access_code.as_ref().map(|_| "<redacted>"))
|
||||
.field("sn", &self.sn)
|
||||
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The closed set of things the cloud is allowed to ask a printer to do.
|
||||
@@ -272,7 +342,14 @@ pub enum PrinterCommand {
|
||||
/// hides it. `file_name` is what the file is called on the printer;
|
||||
/// `.3mf` is treated as a sliced project, anything else as bare gcode.
|
||||
/// `job_id` is only for lining gateway logs up against a cloud job.
|
||||
StartPrint { job_id: String, file_name: String, url: String },
|
||||
StartPrint {
|
||||
job_id: String,
|
||||
file_name: String,
|
||||
url: String,
|
||||
/// Use this AMS slot (0-based, all units) instead of the project's own pick.
|
||||
#[serde(default)]
|
||||
ams_slot: Option<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PrinterCommand {
|
||||
|
||||
@@ -144,3 +144,13 @@ fn merge_keeps_stage_and_spool_when_a_partial_update_omits_them() {
|
||||
assert_eq!(base.stg, Some(vec![54, 14, 13]));
|
||||
assert!(base.vt_tray.is_some());
|
||||
}
|
||||
|
||||
/// A P1P mid-print pushes `ams` objects without the inner unit list. That
|
||||
/// used to fail the whole report ("missing field `ams`"), silencing telemetry
|
||||
/// for the rest of the print - found on the first real dispatch.
|
||||
#[test]
|
||||
fn a_partial_ams_object_parses_and_keeps_known_units() {
|
||||
let partial = serde_json::json!({"command":"push_status","sequence_id":"1","msg":1,"gcode_state":"RUNNING","ams":{"tray_now":"2"}});
|
||||
let report: PrintReport = serde_json::from_value(partial).expect("a partial ams object should parse");
|
||||
assert!(report.ams.as_ref().is_some_and(|a| a.ams.is_empty()));
|
||||
}
|
||||
|
||||
@@ -18,10 +18,12 @@ use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
|
||||
|
||||
use continuum_proxy::config::Config;
|
||||
use continuum_proxy::uplink;
|
||||
@@ -44,6 +46,7 @@ fn config(port: u16) -> Config {
|
||||
go2rtc_api_listen: "127.0.0.1:1984".into(),
|
||||
stream_base_url: None,
|
||||
printers_path: "/nonexistent/printers.toml".into(),
|
||||
store_path: "/nonexistent/continuum.db".into(),
|
||||
// Off, which is the default and also the only safe thing in a test:
|
||||
// an enabled one would sweep whatever network the test host is on.
|
||||
discovery: Default::default(),
|
||||
@@ -69,6 +72,36 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the cloud's `/auth/edge-token`: reads one HTTP request, checks that
|
||||
/// the gateway sent its farm and long-lived token, and answers with a JWT.
|
||||
/// Returns the request text so the caller can assert on it.
|
||||
async fn answer_token_exchange(listener: &TcpListener) -> String {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let n = tokio::time::timeout(WAIT, stream.read(&mut buf)).await.unwrap().unwrap();
|
||||
assert!(n > 0, "the gateway hung up before finishing its token request");
|
||||
request.extend_from_slice(&buf[..n]);
|
||||
let text = String::from_utf8_lossy(&request).into_owned();
|
||||
if let Some((head, body)) = text.split_once("\r\n\r\n") {
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap()))
|
||||
.unwrap_or(0);
|
||||
if body.len() >= length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = r#"{"token":"minted-edge-jwt","expiresInSeconds":3600}"#;
|
||||
stream
|
||||
.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&request).into_owned()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_long_command_that_fails_reports_its_reason_after_the_fact() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
@@ -76,10 +109,23 @@ async fn a_long_command_that_fails_reports_its_reason_after_the_fact() {
|
||||
|
||||
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
|
||||
let (outbound, inbox) = uplink::channel();
|
||||
tokio::spawn(uplink::run(config(port), fleet, outbound, inbox));
|
||||
tokio::spawn(uplink::run(config(port), fleet, None, outbound, inbox));
|
||||
|
||||
// The gateway trades its long-lived token for an edge JWT first, and the
|
||||
// JWT - not the token it was configured with - is what opens the socket.
|
||||
let exchange = answer_token_exchange(&listener).await;
|
||||
assert!(exchange.starts_with("POST /auth/edge-token "), "{exchange}");
|
||||
assert!(exchange.contains(r#""farmId":"test-farm""#) && exchange.contains(r#""edgeProxyToken":"test-token""#), "{exchange}");
|
||||
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
let mut upgrade_auth = String::new();
|
||||
let mut ws = tokio_tungstenite::accept_hdr_async(stream, |request: &Request, response: Response| {
|
||||
upgrade_auth = request.headers().get("authorization").unwrap().to_str().unwrap().to_string();
|
||||
Ok(response)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(upgrade_auth, "Bearer minted-edge-jwt", "the socket must be opened with the minted JWT");
|
||||
|
||||
let hello = next_json(&mut ws).await;
|
||||
assert_eq!(hello["type"], "hello", "a gateway introduces itself first");
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Job outcomes survive the cloud being away: an event in the gateway's store
|
||||
//! is sent on connect, sent again after a reconnect if nobody acknowledged it,
|
||||
//! and gone once acknowledged. A start that fails after being accepted is
|
||||
//! queued the same way instead of being sent once to whoever is listening.
|
||||
//!
|
||||
//! Real `uplink::run` against a real websocket server; no printer is touched
|
||||
//! (the fleet is empty, so the start fails on its lookup before any download).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
|
||||
|
||||
use continuum_proxy::config::Config;
|
||||
use continuum_proxy::store::Store;
|
||||
use continuum_proxy::uplink;
|
||||
|
||||
/// Long enough for a loopback round trip, short enough that a regression
|
||||
/// fails the test instead of hanging the suite.
|
||||
const WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
fn config(port: u16) -> Config {
|
||||
Config {
|
||||
gateway_id: "test-gw".into(),
|
||||
farm_id: "test-farm".into(),
|
||||
gateway_token: "test-token".into(),
|
||||
uplink_url: format!("ws://127.0.0.1:{port}/ws/edge/v1"),
|
||||
// Nothing here starts go2rtc or reads the fleet file - `uplink::run`
|
||||
// is handed its fleet rather than loading one - but Config has no
|
||||
// Default, so these have to say something.
|
||||
go2rtc_bin: "/nonexistent-go2rtc".into(),
|
||||
go2rtc_config: "/nonexistent/go2rtc.yaml".into(),
|
||||
go2rtc_api_listen: "127.0.0.1:1984".into(),
|
||||
stream_base_url: None,
|
||||
printers_path: "/nonexistent/printers.toml".into(),
|
||||
store_path: "/nonexistent/continuum.db".into(),
|
||||
// Off, which is the default and also the only safe thing in a test:
|
||||
// an enabled one would sweep whatever network the test host is on.
|
||||
discovery: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next frame as JSON. Heartbeats and telemetry share this socket, so
|
||||
/// callers filter; `interval` fires on its first tick, which means a
|
||||
/// heartbeat arrives immediately after the hello.
|
||||
async fn next_json<S>(ws: &mut WebSocketStream<S>) -> Value
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
loop {
|
||||
let frame = tokio::time::timeout(WAIT, ws.next())
|
||||
.await
|
||||
.expect("timed out waiting for a frame from the gateway")
|
||||
.expect("the gateway closed the connection")
|
||||
.expect("websocket error");
|
||||
if let Message::Text(text) = frame {
|
||||
return serde_json::from_str(&text).expect("the gateway sent invalid JSON");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the cloud's `/auth/edge-token`: reads one HTTP request, checks that
|
||||
/// the gateway sent its farm and long-lived token, and answers with a JWT.
|
||||
/// Returns the request text so the caller can assert on it.
|
||||
async fn answer_token_exchange(listener: &TcpListener) -> String {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let n = tokio::time::timeout(WAIT, stream.read(&mut buf)).await.unwrap().unwrap();
|
||||
assert!(n > 0, "the gateway hung up before finishing its token request");
|
||||
request.extend_from_slice(&buf[..n]);
|
||||
let text = String::from_utf8_lossy(&request).into_owned();
|
||||
if let Some((head, body)) = text.split_once("\r\n\r\n") {
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap()))
|
||||
.unwrap_or(0);
|
||||
if body.len() >= length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = r#"{"token":"minted-edge-jwt","expiresInSeconds":3600}"#;
|
||||
stream
|
||||
.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&request).into_owned()
|
||||
}
|
||||
|
||||
|
||||
/// Answers the token exchange and accepts the websocket, returning it after
|
||||
/// the gateway's hello.
|
||||
async fn accept(listener: &TcpListener) -> WebSocketStream<tokio::net::TcpStream> {
|
||||
answer_token_exchange(listener).await;
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_hdr_async(stream, |_: &Request, response: Response| Ok(response)).await.unwrap();
|
||||
let hello = next_json(&mut ws).await;
|
||||
assert_eq!(hello["type"], "hello");
|
||||
ws
|
||||
}
|
||||
|
||||
/// Reads frames until `want` job events have arrived, returning them by job id.
|
||||
async fn job_events(ws: &mut WebSocketStream<tokio::net::TcpStream>, want: usize) -> Vec<Value> {
|
||||
let mut events = Vec::new();
|
||||
while events.len() < want {
|
||||
let msg = next_json(ws).await;
|
||||
if msg["type"] == "job_event" {
|
||||
events.push(msg);
|
||||
}
|
||||
}
|
||||
events.sort_by_key(|e| e["event_id"].as_i64());
|
||||
events
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unacknowledged_job_events_are_resent_after_a_reconnect_and_stop_once_acknowledged() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("continuum-outbox-test-{}", std::process::id()));
|
||||
let store = Arc::new(Store::open(&dir.join("s.db")).unwrap());
|
||||
// An outcome reached while the cloud was away.
|
||||
store.outbox_push("11111111-1111-4111-8111-111111111111", "finished", None, 1_000).unwrap();
|
||||
|
||||
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
|
||||
let (outbound, inbox) = uplink::channel();
|
||||
tokio::spawn(uplink::run(config(port), fleet, Some(store.clone()), outbound, inbox));
|
||||
|
||||
let mut ws = accept(&listener).await;
|
||||
let first = job_events(&mut ws, 1).await;
|
||||
assert_eq!(first[0]["job_id"], "11111111-1111-4111-8111-111111111111");
|
||||
assert_eq!(first[0]["event"], "finished");
|
||||
assert_eq!(first[0]["at_ms"], 1_000);
|
||||
|
||||
// A start that fails after being accepted goes through the same outbox.
|
||||
ws.send(Message::Text(
|
||||
json!({
|
||||
"type": "command", "command_id": "cmd-1", "printer_id": "not-in-this-fleet",
|
||||
"command": { "action": "start_print", "job_id": "22222222-2222-4222-8222-222222222222",
|
||||
"file_name": "cube.gcode", "url": "http://127.0.0.1:1/never-fetched" }
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
// Only the new one: the first was already sent on this connection.
|
||||
let mut both = first.clone();
|
||||
both.extend(job_events(&mut ws, 1).await);
|
||||
assert_eq!(both[1]["job_id"], "22222222-2222-4222-8222-222222222222");
|
||||
assert_eq!(both[1]["event"], "failed");
|
||||
assert!(both[1]["detail"].as_str().unwrap().contains("not-in-this-fleet"), "{}", both[1]);
|
||||
|
||||
// The cloud never answers; the connection drops.
|
||||
drop(ws);
|
||||
|
||||
let mut ws = accept(&listener).await;
|
||||
let again = job_events(&mut ws, 2).await;
|
||||
assert_eq!(
|
||||
again.iter().map(|e| e["event_id"].clone()).collect::<Vec<_>>(),
|
||||
both.iter().map(|e| e["event_id"].clone()).collect::<Vec<_>>(),
|
||||
"after a reconnect everything unacknowledged is sent again"
|
||||
);
|
||||
|
||||
ws.send(Message::Text(json!({ "type": "job_event_ack", "event_id": again[0]["event_id"] }).to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
let deadline = tokio::time::Instant::now() + WAIT;
|
||||
while store.outbox_pending().unwrap().len() != 1 {
|
||||
assert!(tokio::time::Instant::now() < deadline, "the acknowledged event was not cleared");
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
assert_eq!(store.outbox_pending().unwrap()[0].job_id, "22222222-2222-4222-8222-222222222222");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -106,7 +106,7 @@ fn a_vendor_specific_control_verb_is_rejected() {
|
||||
fn a_start_print_command_arrives_with_its_fields_intact() {
|
||||
let raw = r#"{"type":"command","command_id":"c","printer_id":"p1p","command":{"action":"start_print","job_id":"5b0e7c1a-0000-4000-8000-000000000000","file_name":"plate_1.3mf","url":"https://objects.example.com/jobs/abc.3mf?X-Amz-Signature=deadbeef&X-Amz-Expires=300"}}"#;
|
||||
match serde_json::from_str::<ServerMessage>(raw).expect("should parse") {
|
||||
ServerMessage::Command { command: PrinterCommand::StartPrint { job_id, file_name, url }, .. } => {
|
||||
ServerMessage::Command { command: PrinterCommand::StartPrint { job_id, file_name, url, .. }, .. } => {
|
||||
assert_eq!(job_id, "5b0e7c1a-0000-4000-8000-000000000000");
|
||||
assert_eq!(file_name, "plate_1.3mf");
|
||||
assert_eq!(
|
||||
@@ -188,6 +188,7 @@ fn empty_telemetry_fields_are_omitted_not_null() {
|
||||
// The common case, and the third field that must be omitted: a
|
||||
// P1P reports that it has a camera without giving a URL for it.
|
||||
camera_stream: None,
|
||||
camera_snapshot: None,
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -226,6 +227,7 @@ fn a_zero_stage_is_sent_rather_than_treated_as_absent() {
|
||||
print_error: Some(0),
|
||||
active_alerts: Some(0),
|
||||
camera_stream: Some("p1p".into()),
|
||||
camera_snapshot: None,
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -299,3 +301,28 @@ fn a_hello_carries_no_credential() {
|
||||
assert!(!json.contains("token"), "the hello must carry no token, got:\n {json}");
|
||||
assert_eq!(json, r#"{"type":"hello","gateway_id":"gw-1","farm_id":"farm-1"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_request_is_readable_and_its_answer_matches_the_schema() {
|
||||
let request = r#"{"type":"camera_snapshot_request","request_id":"r1","printer_id":"p1p"}"#;
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<ServerMessage>(request),
|
||||
Ok(ServerMessage::CameraSnapshotRequest { .. })
|
||||
));
|
||||
|
||||
let ok = serde_json::to_string(&ClientMessage::CameraSnapshot {
|
||||
request_id: "r1".into(),
|
||||
jpeg_base64: Some("/9j/".into()),
|
||||
error: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(ok, r#"{"type":"camera_snapshot","request_id":"r1","jpeg_base64":"/9j/"}"#);
|
||||
|
||||
let failed = serde_json::to_string(&ClientMessage::CameraSnapshot {
|
||||
request_id: "r1".into(),
|
||||
jpeg_base64: None,
|
||||
error: Some("no frame".into()),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(failed, r#"{"type":"camera_snapshot","request_id":"r1","error":"no frame"}"#);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user