Compare commits

10 Commits
Author SHA1 Message Date
octoturge f027fe7947 Add continuum-proxy enrol to connect a gateway with a one-time code
Windows discovery tool / windows (push) Successful in 1m0s
Exchanges a short code from the Setup page for the farm's gateway
credentials and writes them into the config file (owner-only, atomic,
other keys kept). PC installs only; refused on a Proxy Box.
2026-10-30 08:13:00 +00:00
octoturge f21ad0d497 Keep job outcomes until the cloud acknowledges them
Windows discovery tool / windows (push) Successful in 1m2s
The gateway now follows the job each printer was handed. Started, finished
and failed are written to its SQLite store and sent as job_event on every
connection until a job_event_ack clears them, so an outcome reached during
an outage, or before a restart, is still delivered.

A start that fails after being accepted goes through the same outbox
instead of a one-shot message to whoever was connected. Nothing is
concluded from a printer's state until the job has been seen running,
because a printer told to start still reports the previous job's FINISH.

The backend still owns the queue; this only reports.
2026-10-30 08:05:04 +00:00
octoturge 9359add6aa Drive Klipper, OctoPrint and PrusaLink for real
Windows discovery tool / windows (push) Successful in 1m2s
The three were println stubs. Each is now an HTTP client (Moonraker,
OctoPrint's REST API, PrusaLink's v1 API) behind a small shared helper:
status polled in the background, pause/resume/cancel, G-code lines, fan
speed, and upload-and-start of a sliced file. A slicer project (.3mf) is
refused for these, and so is an upload while a print is running.

Telemetry and the completion watcher now read a vendor-neutral
PrinterStatus instead of Bambu's PrintReport, so these printers report
progress, temperatures and job state to the cloud.

Written from the public API docs and tested against mock servers only; no
real Klipper, OctoPrint or PrusaLink machine has been on the bench.
2026-10-30 07:49:31 +00:00
octoturge b1e733a5fe Bound MQTT publishes and send status refreshes unacked
Windows discovery tool / windows (push) Successful in 1m35s
On the first real dispatch the proxy froze about 4.5 minutes in: pushall was
published at QoS 1 from two tasks while holding the fleet lock, and once the
in-flight window filled every publish blocked forever, stalling telemetry,
heartbeats and the uplink. Publishes now time out after 10 s and pushall is
QoS 0.

Also adds an optional ams_slot to start_print so a job can choose its AMS
slot (single-filament projects, same material type only).
2026-10-30 06:56:24 +00:00
octoturge 47290175aa Accept partial ams objects; allow plain-HTTP downloads from a local store on request
Windows discovery tool / windows (push) Successful in 57s
A P1P mid-print pushes ams objects with no unit list; every such update was
dropped as "missing field ams", silencing telemetry for the rest of the print
(found on the first real dispatch). The unit list now defaults to empty and a
partial object does not wipe known units.

CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true lets the downloader fetch a
presigned URL over plain HTTP, but only from a loopback or RFC 1918 address.
2026-10-29 22:13:47 +00:00
octoturge 0c449c47bc Add a read-only dry run of a print's filament mapping
Windows discovery tool / windows (push) Successful in 1m4s
bambu_mapping_check downloads one project from the card and prints the AMS
mapping the production path would send, starting nothing.
2026-10-29 16:20:03 +00:00
octoturge 0c8ad9cc3d Serve P1/A1 camera frames over the uplink
A P1 or A1 has no RTSP endpoint; its camera is a TLS stream on port 6000.
bambu_camera logs in and reads one JPEG frame, and the uplink answers a
camera_snapshot_request with it. Telemetry advertises camera_snapshot for
printers that report a camera block but no RTSP URL.
2026-10-29 15:55:21 +00:00
octoturge b4948076a0 Trade the gateway token for an edge JWT on every connection
Windows discovery tool / windows (push) Successful in 1m24s
The proxy put its configured gateway_token straight into the upgrade's
Authorization header, but the backend only accepts a one-hour edge JWT
(scope "edge") there, minted by POST /auth/edge-token from the farm's edge
proxy token. Nothing in the proxy ever made that call, so a gateway
configured with the token the console hands out could not connect at all,
and one given a hand-minted JWT (which is what every test did) would have
been locked out an hour later on its first reconnect.

Each connection attempt now starts by trading gateway_token for a fresh JWT,
at an address derived from the socket URL so there is one to configure. A
401 is reported as what it means (token rotated or wrong), since retrying
will not fix it. Config's Debug no longer prints the token.

tests/uplink_late_result.rs now plays the cloud's exchange endpoint and
asserts the socket opens with the minted JWT, not the configured token.
.env.example and continuum.example.toml say what the token is.
2026-10-29 15:10:27 +00:00
octoturge 5fc09aef04 Test that an added printer joins the fleet and survives a restart
The OctoPrint client's connect succeeds without hardware, so it stands in
to prove the plumbing of a successful add: saved, joins the running fleet,
refused when added twice, and rebuilt from the store after a restart.
2026-10-29 15:07:44 +00:00
octoturge 9dff600160 Let the console add and remove printers; keep them in a local SQLite store
The only way to give a gateway a printer was to edit printers.toml and
restart it. A printer added from the console now arrives as a
provision_printer message, is checked by actually connecting to it (so a
mistyped access code is reported to the operator, not discovered later),
and only then is saved to the gateway's own SQLite file and joined to the
running fleet - no restart. remove_printer undoes it. Both are answered
with a command_result carrying the request id.

The access code exists only on the gateway: the cloud relays it once and
keeps nothing, which is the platform's rule for printer credentials. The
store file is created owner-only; the message type's Debug and the fleet
entry's Debug redact the credential; and an unparseable message from the
cloud is no longer logged with its payload, because the message that
fails to parse is exactly the one carrying an access code.

The cloud may not choose a certificate path or serial port on the
gateway's disk, so provision_printer cannot carry them.

Startup now reads printers.toml (strict, as before; a missing file is fine)
and the store (a bad stored entry is skipped, not fatal), so the gateway
comes up with its fleet with the API unreachable. Discovery treats stored
printers as already configured. rusqlite (MIT, bundled SQLite) passes
cargo deny.
2026-10-29 15:02:48 +00:00
37 changed files with 3369 additions and 221 deletions
+6 -4
View File
@@ -1,10 +1,12 @@
# Identity # Identity
CONTINUUM_GATEWAY_ID=gw-farm01-rpi01 CONTINUUM_GATEWAY_ID=gw-farm01-rpi01
CONTINUUM_FARM_ID=replace-with-farm-uuid CONTINUUM_FARM_ID=replace-with-farm-uuid
# The edge JWT from POST /auth/edge-token. Sent as an Authorization: Bearer # The farm's edge proxy token, shown once by the console's Setup page (or
# header on the uplink's WebSocket upgrade - not in a message and not in the # POST /farms/:id/edge-proxy-token). The gateway trades it for a one-hour edge
# URL - so the backend verifies it before any socket exists. # JWT (POST /auth/edge-token) on every connection, and the JWT - sent as an
CONTINUUM_GATEWAY_TOKEN=replace-with-provisioning-token # 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 # Cloud uplink
CONTINUUM_UPLINK_URL=wss://api.continuum.local/ws/edge/v1 CONTINUUM_UPLINK_URL=wss://api.continuum.local/ws/edge/v1
+4
View File
@@ -14,3 +14,7 @@ go2rtc.yaml
go2rtc.yaml.tmp go2rtc.yaml.tmp
continuum.toml continuum.toml
go2rtc.yaml.pid go2rtc.yaml.pid
# The local store holds printer access codes.
continuum.db
continuum.db-*
Generated
+76
View File
@@ -208,6 +208,7 @@ dependencies = [
"openssl", "openssl",
"reqwest", "reqwest",
"rumqttc", "rumqttc",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"suppaftp", "suppaftp",
@@ -375,6 +376,18 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.5.0" version = "2.5.0"
@@ -415,6 +428,12 @@ dependencies = [
"spin", "spin",
] ]
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.3.2" version = "0.3.2"
@@ -533,6 +552,18 @@ name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" 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]] [[package]]
name = "hmac" name = "hmac"
@@ -875,6 +906,17 @@ version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" 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]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@@ -1369,6 +1411,15 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rumqttc" name = "rumqttc"
version = "0.25.1" version = "0.25.1"
@@ -1388,6 +1439,21 @@ dependencies = [
"tokio-util", "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]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -1670,6 +1736,16 @@ dependencies = [
"lock_api", "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]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
+6
View File
@@ -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. # `bind_ssdp`). Windows PCs routinely have VPN/Hyper-V/WSL adapters.
# MIT OR BSD-3-Clause - checked against docs/LICENSES.md before adding. # MIT OR BSD-3-Clause - checked against docs/LICENSES.md before adding.
if-addrs = "0.15" 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"] }
+2
View File
@@ -19,6 +19,8 @@
gateway_id = "REPLACE_ME" gateway_id = "REPLACE_ME"
farm_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" gateway_token = "REPLACE_ME"
# uplink_url = "wss://api.continuum.local/ws/edge/v1" # uplink_url = "wss://api.continuum.local/ws/edge/v1"
+59
View File
@@ -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
View File
@@ -18,9 +18,10 @@
//! Only the X1/H2 class publishes an `rtsp_url`. Confirmed against two real //! 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 //! 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 //! 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 //! reachable only over Bambu's proprietary port-6000 JPEG stream. That is
//! crate does not speak, so those printers get no stream here and say so //! not restreamable here (go2rtc cannot make WebRTC of MJPEG without ffmpeg),
//! rather than advertising one that will not play. //! 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 //! 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 //! 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. /// Renders a go2rtc configuration.
/// ///
/// Hand-written YAML rather than a serializer, for the same reason the /// Hand-written YAML rather than a serializer, for the same reason the
+1 -1
View File
@@ -105,7 +105,7 @@ pub async fn run(fleet: SharedFleet) {
if let Err(err) = printer.refresh_status().await { if let Err(err) = printer.refresh_status().await {
warn!(printer = %printer.base().id, %err, "could not request a status refresh"); warn!(printer = %printer.base().id, %err, "could not request a status refresh");
} }
if let Some(state) = printer.print_status().await.and_then(|r| r.gcode_state) { if let Some(state) = printer.status().await.and_then(|s| s.state) {
states.push((printer.base().id.clone(), state)); states.push((printer.base().id.clone(), state));
} }
} }
+22 -1
View File
@@ -45,7 +45,7 @@ use std::time::Duration;
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Clone)] #[derive(Clone)]
pub struct Config { pub struct Config {
pub gateway_id: String, pub gateway_id: String,
pub farm_id: String, pub farm_id: String,
@@ -76,6 +76,9 @@ pub struct Config {
/// `BambuPrinter::ensure_trusted`) lives entirely in that file now, /// `BambuPrinter::ensure_trusted`) lives entirely in that file now,
/// not as a separate fleet-wide env-configured policy here. /// not as a separate fleet-wide env-configured policy here.
pub printers_path: String, 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. /// How, and whether, to look for printers nobody has configured.
/// ///
/// Off unless switched on - see [`DiscoverySettings`] for why that is /// Off unless switched on - see [`DiscoverySettings`] for why that is
@@ -104,6 +107,7 @@ pub struct ProxyFile {
pub go2rtc_api_listen: Option<String>, pub go2rtc_api_listen: Option<String>,
pub stream_base_url: Option<String>, pub stream_base_url: Option<String>,
pub printers_path: 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 /// Only meaningful on a Proxy Box; see the module docs for why it is
/// exposed and not applied. /// exposed and not applied.
pub network: Option<NetworkSettings>, 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 { impl Config {
/// Environment only - the behaviour from before there was a file, kept /// Environment only - the behaviour from before there was a file, kept
/// so existing callers and tests do not change. /// so existing callers and tests do not change.
@@ -326,6 +346,7 @@ impl Config {
.or_else(|| file.stream_base_url.clone()) .or_else(|| file.stream_base_url.clone())
.filter(|v| !v.is_empty()), .filter(|v| !v.is_empty()),
printers_path: or_default("CONTINUUM_PRINTERS_PATH", &file.printers_path, "printers.toml"), 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)?, discovery: discovery_settings(file.discovery.as_ref(), env)?,
}) })
} }
+20 -2
View File
@@ -32,7 +32,12 @@ use crate::uplink::ClientMessage;
/// long-running tasks. With discovery off it parks on a future that never /// long-running tasks. With discovery off it parks on a future that never
/// completes, which costs nothing and keeps the "every arm runs forever" /// completes, which costs nothing and keeps the "every arm runs forever"
/// property of that `select!` true. /// 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 { if !settings.enabled {
tracing::info!("printer discovery is off (discovery.enabled); not scanning this network"); tracing::info!("printer discovery is off (discovery.enabled); not scanning this network");
std::future::pending::<()>().await; 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 // restart - but someone who has *edited* the file and not restarted
// should not keep being offered the printer they just configured. // should not keep being offered the printer they just configured.
let configured = match crate::fleet::configured(std::path::Path::new(&printers_path)) { 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 // Fail the pass rather than report against an empty set. The
// alternative offers every printer the farm already runs as a // alternative offers every printer the farm already runs as a
// fresh find, which is worse than no report: it is a list of // fresh find, which is worse than no report: it is a list of
+188
View File
@@ -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
View File
@@ -6,7 +6,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::path::Path; use std::path::Path;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use crate::printer::{ use crate::printer::{
BambuPrinter, BambuProtocolPreference, BambuTls, KlipperPrinter, OctoPrintFlavour, OctoPrintPrinter, BambuPrinter, BambuProtocolPreference, BambuTls, KlipperPrinter, OctoPrintFlavour, OctoPrintPrinter,
@@ -19,10 +19,14 @@ struct FleetFile {
printers: Vec<PrinterEntry>, printers: Vec<PrinterEntry>,
} }
#[derive(Debug, Deserialize)] /// One printer's connection details, as written in `printers.toml` or stored
struct PrinterEntry { /// in the local store (src/store.rs). `Debug` is written by hand below: the
id: String, /// derived one would print `access_code` and `api_key` into any log line that
name: String, /// 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 - /// 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 /// the default, since a firmware update can move the same physical
/// printer between them), "bambu_v1"/"bambu_v2" (force a specific one), /// 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 /// 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; /// 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. /// discovery cannot tell, so it suggests `octoprint` and says so.
vendor: String, pub vendor: String,
host: Option<String>, pub host: Option<String>,
access_code: Option<String>, pub access_code: Option<String>,
/// The printer's serial number - Bambu's real MQTT topics are addressed /// The printer's serial number - Bambu's real MQTT topics are addressed
/// by serial (`device/{sn}/report`), not by our own `id`. /// by serial (`device/{sn}/report`), not by our own `id`.
sn: Option<String>, pub sn: Option<String>,
api_key: Option<String>, pub api_key: Option<String>,
/// Serial device path, e.g. `/dev/ttyACM0` on Linux. /// Serial device path, e.g. `/dev/ttyACM0` on Linux.
port: Option<String>, pub port: Option<String>,
#[serde(default = "default_baud_rate")] #[serde(default = "default_baud_rate")]
baud_rate: u32, pub baud_rate: u32,
/// Path to a printer-specific CA certificate. Leave unset to use the /// 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 /// bundled Bambu CA - only needed for a printer that doesn't chain to
/// it (P1P, at least; see certs/README.md). /// it (P1P, at least; see certs/README.md).
ca_cert_path: Option<String>, pub ca_cert_path: Option<String>,
#[serde(default = "default_true")] #[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 { 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() 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 /// Everything the fleet file already names, for telling a printer the
/// operator has dealt with from one they have never seen. /// 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 file: FleetFile = toml::from_str(&text)?;
let mut out = Configured::default(); 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>| { let add = |set: &mut HashSet<String>, value: Option<String>| {
if let Some(value) = value { if let Some(value) = value {
let value = value.trim().to_lowercase(); let value = value.trim().to_lowercase();
@@ -128,11 +173,12 @@ pub fn configured(path: &Path) -> anyhow::Result<Configured> {
} }
} }
}; };
add(&mut out.hosts, entry.host); for entry in entries {
add(&mut out.serials, entry.sn); add(&mut self.hosts, entry.host);
add(&mut out.ports, entry.port); add(&mut self.serials, entry.sn);
add(&mut self.ports, entry.port);
}
} }
Ok(out)
} }
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> { fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
+185
View File
@@ -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);
}
}
+4
View File
@@ -13,7 +13,11 @@ pub mod camera;
pub mod completion; pub mod completion;
pub mod config; pub mod config;
pub mod config_import; pub mod config_import;
pub mod enrol;
pub mod discovery; pub mod discovery;
pub mod fleet; pub mod fleet;
pub mod jobs;
pub mod printer; pub mod printer;
pub mod provision;
pub mod store;
pub mod uplink; pub mod uplink;
+35 -3
View File
@@ -13,7 +13,7 @@ use continuum_proxy::bootstrap::{self, Bootstrap};
use continuum_proxy::config::Config; use continuum_proxy::config::Config;
use continuum_proxy::config_import; use continuum_proxy::config_import;
use continuum_proxy::printer::{GenericPrinter, PrinterHandle}; use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
use continuum_proxy::{camera, completion, fleet, uplink}; use continuum_proxy::{camera, completion, fleet, jobs, uplink};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
@@ -23,6 +23,13 @@ async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok(); 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 // 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 // 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. // 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 // Loaded and connected here, once, at startup - nothing yet re-reads
// printers.toml on a running daemon, so adding or removing a printer // printers.toml on a running daemon, so adding or removing a printer
// still means a restart. // 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; connect_fleet(&mut fleet).await;
// The fleet is now shared with the uplink rather than just parked here // The fleet is now shared with the uplink rather than just parked here
@@ -134,10 +164,11 @@ async fn main() -> anyhow::Result<()> {
_ = shutdown_requested() => { _ = shutdown_requested() => {
info!("stop requested; exiting so go2rtc is stopped with us"); 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( _ = continuum_proxy::discovery::report::run(
config.discovery.clone(), config.discovery.clone(),
config.printers_path.clone(), config.printers_path.clone(),
Some(store.clone()),
outbound.clone(), outbound.clone(),
) => {} ) => {}
_ = go2rtc::watchdog( _ = go2rtc::watchdog(
@@ -153,6 +184,7 @@ async fn main() -> anyhow::Result<()> {
restart_go2rtc.clone(), restart_go2rtc.clone(),
) => {} ) => {}
_ = completion::run(fleet.clone()) => {} _ = completion::run(fleet.clone()) => {}
_ = jobs::run(fleet.clone(), store.clone()) => {}
// Returns only after installing a new config from a stick inserted // Returns only after installing a new config from a stick inserted
// while running. Settings are read once at startup, so the process // while running. Settings are read once at startup, so the process
// ends here and relies on its supervisor (systemd `Restart=always` // ends here and relies on its supervisor (systemd `Restart=always`
+61 -2
View File
@@ -207,6 +207,28 @@ pub enum BambuProtocolPreference {
} }
// endregion: BambuMQTTProtocol // 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 /// A Bambu printer. Only one struct now, not two: the old
/// `BambuGenericPrinter` (shared fields/behavior) + `BambuV1Printer`/ /// `BambuGenericPrinter` (shared fields/behavior) + `BambuV1Printer`/
/// `BambuV2Printer` (thin per-version wrappers) split existed to let two /// `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 /// alongside the protocol version it settled on, since the two are
/// decided together and always used together - see `bambu_mqtt.rs`. /// decided together and always used together - see `bambu_mqtt.rs`.
connection: Option<(super::bambu_mqtt::BambuMqttSession, BambuProtocolVersion)>, 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. /// Which plate of a multi-plate project gets printed.
@@ -469,9 +493,17 @@ impl BambuPrinter {
tls, tls,
preference, preference,
connection: None, 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 { pub fn base(&self) -> &PrinterBase {
&self.base &self.base
} }
@@ -736,7 +768,8 @@ impl BambuPrinter {
.map_err(|err| PrinterError(format!("failed to build pushall request: {err}")))?; .map_err(|err| PrinterError(format!("failed to build pushall request: {err}")))?;
body["push_target"] = serde_json::Value::from(1); 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/` /// 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 // naming a filament that also happens to be on the spool should
// still use the AMS, because that is what it was sliced for. // still use the AMS, because that is what it was sliced for.
if !trays.is_empty() { 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) => { Ok(mapping) => {
tracing::info!( tracing::info!(
printer = %self.base.name, printer = %self.base.name,
@@ -934,6 +978,21 @@ impl BambuPrinter {
.map_err(|err| PrinterError(format!("{}: FTPS download failed: {err}", self.base.name))) .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. /// Removes one file from the printer's SD card.
/// ///
/// Takes one explicit path, because the convenient version of this is /// Takes one explicit path, because the convenient version of this is
+134
View File
@@ -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());
}
}
+9 -1
View File
@@ -160,7 +160,11 @@ impl PrintReport {
self.envelope = newer.envelope; self.envelope = newer.envelope;
self.msg = newer.msg; 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.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.insert_flag = newer.insert_flag.or(self.insert_flag.take());
self.power_on_flag = newer.power_on_flag.or(self.power_on_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. /// actually sends.
#[derive(serde::Deserialize, Debug, Clone)] #[derive(serde::Deserialize, Debug, Clone)]
pub struct AmsState { 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>, pub ams: Vec<AmsUnit>,
} }
+33 -10
View File
@@ -76,6 +76,9 @@ use super::PrinterError;
type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>; type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); 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* /// Both rumqttc clients default to a 10KB max packet size in *both*
/// directions - fine for a request, nowhere near enough for a reply: /// directions - fine for a request, nowhere near enough for a reply:
/// confirmed live against a real H2C, whose `get_version` reply alone /// 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, /// `publish` above and `GenericPrinter::publish` (a raw pass-through,
/// no envelope opinion at all) are built on. /// no envelope opinion at all) are built on.
pub async fn publish_raw(&self, payload: String) -> Result<(), PrinterError> { 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); let topic = format!("device/{}/request", self.sn);
match &self.client { // Bounded: a publish waits for room in rumqttc's request queue, and an
BambuMqttClient::V1(client) => client // unbounded wait here wedges every caller that holds the fleet lock.
.publish(topic, QoS::AtLeastOnce, false, payload) let publishing = async {
.await match &self.client {
.map_err(|err| PrinterError(format!("publish failed: {err}"))), BambuMqttClient::V1(client) => client
BambuMqttClient::V2(client) => client .publish(topic, if acked { QoS::AtLeastOnce } else { QoS::AtMostOnce }, false, payload)
.publish(topic, QoSV5::AtLeastOnce, false, payload) .await
.await .map_err(|err| PrinterError(format!("publish failed: {err}"))),
.map_err(|err| PrinterError(format!("publish failed: {err}"))), BambuMqttClient::V2(client) => client
} .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 /// The current in-memory `print.push_status` object, if at least one
+202
View File
@@ -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
View File
@@ -1,75 +1,304 @@
use super::{GenericPrinter, PrinterBase, PrinterError}; //! Klipper, through Moonraker's HTTP API (default port 7125).
//!
//! Written from Moonraker's published API and exercised against a mock
//! server in the tests below - no real Klipper machine has been on the
//! bench, so treat the field names as unconfirmed until one has.
use std::time::Duration;
use serde_json::{json, Value};
use super::http::{safe_file_name, Http};
use super::polled::StatusFeed;
use super::{GenericPrinter, PrintControl, PrinterBase, PrinterError, PrinterStatus};
const MOONRAKER_PORT: u16 = 7125;
const POLL_EVERY: Duration = Duration::from_secs(2);
/// What one `objects/query` asks for - everything `parse_status` reads.
const STATUS_QUERY: &str =
"/printer/objects/query?print_stats&display_status&extruder&heater_bed&virtual_sdcard";
pub struct KlipperPrinter { pub struct KlipperPrinter {
base: PrinterBase, base: PrinterBase,
http: Http,
feed: Option<StatusFeed>,
} }
impl KlipperPrinter { impl KlipperPrinter {
pub fn new(id: &str, name: &str, host: &str) -> Self { pub fn new(id: &str, name: &str, host: &str) -> Self {
Self { Self {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() }, base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
http: Http::new(host, MOONRAKER_PORT, None),
feed: None,
}
}
pub fn status(&self) -> Option<PrinterStatus> {
self.feed.as_ref().and_then(StatusFeed::latest)
}
}
/// Moonraker's `print_stats.state` in the vocabulary the cloud already
/// understands (the strings Bambu reports; see `mapStatus` in the backend).
fn map_state(state: &str) -> Option<&'static str> {
match state {
"standby" => Some("IDLE"),
"printing" => Some("RUNNING"),
"paused" => Some("PAUSE"),
"complete" => Some("FINISH"),
// A cancelled print leaves nothing running; the machine is free.
"cancelled" => Some("IDLE"),
"error" => Some("FAILED"),
_ => None,
}
}
/// The `result.status` object of an `objects/query` answer.
fn parse_status(answer: &Value) -> Option<PrinterStatus> {
let status = answer.get("result")?.get("status")?;
let stats = status.get("print_stats")?;
let state = stats.get("state")?.as_str()?;
let number = |v: Option<&Value>| v.and_then(Value::as_f64).map(|n| n as f32);
let progress = number(status.get("virtual_sdcard").and_then(|s| s.get("progress"))).unwrap_or(0.0);
let percent = match state {
"complete" => 100,
"printing" | "paused" => (progress * 100.0).round() as i32,
_ => 0,
};
let duration = number(stats.get("print_duration")).unwrap_or(0.0);
// Klipper has no estimate of its own; this is elapsed time scaled by
// progress, which is wrong early in a print and settles as it goes.
let remaining_minutes = (matches!(state, "printing" | "paused") && progress > 0.01)
.then(|| ((duration / progress - duration) / 60.0).round() as i32);
let layer_info = stats.get("info");
let int = |v: Option<&Value>| v.and_then(Value::as_i64).map(|n| n as i32);
Some(PrinterStatus {
state: map_state(state).map(str::to_string),
percent: Some(percent),
layer: int(layer_info.and_then(|i| i.get("current_layer"))),
total_layers: int(layer_info.and_then(|i| i.get("total_layer"))),
remaining_minutes,
job_name: stats
.get("filename")
.and_then(Value::as_str)
.filter(|f| !f.is_empty())
.map(str::to_string),
nozzle_temp: number(status.get("extruder").and_then(|e| e.get("temperature"))),
nozzle_target: number(status.get("extruder").and_then(|e| e.get("target"))),
bed_temp: number(status.get("heater_bed").and_then(|e| e.get("temperature"))),
bed_target: number(status.get("heater_bed").and_then(|e| e.get("target"))),
print_error: (state == "error").then_some(1),
..PrinterStatus::default()
})
}
async fn poll(http: &Http) -> Option<PrinterStatus> {
match http.get_json(STATUS_QUERY).await {
Ok(answer) => parse_status(&answer),
// Moonraker answers 503 to object queries while Klipper itself is
// down, but still answers `/printer/info` - which is the
// difference between "off" and "faulted".
Err(_) => {
let info = http.get_json("/printer/info").await.ok()?;
let state = info.get("result")?.get("state")?.as_str()?;
matches!(state, "error" | "shutdown").then(|| PrinterStatus {
state: Some("FAILED".into()),
print_error: Some(1),
..PrinterStatus::default()
})
} }
} }
} }
impl GenericPrinter for KlipperPrinter { impl GenericPrinter for KlipperPrinter {
/// OVERRIDE: real version is `POST /printer/print/pause`, `/resume` or /// Note Klipper calls it "cancel" where Bambu calls it "stop".
/// `/cancel` on Moonraker - note Klipper calls it "cancel" where Bambu async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> {
/// calls it "stop".
async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> {
let endpoint = match action { let endpoint = match action {
super::PrintControl::Pause => "pause", PrintControl::Pause => "pause",
super::PrintControl::Resume => "resume", PrintControl::Resume => "resume",
super::PrintControl::Stop => "cancel", PrintControl::Stop => "cancel",
}; };
println!("[{}] POST /printer/print/{endpoint}", self.base.name); self.http.post_empty(&format!("/printer/print/{endpoint}")).await.map(|_| ())
Ok(())
} }
fn base(&self) -> &PrinterBase { fn base(&self) -> &PrinterBase {
&self.base &self.base
} }
/// OVERRIDE: a third, again-different body - Moonraker's real feed is a /// Checks Moonraker answers, then starts the status poller. Klipper
/// WebSocket (see `adapters::moonraker`). This also shows the `Err` /// itself being down is not a connect failure: Moonraker is up and
/// path: an empty `host` fails immediately, same as any other vendor /// will report it.
/// would if it couldn't reach its printer.
async fn connect(&mut self) -> Result<(), PrinterError> { async fn connect(&mut self) -> Result<(), PrinterError> {
if self.base.host.is_empty() { if self.base.host.trim().is_empty() {
return Err(PrinterError(format!("{} has no host configured", self.base.name))); return Err(PrinterError(format!("{} has no host configured", self.base.name)));
} }
println!("[{}] connecting over WebSocket (Moonraker)", self.base.name); self.http
.get_json("/printer/info")
.await
.map_err(|err| PrinterError(format!("{} did not answer as Moonraker: {err}", self.base.name)))?;
let http = self.http.clone();
self.feed = Some(StatusFeed::start(POLL_EVERY, move || {
let http = http.clone();
async move { poll(&http).await }
}));
Ok(()) Ok(())
} }
/// Nothing to publish to: Moonraker is request/response.
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> { async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
// TODO: add publishing via HTTP Err(PrinterError("Klipper has no publish channel".into()))
Ok(())
} }
/// OVERRIDE: Moonraker's own **hypothetical** native API, not gcode injection - no /// `M106` works on Klipper for the part-cooling fan, same as elsewhere.
/// percent-to-PWM conversion needed since it already takes a
/// percentage directly. Real version:
/// `POST /printer/gcode/script?script=SET_FAN_SPEED SPEED=0.NN`.
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> { async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
println!("[{}] Moonraker: SET_FAN_SPEED SPEED={:.2}", self.base.name, percent as f32 / 100.0); self.send_gcode_line(format!("M106 S{}", super::percent_to_pwm(percent))).await
Ok(())
} }
/// OVERRIDE: real version is a multipart `POST /server/files/upload` /// Uploads to the printer's `gcodes` root and starts it (`print=true`),
/// with `data` as the file part - Moonraker stores it on the printer's /// like Bambu's upload-then-`project_file`. Klipper runs plain G-code,
/// virtual SD card, same upload-then-start model as Bambu/PrusaLink. /// so a slicer project (`.3mf`) is refused here rather than uploaded
/// and left to fail on the machine.
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> { async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
println!("[{}] POST /server/files/upload {file_name} ({} bytes)", self.base.name, data.len()); let name = safe_file_name(&file_name)?;
let lower = name.to_lowercase();
if !(lower.ends_with(".gcode") || lower.ends_with(".gco") || lower.ends_with(".g")) {
return Err(PrinterError(format!(
"{name} is not G-code - Klipper needs a sliced .gcode file, not a project"
)));
}
if let Some(status) = self.status()
&& matches!(status.state.as_deref(), Some("RUNNING" | "PAUSE"))
{
return Err(PrinterError(format!("{} is already printing", self.base.name)));
}
let answer = self
.http
.post_file("/server/files/upload", &[("root", "gcodes"), ("print", "true")], "file", &name, &data)
.await?;
if answer.get("print_started").and_then(Value::as_bool) == Some(false) {
return Err(PrinterError(format!("{name} was uploaded but Klipper did not start it")));
}
Ok(()) Ok(())
} }
/// OVERRIDE: Moonraker's gcode-script endpoint, one line per call - same
/// idea as `set_fan_speed`'s `SET_FAN_SPEED` call above, just with an
/// arbitrary line instead of a hardcoded one.
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> { async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
println!("[{}] POST /printer/gcode/script?script={gcode_line}", self.base.name); self.http.post_json("/printer/gcode/script", &json!({ "script": gcode_line })).await.map(|_| ())
Ok(()) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::printer::mock_http::MockServer;
const PRINTING: &str = r#"{"result":{"status":{
"print_stats":{"state":"printing","filename":"cube.gcode","print_duration":600.0,"info":{"current_layer":12,"total_layer":60}},
"virtual_sdcard":{"progress":0.25},
"extruder":{"temperature":214.8,"target":215.0},
"heater_bed":{"temperature":59.9,"target":60.0}}}}"#;
#[test]
fn a_printing_machine_maps_to_the_shared_vocabulary() {
let status = parse_status(&serde_json::from_str(PRINTING).unwrap()).unwrap();
assert_eq!(status.state.as_deref(), Some("RUNNING"));
assert_eq!(status.percent, Some(25));
assert_eq!((status.layer, status.total_layers), (Some(12), Some(60)));
// 600 s for a quarter: 1800 s more.
assert_eq!(status.remaining_minutes, Some(30));
assert_eq!(status.job_name.as_deref(), Some("cube.gcode"));
assert_eq!(status.nozzle_temp, Some(214.8));
assert_eq!(status.bed_target, Some(60.0));
assert_eq!(status.print_error, None);
}
#[test]
fn complete_is_100_percent_and_error_carries_a_code() {
let done = r#"{"result":{"status":{"print_stats":{"state":"complete","filename":"a.gcode"},"virtual_sdcard":{"progress":1.0}}}}"#;
let status = parse_status(&serde_json::from_str(done).unwrap()).unwrap();
assert_eq!((status.state.as_deref(), status.percent), (Some("FINISH"), Some(100)));
let failed = r#"{"result":{"status":{"print_stats":{"state":"error"}}}}"#;
let status = parse_status(&serde_json::from_str(failed).unwrap()).unwrap();
assert_eq!((status.state.as_deref(), status.print_error), (Some("FAILED"), Some(1)));
}
#[tokio::test]
async fn control_gcode_and_fan_go_to_the_right_endpoints() {
let mock = MockServer::start().await;
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
mock.answer("POST /printer/print/cancel", 200, r#"{"result":"ok"}"#);
mock.answer("POST /printer/gcode/script", 200, r#"{"result":"ok"}"#);
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
printer.connect().await.unwrap();
printer.control_print(PrintControl::Stop).await.unwrap();
assert_eq!(mock.sent("POST", "/printer/print/cancel").len(), 1);
printer.set_fan_speed(50).await.unwrap();
let sent = mock.sent("POST", "/printer/gcode/script");
assert_eq!(sent[0].text(), r#"{"script":"M106 S127"}"#);
}
#[tokio::test]
async fn connect_fails_when_nothing_answers_as_moonraker() {
let mock = MockServer::start().await; // every route is a 404
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
assert!(printer.connect().await.is_err());
}
#[tokio::test]
async fn the_poller_feeds_status_and_clears_it_when_the_printer_goes() {
let mock = MockServer::start().await;
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
mock.answer("GET /printer/objects/query", 200, PRINTING);
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
printer.connect().await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(printer.status().unwrap().percent, Some(25));
// Klipper shuts down: queries 503, /printer/info says why.
mock.answer("GET /printer/objects/query", 503, "{}");
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"shutdown"}}"#);
tokio::time::sleep(Duration::from_millis(2500)).await;
assert_eq!(printer.status().unwrap().state.as_deref(), Some("FAILED"));
}
#[tokio::test]
async fn upload_sends_the_file_with_print_true_and_refuses_a_project() {
let mock = MockServer::start().await;
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
mock.answer("POST /server/files/upload", 201, r#"{"print_started":true}"#);
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
printer.connect().await.unwrap();
printer.upload_project_file("jobs/cube.gcode".into(), b"G28\nG1 X1\n".to_vec()).await.unwrap();
let sent = mock.sent("POST", "/server/files/upload");
assert_eq!(sent.len(), 1);
let body = sent[0].text();
assert!(body.contains("name=\"print\"\r\n\r\ntrue"));
assert!(body.contains("filename=\"cube.gcode\""));
assert!(body.contains("G28\nG1 X1\n"));
assert!(sent[0].header("content-type").unwrap().starts_with("multipart/form-data; boundary="));
let err = printer.upload_project_file("x.3mf".into(), vec![1]).await.unwrap_err();
assert!(err.0.contains("not G-code"), "{err}");
assert_eq!(mock.sent("POST", "/server/files/upload").len(), 1);
}
#[tokio::test]
async fn an_upload_is_refused_while_a_print_runs() {
let mock = MockServer::start().await;
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
mock.answer("GET /printer/objects/query", 200, PRINTING);
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
printer.connect().await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
let err = printer.upload_project_file("b.gcode".into(), vec![1]).await.unwrap_err();
assert!(err.0.contains("already printing"), "{err}");
assert!(mock.sent("POST", "/server/files/upload").is_empty());
} }
} }
+118
View File
@@ -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
View File
@@ -24,26 +24,32 @@
//! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's //! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's
//! protocol, `bambu_ftps.rs` for getting files onto a Bambu printer, //! protocol, `bambu_ftps.rs` for getting files onto a Bambu printer,
//! `marlin_serial.rs` for a real Marlin serial connection - see each //! `marlin_serial.rs` for a real Marlin serial connection - see each
//! module's own doc comment. PrusaLink/Klipper's HTTP clients are still //! module's own doc comment. Klipper (`klipper.rs`) and OctoPrint/PrusaLink
//! stubs; that's the next piece to build past this module. //! (`octoprint.rs`) are real HTTP clients, tested against mock servers only.
mod bambu; mod bambu;
mod bambu_camera;
mod bambu_commands; mod bambu_commands;
mod bambu_ftps; mod bambu_ftps;
mod bambu_mqtt; mod bambu_mqtt;
mod http;
mod klipper; mod klipper;
mod marlin_serial; mod marlin_serial;
#[cfg(test)]
pub(crate) mod mock_http;
mod octoprint;
mod polled;
mod prusa; mod prusa;
// Reading a `.3mf` well enough to know which filament a plate wants. Kept // Reading a `.3mf` well enough to know which filament a plate wants. Kept
// free of printer types so it is testable without a printer. // free of printer types so it is testable without a printer.
mod project_file; mod project_file;
pub use 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, ProjectFileError,
}; };
pub use bambu::{ pub use bambu::{SnapshotJob,
BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource, BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource,
LightState, CHAMBER_LIGHT, LightState, CHAMBER_LIGHT,
}; };
@@ -53,7 +59,54 @@ pub use bambu_commands::{
XcamSettings, XcamSettings,
}; };
pub use klipper::KlipperPrinter; pub use klipper::KlipperPrinter;
pub use prusa::{OctoPrintFlavour, OctoPrintPrinter, PrusaSerialPrinter}; pub use octoprint::{OctoPrintFlavour, OctoPrintPrinter};
pub use polled::StatusFeed;
pub use prusa::PrusaSerialPrinter;
/// What the cloud is told about a printer's job, whatever the vendor.
///
/// `state` uses the strings Bambu reports (`IDLE`, `RUNNING`, `PAUSE`,
/// `FINISH`, `FAILED`) because the cloud's `mapStatus` and the completion
/// watcher already speak them; the other vendors translate into them.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct PrinterStatus {
pub state: Option<String>,
pub percent: Option<i32>,
pub stage: Option<i32>,
pub layer: Option<i32>,
pub total_layers: Option<i32>,
pub remaining_minutes: Option<i32>,
pub job_name: Option<String>,
pub nozzle_temp: Option<f32>,
pub nozzle_target: Option<f32>,
pub bed_temp: Option<f32>,
pub bed_target: Option<f32>,
pub print_error: Option<i32>,
pub active_alerts: Option<usize>,
}
impl From<&PrintReport> for PrinterStatus {
fn from(report: &PrintReport) -> Self {
Self {
state: report.gcode_state.clone(),
percent: report.mc_percent,
stage: report.stg_cur,
layer: report.layer_num,
total_layers: report.total_layer_num,
remaining_minutes: report.mc_remaining_time,
// An idle printer reports `""` rather than omitting the field;
// empty-string-as-absent is worth normalising here so the cloud
// doesn't have to know that.
job_name: report.subtask_name.clone().filter(|s| !s.is_empty()),
nozzle_temp: report.nozzle_temper,
nozzle_target: report.nozzle_target_temper,
bed_temp: report.bed_temper,
bed_target: report.bed_target_temper,
print_error: report.print_error,
active_alerts: report.hms.as_ref().map(|h| h.len()),
}
}
}
/// A tiny error type: just a message. `String` would work too - this exists /// A tiny error type: just a message. `String` would work too - this exists
/// mainly so `?` has something concrete to convert into everywhere. /// mainly so `?` has something concrete to convert into everywhere.
@@ -194,6 +247,29 @@ pub enum PrinterHandle {
} }
impl 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. /// The latest status this process has for the printer, if it has one.
/// ///
/// An inherent method rather than a `GenericPrinter` trait method, /// An inherent method rather than a `GenericPrinter` trait method,
+417
View File
@@ -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");
}
}
+47
View File
@@ -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();
}
}
+44
View File
@@ -71,6 +71,8 @@ pub enum ProjectFileError {
/// material or none. /// material or none.
#[error("no loaded tray matches {wanted} (filament index {index}); loaded: {loaded}")] #[error("no loaded tray matches {wanted} (filament index {index}); loaded: {loaded}")]
NoMatchingTray { index: usize, wanted: String, loaded: String }, 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. /// 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. /// 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> { 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_product = filament.tray_info_idx.eq_ignore_ascii_case(&tray.tray_info_idx);
let same_color = filament.color == tray.color; let same_color = filament.color == tray.color;
@@ -288,6 +320,18 @@ mod tests {
use super::*; use super::*;
use std::io::Write; 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"?> const REAL: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
<config> <config>
<header> <header>
-106
View File
@@ -1,110 +1,4 @@
use super::{GenericPrinter, PrinterBase, PrinterError}; use super::{GenericPrinter, PrinterBase, PrinterError};
/****************** OctoPrint API family ******************/
/// Which implementation of the OctoPrint REST API is on the other end.
///
/// PrusaLink deliberately implements OctoPrint's API, so one client serves
/// both - but not identically. PrusaLink adds its own `/api/v1/*` (`info`,
/// `status`, `job`) which is where the useful detail lives, while OctoPrint
/// proper has the older `/api/printer` and `/api/job` and a plugin surface
/// PrusaLink does not have.
///
/// This is recorded from config now, while the client is still a stub,
/// because **no probe can settle it**: both answer `/api/version`, and every
/// PrusaLink identity endpoint needs a credential, so from outside the two
/// are indistinguishable (see `discovery::net::classify_octoprint_api`). It
/// is the operator's answer to a question only they can answer, so it needs
/// somewhere to live the moment they are asked - otherwise the real client
/// would have to ask them a second time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OctoPrintFlavour {
/// Prusa's own firmware-hosted implementation, on an MK4/XL/MINI.
PrusaLink,
/// OctoPrint itself, usually on a Raspberry Pi in front of any printer.
Generic,
}
impl OctoPrintFlavour {
pub fn as_str(self) -> &'static str {
match self {
OctoPrintFlavour::PrusaLink => "PrusaLink",
OctoPrintFlavour::Generic => "OctoPrint",
}
}
}
pub struct OctoPrintPrinter {
base: PrinterBase,
pub api_key: String,
pub flavour: OctoPrintFlavour,
}
impl OctoPrintPrinter {
pub fn new(id: &str, name: &str, host: &str, api_key: &str, flavour: OctoPrintFlavour) -> Self {
Self {
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
api_key: api_key.into(),
flavour,
}
}
}
impl GenericPrinter for OctoPrintPrinter {
/// OVERRIDE: real version is `PUT /api/v1/job/{id}/pause`, `/resume`, or
/// `DELETE /api/v1/job/{id}` to cancel - PrusaLink addresses the running
/// job by id rather than the printer, so a real implementation has to
/// fetch the current job first.
async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> {
println!("[{}] {} job control: {}", self.base.name, self.flavour.as_str(), action.as_str());
Ok(())
}
fn base(&self) -> &PrinterBase {
&self.base
}
/// OVERRIDE: a completely different body from Bambu's - PrusaLink is a
/// plain REST API (real version: `GET /api/v1/status` with the API
/// key, see `adapters::prusalink`), no persistent session to hold.
async fn connect(&mut self) -> Result<(), PrinterError> {
println!("[{}] connecting over HTTP ({})", self.base.name, self.flavour.as_str());
Ok(())
}
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
// TODO: add publishing via HTTP
Ok(())
}
/// OVERRIDE: gcode via PrusaLink's command-injection endpoint - same
/// underlying M106 as Bambu, reusing the shared percent->PWM
/// conversion, but sent over HTTP instead of MQTT.
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
let pwm = super::percent_to_pwm(percent);
println!("[{}] POST /api/printer/command {{\"commands\":[\"M106 S{pwm}\"]}}", self.base.name);
Ok(())
}
/// OVERRIDE: real version is `PUT /api/v1/files/local/{file_name}` with
/// `data` as the request body - PrusaLink stores it on the printer's own
/// SD card, same "upload to storage the firmware owns" model as Bambu.
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
println!("[{}] PUT /api/v1/files/local/{file_name} ({} bytes)", self.base.name, data.len());
Ok(())
}
/// OVERRIDE: PrusaLink's command-injection endpoint, one line per call -
/// same endpoint `set_fan_speed` above already uses for M106, just with
/// an arbitrary line instead of a hardcoded one.
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
println!("[{}] POST /api/printer/command {{\"commands\":[\"{gcode_line}\"]}}", self.base.name);
Ok(())
}
}
/****************** PrusaSerial ******************/ /****************** PrusaSerial ******************/
pub struct PrusaSerialPrinter { pub struct PrusaSerialPrinter {
+247
View File
@@ -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
View File
@@ -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());
}
}
+107
View File
@@ -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
View File
@@ -26,6 +26,26 @@ pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
/// up the whole budget above. /// up the whole budget above.
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); 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. /// Downloads `url` fully into memory, or says why not.
/// ///
/// The error is a `String` naming the failure; the caller prefixes the stage. /// 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}"))?; 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 // A presigned URL carries its authority in the query string; sending it
// over plain HTTP would expose it to anyone on the path. // 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())); return Err(format!("refusing non-HTTPS URL (scheme '{}')", parsed.scheme()));
} }
+215 -25
View File
@@ -26,12 +26,14 @@
//! readings for no consumer. See PLAN.md for what would actually be worth //! readings for no consumer. See PLAN.md for what would actually be worth
//! buffering. //! buffering.
mod auth;
mod download; mod download;
mod webrtc; mod webrtc;
mod protocol; pub mod protocol;
pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage}; pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage};
use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -42,6 +44,9 @@ use tokio_tungstenite::tungstenite::handshake::client::{Request, generate_key};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::config::Config; use crate::config::Config;
use crate::provision;
use crate::store::Store;
use protocol::ProvisionPrinter;
use crate::printer::{GenericPrinter, PrinterHandle}; use crate::printer::{GenericPrinter, PrinterHandle};
/// The websocket, once connected. Named because `connect` has to hand it to /// 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 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. /// a layer, so this samples the cached report instead of chasing every push.
const TELEMETRY_INTERVAL: Duration = Duration::from_secs(5); 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 /// The printer fleet, shared between `main` (which owns it and keeps every
/// connection alive) and this module (which reads status and dispatches /// connection alive) and this module (which reads status and dispatches
@@ -139,6 +146,7 @@ pub fn channel() -> (
pub async fn run( pub async fn run(
config: Config, config: Config,
fleet: SharedFleet, fleet: SharedFleet,
store: Option<Arc<Store>>,
outbound: tokio::sync::mpsc::Sender<ClientMessage>, outbound: tokio::sync::mpsc::Sender<ClientMessage>,
mut inbox: tokio::sync::mpsc::Receiver<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 // would not distinguish a connection that lived for an hour
// from one that failed to open. // from one that failed to open.
delay = RETRY_DELAY; 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:?}"); 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 /// handshake from a connection that opened and later dropped; they want
/// different retry behaviour. /// different retry behaviour.
async fn connect(config: &Config) -> anyhow::Result<WsStream> { 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 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 // 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 - // 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. // those tools already know to redact.
let request = Request::builder() let request = Request::builder()
.uri(&config.uplink_url) .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 // 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 // hand it a request we built ourselves - and a handshake missing any
// of them is rejected by the server rather than ignored. // of them is rejected by the server rather than ignored.
@@ -200,6 +212,7 @@ async fn serve(
ws_stream: WsStream, ws_stream: WsStream,
config: &Config, config: &Config,
fleet: &SharedFleet, fleet: &SharedFleet,
store: Option<&Arc<Store>>,
outbound: &tokio::sync::mpsc::Sender<ClientMessage>, outbound: &tokio::sync::mpsc::Sender<ClientMessage>,
inbox: &mut tokio::sync::mpsc::Receiver<ClientMessage>, inbox: &mut tokio::sync::mpsc::Receiver<ClientMessage>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
@@ -215,9 +228,34 @@ async fn serve(
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL); let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
let mut telemetry = tokio::time::interval(TELEMETRY_INTERVAL); let mut telemetry = tokio::time::interval(TELEMETRY_INTERVAL);
let mut seq: u64 = 0; 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 { loop {
tokio::select! { 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() => { _ = heartbeat.tick() => {
seq += 1; seq += 1;
let msg = ClientMessage::Heartbeat { seq }; let msg = ClientMessage::Heartbeat { seq };
@@ -265,6 +303,7 @@ async fn serve(
command, command,
command_id.clone(), command_id.clone(),
outbound.clone(), outbound.clone(),
store.cloned(),
); );
ClientMessage::CommandResult { ClientMessage::CommandResult {
command_id, ok: true, error: None, command_id, ok: true, error: None,
@@ -306,13 +345,39 @@ async fn serve(
outbound.clone(), 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"), Ok(other) => info!(?other, "received from cloud"),
// Loudly. This used to be an `if let Ok(..)` with // Loudly. This used to be an `if let Ok(..)` with
// no else, which is the same silent-drop bug that // no else, which is the same silent-drop bug that
// hid a real `print.push_status` failure for // hid a real `print.push_status` failure for
// weeks - and here it would mean commands // weeks - and here it would mean commands
// vanishing with the cloud never being told. // 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(()), Some(Ok(Message::Close(_))) | None => return Ok(()),
@@ -346,29 +411,28 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
for printer in fleet.iter() { for printer in fleet.iter() {
let base = printer.base(); let base = printer.base();
let Some(report) = printer.print_status().await else { let Some(status) = printer.status().await else {
continue; continue;
}; };
// Only Bambu has a camera report to derive these from.
let report = printer.print_status().await;
out.push(PrinterTelemetry { out.push(PrinterTelemetry {
printer_id: base.id.clone(), printer_id: base.id.clone(),
name: base.name.clone(), name: base.name.clone(),
state: report.gcode_state.clone(), state: status.state,
percent: report.mc_percent, percent: status.percent,
stage: report.stg_cur, stage: status.stage,
layer: report.layer_num, layer: status.layer,
total_layers: report.total_layer_num, total_layers: status.total_layers,
remaining_minutes: report.mc_remaining_time, remaining_minutes: status.remaining_minutes,
// An idle printer reports `""` rather than omitting the field; job_name: status.job_name,
// empty-string-as-absent is worth normalising here so the cloud nozzle_temp: status.nozzle_temp,
// doesn't have to know that. nozzle_target: status.nozzle_target,
job_name: report.subtask_name.clone().filter(|s| !s.is_empty()), bed_temp: status.bed_temp,
nozzle_temp: report.nozzle_temper, bed_target: status.bed_target,
nozzle_target: report.nozzle_target_temper, print_error: status.print_error,
bed_temp: report.bed_temper, active_alerts: status.active_alerts,
bed_target: report.bed_target_temper,
print_error: report.print_error,
active_alerts: report.hms.as_ref().map(|h| h.len()),
// Derived from the same reported URL `camera::sources` builds // Derived from the same reported URL `camera::sources` builds
// the go2rtc config from, so a printer is advertised as // the go2rtc config from, so a printer is advertised as
// streamable here only if it is also actually being served // streamable here only if it is also actually being served
@@ -376,9 +440,15 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
// the rule means the two cannot drift apart. // the rule means the two cannot drift apart.
camera_stream: crate::camera::source_for( camera_stream: crate::camera::source_for(
printer, printer,
report.ipcam.as_ref().and_then(|c| c.rtsp_url.as_deref()), report.as_ref().and_then(|r| r.ipcam.as_ref()).and_then(|c| c.rtsp_url.as_deref()),
) )
.map(|source| source.stream_name), .map(|source| source.stream_name),
camera_snapshot: crate::camera::wants_snapshot(
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: PrinterCommand,
command_id: String, command_id: String,
outbound: tokio::sync::mpsc::Sender<ClientMessage>, 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 { tokio::spawn(async move {
match dispatch(&fleet, &printer_id, command).await { match dispatch(&fleet, &printer_id, command).await {
Ok(()) => info!(%printer_id, "long-running command finished"), 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) => { Err(err) => {
// Still logged locally, and still at error level: the queue // Still logged locally, and still at error level: the queue
// only reaches a cloud that is up, and this is the copy that // 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. /// Answers one WebRTC offer, off this connection's task.
/// ///
/// Always answers, success or failure: the cloud is holding a browser's /// 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 /// 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 /// `String` rather than a typed error because its only destination is a
/// `CommandResult` going back to the cloud as text. /// `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 } => { PrinterCommand::ControlPrint { control } => {
printer.control_print(control).await.map_err(|err| err.to_string()) 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 bytes = downloaded.ok_or_else(|| "internal error: start_print reached dispatch with no downloaded file".to_string())?;
let byte_count = bytes.len(); let byte_count = bytes.len();
info!(%printer_id, %job_id, %file_name, byte_count, "job file downloaded, sending to printer"); 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. // all as one error - so the stage here is "printer", not finer.
// The printer's own message says which part (mapping, upload of // The printer's own message says which part (mapping, upload of
// the file, or start) it was. // 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) .upload_project_file(file_name, bytes)
.await .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
View File
@@ -32,6 +32,17 @@ pub enum ClientMessage {
stream_base_url: Option<String>, stream_base_url: Option<String>,
}, },
Heartbeat { seq: u64 }, 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 /// 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 /// 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 /// 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 /// waiting on this and a viewer otherwise watches a video element that
/// never loads with nothing anywhere saying why. /// never loads with nothing anywhere saying why.
WebrtcError { session_id: String, message: String }, 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 /// 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 /// *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 /// making an operator walk to a machine and read its IP address off the
@@ -191,6 +212,11 @@ pub struct PrinterTelemetry {
/// `crate::camera`. /// `crate::camera`.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub camera_stream: Option<String>, 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. /// What `continuum-backend` sends back over the same connection.
@@ -198,6 +224,8 @@ pub struct PrinterTelemetry {
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage { pub enum ServerMessage {
HelloAck { session_id: String }, 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 /// The handshake was rejected - a bad, expired or wrong-farm token. The
/// backend closes the connection right after sending this. /// 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 /// and checks its password before CORS, so the unauthenticated preflight
/// a browser always sends is refused - measured, not assumed. /// a browser always sends is refused - measured, not assumed.
WebrtcOffer { session_id: String, camera_stream: String, sdp: String }, 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. /// 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; /// 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. /// `.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. /// `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 { impl PrinterCommand {
+10
View File
@@ -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_eq!(base.stg, Some(vec![54, 14, 13]));
assert!(base.vt_tray.is_some()); 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()));
}
+48 -2
View File
@@ -18,10 +18,12 @@ use std::time::Duration;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value}; use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
use continuum_proxy::config::Config; use continuum_proxy::config::Config;
use continuum_proxy::uplink; use continuum_proxy::uplink;
@@ -44,6 +46,7 @@ fn config(port: u16) -> Config {
go2rtc_api_listen: "127.0.0.1:1984".into(), go2rtc_api_listen: "127.0.0.1:1984".into(),
stream_base_url: None, stream_base_url: None,
printers_path: "/nonexistent/printers.toml".into(), 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: // 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. // an enabled one would sweep whatever network the test host is on.
discovery: Default::default(), 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] #[tokio::test]
async fn a_long_command_that_fails_reports_its_reason_after_the_fact() { async fn a_long_command_that_fails_reports_its_reason_after_the_fact() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); 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 fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
let (outbound, inbox) = uplink::channel(); 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 (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; let hello = next_json(&mut ws).await;
assert_eq!(hello["type"], "hello", "a gateway introduces itself first"); assert_eq!(hello["type"], "hello", "a gateway introduces itself first");
+183
View File
@@ -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);
}
+28 -1
View File
@@ -106,7 +106,7 @@ fn a_vendor_specific_control_verb_is_rejected() {
fn a_start_print_command_arrives_with_its_fields_intact() { 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"}}"#; 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") { 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!(job_id, "5b0e7c1a-0000-4000-8000-000000000000");
assert_eq!(file_name, "plate_1.3mf"); assert_eq!(file_name, "plate_1.3mf");
assert_eq!( 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 // 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. // P1P reports that it has a camera without giving a URL for it.
camera_stream: None, 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), print_error: Some(0),
active_alerts: Some(0), active_alerts: Some(0),
camera_stream: Some("p1p".into()), 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!(!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"}"#); 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"}"#);
}