Compare commits
39
Commits
main
..
4f37384706
+4
-6
@@ -1,12 +1,10 @@
|
||||
# Identity
|
||||
CONTINUUM_GATEWAY_ID=gw-farm01-rpi01
|
||||
CONTINUUM_FARM_ID=replace-with-farm-uuid
|
||||
# The farm's edge proxy token, shown once by the console's Setup page (or
|
||||
# POST /farms/:id/edge-proxy-token). The gateway trades it for a one-hour edge
|
||||
# JWT (POST /auth/edge-token) on every connection, and the JWT - sent as an
|
||||
# Authorization: Bearer header on the WebSocket upgrade, never in a message
|
||||
# or the URL - is what the backend verifies before any socket exists.
|
||||
CONTINUUM_GATEWAY_TOKEN=replace-with-the-farms-edge-proxy-token
|
||||
# The edge JWT from POST /auth/edge-token. Sent as an Authorization: Bearer
|
||||
# header on the uplink's WebSocket upgrade - not in a message and not in the
|
||||
# URL - so the backend verifies it before any socket exists.
|
||||
CONTINUUM_GATEWAY_TOKEN=replace-with-provisioning-token
|
||||
|
||||
# Cloud uplink
|
||||
CONTINUUM_UPLINK_URL=wss://api.continuum.local/ws/edge/v1
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
# Builds `discover_printers` as a Windows executable, for trying SSDP
|
||||
# discovery on a machine that shares the printers' network segment (multicast
|
||||
# does not cross a router, so the development container cannot hear them).
|
||||
#
|
||||
# Runs on the `windows-latest` self-hosted runner that continuum-cad's
|
||||
# pipeline also uses: a host executor running as SYSTEM, Windows PowerShell
|
||||
# 5.1 (no pwsh), MSVC build tools present. The notes in
|
||||
# continuum-cad/.gitea/workflows/ci.yml on that machine apply here too.
|
||||
# Its artifact UPLOAD works (`upload-artifact@v3`); DOWNLOAD does not, which
|
||||
# is why nothing here downloads one.
|
||||
#
|
||||
# A job whose label matches no registered runner is skipped silently, not
|
||||
# failed - if this never runs, check that the runner is online.
|
||||
#
|
||||
# Cargo.lock is not committed in this repo, so this resolves dependencies
|
||||
# fresh; the artifact is built from whatever versions that picks today.
|
||||
name: Windows discovery tool
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "src/**"
|
||||
- "examples/discover_printers.rs"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- ".gitea/workflows/discover-windows.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: never
|
||||
# Outside the runner's deep work directory, and short, for the same MAX_PATH
|
||||
# reason continuum-cad gives. A directory of its own - not C:\vcpkg or
|
||||
# C:\ccad-target - so the two pipelines cannot step on each other's state
|
||||
# on the one machine.
|
||||
CARGO_TARGET_DIR: 'C:\continuum-proxy-target'
|
||||
VCPKG_ROOT: 'C:\continuum-vcpkg'
|
||||
# The same release tag continuum-cad pins, known to exist.
|
||||
VCPKG_REF: "2026.07.29"
|
||||
|
||||
jobs:
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: powershell
|
||||
# Every `run` step below works in the proxy's checkout.
|
||||
working-directory: continuum-proxy
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
# Cargo.toml has path dependencies on two sibling repositories
|
||||
# (`../continuum-schemas`, `../continuum-common`), so all three are laid
|
||||
# out side by side inside the workspace - the checkout action will not
|
||||
# place a repo outside it, which is why the proxy is in a subdirectory
|
||||
# rather than at the root.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: continuum-proxy
|
||||
|
||||
# Plain git for the siblings, not a second `actions/checkout`: on this
|
||||
# Windows runner a second use of the action in one job fails syncing the
|
||||
# action's own cached clone ("...\.git\objects\pack\...idx: Access is
|
||||
# denied", run 413). Same branch name as this one, because the proxy's
|
||||
# code needs their unreleased commits. The job's token goes in a header
|
||||
# for the clone and is never written to a remote URL or a log.
|
||||
- name: Check out the sibling repositories
|
||||
shell: powershell
|
||||
working-directory: .
|
||||
env:
|
||||
CI_TOKEN: ${{ github.token }}
|
||||
CI_SERVER: ${{ github.server_url }}
|
||||
CI_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
$auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("x-access-token:$($env:CI_TOKEN)"))
|
||||
foreach ($repo in @("continuum-schemas", "continuum-common")) {
|
||||
if (Test-Path $repo) { Remove-Item -Recurse -Force $repo }
|
||||
git -c "http.extraheader=AUTHORIZATION: basic $auth" clone --quiet --depth 1 --branch $env:CI_BRANCH "$($env:CI_SERVER)/Continuum/$repo.git" $repo
|
||||
if ($LASTEXITCODE -ne 0) { throw "could not clone $repo at branch $($env:CI_BRANCH)" }
|
||||
Write-Host "$repo at $(git -C $repo rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
- name: Install the Rust MSVC toolchain
|
||||
shell: powershell
|
||||
run: |
|
||||
# Genuine no-BOM UTF-8: Windows PowerShell 5.1's `-Encoding utf8`
|
||||
# writes a BOM, which corrupts the first entry of GITHUB_PATH.
|
||||
$NoBom = New-Object System.Text.UTF8Encoding $false
|
||||
function Add-CiPath([string] $Value) {
|
||||
[System.IO.File]::AppendAllText($env:GITHUB_PATH, "$Value`r`n", $NoBom)
|
||||
}
|
||||
if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
Invoke-WebRequest -UseBasicParsing -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
|
||||
.\rustup-init.exe -y --default-toolchain stable-x86_64-pc-windows-msvc
|
||||
if ($LASTEXITCODE -ne 0) { throw "rustup-init failed" }
|
||||
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
|
||||
if (-not (Test-Path (Join-Path $cargoBin "rustup.exe"))) {
|
||||
throw "rustup-init reported success but rustup.exe is not in $cargoBin"
|
||||
}
|
||||
# GITHUB_PATH only takes effect for later steps; the in-process
|
||||
# PATH is what the calls just below need.
|
||||
$env:PATH = "$cargoBin;$env:PATH"
|
||||
Add-CiPath $cargoBin
|
||||
}
|
||||
rustup default stable-x86_64-pc-windows-msvc
|
||||
if ($LASTEXITCODE -ne 0) { throw "could not select the MSVC toolchain" }
|
||||
rustc -vV
|
||||
|
||||
# The proxy links OpenSSL directly (Ed25519 verification, SHA-256, and
|
||||
# reading a printer's certificate), and Windows has none. vcpkg builds
|
||||
# it, fetching its own Perl and NASM. `x64-windows-static-md` links it
|
||||
# into the .exe, so the tool needs no DLL beside it; openssl-sys finds
|
||||
# it through VCPKG_ROOT with no further settings.
|
||||
- name: Build OpenSSL with vcpkg
|
||||
shell: powershell
|
||||
run: |
|
||||
git config --global core.longpaths true
|
||||
if (-not (Test-Path "$env:VCPKG_ROOT\.git")) {
|
||||
git clone --depth 1 --branch $env:VCPKG_REF https://github.com/microsoft/vcpkg $env:VCPKG_ROOT
|
||||
if ($LASTEXITCODE -ne 0) { throw "could not clone vcpkg at $env:VCPKG_REF" }
|
||||
}
|
||||
Push-Location $env:VCPKG_ROOT
|
||||
try {
|
||||
if (-not (Test-Path .\vcpkg.exe)) {
|
||||
.\bootstrap-vcpkg.bat -disableMetrics
|
||||
if ($LASTEXITCODE -ne 0) { throw "bootstrap-vcpkg failed" }
|
||||
}
|
||||
.\vcpkg.exe install openssl:x64-windows-static-md
|
||||
if ($LASTEXITCODE -ne 0) { throw "vcpkg failed to build OpenSSL" }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
- name: Build discover_printers
|
||||
shell: powershell
|
||||
run: |
|
||||
$env:VCPKGRS_TRIPLET = "x64-windows-static-md"
|
||||
cargo build --release --example discover_printers
|
||||
if ($LASTEXITCODE -ne 0) { throw "cargo build failed" }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
||||
Copy-Item "$env:CARGO_TARGET_DIR\release\examples\discover_printers.exe" dist\
|
||||
Copy-Item scripts\discover-windows-README.txt dist\README.txt
|
||||
|
||||
# It must run on a machine with nothing installed. No arguments
|
||||
# prints the usage and exits non-zero, which proves the binary
|
||||
# loads (no missing DLL) without touching the network.
|
||||
$ErrorActionPreference = "Continue"
|
||||
$out = cmd /c "dist\discover_printers.exe 2>&1" | Out-String
|
||||
if ($out -notmatch "identify") { throw "discover_printers.exe did not print its usage: $out" }
|
||||
Write-Host "usage text looks right"
|
||||
|
||||
# Imports of the .exe, so a DLL that a clean machine lacks shows up
|
||||
# in the log (the VC++ runtime is the one that commonly does).
|
||||
if (Get-Command dumpbin -ErrorAction SilentlyContinue) {
|
||||
& dumpbin /dependents dist\discover_printers.exe | Select-String "\.dll" |
|
||||
ForEach-Object { Write-Host $_.Line.Trim() }
|
||||
}
|
||||
|
||||
$hash = (Get-FileHash dist\discover_printers.exe -Algorithm SHA256).Hash.ToLower()
|
||||
Set-Content -Path dist\SHA256.txt -Value "$hash discover_printers.exe" -Encoding ASCII
|
||||
Write-Host "sha256 $hash"
|
||||
|
||||
# The usage run above exits 1 on purpose, and PowerShell hands the
|
||||
# last native command's exit code to the runner when a script ends -
|
||||
# so without this a build that passed every check was reported
|
||||
# failed (run 414).
|
||||
exit 0
|
||||
|
||||
- name: Upload the executable
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: continuum-discover-printers-windows
|
||||
path: continuum-proxy/dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
# After the upload on purpose: the executable is useful even if a test
|
||||
# that only breaks on Windows fails, and a red job still says so.
|
||||
- name: Run the discovery tests on Windows
|
||||
shell: powershell
|
||||
run: |
|
||||
$env:VCPKGRS_TRIPLET = "x64-windows-static-md"
|
||||
cargo test --lib discovery
|
||||
if ($LASTEXITCODE -ne 0) { throw "discovery tests failed on Windows" }
|
||||
+1
-6
@@ -1,4 +1,5 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
*.db
|
||||
*.db-journal
|
||||
*.sqlite
|
||||
@@ -12,9 +13,3 @@ printers.toml
|
||||
# access codes.
|
||||
go2rtc.yaml
|
||||
go2rtc.yaml.tmp
|
||||
continuum.toml
|
||||
go2rtc.yaml.pid
|
||||
|
||||
# The local store holds printer access codes.
|
||||
continuum.db
|
||||
continuum.db-*
|
||||
|
||||
Generated
-2638
File diff suppressed because it is too large
Load Diff
+1
-12
@@ -75,7 +75,7 @@ rumqttc = { version = "0.25.1", default-features = false, features = ["use-nativ
|
||||
# of explicit AUTH TLS. The printers do not care.
|
||||
# MIT OR Apache-2.0 - checked against docs/LICENSES.md before adding.
|
||||
suppaftp = { version = "12.1.1", features = ["native-tls", "deprecated"] }
|
||||
zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2", "aes-crypto"] }
|
||||
zip = { version = "9.0.3", default-features = false, features = ["deflate-flate2"] }
|
||||
# Reads the subject CN out of the certificate a printer presents on 8883,
|
||||
# which is where its serial number is (see src/discovery.rs). Already in the
|
||||
# tree - native-tls is openssl on Linux - so naming it directly adds nothing
|
||||
@@ -87,14 +87,3 @@ flate2 = "1.1.10"
|
||||
# src/uplink/download.rs). Features are the bare minimum: TLS via rustls, no
|
||||
# openssl-sys; no json/gzip/cookies/etc, since the body is an opaque binary.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots"] }
|
||||
# The local IPv4 addresses, so the SSDP listener can join the multicast group
|
||||
# on every interface rather than the one the OS happens to pick (see
|
||||
# `bind_ssdp`). Windows PCs routinely have VPN/Hyper-V/WSL adapters.
|
||||
# MIT OR BSD-3-Clause - checked against docs/LICENSES.md before adding.
|
||||
if-addrs = "0.15"
|
||||
# The proxy's local store (src/store.rs): printers added from the console live
|
||||
# here, so the gateway keeps its fleet with the API unreachable. `bundled`
|
||||
# compiles SQLite from source so the appliance image needs no system library
|
||||
# (SQLite itself is public domain).
|
||||
# MIT - checked with `cargo deny check licenses` (docs/LICENSES.md policy).
|
||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
||||
|
||||
@@ -110,8 +110,6 @@ src/
|
||||
uplink/ WebSocket client to continuum-backend: connect, heartbeat, reconnect on drop
|
||||
camera.rs Writes go2rtc's config from the camera URLs the printers report
|
||||
go2rtc.rs Restarts the go2rtc camera-restreaming process if it dies, or when the config changes
|
||||
and stops one a hard-killed proxy left behind (pid file next to its config)
|
||||
deploy/ systemd unit template; its KillMode=control-group is what stops go2rtc outliving a SIGKILL'd proxy
|
||||
printer/
|
||||
mod.rs GenericPrinter trait + PrinterBase + PrinterHandle enum - the trait+enum
|
||||
"inheritance" pattern this project uses instead of class inheritance
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Says what kind of installation this is and where the real config lives.
|
||||
# Read from CONTINUUM_BOOTSTRAP, else /etc/continuum/bootstrap.toml, else
|
||||
# absent (which means: PC mode, ./continuum.toml). See src/bootstrap.rs.
|
||||
#
|
||||
# A Proxy Box image ships this root-owned. It must NOT come from a pendrive:
|
||||
# whether a pendrive may change settings is what it decides.
|
||||
|
||||
mode = "proxy_box" # or "pc"
|
||||
# config_path = "/var/lib/continuum/config.toml"
|
||||
# pendrive_dirs = ["/media", "/run/media"] # ignored (and dropped) in pc mode
|
||||
@@ -1,79 +0,0 @@
|
||||
# The proxy's OWN settings - not the printers (that is printers.toml, which
|
||||
# holds per-site secrets and is never shipped on a pendrive). See
|
||||
# src/config.rs for how this file layers with CONTINUUM_* environment
|
||||
# variables: this file is the base, an env var overrides the same key.
|
||||
#
|
||||
# PC / server: copy to continuum.toml next to the proxy and edit it. Nothing
|
||||
# else is needed; removable media is never read.
|
||||
# Proxy Box: this file is NOT written by hand. Continuum Cloud's
|
||||
# configurator produces continuum-config.zip: config.toml,
|
||||
# config.toml.sha256 and config.toml.sig (an Ed25519 signature
|
||||
# naming this box and a config_version), every entry
|
||||
# AES-256-encrypted under the box's own key. A Box refuses
|
||||
# plaintext, unsigned, other-box and older-version bundles.
|
||||
# See the module docs of src/config_import.rs for the exact
|
||||
# format the cloud must produce.
|
||||
#
|
||||
# Unknown keys are rejected on purpose, so a typo cannot silently fall back to
|
||||
# a default. On a Proxy Box the file must be complete by itself.
|
||||
|
||||
gateway_id = "REPLACE_ME"
|
||||
farm_id = "REPLACE_ME"
|
||||
# The farm's edge proxy token from the console (Setup page). Traded for a
|
||||
# short-lived edge JWT on every connection - see src/uplink/auth.rs.
|
||||
gateway_token = "REPLACE_ME"
|
||||
|
||||
# uplink_url = "wss://api.continuum.local/ws/edge/v1"
|
||||
# go2rtc_bin = "go2rtc"
|
||||
# go2rtc_config = "./go2rtc.yaml"
|
||||
# go2rtc_api_listen = ":1984"
|
||||
# stream_base_url = "https://proxy.example/"
|
||||
# printers_path = "/var/lib/continuum/printers.toml" # absolute on a box
|
||||
|
||||
# Proxy Box only. PARSED AND VALIDATED, NOT APPLIED - this build never
|
||||
# touches the OS network configuration.
|
||||
# [network]
|
||||
# mode = "static" # or "dhcp"
|
||||
# ip = "192.168.1.60"
|
||||
# prefix_len = 24
|
||||
# gateway = "192.168.1.1"
|
||||
# dns = ["1.1.1.1"]
|
||||
# [network.wifi]
|
||||
# ssid = "farm"
|
||||
# psk = "REPLACE_ME"
|
||||
|
||||
# Printer discovery: look for printers nobody has configured and report them
|
||||
# to the cloud, so adding one in the web console does not start with walking
|
||||
# to a machine to read its IP address off the screen.
|
||||
#
|
||||
# OFF by default, and the whole block is commented out here for that reason.
|
||||
# A sweep is unsolicited traffic on someone else's network - 254 TLS
|
||||
# handshakes and up to three HTTP requests per host, which nothing watching
|
||||
# can distinguish from a port scan. Switch it on only for a network you are
|
||||
# responsible for.
|
||||
#
|
||||
# Discovery never supplies a credential, because nothing can: a Bambu access
|
||||
# code is displayed on the printer and a PrusaLink API key is in its web UI.
|
||||
# It supplies the address, the serial and the vendor - the parts you would
|
||||
# otherwise have to look up.
|
||||
#
|
||||
# `subnets` is explicit and never derived from this host's interfaces, so a
|
||||
# second NIC, a VPN or a container bridge cannot widen what gets swept.
|
||||
# Anything wider than a /24 is refused.
|
||||
#
|
||||
# `serial` reads USB descriptors from sysfs and opens nothing. It will never
|
||||
# send an `M115` handshake: opening a serial port toggles DTR, which reboots
|
||||
# an Arduino-based board, and on a printer mid-job that ruins the print. Use
|
||||
# `cargo run --example discover_printers -- serial-probe <port>` when you want
|
||||
# the handshake and are standing in front of the machine.
|
||||
#
|
||||
# `ssdp` listens passively for Bambu announcements. It sends nothing, but
|
||||
# multicast does not cross a router, so on a real farm network a sweep is
|
||||
# what actually works.
|
||||
#
|
||||
# [discovery]
|
||||
# enabled = true
|
||||
# subnets = ["192.168.0.0/24"]
|
||||
# ssdp = false
|
||||
# serial = false
|
||||
# interval_minutes = 30
|
||||
@@ -1,38 +0,0 @@
|
||||
# Continuum proxy as a systemd service - the template for a Proxy Box or a
|
||||
# Linux PC install. Adjust the paths; create the `continuum` user and put the
|
||||
# settings it reads (continuum.toml, or the signed bundle on a Box) under
|
||||
# /var/lib/continuum.
|
||||
#
|
||||
# Not exercised under a real systemd yet: this container's init is not
|
||||
# systemd, so only `systemd-analyze verify` has been run against it.
|
||||
[Unit]
|
||||
Description=Continuum proxy (printer gateway)
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=continuum
|
||||
# The proxy's relative defaults (./go2rtc.yaml, ./printers.toml, the go2rtc
|
||||
# pid file) resolve here, and StateDirectory creates it owned by the user.
|
||||
StateDirectory=continuum
|
||||
WorkingDirectory=/var/lib/continuum
|
||||
ExecStart=/usr/local/bin/continuum-proxy
|
||||
|
||||
# Always, including a clean exit: installing a new config from a pendrive
|
||||
# deliberately ends the process and relies on this to start it on the new
|
||||
# settings (src/main.rs, config_import).
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
# Do not change this to `process` or `none`. go2rtc is the proxy's child, so
|
||||
# it lives in this unit's cgroup, and this is what makes systemd kill it
|
||||
# whenever the proxy goes - including SIGKILL and the OOM killer, which give
|
||||
# the proxy no chance to stop it itself. It is systemd's default; it is
|
||||
# written out so nobody weakens it by accident. An orphaned go2rtc keeps
|
||||
# :1984/:8555 bound and a printer's access code in memory.
|
||||
KillMode=control-group
|
||||
TimeoutStopSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,59 +0,0 @@
|
||||
//! 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(())
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
//! cargo run --example discover_printers -- identify <host>... # one known address
|
||||
//! cargo run --example discover_printers -- sweep <a.b.c.0/24> # every address on a segment
|
||||
//! cargo run --example discover_printers -- serial # USB/serial ports, passive
|
||||
//! cargo run --example discover_printers -- wire [subnet] [--ssdp] # the frame the daemon sends
|
||||
//! cargo run --example discover_printers -- serial-probe <port> [baud] --yes-reset-the-printer
|
||||
//! ```
|
||||
//!
|
||||
@@ -69,12 +68,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
// picking one would mean probing a network nobody named.
|
||||
None => println!("(no subnet given, so no sweep - pass one like 192.168.0.0/24 to include it)"),
|
||||
}
|
||||
// Not `?`: serial listing reads Linux sysfs and fails on any other
|
||||
// OS, and that must not throw away what the listen and sweep found.
|
||||
match list_serial() {
|
||||
Ok(ports) => found.extend(ports),
|
||||
Err(err) => println!("(serial ports skipped: {err})"),
|
||||
}
|
||||
found.extend(list_serial()?);
|
||||
found
|
||||
}
|
||||
"listen" | "raw" => {
|
||||
@@ -120,50 +114,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
println!("opening {port} at {baud} baud - this resets the board; waiting for it to boot ...");
|
||||
vec![serial::probe_m115(port, baud, SERIAL_TIMEOUT).await?]
|
||||
}
|
||||
// Not merged with the others, because this one answers a different
|
||||
// question: not "what is out there" but "what exactly would the
|
||||
// daemon send about it". So it runs the real `scan` - the same
|
||||
// function the background task calls, with the same fleet filtering
|
||||
// and the same refusal to open a serial port - and prints the frame.
|
||||
//
|
||||
// The reason to have it: the gateway and the backend agree about this
|
||||
// message only by hand, and a frame captured from real hardware is
|
||||
// the only thing that proves the two halves match. See
|
||||
// `continuum-backend/tests/edge_protocol.test.ts`, which validates
|
||||
// captures like this one.
|
||||
"wire" => {
|
||||
let settings = continuum_proxy::config::DiscoverySettings {
|
||||
enabled: true,
|
||||
subnets: args.get(1).map(|s| vec![s.clone()]).unwrap_or_default(),
|
||||
// On by default here and off in the daemon: a human asked for
|
||||
// this one and is waiting for the output.
|
||||
ssdp: args.iter().any(|a| a == "--ssdp"),
|
||||
serial: true,
|
||||
interval: Duration::from_secs(60),
|
||||
};
|
||||
let path = std::env::var("CONTINUUM_PRINTERS_PATH").unwrap_or_else(|_| "printers.toml".into());
|
||||
let configured = continuum_proxy::fleet::configured(std::path::Path::new(&path))?;
|
||||
let scan = discovery::scan::scan(&settings, &configured).await;
|
||||
if !scan.looked() {
|
||||
anyhow::bail!("every discovery source failed, so there is nothing to report");
|
||||
}
|
||||
let printers: Vec<_> = scan.candidates.iter().map(Candidate::to_wire).collect();
|
||||
let count = printers.len();
|
||||
// The real enum through the real serializer, not a hand-built
|
||||
// JSON object. A hand-built one would agree with this file rather
|
||||
// than with the daemon, which is the whole thing this subcommand
|
||||
// exists to rule out.
|
||||
let frame = continuum_proxy::uplink::ClientMessage::DiscoveredPrinters {
|
||||
printers,
|
||||
scanned_at: discovery::report::now_rfc3339(),
|
||||
};
|
||||
println!("{}", serde_json::to_string_pretty(&frame)?);
|
||||
eprintln!(
|
||||
"({count} candidate(s) after removing anything {path} already names; point \
|
||||
CONTINUUM_PRINTERS_PATH at an empty file to see them all)"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
other => anyhow::bail!("unknown command {other:?}\n{}", usage()),
|
||||
};
|
||||
|
||||
@@ -173,8 +123,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: discover_printers all [subnet] | listen [seconds] | raw [seconds] | identify <host>... \
|
||||
| sweep <a.b.c.0/24> | serial | wire [subnet] [--ssdp] \
|
||||
| serial-probe <port> [baud] --yes-reset-the-printer"
|
||||
| sweep <a.b.c.0/24> | serial | serial-probe <port> [baud] --yes-reset-the-printer"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
discover_printers.exe - finds Continuum-compatible 3D printers on this network
|
||||
=============================================================================
|
||||
|
||||
Run it from PowerShell, in the folder it is in. It only LISTENS and asks
|
||||
printers who they are. It sends no passwords or access codes and changes
|
||||
nothing on any printer.
|
||||
|
||||
The test we need (Bambu printers announce themselves over SSDP multicast,
|
||||
which only works if this PC is on the SAME network segment as the printers -
|
||||
same Wi-Fi/Ethernet, not through a VPN, not a guest network):
|
||||
|
||||
.\discover_printers.exe raw 120 2>&1 | Tee-Object ssdp.txt
|
||||
|
||||
That listens for 2 minutes and prints EVERY datagram it hears, then what it
|
||||
understood. Send back ssdp.txt. "listening for SSDP ... interfaces=[...]"
|
||||
near the top lists the adapters it joined; the one on the printers' network
|
||||
should be there.
|
||||
|
||||
Other commands:
|
||||
|
||||
.\discover_printers.exe listen 30 SSDP only, Bambu printers
|
||||
.\discover_printers.exe sweep 192.168.0.0/24 probe every address on a subnet
|
||||
.\discover_printers.exe all 192.168.0.0/24 both, merged
|
||||
.\discover_printers.exe identify 192.168.0.21 one known address
|
||||
|
||||
Windows Firewall will ask whether to allow it on first run. Allow it on
|
||||
PRIVATE networks; without that, Windows drops the multicast and the result is
|
||||
"No printers found" even when printers are there.
|
||||
|
||||
Check the file is intact: Get-FileHash .\discover_printers.exe -Algorithm SHA256
|
||||
(compare with SHA256.txt)
|
||||
@@ -1,274 +0,0 @@
|
||||
//! The tiny, fixed-location answer to "what kind of installation is this, and
|
||||
//! where is my real config?" - which has to be known *before* the real config
|
||||
//! can be found.
|
||||
//!
|
||||
//! # Why a second file at all
|
||||
//!
|
||||
//! The proxy's settings live in a config file (see `config.rs`), but a Proxy
|
||||
//! Box replaces that file from a pendrive, and a PC user keeps it wherever
|
||||
//! they like. Something has to say which of those two worlds this is, and
|
||||
//! where the file is. That something cannot be in the file it locates, and it
|
||||
//! must not be on the pendrive: whether removable media may change settings
|
||||
//! at all is the very thing being decided, so the media cannot be the one to
|
||||
//! say yes.
|
||||
//!
|
||||
//! # Discovery order
|
||||
//!
|
||||
//! 1. `CONTINUUM_BOOTSTRAP` - an explicit path; if set and unreadable that is
|
||||
//! an error, not a fallthrough, because someone pointed at a file on
|
||||
//! purpose.
|
||||
//! 2. `/etc/continuum/bootstrap.toml` - the fixed location a Proxy Box image
|
||||
//! ships. Root-owned, so a settings change on a box cannot be made by the
|
||||
//! service user or by a pendrive.
|
||||
//! 3. Nothing found: **PC mode with defaults**. This is what every existing
|
||||
//! deployment (env vars only, no files) lands in, so adding the file layer
|
||||
//! changes nothing for them.
|
||||
//!
|
||||
//! A file in the working directory is deliberately *not* searched. It would
|
||||
//! be convenient on a PC, but "whichever directory the service happened to
|
||||
//! start in can flip the box into a different mode" is a bad property for the
|
||||
//! one file that gates removable-media import.
|
||||
//!
|
||||
//! # Overrides
|
||||
//!
|
||||
//! The same precedence as `config.rs`: bootstrap file, then env on top.
|
||||
//! `CONTINUUM_MODE` (`proxy_box` | `pc`), `CONTINUUM_CONFIG_PATH`,
|
||||
//! `CONTINUUM_PENDRIVE_DIRS` (colon-separated).
|
||||
//!
|
||||
//! Note what that means on a box: an environment variable can still override
|
||||
//! a setting. The env of a service is set by its unit file, which is part of
|
||||
//! the root-owned image, so it sits on the same side of the trust line as the
|
||||
//! bootstrap file. It is not a way for a pendrive or the service user to
|
||||
//! change anything.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::ProxyFile;
|
||||
|
||||
/// Where a Proxy Box image puts its bootstrap file.
|
||||
pub const BOOTSTRAP_PATH: &str = "/etc/continuum/bootstrap.toml";
|
||||
|
||||
/// Where a Proxy Box keeps the config it is running on. Under `/var/lib`, not
|
||||
/// `/etc`: the service has to be able to replace this file atomically (which
|
||||
/// needs a writable directory), and it should not need write access to `/etc`
|
||||
/// to do so.
|
||||
const BOX_CONFIG_PATH: &str = "/var/lib/continuum/config.toml";
|
||||
|
||||
/// Relative on purpose: next to wherever the user runs the proxy from, like
|
||||
/// `printers.toml`.
|
||||
const PC_CONFIG_PATH: &str = "continuum.toml";
|
||||
|
||||
/// Where desktop environments and udev rules mount removable media. Only
|
||||
/// consulted on a Proxy Box.
|
||||
const DEFAULT_PENDRIVE_DIRS: [&str; 2] = ["/media", "/run/media"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Mode {
|
||||
/// A dedicated appliance. Settings change only by supplying a new
|
||||
/// pendrive/upload.
|
||||
ProxyBox,
|
||||
/// A program on someone's PC or server. The config is a local file they
|
||||
/// edit; removable media is never read.
|
||||
Pc,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BootstrapFile {
|
||||
mode: Option<Mode>,
|
||||
config_path: Option<PathBuf>,
|
||||
pendrive_dirs: Option<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Bootstrap {
|
||||
pub mode: Mode,
|
||||
/// The config the proxy runs on - on a box, the installed copy that
|
||||
/// imports replace.
|
||||
pub config_path: PathBuf,
|
||||
/// Whether `config_path` was named by someone, rather than defaulted. An
|
||||
/// explicit path that does not exist is an error; a defaulted one just
|
||||
/// means "no file", which keeps env-only deployments working.
|
||||
pub config_path_is_explicit: bool,
|
||||
/// Directories searched for a config bundle. Always empty in PC mode,
|
||||
/// whatever the bootstrap said - see `from_parts`.
|
||||
pub pendrive_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Bootstrap {
|
||||
pub fn discover() -> anyhow::Result<Self> {
|
||||
Self::discover_at(Path::new(BOOTSTRAP_PATH), &|key| std::env::var(key).ok())
|
||||
}
|
||||
|
||||
/// `discover` with the fixed path and the environment injected, so tests
|
||||
/// do not depend on the machine's `/etc` or mutate process-wide env.
|
||||
pub fn discover_at(fixed: &Path, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
|
||||
let file = match env("CONTINUUM_BOOTSTRAP") {
|
||||
Some(explicit) => Self::read(Path::new(&explicit))?,
|
||||
None => match std::fs::read_to_string(fixed) {
|
||||
Ok(text) => Self::parse(&text, fixed)?,
|
||||
// Absent is the normal case on a PC. Anything else (a file
|
||||
// we cannot read) is not "absent" and must not quietly turn
|
||||
// a box into a PC.
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => BootstrapFile::default(),
|
||||
Err(err) => anyhow::bail!("failed to read bootstrap config {fixed:?}: {err}"),
|
||||
},
|
||||
};
|
||||
Self::from_parts(file, env)
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> anyhow::Result<BootstrapFile> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|err| anyhow::anyhow!("failed to read bootstrap config {path:?} (from CONTINUUM_BOOTSTRAP): {err}"))?;
|
||||
Self::parse(&text, path)
|
||||
}
|
||||
|
||||
fn parse(text: &str, path: &Path) -> anyhow::Result<BootstrapFile> {
|
||||
toml::from_str(text).map_err(|err| anyhow::anyhow!("bootstrap config {path:?} is invalid: {err}"))
|
||||
}
|
||||
|
||||
fn from_parts(file: BootstrapFile, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
|
||||
let mode = match env("CONTINUUM_MODE") {
|
||||
Some(value) => match value.as_str() {
|
||||
"proxy_box" => Mode::ProxyBox,
|
||||
"pc" => Mode::Pc,
|
||||
other => anyhow::bail!("CONTINUUM_MODE must be \"proxy_box\" or \"pc\", not {other:?}"),
|
||||
},
|
||||
None => file.mode.unwrap_or(Mode::Pc),
|
||||
};
|
||||
|
||||
let explicit_config = env("CONTINUUM_CONFIG_PATH").map(PathBuf::from).or(file.config_path);
|
||||
let config_path_is_explicit = explicit_config.is_some();
|
||||
let config_path = explicit_config.unwrap_or_else(|| match mode {
|
||||
Mode::ProxyBox => PathBuf::from(BOX_CONFIG_PATH),
|
||||
Mode::Pc => PathBuf::from(PC_CONFIG_PATH),
|
||||
});
|
||||
|
||||
let requested_dirs = env("CONTINUUM_PENDRIVE_DIRS")
|
||||
.map(|v| v.split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect())
|
||||
.or(file.pendrive_dirs);
|
||||
let pendrive_dirs = match mode {
|
||||
Mode::ProxyBox => requested_dirs
|
||||
.unwrap_or_else(|| DEFAULT_PENDRIVE_DIRS.iter().map(PathBuf::from).collect()),
|
||||
// Dropped here, at the source, and not merely ignored later: the
|
||||
// import code then has nothing to scan even if a future caller
|
||||
// forgets to check the mode. In PC mode pendrive import is a way
|
||||
// to change settings behind the user's back, so the safe state
|
||||
// is the one where the list cannot be non-empty.
|
||||
Mode::Pc => {
|
||||
if requested_dirs.is_some_and(|dirs| !dirs.is_empty()) {
|
||||
tracing::warn!("pendrive_dirs is set but this is PC mode; removable media will NOT be read");
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self { mode, config_path, config_path_is_explicit, pendrive_dirs })
|
||||
}
|
||||
|
||||
/// Reads the real config. `None` means "no file, use env and defaults" -
|
||||
/// only ever for a path nobody named, so env-only deployments keep
|
||||
/// working. A named path that is missing, or any file that does not
|
||||
/// parse, is an error: starting on defaults because the operator's file
|
||||
/// had a typo would run a proxy that looks configured and is not.
|
||||
pub fn load_config_file(&self) -> anyhow::Result<Option<ProxyFile>> {
|
||||
match std::fs::read_to_string(&self.config_path) {
|
||||
Ok(text) => ProxyFile::parse(&text)
|
||||
.map(Some)
|
||||
.map_err(|err| anyhow::anyhow!("config file {:?} is invalid: {err}", self.config_path)),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound && !self.config_path_is_explicit => Ok(None),
|
||||
Err(err) => Err(anyhow::anyhow!("failed to read config file {:?}: {err}", self.config_path)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn no_env(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn from_text(text: &str, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Bootstrap> {
|
||||
Bootstrap::from_parts(Bootstrap::parse(text, Path::new("test"))?, env)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_bootstrap_anywhere_the_proxy_is_a_pc_and_reads_no_pendrives() {
|
||||
let missing = Path::new("/definitely/not/here/bootstrap.toml");
|
||||
let b = Bootstrap::discover_at(missing, &no_env).unwrap();
|
||||
assert_eq!(b.mode, Mode::Pc);
|
||||
assert_eq!(b.config_path, PathBuf::from("continuum.toml"));
|
||||
assert!(!b.config_path_is_explicit);
|
||||
assert!(b.pendrive_dirs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proxy_box_defaults_to_the_var_lib_config_and_the_standard_mount_points() {
|
||||
let b = from_text("mode = \"proxy_box\"", &no_env).unwrap();
|
||||
assert_eq!(b.config_path, PathBuf::from(BOX_CONFIG_PATH));
|
||||
assert_eq!(b.pendrive_dirs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pc_mode_drops_pendrive_dirs_even_when_the_bootstrap_lists_some() {
|
||||
let b = from_text("mode = \"pc\"\npendrive_dirs = [\"/media\"]", &no_env).unwrap();
|
||||
assert!(b.pendrive_dirs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pc_mode_drops_pendrive_dirs_supplied_through_the_environment_too() {
|
||||
let env = |k: &str| (k == "CONTINUUM_PENDRIVE_DIRS").then(|| "/media:/mnt".to_string());
|
||||
let b = from_text("mode = \"pc\"", &env).unwrap();
|
||||
assert!(b.pendrive_dirs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_environment_overrides_the_bootstrap_file() {
|
||||
let env = |k: &str| match k {
|
||||
"CONTINUUM_MODE" => Some("pc".to_string()),
|
||||
"CONTINUUM_CONFIG_PATH" => Some("/tmp/mine.toml".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let b = from_text("mode = \"proxy_box\"", &env).unwrap();
|
||||
assert_eq!(b.mode, Mode::Pc);
|
||||
assert_eq!(b.config_path, PathBuf::from("/tmp/mine.toml"));
|
||||
assert!(b.config_path_is_explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_mode_in_the_environment_is_an_error_not_a_guess() {
|
||||
let env = |k: &str| (k == "CONTINUUM_MODE").then(|| "appliance".to_string());
|
||||
assert!(from_text("", &env).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_misspelled_bootstrap_key_is_refused() {
|
||||
assert!(from_text("mod = \"proxy_box\"", &no_env).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_bootstrap_path_that_does_not_exist_is_an_error() {
|
||||
let env = |k: &str| (k == "CONTINUUM_BOOTSTRAP").then(|| "/definitely/not/here.toml".to_string());
|
||||
assert!(Bootstrap::discover_at(Path::new("/also/missing"), &env).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_defaulted_config_path_that_does_not_exist_means_no_file_not_an_error() {
|
||||
let mut b = from_text("", &no_env).unwrap();
|
||||
b.config_path = PathBuf::from("/definitely/not/here/continuum.toml");
|
||||
assert!(b.load_config_file().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_config_path_that_does_not_exist_is_an_error() {
|
||||
let mut b = from_text("", &no_env).unwrap();
|
||||
b.config_path = PathBuf::from("/definitely/not/here/continuum.toml");
|
||||
b.config_path_is_explicit = true;
|
||||
assert!(b.load_config_file().is_err());
|
||||
}
|
||||
}
|
||||
+11
-65
@@ -18,10 +18,9 @@
|
||||
//! Only the X1/H2 class publishes an `rtsp_url`. Confirmed against two real
|
||||
//! machines: an H2C reports `rtsps://<ip>:322/streaming/live/1`, and a P1P
|
||||
//! reports an `ipcam` block with no URL in it at all. A P1/A1 camera is
|
||||
//! reachable only over Bambu's proprietary port-6000 JPEG stream. That is
|
||||
//! not restreamable here (go2rtc cannot make WebRTC of MJPEG without ffmpeg),
|
||||
//! so those printers get no go2rtc stream; they are served single frames by
|
||||
//! `printer::bambu_camera` instead and advertise `camera_snapshot`.
|
||||
//! reachable only over Bambu's proprietary port-6000 JPEG stream, which this
|
||||
//! crate does not speak, so those printers get no stream here and say so
|
||||
//! rather than advertising one that will not play.
|
||||
//!
|
||||
//! 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
|
||||
@@ -56,16 +55,11 @@ use crate::uplink::SharedFleet;
|
||||
|
||||
/// How often to rebuild the configuration from what the printers report.
|
||||
///
|
||||
/// The first tick fires immediately, before any printer has reported, so it
|
||||
/// always writes a config with no streams. The one that matters is the next,
|
||||
/// once the first reports have arrived - and until it runs, telemetry already
|
||||
/// advertises a camera that go2rtc cannot serve, so a viewer in that window
|
||||
/// gets "stream not found". At 30s that window was half a minute after every
|
||||
/// start (found by scripts/e2e-camera.sh, which asked at +7s).
|
||||
///
|
||||
/// Cheap to poll: it reads in-memory reports and compares the rendered
|
||||
/// config byte for byte, and go2rtc is only restarted when that changes.
|
||||
const REBUILD_INTERVAL: Duration = Duration::from_secs(5);
|
||||
/// A camera URL changes when a printer's address changes, which is a DHCP
|
||||
/// lease event, not something that needs watching closely. The first useful
|
||||
/// rebuild is the one a few seconds after startup, once the first reports
|
||||
/// have arrived - before that the fleet has nothing to say.
|
||||
const REBUILD_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The username every Bambu uses for its own services - MQTT, FTPS, and the
|
||||
/// camera. Not a guess: it is what `bambu_mqtt` and `bambu_ftps` already
|
||||
@@ -132,14 +126,6 @@ pub fn source_for(printer: &PrinterHandle, report_url: Option<&str>) -> Option<C
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this printer's camera is the P1/A1 kind: it reports a camera
|
||||
/// block but no RTSP URL, so the only way to see it is single JPEG frames
|
||||
/// from port 6000 (`printer::bambu_camera`). The one rule for "snapshot
|
||||
/// camera", used by telemetry, so what is advertised is what is served.
|
||||
pub fn wants_snapshot(printer: &PrinterHandle, has_ipcam: bool, report_url: Option<&str>) -> bool {
|
||||
matches!(printer, PrinterHandle::Bambu(_)) && has_ipcam && report_url.is_none_or(str::is_empty)
|
||||
}
|
||||
|
||||
/// Renders a go2rtc configuration.
|
||||
///
|
||||
/// Hand-written YAML rather than a serializer, for the same reason the
|
||||
@@ -176,8 +162,8 @@ impl ApiAuth {
|
||||
/// Generated once per run and then held, never re-derived: `render_config`
|
||||
/// is called every `REBUILD_INTERVAL` and its output is compared
|
||||
/// byte-for-byte to decide whether to restart go2rtc, so a password that
|
||||
/// changed per call would restart it - and drop every live viewer - on
|
||||
/// every tick, forever.
|
||||
/// changed per call would restart it - and drop every live viewer - every
|
||||
/// 30 seconds, forever.
|
||||
///
|
||||
/// A generated password is never logged. It is written into the config
|
||||
/// file, which already holds every printer's access code and is already
|
||||
@@ -383,23 +369,8 @@ pub async fn run(
|
||||
/// on the same filesystem, so a reader sees either the old file or the new
|
||||
/// one.
|
||||
async fn write_atomically(path: &str, contents: &str) -> std::io::Result<()> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let temp = format!("{path}.tmp");
|
||||
// `tokio::fs::write` creates with 0666 minus the umask, so 0644: any local
|
||||
// user could read the printer's access code and go2rtc's API password out
|
||||
// of this file. Created owner-only instead, and created fresh - a temp file
|
||||
// left behind by a crash would keep whatever mode it was given, because the
|
||||
// mode applies only when the file is created.
|
||||
let _ = tokio::fs::remove_file(&temp).await;
|
||||
let mut options = tokio::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
options.mode(0o600);
|
||||
let mut file = options.open(&temp).await?;
|
||||
file.write_all(contents.as_bytes()).await?;
|
||||
file.flush().await?;
|
||||
drop(file);
|
||||
tokio::fs::write(&temp, contents).await?;
|
||||
tokio::fs::rename(&temp, path).await
|
||||
}
|
||||
|
||||
@@ -567,29 +538,4 @@ mod tests {
|
||||
"got:\n{config}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The file holds a printer's access code and go2rtc's API password, so it
|
||||
/// must not be readable by other local users. The second write matters: a
|
||||
/// temp file left by a crash, created 0644, must not poison the next one.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn the_go2rtc_config_is_written_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("continuum-camera-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("go2rtc.yaml");
|
||||
let path_str = path.to_str().unwrap();
|
||||
|
||||
std::fs::write(format!("{path_str}.tmp"), "stale").unwrap();
|
||||
std::fs::set_permissions(format!("{path_str}.tmp"), std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
|
||||
write_atomically(path_str, "secret: yes\n").await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "secret: yes\n");
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
|
||||
write_atomically(path_str, "secret: again\n").await.unwrap();
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ pub async fn run(fleet: SharedFleet) {
|
||||
if let Err(err) = printer.refresh_status().await {
|
||||
warn!(printer = %printer.base().id, %err, "could not request a status refresh");
|
||||
}
|
||||
if let Some(state) = printer.status().await.and_then(|s| s.state) {
|
||||
if let Some(state) = printer.print_status().await.and_then(|r| r.gcode_state) {
|
||||
states.push((printer.base().id.clone(), state));
|
||||
}
|
||||
}
|
||||
|
||||
+14
-608
@@ -1,51 +1,6 @@
|
||||
//! The proxy's own settings: what it is called, where the cloud is, where
|
||||
//! go2rtc lives, and (on a Proxy Box) how it is networked.
|
||||
//!
|
||||
//! # Where settings come from, in one place
|
||||
//!
|
||||
//! **A config file is the base; `CONTINUUM_*` environment variables override
|
||||
//! it; built-in defaults fill what neither says.** That is the whole
|
||||
//! precedence rule and nothing else in the crate re-states it. Env wins
|
||||
//! because that is what every existing deployment, test and `.env` file
|
||||
//! already relies on - `Config::from_env` predates the file, and a file that
|
||||
//! silently beat the env would change the behaviour of setups that never
|
||||
//! asked for a file.
|
||||
//!
|
||||
//! *Which* file is not decided here: that is the bootstrap's job (see
|
||||
//! `bootstrap.rs`), because the proxy has to know whether it is a Proxy Box
|
||||
//! or a program on someone's PC before it knows where to look. This module
|
||||
//! only parses a file it is handed and layers it under the environment.
|
||||
//!
|
||||
//! # Not the fleet file
|
||||
//!
|
||||
//! `printers.toml` (see `fleet.rs`) lists the printers and holds their access
|
||||
//! codes. This file configures the *proxy itself*. They are separate on
|
||||
//! purpose: the proxy config is meant to be shipped to a box on a pendrive by
|
||||
//! whoever provisions it, and the fleet file is full of per-site secrets that
|
||||
//! should never ride along on a stick that gets passed around. The two meet
|
||||
//! at exactly one field, `printers_path`, which says where the fleet file is.
|
||||
//! On a Proxy Box the fleet file is therefore NOT delivered by this
|
||||
//! mechanism - how it gets there is a separate, unsolved question.
|
||||
//!
|
||||
//! # Network settings are parsed, not applied
|
||||
//!
|
||||
//! `[network]` is read, validated and exposed (`ProxyFile::network`), and
|
||||
//! that is where it stops. Nothing in this crate touches `/etc/network`,
|
||||
//! NetworkManager or any other OS state. Applying them means choosing a
|
||||
//! network stack for an image that does not exist yet, running as root, and a
|
||||
//! wrong write can take the box off the network with nobody able to log in
|
||||
//! to fix it - so it belongs in the appliance's provisioning layer, designed
|
||||
//! together with a rollback. The boundary is `NetworkSettings` itself:
|
||||
//! whatever applies it consumes that type and the proxy never will.
|
||||
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub gateway_id: String,
|
||||
pub farm_id: String,
|
||||
@@ -76,577 +31,28 @@ pub struct Config {
|
||||
/// `BambuPrinter::ensure_trusted`) lives entirely in that file now,
|
||||
/// not as a separate fleet-wide env-configured policy here.
|
||||
pub printers_path: String,
|
||||
/// The gateway's own database (src/store.rs): printers added from the
|
||||
/// console, credentials included, so the file is created owner-only.
|
||||
pub store_path: String,
|
||||
/// How, and whether, to look for printers nobody has configured.
|
||||
///
|
||||
/// Off unless switched on - see [`DiscoverySettings`] for why that is
|
||||
/// not merely a cautious default.
|
||||
pub discovery: DiscoverySettings,
|
||||
}
|
||||
|
||||
/// The file form of the proxy's settings. Every key is optional here: a key
|
||||
/// the file omits falls through to the environment and then the default, so
|
||||
/// a PC user can keep secrets in the environment and a file for the rest.
|
||||
///
|
||||
/// `deny_unknown_fields` is deliberate and the opposite of lenient. On a
|
||||
/// Proxy Box nobody can edit the file afterwards, so a typo'd key
|
||||
/// (`uplink_ulr`) that was silently ignored would ship a box running on the
|
||||
/// default cloud URL with nothing saying so. Refusing at import time turns
|
||||
/// that into a rejected pendrive the operator sees immediately.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProxyFile {
|
||||
pub gateway_id: Option<String>,
|
||||
pub farm_id: Option<String>,
|
||||
pub gateway_token: Option<String>,
|
||||
pub uplink_url: Option<String>,
|
||||
pub go2rtc_bin: Option<String>,
|
||||
pub go2rtc_config: Option<String>,
|
||||
pub go2rtc_api_listen: Option<String>,
|
||||
pub stream_base_url: Option<String>,
|
||||
pub printers_path: Option<String>,
|
||||
pub store_path: Option<String>,
|
||||
/// Only meaningful on a Proxy Box; see the module docs for why it is
|
||||
/// exposed and not applied.
|
||||
pub network: Option<NetworkSettings>,
|
||||
/// Absent means off, which is the same as `enabled = false`. A file that
|
||||
/// says nothing about discovery gets none.
|
||||
pub discovery: Option<DiscoveryFile>,
|
||||
}
|
||||
|
||||
impl ProxyFile {
|
||||
pub fn parse(text: &str) -> anyhow::Result<Self> {
|
||||
let file: Self = toml::from_str(text)?;
|
||||
if let Some(network) = &file.network {
|
||||
network.validate()?;
|
||||
}
|
||||
if let Some(discovery) = &file.discovery {
|
||||
discovery.validate()?;
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether and how the gateway looks for printers nobody configured, and the
|
||||
/// one thing it must never do while looking.
|
||||
///
|
||||
/// # Off by default, and not just out of caution
|
||||
///
|
||||
/// Discovery sends unsolicited traffic to addresses on someone else's
|
||||
/// network. A `/24` sweep is 254 TLS handshakes and up to three HTTP requests
|
||||
/// per host. Anything watching cannot tell that from a port scan, and on a
|
||||
/// corporate network being indistinguishable from a port scan is itself the
|
||||
/// problem. A gateway that did this by default would scan networks
|
||||
/// its operator never meant to expose it to, and the first they would hear of
|
||||
/// it is from their own security team.
|
||||
///
|
||||
/// So `enabled` is false until someone says otherwise, per gateway, in that
|
||||
/// gateway's own config. The cloud cannot switch it on: a setting whose whole
|
||||
/// purpose is to bound what the gateway does to the local network has to be
|
||||
/// decided where that network is understood, which is not in a data centre.
|
||||
///
|
||||
/// # Serial discovery never opens a port
|
||||
///
|
||||
/// `serial` enumerates USB devices from sysfs and stops there. It will not
|
||||
/// send `M115`, ever, and that is a correctness requirement rather than a
|
||||
/// preference: opening a serial port toggles DTR, which reboots an
|
||||
/// Arduino-based board. On a printer that is mid-job that ruins the print.
|
||||
/// `examples/discover_printers.rs` can do the handshake because a human is
|
||||
/// standing there and has typed `--yes-reset-the-printer`; a background task
|
||||
/// on a schedule has nobody to ask, so it does not get the option.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiscoverySettings {
|
||||
pub enabled: bool,
|
||||
/// Subnets to sweep, as CIDR, e.g. `192.168.0.0/24`. Empty means no
|
||||
/// sweep - the gateway then only listens, if `ssdp` is on.
|
||||
///
|
||||
/// Explicit rather than derived from the gateway's own interfaces. A box
|
||||
/// with a second NIC, a VPN or a container bridge would otherwise sweep
|
||||
/// a network nobody meant to point it at, and the operator naming the
|
||||
/// subnet is also the operator taking responsibility for it.
|
||||
pub subnets: Vec<String>,
|
||||
/// Passive SSDP listening: no traffic sent, just whatever Bambu printers
|
||||
/// announce themselves. Cheap and unobjectionable, but useless across a
|
||||
/// router - multicast does not cross one - so a sweep is what works on a
|
||||
/// real farm network.
|
||||
pub ssdp: bool,
|
||||
/// Enumerate USB serial devices. Reads sysfs; opens nothing. See above.
|
||||
pub serial: bool,
|
||||
pub interval: Duration,
|
||||
}
|
||||
|
||||
impl Default for DiscoverySettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
subnets: Vec::new(),
|
||||
ssdp: false,
|
||||
serial: false,
|
||||
// Only ever reached when `enabled` is true, so this is "how
|
||||
// often a farm's hardware changes", not "how often to scan a
|
||||
// network that did not ask for it". Half an hour is far more
|
||||
// often than printers get bought and still negligible traffic.
|
||||
interval: Duration::from_secs(30 * 60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file form. Every key optional, same as the rest of [`ProxyFile`].
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DiscoveryFile {
|
||||
pub enabled: Option<bool>,
|
||||
pub subnets: Option<Vec<String>>,
|
||||
pub ssdp: Option<bool>,
|
||||
pub serial: Option<bool>,
|
||||
pub interval_minutes: Option<u64>,
|
||||
}
|
||||
|
||||
impl DiscoveryFile {
|
||||
/// Rejects at import time what would otherwise be found at scan time -
|
||||
/// which on a Proxy Box means a stick the operator has already walked
|
||||
/// away from.
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if let Some(minutes) = self.interval_minutes {
|
||||
// A floor, because the cost of a sweep falls on the printers and
|
||||
// on whatever else answers on that network, not on us. One
|
||||
// minute is already more often than anything here changes.
|
||||
anyhow::ensure!(minutes >= 1, "discovery.interval_minutes must be at least 1");
|
||||
}
|
||||
for subnet in self.subnets.iter().flatten() {
|
||||
// The same parser the sweep will use, so a `/16` typo is refused
|
||||
// here rather than at 03:00 when the timer first fires. It
|
||||
// refuses anything wider than a /24 - see `hosts_in_subnet`.
|
||||
crate::discovery::net::hosts_in_subnet(subnet)
|
||||
.map_err(|err| anyhow::anyhow!("discovery.subnets: {err}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NetworkMode {
|
||||
Dhcp,
|
||||
Static,
|
||||
}
|
||||
|
||||
/// What a Proxy Box should be configured to do on the network. Parsed and
|
||||
/// validated only - nothing applies it (module docs).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NetworkSettings {
|
||||
pub mode: NetworkMode,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub prefix_len: Option<u8>,
|
||||
pub gateway: Option<IpAddr>,
|
||||
#[serde(default)]
|
||||
pub dns: Vec<IpAddr>,
|
||||
pub wifi: Option<WifiSettings>,
|
||||
}
|
||||
|
||||
impl NetworkSettings {
|
||||
/// Rejects a static config that could not possibly work. This is the one
|
||||
/// place a bad network block is caught before it is installed: a box
|
||||
/// whose stored config says "static" with no address would, once
|
||||
/// something does apply it, come up unreachable - the failure we least
|
||||
/// want to learn about from a customer.
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if self.mode == NetworkMode::Static {
|
||||
let ip = self.ip.ok_or_else(|| anyhow::anyhow!("network.mode is \"static\" but network.ip is not set"))?;
|
||||
let prefix = self
|
||||
.prefix_len
|
||||
.ok_or_else(|| anyhow::anyhow!("network.mode is \"static\" but network.prefix_len is not set"))?;
|
||||
let max = if ip.is_ipv4() { 32 } else { 128 };
|
||||
anyhow::ensure!(prefix <= max, "network.prefix_len {prefix} is out of range for {ip} (max {max})");
|
||||
}
|
||||
if let Some(wifi) = &self.wifi {
|
||||
anyhow::ensure!(!wifi.ssid.is_empty(), "network.wifi.ssid is empty");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `psk` is a secret, so `Debug` is written by hand: the derived one would
|
||||
/// put the wifi password in any log line that prints the parsed config.
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WifiSettings {
|
||||
pub ssid: String,
|
||||
pub psk: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for WifiSettings {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("WifiSettings")
|
||||
.field("ssid", &self.ssid)
|
||||
.field("psk", &self.psk.as_ref().map(|_| "<redacted>"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// By hand: the derived `Debug` would print `gateway_token`, the credential
|
||||
/// that opens this farm's gateway socket, into any log line that formats the
|
||||
/// config.
|
||||
impl fmt::Debug for Config {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Config")
|
||||
.field("gateway_id", &self.gateway_id)
|
||||
.field("farm_id", &self.farm_id)
|
||||
.field("gateway_token", &"<redacted>")
|
||||
.field("uplink_url", &self.uplink_url)
|
||||
.field("printers_path", &self.printers_path)
|
||||
.field("store_path", &self.store_path)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Environment only - the behaviour from before there was a file, kept
|
||||
/// so existing callers and tests do not change.
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
Self::layered(None, &|key| env::var(key).ok())
|
||||
}
|
||||
|
||||
/// File as the base, environment on top (module docs).
|
||||
pub fn from_file_and_env(file: Option<&ProxyFile>) -> anyhow::Result<Self> {
|
||||
Self::layered(file, &|key| env::var(key).ok())
|
||||
}
|
||||
|
||||
/// The layering itself, with the environment passed in as a lookup so
|
||||
/// tests can exercise precedence without mutating the process-wide env,
|
||||
/// which the parallel test runner would turn into a race.
|
||||
pub fn layered(file: Option<&ProxyFile>, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
|
||||
let empty = ProxyFile::default();
|
||||
let file = file.unwrap_or(&empty);
|
||||
|
||||
let require = |key: &str, field: &Option<String>, name: &str| {
|
||||
env(key).or_else(|| field.clone()).ok_or_else(|| {
|
||||
anyhow::anyhow!("missing required env var {key} (or `{name}` in the proxy config file)")
|
||||
})
|
||||
};
|
||||
let or_default = |key: &str, field: &Option<String>, default: &str| {
|
||||
env(key).or_else(|| field.clone()).unwrap_or_else(|| default.to_string())
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
gateway_id: require("CONTINUUM_GATEWAY_ID", &file.gateway_id, "gateway_id")?,
|
||||
farm_id: require("CONTINUUM_FARM_ID", &file.farm_id, "farm_id")?,
|
||||
gateway_token: require("CONTINUUM_GATEWAY_TOKEN", &file.gateway_token, "gateway_token")?,
|
||||
uplink_url: or_default("CONTINUUM_UPLINK_URL", &file.uplink_url, "wss://api.continuum.local/ws/edge/v1"),
|
||||
go2rtc_bin: or_default("CONTINUUM_GO2RTC_BIN", &file.go2rtc_bin, "go2rtc"),
|
||||
go2rtc_config: or_default("CONTINUUM_GO2RTC_CONFIG", &file.go2rtc_config, "./go2rtc.yaml"),
|
||||
go2rtc_api_listen: or_default("CONTINUUM_GO2RTC_API_LISTEN", &file.go2rtc_api_listen, ":1984"),
|
||||
// An empty value means "unset" at every layer, so an env var
|
||||
// exported as empty can switch the advertisement off even when
|
||||
// the file sets one.
|
||||
stream_base_url: env("CONTINUUM_STREAM_BASE_URL")
|
||||
.or_else(|| file.stream_base_url.clone())
|
||||
.filter(|v| !v.is_empty()),
|
||||
printers_path: or_default("CONTINUUM_PRINTERS_PATH", &file.printers_path, "printers.toml"),
|
||||
store_path: or_default("CONTINUUM_STORE_PATH", &file.store_path, "continuum.db"),
|
||||
discovery: discovery_settings(file.discovery.as_ref(), env)?,
|
||||
gateway_id: require("CONTINUUM_GATEWAY_ID")?,
|
||||
farm_id: require("CONTINUUM_FARM_ID")?,
|
||||
gateway_token: require("CONTINUUM_GATEWAY_TOKEN")?,
|
||||
uplink_url: env_or("CONTINUUM_UPLINK_URL", "wss://api.continuum.local/ws/edge/v1"),
|
||||
go2rtc_bin: env_or("CONTINUUM_GO2RTC_BIN", "go2rtc"),
|
||||
go2rtc_config: env_or("CONTINUUM_GO2RTC_CONFIG", "./go2rtc.yaml"),
|
||||
go2rtc_api_listen: env_or("CONTINUUM_GO2RTC_API_LISTEN", ":1984"),
|
||||
stream_base_url: env::var("CONTINUUM_STREAM_BASE_URL").ok().filter(|v| !v.is_empty()),
|
||||
printers_path: env_or("CONTINUUM_PRINTERS_PATH", "printers.toml"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Layered like everything else, except that the default is "do nothing" and
|
||||
/// every layer has to say so explicitly to change that.
|
||||
///
|
||||
/// Note what is *not* here: no way for a missing or unparseable value to end
|
||||
/// up as "on". `CONTINUUM_DISCOVERY_ENABLED=yes` is an error rather than a
|
||||
/// shrug, because the two readings of a typo are not equally safe - treating
|
||||
/// it as false surprises an operator who wanted scanning, and treating it as
|
||||
/// true starts scanning a network nobody authorised. Refusing to start says
|
||||
/// so while someone is still looking.
|
||||
fn discovery_settings(
|
||||
file: Option<&DiscoveryFile>,
|
||||
env: &dyn Fn(&str) -> Option<String>,
|
||||
) -> anyhow::Result<DiscoverySettings> {
|
||||
let defaults = DiscoverySettings::default();
|
||||
let empty = DiscoveryFile::default();
|
||||
let file = file.unwrap_or(&empty);
|
||||
|
||||
let flag = |key: &str, field: Option<bool>, default: bool| -> anyhow::Result<bool> {
|
||||
match env(key) {
|
||||
Some(raw) => raw
|
||||
.trim()
|
||||
.parse::<bool>()
|
||||
.map_err(|_| anyhow::anyhow!("{key} must be `true` or `false`, not {raw:?}")),
|
||||
None => Ok(field.unwrap_or(default)),
|
||||
}
|
||||
};
|
||||
|
||||
let enabled = flag("CONTINUUM_DISCOVERY_ENABLED", file.enabled, defaults.enabled)?;
|
||||
let subnets = match env("CONTINUUM_DISCOVERY_SUBNETS") {
|
||||
// Comma-separated, and an empty value means "none" rather than one
|
||||
// empty subnet - the same "empty env var switches it off" rule
|
||||
// `stream_base_url` follows above.
|
||||
Some(raw) => raw
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
None => file.subnets.clone().unwrap_or_default(),
|
||||
};
|
||||
let interval_minutes = match env("CONTINUUM_DISCOVERY_INTERVAL_MINUTES") {
|
||||
Some(raw) => Some(
|
||||
raw.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|_| anyhow::anyhow!("CONTINUUM_DISCOVERY_INTERVAL_MINUTES must be a whole number of minutes, not {raw:?}"))?,
|
||||
),
|
||||
None => file.interval_minutes,
|
||||
};
|
||||
|
||||
let settings = DiscoverySettings {
|
||||
enabled,
|
||||
subnets,
|
||||
ssdp: flag("CONTINUUM_DISCOVERY_SSDP", file.ssdp, defaults.ssdp)?,
|
||||
serial: flag("CONTINUUM_DISCOVERY_SERIAL", file.serial, defaults.serial)?,
|
||||
interval: interval_minutes
|
||||
.map(|m| Duration::from_secs(m * 60))
|
||||
.unwrap_or(defaults.interval),
|
||||
};
|
||||
|
||||
// The env path bypasses `DiscoveryFile::validate`, so re-run the checks
|
||||
// it makes on whatever actually came out of the layering. Only when
|
||||
// enabled: a stale `CONTINUUM_DISCOVERY_SUBNETS` left in someone's shell
|
||||
// profile should not stop a gateway that is not scanning anything.
|
||||
if settings.enabled {
|
||||
let effective = DiscoveryFile {
|
||||
subnets: Some(settings.subnets.clone()),
|
||||
interval_minutes,
|
||||
..DiscoveryFile::default()
|
||||
};
|
||||
effective.validate()?;
|
||||
if settings.subnets.is_empty() && !settings.ssdp && !settings.serial {
|
||||
// Enabled and told to look nowhere. Almost certainly a config
|
||||
// that was meant to do something, so say so instead of running a
|
||||
// timer that reports an empty list forever.
|
||||
anyhow::bail!(
|
||||
"discovery is enabled but has nothing to look at: set `discovery.subnets`, or turn on `discovery.ssdp` or `discovery.serial`"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(settings)
|
||||
fn require(key: &str) -> anyhow::Result<String> {
|
||||
env::var(key).map_err(|_| anyhow::anyhow!("missing required env var {key}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn no_env(_: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn file(text: &str) -> ProxyFile {
|
||||
ProxyFile::parse(text).expect("test config parses")
|
||||
}
|
||||
|
||||
const MINIMAL: &str = "gateway_id = \"g\"\nfarm_id = \"f\"\ngateway_token = \"t\"\n";
|
||||
|
||||
#[test]
|
||||
fn with_no_file_and_no_env_the_required_settings_are_reported_missing() {
|
||||
let err = Config::layered(None, &no_env).unwrap_err().to_string();
|
||||
assert!(err.contains("CONTINUUM_GATEWAY_ID"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_environment_alone_still_configures_the_proxy_as_it_did_before_the_file_existed() {
|
||||
let env = |k: &str| match k {
|
||||
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
|
||||
"CONTINUUM_FARM_ID" => Some("f".into()),
|
||||
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
|
||||
_ => None,
|
||||
};
|
||||
let config = Config::layered(None, &env).unwrap();
|
||||
assert_eq!(config.gateway_id, "g");
|
||||
assert_eq!(config.go2rtc_api_listen, ":1984");
|
||||
assert_eq!(config.printers_path, "printers.toml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_alone_is_enough_and_unset_keys_take_the_defaults() {
|
||||
let config = Config::layered(Some(&file(MINIMAL)), &no_env).unwrap();
|
||||
assert_eq!(config.farm_id, "f");
|
||||
assert_eq!(config.go2rtc_bin, "go2rtc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_environment_variable_overrides_the_same_key_in_the_file() {
|
||||
let text = format!("{MINIMAL}uplink_url = \"wss://from-file\"\n");
|
||||
let env = |k: &str| (k == "CONTINUUM_UPLINK_URL").then(|| "wss://from-env".to_string());
|
||||
let config = Config::layered(Some(&file(&text)), &env).unwrap();
|
||||
assert_eq!(config.uplink_url, "wss://from-env");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_key_the_environment_does_not_mention_survives_the_overlay() {
|
||||
let text = format!("{MINIMAL}go2rtc_bin = \"/opt/go2rtc\"\n");
|
||||
let env = |k: &str| (k == "CONTINUUM_UPLINK_URL").then(|| "wss://x".to_string());
|
||||
let config = Config::layered(Some(&file(&text)), &env).unwrap();
|
||||
assert_eq!(config.go2rtc_bin, "/opt/go2rtc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_stream_base_url_in_the_environment_switches_off_one_set_in_the_file() {
|
||||
let text = format!("{MINIMAL}stream_base_url = \"https://cam.example\"\n");
|
||||
let env = |k: &str| (k == "CONTINUUM_STREAM_BASE_URL").then(String::new);
|
||||
let config = Config::layered(Some(&file(&text)), &env).unwrap();
|
||||
assert_eq!(config.stream_base_url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_misspelled_key_is_refused_rather_than_silently_ignored() {
|
||||
let err = ProxyFile::parse(&format!("{MINIMAL}uplink_ulr = \"x\"\n")).unwrap_err().to_string();
|
||||
assert!(err.contains("uplink_ulr"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_static_network_block_without_an_address_is_refused() {
|
||||
let err = ProxyFile::parse("[network]\nmode = \"static\"\n").unwrap_err().to_string();
|
||||
assert!(err.contains("network.ip"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_static_network_block_with_an_impossible_prefix_is_refused() {
|
||||
let text = "[network]\nmode = \"static\"\nip = \"10.0.0.5\"\nprefix_len = 40\n";
|
||||
assert!(ProxyFile::parse(text).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_complete_network_block_is_parsed_and_exposed_but_not_applied() {
|
||||
let text = "[network]\nmode = \"static\"\nip = \"10.0.0.5\"\nprefix_len = 24\ngateway = \"10.0.0.1\"\ndns = [\"1.1.1.1\"]\n";
|
||||
let network = ProxyFile::parse(text).unwrap().network.unwrap();
|
||||
assert_eq!(network.ip, Some("10.0.0.5".parse().unwrap()));
|
||||
assert_eq!(network.dns.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wifi_password_never_appears_in_debug_output() {
|
||||
let text = "[network]\nmode = \"dhcp\"\n[network.wifi]\nssid = \"farm\"\npsk = \"hunter2hunter2\"\n";
|
||||
let parsed = ProxyFile::parse(text).unwrap();
|
||||
let shown = format!("{parsed:?}");
|
||||
assert!(shown.contains("farm") && !shown.contains("hunter2"), "{shown}");
|
||||
}
|
||||
|
||||
/// The one default in this file that is a security property rather than a
|
||||
/// convenience, so it is pinned from several directions.
|
||||
mod discovery_is_off_unless_asked {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn with_no_config_at_all() {
|
||||
let env = |k: &str| match k {
|
||||
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
|
||||
"CONTINUUM_FARM_ID" => Some("f".into()),
|
||||
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
|
||||
_ => None,
|
||||
};
|
||||
assert!(!Config::layered(None, &env).unwrap().discovery.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_a_file_that_says_nothing_about_it() {
|
||||
let config = Config::layered(Some(&file(MINIMAL)), &no_env).unwrap();
|
||||
assert!(!config.discovery.enabled);
|
||||
assert!(config.discovery.subnets.is_empty());
|
||||
assert!(!config.discovery.ssdp);
|
||||
assert!(!config.discovery.serial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_a_discovery_block_that_only_sets_a_subnet() {
|
||||
// Naming a subnet is not consent to scan it. Someone who wrote
|
||||
// the address down and left `enabled` out gets nothing, which is
|
||||
// the safe reading of an incomplete instruction.
|
||||
let text = format!("{MINIMAL}[discovery]\nsubnets = [\"192.168.0.0/24\"]\n");
|
||||
let config = Config::layered(Some(&file(&text)), &no_env).unwrap();
|
||||
assert!(!config.discovery.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_can_switch_discovery_on_and_say_where_to_look() {
|
||||
let text = format!(
|
||||
"{MINIMAL}[discovery]\nenabled = true\nsubnets = [\"192.168.0.0/24\"]\nserial = true\ninterval_minutes = 5\n"
|
||||
);
|
||||
let config = Config::layered(Some(&file(&text)), &no_env).unwrap();
|
||||
assert!(config.discovery.enabled);
|
||||
assert_eq!(config.discovery.subnets, ["192.168.0.0/24"]);
|
||||
assert!(config.discovery.serial);
|
||||
assert!(!config.discovery.ssdp);
|
||||
assert_eq!(config.discovery.interval, Duration::from_secs(300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_environment_can_switch_discovery_off_over_a_file_that_enables_it() {
|
||||
// Which direction matters: the override that must work is the one
|
||||
// that stops the scanning, so an operator who finds their gateway
|
||||
// probing a network can kill it without editing a file they may not
|
||||
// be able to edit (a Proxy Box's config is signed in the cloud).
|
||||
let text = format!("{MINIMAL}[discovery]\nenabled = true\nserial = true\n");
|
||||
let env = |k: &str| (k == "CONTINUUM_DISCOVERY_ENABLED").then(|| "false".to_string());
|
||||
let config = Config::layered(Some(&file(&text)), &env).unwrap();
|
||||
assert!(!config.discovery.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_discovery_flag_that_is_neither_true_nor_false_is_an_error_rather_than_a_guess() {
|
||||
// The two readings of a typo are not equally safe: false surprises
|
||||
// someone who wanted scanning, true scans a network nobody
|
||||
// authorised. Refusing to start says so while someone is watching.
|
||||
let env = |k: &str| match k {
|
||||
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
|
||||
"CONTINUUM_FARM_ID" => Some("f".into()),
|
||||
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
|
||||
"CONTINUUM_DISCOVERY_ENABLED" => Some("yes".into()),
|
||||
_ => None,
|
||||
};
|
||||
let err = Config::layered(None, &env).unwrap_err().to_string();
|
||||
assert!(err.contains("CONTINUUM_DISCOVERY_ENABLED"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subnet_wider_than_a_slash_24_is_refused_when_the_file_is_read() {
|
||||
// At import time, which on a Proxy Box is while the operator is still
|
||||
// holding the stick - rather than at 03:00 when the timer first
|
||||
// fires and tries to probe 65k addresses.
|
||||
let text = format!("{MINIMAL}[discovery]\nenabled = true\nsubnets = [\"10.0.0.0/16\"]\n");
|
||||
let err = ProxyFile::parse(&text).unwrap_err().to_string();
|
||||
assert!(err.contains("discovery.subnets"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_enabled_with_nowhere_to_look_is_refused_as_the_mistake_it_is() {
|
||||
let text = format!("{MINIMAL}[discovery]\nenabled = true\n");
|
||||
let err = Config::layered(Some(&file(&text)), &no_env).unwrap_err().to_string();
|
||||
assert!(err.contains("nothing to look at"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_misspelled_discovery_key_is_refused_like_every_other_one() {
|
||||
let text = format!("{MINIMAL}[discovery]\nenabled = true\nsubnet = [\"192.168.0.0/24\"]\n");
|
||||
assert!(ProxyFile::parse(&text).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_subnet_in_the_environment_does_not_stop_a_gateway_that_is_not_scanning() {
|
||||
// Validation of the subnets only runs when discovery is on. A
|
||||
// leftover export in someone's shell profile should not be fatal to
|
||||
// a gateway that will never read it.
|
||||
let env = |k: &str| match k {
|
||||
"CONTINUUM_GATEWAY_ID" => Some("g".into()),
|
||||
"CONTINUUM_FARM_ID" => Some("f".into()),
|
||||
"CONTINUUM_GATEWAY_TOKEN" => Some("t".into()),
|
||||
"CONTINUUM_DISCOVERY_SUBNETS" => Some("not-a-subnet".into()),
|
||||
_ => None,
|
||||
};
|
||||
let config = Config::layered(None, &env).unwrap();
|
||||
assert!(!config.discovery.enabled);
|
||||
}
|
||||
|
||||
fn env_or(key: &str, default: &str) -> String {
|
||||
env::var(key).unwrap_or_else(|_| default.to_string())
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+12
-111
@@ -72,15 +72,13 @@
|
||||
//! shows its working rather than only its conclusion.
|
||||
|
||||
pub mod net;
|
||||
pub mod report;
|
||||
pub mod scan;
|
||||
pub mod serial;
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr, UdpSocket as StdUdpSocket};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::net::UdpSocket;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// The SSDP multicast group, which is the standard one - Bambu's discovery is
|
||||
/// ordinary SSDP with vendor headers, not a protocol of its own.
|
||||
@@ -165,21 +163,6 @@ pub enum Source {
|
||||
SerialHandshake,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// The `source` value in a `discovered_printers` message - see the
|
||||
/// backend's `discovered_printer.schema.json`, which is an enum there, so
|
||||
/// these strings are a contract and not a label.
|
||||
pub fn wire_value(self) -> &'static str {
|
||||
match self {
|
||||
Source::Ssdp => "ssdp",
|
||||
Source::Certificate => "certificate",
|
||||
Source::HttpApi => "http_api",
|
||||
Source::UsbDescriptor => "usb_descriptor",
|
||||
Source::SerialHandshake => "serial_handshake",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A printer found on the network or on a USB port, minus its credential.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
@@ -229,28 +212,6 @@ impl Candidate {
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire form for `ClientMessage::DiscoveredPrinters`.
|
||||
///
|
||||
/// `bind_state` is deliberately dropped. It is a Bambu-only fact about a
|
||||
/// cloud account, the cloud already knows more about its own accounts
|
||||
/// than an SSDP packet does, and the one thing it is good for here -
|
||||
/// explaining a printer that is visible and then refuses to connect -
|
||||
/// is a diagnosis for someone standing in front of the machine, which is
|
||||
/// what `examples/discover_printers.rs` prints it for.
|
||||
pub fn to_wire(&self) -> crate::uplink::DiscoveredPrinter {
|
||||
crate::uplink::DiscoveredPrinter {
|
||||
vendor: self.vendor.toml_value(),
|
||||
address: self.host.clone(),
|
||||
serial: self.serial.clone(),
|
||||
model: self.model.clone(),
|
||||
name: self.name.clone(),
|
||||
detail: self.detail.clone(),
|
||||
ambiguous_with: self.ambiguous_with.map(Vendor::toml_value),
|
||||
source: self.source.wire_value(),
|
||||
driver_ready: self.vendor.driver_is_implemented(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `[[printer]]` block to paste into `printers.toml`.
|
||||
///
|
||||
/// Each vendor gets the fields `fleet.rs` actually requires of it and no
|
||||
@@ -409,10 +370,10 @@ pub(crate) fn valid_serial(value: String) -> Option<String> {
|
||||
/// one thing in a datagram that cannot be mistyped.
|
||||
///
|
||||
/// Tolerant on purpose: it extracts whatever headers are present rather than
|
||||
/// requiring a full set. The vendor header spelling was checked against a
|
||||
/// real H2C's `200 OK` reply to a unicast M-SEARCH (2026-10-29); an
|
||||
/// unsolicited NOTIFY has not been seen, so a missing vendor header has to
|
||||
/// mean "one fewer field filled in", not "no printer found".
|
||||
/// requiring a full set. The exact header spelling is from Bambu's published
|
||||
/// discovery format and has not been checked against a real announcement
|
||||
/// here, so a missing vendor header has to mean "one fewer field filled in",
|
||||
/// not "no printer found".
|
||||
pub fn parse_ssdp(message: &str, from: Ipv4Addr) -> Option<Candidate> {
|
||||
let mut lines = message.lines();
|
||||
let start = lines.next()?.trim();
|
||||
@@ -523,47 +484,18 @@ pub async fn listen_ssdp(port: u16, window: Duration, raw: bool) -> anyhow::Resu
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Binds the SSDP port and joins the group on every IPv4 interface.
|
||||
/// Binds the SSDP port and joins the group on every interface.
|
||||
///
|
||||
/// Passing `UNSPECIFIED` as the interface asks the OS to pick one, and that
|
||||
/// is the wrong default on a machine with several: a Windows PC with Wi-Fi
|
||||
/// plus a VPN, Hyper-V or WSL adapter joins on whichever it prefers, and
|
||||
/// then hears nothing from printers on the other while looking perfectly
|
||||
/// healthy. Only the operator knows which one faces the printers, so join on
|
||||
/// all of them and say which were joined. Falls back to the OS's choice if
|
||||
/// the interfaces cannot be listed.
|
||||
/// `Ipv4Addr::UNSPECIFIED` as the interface asks the kernel to pick, which is
|
||||
/// right for a gateway appliance: it may well have two interfaces and only
|
||||
/// the operator knows which one faces the printers.
|
||||
fn bind_ssdp(port: u16) -> anyhow::Result<UdpSocket> {
|
||||
let socket = StdUdpSocket::bind((Ipv4Addr::UNSPECIFIED, port)).map_err(|err| {
|
||||
anyhow::anyhow!("could not bind UDP :{port} for discovery: {err} (is another process using it?)")
|
||||
})?;
|
||||
|
||||
let mut interfaces: Vec<Ipv4Addr> = if_addrs::get_if_addrs()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|interface| !interface.is_loopback())
|
||||
.filter_map(|interface| match interface.ip() {
|
||||
std::net::IpAddr::V4(v4) => Some(v4),
|
||||
std::net::IpAddr::V6(_) => None,
|
||||
})
|
||||
.collect();
|
||||
if interfaces.is_empty() {
|
||||
interfaces.push(Ipv4Addr::UNSPECIFIED);
|
||||
}
|
||||
|
||||
let mut joined = Vec::new();
|
||||
for interface in &interfaces {
|
||||
match socket.join_multicast_v4(&SSDP_GROUP, interface) {
|
||||
Ok(()) => joined.push(*interface),
|
||||
// One adapter that refuses (down, no multicast) is not a reason to
|
||||
// stop listening on the others.
|
||||
Err(err) => debug!(%interface, ?err, "could not join the SSDP group on this interface"),
|
||||
}
|
||||
}
|
||||
if joined.is_empty() {
|
||||
anyhow::bail!("could not join {SSDP_GROUP} on :{port} on any of {interfaces:?}");
|
||||
}
|
||||
info!(port, interfaces = ?joined, "listening for SSDP");
|
||||
|
||||
socket
|
||||
.join_multicast_v4(&SSDP_GROUP, &Ipv4Addr::UNSPECIFIED)
|
||||
.map_err(|err| anyhow::anyhow!("could not join {SSDP_GROUP} on :{port}: {err}"))?;
|
||||
socket.set_nonblocking(true)?;
|
||||
Ok(UdpSocket::from_std(socket)?)
|
||||
}
|
||||
@@ -614,37 +546,6 @@ mod tests {
|
||||
DevConnect.bambu.com: lan\r\n\
|
||||
DevBind.bambu.com: free\r\n";
|
||||
|
||||
/// The reply a real H2C sent to a unicast M-SEARCH on 1990 and on 2021
|
||||
/// (2026-10-29), byte for byte in shape; the serial, name and addresses
|
||||
/// are made up. It is a `200 OK`, not a NOTIFY, and carries
|
||||
/// `Devseclink`/`DevInf`/`DevCap`, which `NOTIFY` above does not.
|
||||
#[test]
|
||||
fn a_real_printers_reply_to_an_msearch_parses() {
|
||||
let reply = "HTTP/1.1 200 OK\r\n\
|
||||
Server: UPnP/1.0\r\n\
|
||||
Date: Mon, 26 Oct 2026 17:21:14 GMT\r\n\
|
||||
Location: 192.168.0.99\r\n\
|
||||
ST: urn:bambulab-com:device:3dprinter:1\r\n\
|
||||
EXT: \r\n\
|
||||
USN: 31B8BP5A0000000\r\n\
|
||||
Cache-Control: max-age=1800\r\n\
|
||||
DevModel.bambu.com: O1C2\r\n\
|
||||
DevName.bambu.com: Workshop\r\n\
|
||||
DevConnect.bambu.com: lan\r\n\
|
||||
DevBind.bambu.com: free\r\n\
|
||||
Devseclink.bambu.com: secure\r\n\
|
||||
DevInf.bambu.com: wlan0\r\n\
|
||||
DevVersion.bambu.com: 01.08.00.00\r\n\
|
||||
DevCap.bambu.com: 1\r\n\r\n";
|
||||
let found = parse_ssdp(reply, Ipv4Addr::new(10, 0, 0, 1)).expect("a Bambu reply");
|
||||
assert_eq!(found.host, "192.168.0.99");
|
||||
assert_eq!(found.serial.as_deref(), Some("31B8BP5A0000000"));
|
||||
assert_eq!(found.model.as_deref(), Some("O1C2"));
|
||||
assert_eq!(found.name.as_deref(), Some("Workshop"));
|
||||
assert_eq!(found.bind_state.as_deref(), Some("free"));
|
||||
assert_eq!(found.detail.as_deref(), Some("Bambu firmware 01.08.00.00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_announcement_yields_every_field_it_carries() {
|
||||
let found = parse_ssdp(NOTIFY, Ipv4Addr::new(10, 0, 0, 1)).expect("a Bambu announcement");
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
//! The task that tells the cloud what this gateway can see.
|
||||
//!
|
||||
//! Its reason for existing is small and specific: adding a printer in the web
|
||||
//! console should not begin with someone walking to a machine to read its IP
|
||||
//! address off the screen. It cannot finish the job - the credential is on
|
||||
//! that screen and no probe can read it - but it removes the address, the
|
||||
//! serial number and the guesswork about which vendor the thing is.
|
||||
//!
|
||||
//! # It does nothing unless asked
|
||||
//!
|
||||
//! `discovery.enabled` is false by default, and this task then parks forever
|
||||
//! rather than looping over a disabled check. The reason is in
|
||||
//! `DiscoverySettings`: a sweep is unsolicited traffic on someone else's
|
||||
//! network, and a gateway that did it by default would be scanning networks
|
||||
//! its operator never meant to point it at.
|
||||
//!
|
||||
//! Note which way the switch faces. It is read from the gateway's own config
|
||||
//! and the cloud cannot set it, because a setting whose entire purpose is to
|
||||
//! bound what this process does to the local network has to be decided where
|
||||
//! that network is understood.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
use crate::config::DiscoverySettings;
|
||||
use crate::uplink::ClientMessage;
|
||||
|
||||
/// Scans on a timer and reports each pass, forever.
|
||||
///
|
||||
/// Never returns, so it sits in `main`'s `select!` alongside the other
|
||||
/// long-running tasks. With discovery off it parks on a future that never
|
||||
/// completes, which costs nothing and keeps the "every arm runs forever"
|
||||
/// property of that `select!` true.
|
||||
pub async fn run(
|
||||
settings: DiscoverySettings,
|
||||
printers_path: String,
|
||||
store: Option<std::sync::Arc<crate::store::Store>>,
|
||||
outbound: Sender<ClientMessage>,
|
||||
) {
|
||||
if !settings.enabled {
|
||||
tracing::info!("printer discovery is off (discovery.enabled); not scanning this network");
|
||||
std::future::pending::<()>().await;
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
subnets = ?settings.subnets,
|
||||
ssdp = settings.ssdp,
|
||||
serial = settings.serial,
|
||||
every = ?settings.interval,
|
||||
"printer discovery is ON; this gateway will probe the network above"
|
||||
);
|
||||
|
||||
let mut ticker = tokio::time::interval(settings.interval);
|
||||
// A pass takes tens of seconds - an SSDP window alone is 32 - so a tick
|
||||
// can come due while one is still running. `Delay` drops the ones that
|
||||
// came due rather than firing them back to back afterwards, which is what
|
||||
// `Burst` (the default) would do and would turn a slow scan into a
|
||||
// continuous one.
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
|
||||
loop {
|
||||
// Fires immediately the first time, so a gateway reports what it can
|
||||
// see shortly after boot rather than one interval later.
|
||||
ticker.tick().await;
|
||||
|
||||
// Re-read every pass, not once at startup. Nothing re-reads the fleet
|
||||
// file on a running daemon yet, so adding a printer still means a
|
||||
// restart - but someone who has *edited* the file and not restarted
|
||||
// should not keep being offered the printer they just configured.
|
||||
let configured = match crate::fleet::configured(std::path::Path::new(&printers_path)) {
|
||||
Ok(mut configured) => {
|
||||
// Printers added from the console live in the store, not the
|
||||
// file, and are just as "already configured".
|
||||
if let Some(store) = &store {
|
||||
match store.printers() {
|
||||
Ok(stored) => configured.add_entries(stored),
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "cannot read the local store; skipping this discovery pass");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
configured
|
||||
}
|
||||
// Fail the pass rather than report against an empty set. The
|
||||
// alternative offers every printer the farm already runs as a
|
||||
// fresh find, which is worse than no report: it is a list of
|
||||
// things to add that are already added.
|
||||
Err(err) => {
|
||||
tracing::warn!(%err, "cannot tell which printers are already configured; skipping this discovery pass");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let scan = super::scan::scan(&settings, &configured).await;
|
||||
if !scan.looked() {
|
||||
// Every enabled source failed. An empty report would assert that
|
||||
// this network has no unconfigured printers, which is a claim
|
||||
// about the farm we have no evidence for - see `Scan::looked`.
|
||||
tracing::warn!(
|
||||
attempted = scan.attempted,
|
||||
"every discovery source failed; not reporting, because an empty report would mean 'nothing is there'"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let printers: Vec<_> = scan.candidates.iter().map(super::Candidate::to_wire).collect();
|
||||
tracing::info!(count = printers.len(), "reporting discovered printers");
|
||||
let message = ClientMessage::DiscoveredPrinters {
|
||||
printers,
|
||||
scanned_at: now_rfc3339(),
|
||||
};
|
||||
|
||||
// `send`, not `try_send`: the queue outlives any one connection, so
|
||||
// waiting here is waiting for the cloud to come back, which is
|
||||
// exactly the right thing to do with a scan result. If the channel is
|
||||
// ever closed the uplink is gone and so is the process.
|
||||
if outbound.send(message).await.is_err() {
|
||||
tracing::warn!("uplink queue closed; stopping discovery reports");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 3339 in UTC, which is what the schema's `date-time` means.
|
||||
///
|
||||
/// Public so `examples/discover_printers.rs` can print a frame with a real
|
||||
/// timestamp in it - a capture is meant to be feedable straight to the
|
||||
/// backend's validator, and a placeholder there would not be.
|
||||
pub fn now_rfc3339() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or(Duration::ZERO)
|
||||
.as_secs();
|
||||
format_rfc3339(secs)
|
||||
}
|
||||
|
||||
/// Formats a Unix second count, with the clock passed in so it is testable.
|
||||
///
|
||||
/// Hand-rolled because this crate has no date library and one field does not
|
||||
/// justify adding one - `scanned_at` is read by a human looking at a "found
|
||||
/// 20 minutes ago" label, not by arithmetic.
|
||||
fn format_rfc3339(secs: u64) -> String {
|
||||
let (days, time) = (secs / 86_400, secs % 86_400);
|
||||
let (hour, minute, second) = (time / 3600, (time % 3600) / 60, time % 60);
|
||||
|
||||
// Civil date from a day count, by the algorithm in Howard Hinnant's
|
||||
// `civil_from_days`: shift the epoch to 0000-03-01 so a leap day lands at
|
||||
// the end of the cycle and the month-length pattern repeats cleanly.
|
||||
let z = days as i64 + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
|
||||
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn timestamps_match_what_date_u_prints() {
|
||||
// Pinned against `date -u -d @N`: an off-by-one in the civil date
|
||||
// arithmetic is invisible until it is a day or a year out.
|
||||
assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z");
|
||||
// A leap day, which is the whole reason this arithmetic needs care.
|
||||
assert_eq!(format_rfc3339(1_709_164_800), "2024-02-29T00:00:00Z");
|
||||
assert_eq!(format_rfc3339(1_792_413_296), "2026-10-19T12:34:56Z");
|
||||
// Past 2100, where the "divisible by 100 is not a leap year" rule
|
||||
// bites and a naive every-four-years version drifts.
|
||||
assert_eq!(format_rfc3339(4_102_444_800), "2100-01-01T00:00:00Z");
|
||||
// The last second of a year, which catches a December/January slip.
|
||||
assert_eq!(format_rfc3339(1_767_225_599), "2025-12-31T23:59:59Z");
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
//! One pass of discovery, as a background task runs it - which is a narrower
|
||||
//! thing than what `examples/discover_printers.rs` can do, and deliberately.
|
||||
//!
|
||||
//! The example has a human standing in front of the printers who can be asked
|
||||
//! for consent and can read a warning. This has a timer. So the two differ on
|
||||
//! exactly the operations where that distinction matters:
|
||||
//!
|
||||
//! * No `M115`. Opening a serial port toggles DTR and reboots an
|
||||
//! Arduino-based board, which on a machine mid-job ruins the print. The
|
||||
//! example will do it behind `--yes-reset-the-printer`; nothing on a
|
||||
//! schedule gets that option, because there is nobody to type it.
|
||||
//! * No subnet the operator did not name. `DiscoverySettings::subnets` is
|
||||
//! explicit rather than derived from the gateway's own interfaces, so a
|
||||
//! second NIC or a VPN cannot quietly widen what gets swept.
|
||||
//!
|
||||
//! What it produces is candidates with no credential in them, which is not a
|
||||
//! simplification - see `uplink::DiscoveredPrinter` for why no probe can
|
||||
//! learn one.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{Candidate, net, serial};
|
||||
use crate::config::DiscoverySettings;
|
||||
use crate::fleet::Configured;
|
||||
|
||||
/// How long to sit on the SSDP group.
|
||||
///
|
||||
/// Has to exceed a Bambu printer's announce interval, which is around 30
|
||||
/// seconds, or a scan can open its window just after a printer announced and
|
||||
/// conclude the printer is not there. Nothing here is urgent enough for that
|
||||
/// to be worth shaving.
|
||||
const SSDP_WINDOW: Duration = Duration::from_secs(32);
|
||||
|
||||
/// Per-host budget for the TLS and HTTP probes. Generous, because it is
|
||||
/// spread across a batch that runs concurrently - see `net::sweep`.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// The schema's `maxItems`. A gateway that somehow found more than this is
|
||||
/// pointed at something that is not a printer farm, and a truncated list
|
||||
/// beats a message the backend rejects whole.
|
||||
const MAX_REPORTED: usize = 256;
|
||||
|
||||
/// The result of one pass, which is not just the candidates.
|
||||
///
|
||||
/// "Found nothing" and "could not look" are different answers, and an empty
|
||||
/// candidate list alone cannot tell them apart. The distinction has to
|
||||
/// survive as far as the cloud, because an empty report means *this gateway
|
||||
/// looked and its network has no unconfigured printers* - so publishing one
|
||||
/// after every probe failed would state something untrue about the farm.
|
||||
/// Hence the counts: [`Scan::looked`] is what the reporter checks before it
|
||||
/// sends anything.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Scan {
|
||||
pub candidates: Vec<Candidate>,
|
||||
/// How many sources were enabled - subnets, the SSDP ports, serial.
|
||||
pub attempted: usize,
|
||||
/// How many of them failed outright.
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
impl Scan {
|
||||
/// Whether this pass is evidence about the network at all.
|
||||
///
|
||||
/// False when every enabled source failed, which is the case an empty
|
||||
/// report must not be sent for. A partial failure is still evidence: a
|
||||
/// gateway that swept one of two subnets found what it found, and
|
||||
/// withholding that until every source works would mean a container that
|
||||
/// can never bind SSDP suppresses a perfectly good sweep forever.
|
||||
pub fn looked(&self) -> bool {
|
||||
self.attempted > 0 && self.failed < self.attempted
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything enabled, merged, with the already-configured removed.
|
||||
///
|
||||
/// One failing source does not fail the pass: an unreachable subnet, or an
|
||||
/// SSDP socket a container will not let us bind, should still let the rest
|
||||
/// report what it found. Every failure is logged, and counted in the result
|
||||
/// so the caller can tell a quiet network from a blind gateway.
|
||||
pub async fn scan(settings: &DiscoverySettings, configured: &Configured) -> Scan {
|
||||
let mut out = Scan::default();
|
||||
if !settings.enabled {
|
||||
return out;
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
|
||||
if settings.ssdp {
|
||||
// Both ports Bambu has shipped announcements on, concurrently: a
|
||||
// printer uses one or the other depending on firmware, and listening
|
||||
// on them in series would double the window for no reason.
|
||||
let (first, second) = tokio::join!(
|
||||
super::listen_ssdp(super::SSDP_PORTS[0], SSDP_WINDOW, false),
|
||||
super::listen_ssdp(super::SSDP_PORTS[1], SSDP_WINDOW, false),
|
||||
);
|
||||
for result in [first, second] {
|
||||
out.attempted += 1;
|
||||
match result {
|
||||
Ok(candidates) => found.extend(candidates),
|
||||
// Expected on a host that cannot join a multicast group, and
|
||||
// in a container whose network is a veth pair to a router -
|
||||
// which is also why a sweep, not SSDP, is what works on a
|
||||
// real farm network.
|
||||
Err(err) => {
|
||||
out.failed += 1;
|
||||
tracing::warn!(%err, "SSDP listen failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for subnet in &settings.subnets {
|
||||
out.attempted += 1;
|
||||
match net::sweep(subnet, PROBE_TIMEOUT).await {
|
||||
Ok(candidates) => found.extend(candidates),
|
||||
Err(err) => {
|
||||
out.failed += 1;
|
||||
tracing::warn!(%err, subnet, "subnet sweep failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if settings.serial {
|
||||
out.attempted += 1;
|
||||
match serial::list_ports() {
|
||||
// Every USB-attached port, not only the ones whose vendor id says
|
||||
// "Prusa". The alternative misses most real Marlin printers,
|
||||
// because the common boards sit behind a generic CH340 or FTDI
|
||||
// bridge that is indistinguishable from any other USB serial
|
||||
// device without the `M115` this task will not send. So the
|
||||
// unavoidable error is made the harmless one: a candidate's
|
||||
// `detail` says outright that it could be a printer or anything
|
||||
// else, and an extra row in a list someone is choosing from costs
|
||||
// far less than a printer that never appears.
|
||||
Ok(ports) => found.extend(ports.iter().map(serial::SerialDevice::to_candidate)),
|
||||
Err(err) => {
|
||||
out.failed += 1;
|
||||
tracing::warn!(%err, "serial port enumeration failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.candidates = super::merge(found);
|
||||
let candidates = &mut out.candidates;
|
||||
// Last, not first: merging can fill in a serial that one source knew and
|
||||
// another did not, and a candidate the fleet file names by serial is only
|
||||
// recognisable as such once it has that serial.
|
||||
candidates.retain(|candidate| {
|
||||
!configured.mentions(Some(&candidate.host), candidate.serial.as_deref())
|
||||
});
|
||||
if candidates.len() > MAX_REPORTED {
|
||||
tracing::warn!(
|
||||
found = candidates.len(),
|
||||
reported = MAX_REPORTED,
|
||||
"truncating the discovery report"
|
||||
);
|
||||
candidates.truncate(MAX_REPORTED);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -485,7 +485,6 @@ mod tests {
|
||||
|
||||
/// The one part of serial discovery this environment can demonstrate:
|
||||
/// with no ports attached it finds none, and does not error doing it.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn listing_ports_where_there_are_none_is_empty_and_not_an_error() {
|
||||
let ports = list_ports().expect("sysfs exists on this host");
|
||||
@@ -493,14 +492,4 @@ mod tests {
|
||||
assert!(port.path.starts_with("/dev/"), "{port:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Port listing reads Linux sysfs, so elsewhere it must say so rather
|
||||
/// than report "no ports" - which would look like a clean scan (found on
|
||||
/// Windows CI, run 415, where the Linux test above failed).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn listing_ports_off_linux_is_an_error_not_an_empty_list() {
|
||||
let err = list_ports().expect_err("there is no sysfs here");
|
||||
assert!(err.to_string().contains("not a Linux host"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
//! `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());
|
||||
}
|
||||
}
|
||||
+14
-124
@@ -3,10 +3,9 @@
|
||||
//! by default) is gitignored: it holds real hostnames and access codes for
|
||||
//! your printers, which don't belong in version control.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::printer::{
|
||||
BambuPrinter, BambuProtocolPreference, BambuTls, KlipperPrinter, OctoPrintFlavour, OctoPrintPrinter,
|
||||
@@ -19,14 +18,10 @@ struct FleetFile {
|
||||
printers: Vec<PrinterEntry>,
|
||||
}
|
||||
|
||||
/// One printer's connection details, as written in `printers.toml` or stored
|
||||
/// in the local store (src/store.rs). `Debug` is written by hand below: the
|
||||
/// derived one would print `access_code` and `api_key` into any log line that
|
||||
/// formats an entry.
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct PrinterEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PrinterEntry {
|
||||
id: String,
|
||||
name: String,
|
||||
/// One of: "bambu" (auto-detects MQTT v5-vs-v3.1.1/BambuMQTT v2-vs-v1 -
|
||||
/// the default, since a firmware update can move the same physical
|
||||
/// printer between them), "bambu_v1"/"bambu_v2" (force a specific one),
|
||||
@@ -36,38 +31,23 @@ pub struct PrinterEntry {
|
||||
/// flavour - see `OctoPrintFlavour`. Pick `octoprint` if it is OctoPrint
|
||||
/// on a Pi and `prusa_link` if it is an MK4/XL/MINI serving its own API;
|
||||
/// discovery cannot tell, so it suggests `octoprint` and says so.
|
||||
pub vendor: String,
|
||||
pub host: Option<String>,
|
||||
pub access_code: Option<String>,
|
||||
vendor: String,
|
||||
host: Option<String>,
|
||||
access_code: Option<String>,
|
||||
/// The printer's serial number - Bambu's real MQTT topics are addressed
|
||||
/// by serial (`device/{sn}/report`), not by our own `id`.
|
||||
pub sn: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
sn: Option<String>,
|
||||
api_key: Option<String>,
|
||||
/// Serial device path, e.g. `/dev/ttyACM0` on Linux.
|
||||
pub port: Option<String>,
|
||||
port: Option<String>,
|
||||
#[serde(default = "default_baud_rate")]
|
||||
pub baud_rate: u32,
|
||||
baud_rate: u32,
|
||||
/// Path to a printer-specific CA certificate. Leave unset to use the
|
||||
/// bundled Bambu CA - only needed for a printer that doesn't chain to
|
||||
/// it (P1P, at least; see certs/README.md).
|
||||
pub ca_cert_path: Option<String>,
|
||||
ca_cert_path: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
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()
|
||||
}
|
||||
require_valid_cert: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -91,96 +71,6 @@ pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
|
||||
file.printers.into_iter().map(build_printer).collect()
|
||||
}
|
||||
|
||||
/// The entries in a printers file, or none if there is no file.
|
||||
///
|
||||
/// A missing file is normal now: a gateway whose printers were all added from
|
||||
/// the console has only the store. A file that exists but does not parse is
|
||||
/// still an error - that is a typo to surface, not a fleet to ignore.
|
||||
pub fn entries(path: &Path) -> anyhow::Result<Vec<PrinterEntry>> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(err) => return Err(anyhow::anyhow!("failed to read {path:?}: {err}")),
|
||||
};
|
||||
Ok(toml::from_str::<FleetFile>(&text)?.printers)
|
||||
}
|
||||
|
||||
/// Builds the client for one entry. Public so a printer added while the
|
||||
/// gateway is running goes through exactly the checks a startup one does.
|
||||
pub fn build(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
|
||||
build_printer(entry)
|
||||
}
|
||||
|
||||
/// Everything the fleet file already names, for telling a printer the
|
||||
/// operator has dealt with from one they have never seen.
|
||||
///
|
||||
/// Three sets rather than one, because the three identify a printer with
|
||||
/// very different confidence. A serial is the machine. A host is the address
|
||||
/// it answers on today. A device path is which USB socket it is plugged
|
||||
/// into. A match on any of them is enough to mean "already known", which is
|
||||
/// the conservative direction: the cost of a false match is one printer
|
||||
/// missing from a suggestion list, and the cost of a false miss is the
|
||||
/// console offering to add a printer that is already running jobs.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Configured {
|
||||
pub hosts: HashSet<String>,
|
||||
pub serials: HashSet<String>,
|
||||
pub ports: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Configured {
|
||||
/// Lowercased on both sides: hostnames are case-insensitive, and a Bambu
|
||||
/// serial read from a certificate has no guaranteed case either.
|
||||
pub fn mentions(&self, host: Option<&str>, serial: Option<&str>) -> bool {
|
||||
let known = |set: &HashSet<String>, value: Option<&str>| {
|
||||
value.is_some_and(|value| set.contains(&value.to_lowercase()))
|
||||
};
|
||||
known(&self.hosts, host) || known(&self.serials, serial) || known(&self.ports, host)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the fleet file mentions, without building a single printer client.
|
||||
///
|
||||
/// Parse level on purpose, and the difference matters: [`load`] refuses an
|
||||
/// entry with a blank `access_code`, but that entry is exactly the case this
|
||||
/// needs to catch - a printer discovery already suggested, that the operator
|
||||
/// pasted in and has not yet typed the credential for. It is not a printer
|
||||
/// they have never seen, so reporting it again as a fresh find would be
|
||||
/// noise at the one moment they are mid-way through dealing with it.
|
||||
///
|
||||
/// A missing file is an empty set rather than an error: a gateway with no
|
||||
/// fleet yet is the case discovery exists for.
|
||||
pub fn configured(path: &Path) -> anyhow::Result<Configured> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Configured::default()),
|
||||
Err(err) => return Err(anyhow::anyhow!("failed to read {path:?}: {err}")),
|
||||
};
|
||||
let file: FleetFile = toml::from_str(&text)?;
|
||||
let mut out = Configured::default();
|
||||
out.add_entries(file.printers);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl Configured {
|
||||
/// Folds more entries in - the file's, and the local store's.
|
||||
pub fn add_entries(&mut self, entries: impl IntoIterator<Item = PrinterEntry>) {
|
||||
let add = |set: &mut HashSet<String>, value: Option<String>| {
|
||||
if let Some(value) = value {
|
||||
let value = value.trim().to_lowercase();
|
||||
if !value.is_empty() {
|
||||
set.insert(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
for entry in entries {
|
||||
add(&mut self.hosts, entry.host);
|
||||
add(&mut self.serials, entry.sn);
|
||||
add(&mut self.ports, entry.port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
|
||||
match entry.vendor.as_str() {
|
||||
"bambu" => build_bambu(entry, BambuProtocolPreference::Auto),
|
||||
|
||||
-135
@@ -1,4 +1,3 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -7,76 +6,6 @@ use tokio::process::Command;
|
||||
use tokio::sync::Notify;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Where the running go2rtc's pid is recorded: next to its config, because
|
||||
/// that is the one path this process already owns and the one a restarted
|
||||
/// proxy is certain to compute again.
|
||||
fn pid_path(config_path: &str) -> PathBuf {
|
||||
PathBuf::from(format!("{config_path}.pid"))
|
||||
}
|
||||
|
||||
/// Removes the pid file when the supervisor goes away, however it does:
|
||||
/// the watchdog is dropped, not returned from, on a normal shutdown.
|
||||
struct PidFile(PathBuf);
|
||||
|
||||
impl Drop for PidFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `/proc/<pid>/cmdline` is the go2rtc this proxy started: the
|
||||
/// configured binary as the program (or as the script an interpreter was
|
||||
/// given, which is what a wrapper looks like) *and* our own config path after
|
||||
/// `-config`. Both, because a pid alone proves nothing once the number has
|
||||
/// been reused, and "some go2rtc" is not necessarily ours - another proxy on
|
||||
/// the machine would run one against its own config.
|
||||
fn is_our_go2rtc(cmdline: &[u8], bin: &str, config_path: &str) -> bool {
|
||||
let args: Vec<&[u8]> = cmdline.split(|b| *b == 0).filter(|a| !a.is_empty()).collect();
|
||||
let launched = args.iter().take(2).any(|a| *a == bin.as_bytes());
|
||||
let configured = args.windows(2).any(|w| w[0] == b"-config" && w[1] == config_path.as_bytes());
|
||||
launched && configured
|
||||
}
|
||||
|
||||
/// Stops a go2rtc left behind by a proxy that was killed without a chance to
|
||||
/// stop it (SIGKILL, the OOM killer, a power cut that kept the container).
|
||||
///
|
||||
/// Only acts on a pid this proxy recorded, and only after `/proc` confirms
|
||||
/// that pid is still our go2rtc (`is_our_go2rtc`). Anywhere without `/proc`
|
||||
/// it cannot confirm that, so it leaves the process alone: a leftover
|
||||
/// go2rtc is a nuisance, killing someone else's process is not. The orphan
|
||||
/// matters because it keeps :1984 and :8555 bound - so the new one cannot
|
||||
/// start - and holds a printer's access code in memory.
|
||||
async fn reclaim_orphan(bin: &str, config_path: &str) {
|
||||
let pid_file = pid_path(config_path);
|
||||
let Ok(text) = std::fs::read_to_string(&pid_file) else { return };
|
||||
let _ = std::fs::remove_file(&pid_file);
|
||||
let Ok(pid) = text.trim().parse::<u32>() else { return };
|
||||
|
||||
let ours = || {
|
||||
std::fs::read(format!("/proc/{pid}/cmdline"))
|
||||
.map(|cmdline| is_our_go2rtc(&cmdline, bin, config_path))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if !ours() {
|
||||
return;
|
||||
}
|
||||
|
||||
warn!(pid, "a go2rtc left by an earlier proxy is still running; stopping it");
|
||||
// The `kill` program rather than a libc call: signalling is the only
|
||||
// reason this would need `unsafe` and a new dependency.
|
||||
for (signal, patience) in [("-TERM", 30), ("-KILL", 30)] {
|
||||
let _ = Command::new("kill").arg(signal).arg(pid.to_string()).status().await;
|
||||
for _ in 0..patience {
|
||||
if !ours() {
|
||||
info!(pid, "the old go2rtc is gone");
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
error!(pid, "could not stop the old go2rtc; the new one may fail to bind its ports");
|
||||
}
|
||||
|
||||
/// Supervises the go2rtc subprocess used for camera restreaming
|
||||
/// (RTSP -> WebRTC). Restarts it with a fixed backoff whenever it exits, and
|
||||
/// immediately when `restart` fires.
|
||||
@@ -87,11 +16,6 @@ async fn reclaim_orphan(bin: &str, config_path: &str) {
|
||||
/// Before that existed this watchdog supervised a process whose config file
|
||||
/// nothing wrote - it restarted go2rtc forever over a file that did not exist.
|
||||
pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
|
||||
// Before the first spawn: whatever the last proxy left behind owns the
|
||||
// ports this one is about to ask for.
|
||||
reclaim_orphan(&bin, &config_path).await;
|
||||
let _pid_file = PidFile(pid_path(&config_path));
|
||||
|
||||
loop {
|
||||
info!(%bin, %config_path, "starting go2rtc");
|
||||
|
||||
@@ -105,9 +29,6 @@ pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
|
||||
|
||||
match spawned {
|
||||
Ok(mut child) => {
|
||||
if let Some(Err(err)) = child.id().map(|pid| std::fs::write(pid_path(&config_path), pid.to_string())) {
|
||||
warn!(?err, "could not record go2rtc's pid; a hard kill of the proxy would orphan it unnoticed");
|
||||
}
|
||||
tokio::select! {
|
||||
// Whichever happens first. `kill_on_drop` means the
|
||||
// child dies when `child` goes out of scope at the end
|
||||
@@ -140,59 +61,3 @@ pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cmdline(args: &[&str]) -> Vec<u8> {
|
||||
args.iter().flat_map(|a| a.bytes().chain([0])).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_our_binary_on_our_config_counts() {
|
||||
let ours = cmdline(&["go2rtc", "-config", "./go2rtc.yaml"]);
|
||||
assert!(is_our_go2rtc(&ours, "go2rtc", "./go2rtc.yaml"));
|
||||
// A wrapper script puts the interpreter first.
|
||||
assert!(is_our_go2rtc(&cmdline(&["/bin/sh", "go2rtc", "-config", "./go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
|
||||
// Another proxy's go2rtc, an unrelated process, and a reused pid.
|
||||
assert!(!is_our_go2rtc(&cmdline(&["go2rtc", "-config", "/other.yaml"]), "go2rtc", "./go2rtc.yaml"));
|
||||
assert!(!is_our_go2rtc(&cmdline(&["sleep", "-config", "./go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
|
||||
assert!(!is_our_go2rtc(&cmdline(&["vim", "go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
|
||||
assert!(!is_our_go2rtc(b"", "go2rtc", "./go2rtc.yaml"), "a zombie has an empty cmdline");
|
||||
}
|
||||
|
||||
/// A stand-in go2rtc that runs until killed, started the way the real
|
||||
/// one is, whose pid file is then found by a "restarted proxy".
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn a_leftover_go2rtc_is_stopped_and_a_stranger_is_not() {
|
||||
let dir = std::env::temp_dir().join(format!("continuum-go2rtc-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let script = dir.join("go2rtc");
|
||||
// Stays alive until signalled, and (run as `sh <script> -config ..`)
|
||||
// looks to /proc the way the real one does behind an interpreter.
|
||||
std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 1; done\n").unwrap();
|
||||
let (bin, config) = (script.to_string_lossy().into_owned(), dir.join("go2rtc.yaml").to_string_lossy().into_owned());
|
||||
|
||||
let mut orphan = std::process::Command::new("/bin/sh").args([&bin, "-config", &config]).spawn().unwrap();
|
||||
let stranger = std::process::Command::new("sleep").arg("60").spawn().unwrap();
|
||||
|
||||
// The stranger's pid in the file: refused, and the file is cleared.
|
||||
std::fs::write(pid_path(&config), stranger.id().to_string()).unwrap();
|
||||
reclaim_orphan(&bin, &config).await;
|
||||
assert!(std::fs::read(format!("/proc/{}/cmdline", stranger.id())).is_ok_and(|c| !c.is_empty()), "an unrelated process must survive");
|
||||
assert!(!pid_path(&config).exists());
|
||||
|
||||
// The orphan's pid: stopped.
|
||||
std::fs::write(pid_path(&config), orphan.id().to_string()).unwrap();
|
||||
reclaim_orphan(&bin, &config).await;
|
||||
let status = orphan.wait().unwrap();
|
||||
assert!(!status.success(), "the leftover go2rtc was terminated by a signal");
|
||||
|
||||
let mut stranger = stranger;
|
||||
let _ = stranger.kill();
|
||||
let _ = stranger.wait();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
-185
@@ -1,185 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,10 @@
|
||||
//! should serve is not - that is `camera`, which reads the fleet and is worth
|
||||
//! testing, so it lives here.
|
||||
|
||||
pub mod bootstrap;
|
||||
pub mod camera;
|
||||
pub mod completion;
|
||||
pub mod config;
|
||||
pub mod config_import;
|
||||
pub mod enrol;
|
||||
pub mod discovery;
|
||||
pub mod fleet;
|
||||
pub mod jobs;
|
||||
pub mod printer;
|
||||
pub mod provision;
|
||||
pub mod store;
|
||||
pub mod uplink;
|
||||
|
||||
+12
-137
@@ -9,11 +9,9 @@ mod go2rtc;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use continuum_proxy::bootstrap::{self, Bootstrap};
|
||||
use continuum_proxy::config::Config;
|
||||
use continuum_proxy::config_import;
|
||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||
use continuum_proxy::{camera, completion, fleet, jobs, uplink};
|
||||
use continuum_proxy::{camera, completion, fleet, uplink};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@@ -22,83 +20,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
.init();
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
// `continuum-proxy enrol ...` connects this gateway to a farm and exits;
|
||||
// it needs the bootstrap (for where the config file is) but no settings.
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.first().map(String::as_str) == Some("enrol") {
|
||||
return continuum_proxy::enrol::run(&args[1..], &Bootstrap::discover()?).await;
|
||||
}
|
||||
|
||||
// Order matters: import first, so a stick present at boot is already
|
||||
// the installed config by the time it is read. A rejected stick is
|
||||
// logged inside and does not stop startup - the box runs on what it has.
|
||||
let bootstrap = Bootstrap::discover()?;
|
||||
// Plaintext bundles are refused on a Proxy Box and signatures are
|
||||
// mandatory once a signing key is provisioned; see the config_import
|
||||
// module docs. An unprovisioned box is allowed to start - it runs on its
|
||||
// installed config - but says loudly what it cannot check.
|
||||
//
|
||||
// Loaded only on a box: on a PC nothing imports from removable media, and
|
||||
// a stray or malformed file under /etc/continuum must not stop a PC user's
|
||||
// proxy starting over provisioning it never uses.
|
||||
let policy = if bootstrap.mode == bootstrap::Mode::ProxyBox {
|
||||
config_import::load_provisioning()?
|
||||
} else {
|
||||
config_import::BundlePolicy::unencrypted_only()
|
||||
};
|
||||
if bootstrap.mode == bootstrap::Mode::ProxyBox {
|
||||
if policy.signing_keys.is_empty() {
|
||||
tracing::warn!("no signing key provisioned: config bundles will NOT be authenticated");
|
||||
}
|
||||
if policy.box_id.is_none() {
|
||||
tracing::warn!("no box id provisioned: signed config bundles cannot be accepted");
|
||||
}
|
||||
}
|
||||
for outcome in config_import::import_from_pendrives(&bootstrap, &policy) {
|
||||
config_import::log_outcome(&outcome);
|
||||
}
|
||||
let file = bootstrap.load_config_file()?;
|
||||
let config = Config::from_file_and_env(file.as_ref())?;
|
||||
info!(mode = ?bootstrap.mode, config_file = ?file.as_ref().map(|_| &bootstrap.config_path), "configuration loaded");
|
||||
|
||||
// BOUNDARY: network settings are parsed and validated, and stop here.
|
||||
// Applying them (static IP, DNS, wifi) is the appliance image's job, not
|
||||
// this process's - see the module docs of `config`. Logged so that a box
|
||||
// given a network block does not look like it acted on it.
|
||||
if let Some(network) = file.as_ref().and_then(|f| f.network.as_ref()) {
|
||||
tracing::warn!(?network, "network settings are present but this build does NOT apply them");
|
||||
}
|
||||
let config = Config::from_env()?;
|
||||
|
||||
tracing::info!(gateway_id = %config.gateway_id, farm_id = %config.farm_id, "starting continuum-proxy");
|
||||
|
||||
// Loaded and connected here, once, at startup - nothing yet re-reads
|
||||
// printers.toml on a running daemon, so adding or removing a printer
|
||||
// still means a restart.
|
||||
// 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");
|
||||
}
|
||||
let mut fleet = fleet::load(std::path::Path::new(&config.printers_path))?;
|
||||
connect_fleet(&mut fleet).await;
|
||||
|
||||
// The fleet is now shared with the uplink rather than just parked here
|
||||
@@ -108,18 +37,13 @@ async fn main() -> anyhow::Result<()> {
|
||||
// see `uplink::SharedFleet` for why that type and what it costs.
|
||||
let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet));
|
||||
|
||||
// Five long-running tasks: the cloud uplink, the go2rtc subprocess
|
||||
// watchdog, the camera-config builder that tells it what to serve, the
|
||||
// print-completion watcher that blinks a printer's lights when it
|
||||
// finishes, and printer discovery. Every printer's MQTT session is a
|
||||
// sixth thing running concurrently, but implicitly - each one has its own
|
||||
// background poll task spawned by `connect()`, kept alive by the fleet
|
||||
// above. More will join these later (the plate changer).
|
||||
//
|
||||
// Discovery parks on a future that never completes unless it is switched
|
||||
// on, which it is not by default - see `discovery::report::run`. So this
|
||||
// arm is usually a task that costs nothing, and that is the intended
|
||||
// steady state, not a disabled feature waiting to be finished.
|
||||
// Four long-running tasks: the cloud uplink, the go2rtc subprocess
|
||||
// watchdog, the camera-config builder that tells it what to serve, and
|
||||
// the print-completion watcher that blinks a printer's lights when it
|
||||
// finishes. Every printer's MQTT session is a fifth thing running
|
||||
// concurrently, but implicitly - each one has its own background poll
|
||||
// task spawned by `connect()`, kept alive by the fleet above. More will
|
||||
// join these later (printer discovery, the plate changer).
|
||||
//
|
||||
// The completion watcher is deliberately its own task rather than part
|
||||
// of the uplink: it must keep working with the cloud connection down,
|
||||
@@ -137,7 +61,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let restart_go2rtc = std::sync::Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
// Built once, here, rather than inside the rebuild loop: the config is
|
||||
// re-rendered every few seconds and compared byte-for-byte to decide whether to
|
||||
// re-rendered every 30s and compared byte-for-byte to decide whether to
|
||||
// restart go2rtc, so a credential that changed per render would restart
|
||||
// it - and drop every viewer - on every tick.
|
||||
let camera_auth = camera::ApiAuth::new()?;
|
||||
@@ -147,30 +71,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
"go2rtc's API requires basic auth; the password is in its config file"
|
||||
);
|
||||
|
||||
// Created here rather than inside the uplink so discovery can hand it
|
||||
// messages without the uplink knowing what discovery is. It outlives any
|
||||
// one connection, so a scan that finishes while the cloud is unreachable
|
||||
// waits for the next socket instead of being thrown away.
|
||||
let (outbound, inbox) = uplink::channel();
|
||||
|
||||
tokio::select! {
|
||||
// Ends the process through a normal return, so the supervisors below
|
||||
// are dropped and go2rtc's `kill_on_drop` actually fires. Without this
|
||||
// arm SIGTERM - what `kill`, `docker stop` and a service manager all
|
||||
// send - ends the process on the spot with no destructor run, and
|
||||
// go2rtc is orphaned: it keeps :1984 and :8555 bound, so the restarted
|
||||
// proxy cannot start its own, and it keeps a printer's access code in
|
||||
// memory. Found when a test run left one behind.
|
||||
_ = shutdown_requested() => {
|
||||
info!("stop requested; exiting so go2rtc is stopped with us");
|
||||
}
|
||||
_ = uplink::run(config.clone(), fleet.clone(), Some(store.clone()), outbound.clone(), inbox) => {}
|
||||
_ = continuum_proxy::discovery::report::run(
|
||||
config.discovery.clone(),
|
||||
config.printers_path.clone(),
|
||||
Some(store.clone()),
|
||||
outbound.clone(),
|
||||
) => {}
|
||||
_ = uplink::run(config.clone(), fleet.clone()) => {}
|
||||
_ = go2rtc::watchdog(
|
||||
config.go2rtc_bin.clone(),
|
||||
config.go2rtc_config.clone(),
|
||||
@@ -184,38 +86,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
restart_go2rtc.clone(),
|
||||
) => {}
|
||||
_ = completion::run(fleet.clone()) => {}
|
||||
_ = jobs::run(fleet.clone(), store.clone()) => {}
|
||||
// Returns only after installing a new config from a stick inserted
|
||||
// while running. Settings are read once at startup, so the process
|
||||
// ends here and relies on its supervisor (systemd `Restart=always`
|
||||
// on a box) to start it again on the new config. Not exercised end
|
||||
// to end - there is no supervisor or real stick in the dev
|
||||
// environment.
|
||||
_ = config_import::watch(bootstrap.clone(), policy.clone()) => {
|
||||
info!("exiting so the supervisor restarts the proxy on the newly installed config");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves when the process is asked to stop: SIGTERM or SIGINT on Unix,
|
||||
/// Ctrl-C elsewhere.
|
||||
async fn shutdown_requested() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
if let Ok(mut terminate) = signal(SignalKind::terminate()) {
|
||||
tokio::select! {
|
||||
_ = terminate.recv() => {}
|
||||
_ = tokio::signal::ctrl_c() => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
}
|
||||
|
||||
/// Connects every configured printer concurrently (not one at a time -
|
||||
/// Bambu's auto-detect fallback alone can take several seconds per
|
||||
/// printer if it has to time out on MQTT v5 before trying v3.1.1;
|
||||
|
||||
+2
-61
@@ -207,28 +207,6 @@ pub enum BambuProtocolPreference {
|
||||
}
|
||||
// endregion: BambuMQTTProtocol
|
||||
|
||||
/// One pending camera read for a P1/A1; see `bambu_camera`. Holds the access
|
||||
/// code, so it has no `Debug`.
|
||||
pub struct SnapshotJob {
|
||||
name: String,
|
||||
host: String,
|
||||
access_code: String,
|
||||
connector: native_tls::TlsConnector,
|
||||
}
|
||||
|
||||
impl SnapshotJob {
|
||||
/// One JPEG frame from the printer's port-6000 camera.
|
||||
pub async fn run(self) -> Result<Vec<u8>, PrinterError> {
|
||||
let name = self.name;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
super::bambu_camera::snapshot(&self.host, &self.access_code, self.connector, std::time::Duration::from_secs(8))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| PrinterError(format!("{name}: camera thread failed: {err}")))?
|
||||
.map_err(|err| PrinterError(format!("{name}: camera: {err}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// A Bambu printer. Only one struct now, not two: the old
|
||||
/// `BambuGenericPrinter` (shared fields/behavior) + `BambuV1Printer`/
|
||||
/// `BambuV2Printer` (thin per-version wrappers) split existed to let two
|
||||
@@ -246,8 +224,6 @@ pub struct BambuPrinter {
|
||||
/// alongside the protocol version it settled on, since the two are
|
||||
/// decided together and always used together - see `bambu_mqtt.rs`.
|
||||
connection: Option<(super::bambu_mqtt::BambuMqttSession, BambuProtocolVersion)>,
|
||||
/// See `set_ams_slot_override`.
|
||||
ams_slot_override: Option<u8>,
|
||||
}
|
||||
|
||||
/// Which plate of a multi-plate project gets printed.
|
||||
@@ -493,17 +469,9 @@ impl BambuPrinter {
|
||||
tls,
|
||||
preference,
|
||||
connection: None,
|
||||
ams_slot_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator's chosen AMS slot for the next print (0-based across all
|
||||
/// units), or `None` to map by the project's own filament. Set by the
|
||||
/// uplink around one `start_print` and cleared after it.
|
||||
pub fn set_ams_slot_override(&mut self, slot: Option<u8>) {
|
||||
self.ams_slot_override = slot;
|
||||
}
|
||||
|
||||
pub fn base(&self) -> &PrinterBase {
|
||||
&self.base
|
||||
}
|
||||
@@ -768,8 +736,7 @@ impl BambuPrinter {
|
||||
.map_err(|err| PrinterError(format!("failed to build pushall request: {err}")))?;
|
||||
body["push_target"] = serde_json::Value::from(1);
|
||||
|
||||
// Unacked: nothing waits on a reply to this, and see `publish_unacked`.
|
||||
session.publish_unacked("pushing", &body).await
|
||||
session.publish("pushing", &body).await
|
||||
}
|
||||
|
||||
/// Just the FTPS transfer: puts `data` in the printer's `cache/`
|
||||
@@ -885,18 +852,7 @@ impl BambuPrinter {
|
||||
// naming a filament that also happens to be on the spool should
|
||||
// still use the AMS, because that is what it was sliced for.
|
||||
if !trays.is_empty() {
|
||||
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 {
|
||||
match super::map_to_trays(&filaments, &trays) {
|
||||
Ok(mapping) => {
|
||||
tracing::info!(
|
||||
printer = %self.base.name,
|
||||
@@ -978,21 +934,6 @@ impl BambuPrinter {
|
||||
.map_err(|err| PrinterError(format!("{}: FTPS download failed: {err}", self.base.name)))
|
||||
}
|
||||
|
||||
/// Everything needed to fetch one camera frame, owned, so the caller can
|
||||
/// drop the fleet lock before the network round trip.
|
||||
pub fn snapshot_job(&self) -> Result<SnapshotJob, PrinterError> {
|
||||
let connector = self
|
||||
.tls
|
||||
.build_connector()
|
||||
.map_err(|err| PrinterError(format!("{}: TLS setup failed: {err}", self.base.name)))?;
|
||||
Ok(SnapshotJob {
|
||||
name: self.base.name.clone(),
|
||||
host: self.base.host.clone(),
|
||||
access_code: self.access_code.clone(),
|
||||
connector,
|
||||
})
|
||||
}
|
||||
|
||||
/// Removes one file from the printer's SD card.
|
||||
///
|
||||
/// Takes one explicit path, because the convenient version of this is
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
//! The camera on a Bambu P1 or A1, which is not RTSP.
|
||||
//!
|
||||
//! Those models publish no `rtsp_url` and refuse port 322 (confirmed on a
|
||||
//! real P1P, 2026-10-15). What they have instead is a proprietary stream on
|
||||
//! TCP 6000: TLS, a single 80-byte login packet, then a continuous run of
|
||||
//! JPEG frames, each behind a 16-byte header.
|
||||
//!
|
||||
//! This reads **one frame** and hangs up. A browser cannot play that stream
|
||||
//! (it is MJPEG over a custom framing, and go2rtc cannot turn it into WebRTC
|
||||
//! without ffmpeg, which a gateway does not have), so the console shows a
|
||||
//! refreshing picture instead and a one-shot read is all it needs.
|
||||
//!
|
||||
//! Same blocking-TLS shape as `bambu_ftps`: the caller hands in the printer's
|
||||
//! own `native_tls` connector, so every trust decision stays in
|
||||
//! `BambuTls::build_connector`.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const PORT: u16 = 6000;
|
||||
|
||||
/// Same username as MQTT and FTPS.
|
||||
const USER: &str = "bblp";
|
||||
|
||||
/// A frame this large is a corrupt header, not a picture. Real frames from a
|
||||
/// P1P are tens of kilobytes.
|
||||
const MAX_FRAME: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// The 80-byte login: four little-endian u32s (0x40, 0x3000, 0, 0), then the
|
||||
/// username and the access code, each zero-padded to 32 bytes.
|
||||
fn auth_packet(access_code: &str) -> [u8; 80] {
|
||||
let mut packet = [0u8; 80];
|
||||
packet[0..4].copy_from_slice(&0x40u32.to_le_bytes());
|
||||
packet[4..8].copy_from_slice(&0x3000u32.to_le_bytes());
|
||||
let user = USER.as_bytes();
|
||||
packet[16..16 + user.len()].copy_from_slice(user);
|
||||
// An access code is 8 characters; truncating anything longer keeps the
|
||||
// packet shape rather than overrunning into nothing.
|
||||
let code = access_code.as_bytes();
|
||||
let n = code.len().min(32);
|
||||
packet[48..48 + n].copy_from_slice(&code[..n]);
|
||||
packet
|
||||
}
|
||||
|
||||
/// Reads one frame: a 16-byte header whose first u32 is the payload length,
|
||||
/// then that many bytes, which must be a JPEG.
|
||||
fn read_frame<R: Read>(stream: &mut R) -> anyhow::Result<Vec<u8>> {
|
||||
let mut header = [0u8; 16];
|
||||
stream
|
||||
.read_exact(&mut header)
|
||||
.map_err(|err| anyhow::anyhow!("the camera sent no frame (wrong access code, or the stream is in use?): {err}"))?;
|
||||
let len = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize;
|
||||
if !(4..=MAX_FRAME).contains(&len) {
|
||||
anyhow::bail!("the camera announced a {len}-byte frame, which is not a picture");
|
||||
}
|
||||
let mut jpeg = vec![0u8; len];
|
||||
stream.read_exact(&mut jpeg)?;
|
||||
if jpeg[..2] != [0xFF, 0xD8] || jpeg[len - 2..] != [0xFF, 0xD9] {
|
||||
anyhow::bail!("the camera's frame is not a complete JPEG");
|
||||
}
|
||||
Ok(jpeg)
|
||||
}
|
||||
|
||||
/// Connects, logs in and returns the first frame. Blocking; call it from
|
||||
/// `spawn_blocking`.
|
||||
pub fn snapshot(
|
||||
host: &str,
|
||||
access_code: &str,
|
||||
connector: native_tls::TlsConnector,
|
||||
timeout: Duration,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let addr = (host, PORT)
|
||||
.to_socket_addrs()?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("{host} did not resolve"))?;
|
||||
let tcp = std::net::TcpStream::connect_timeout(&addr, timeout)
|
||||
.map_err(|err| anyhow::anyhow!("{host}:{PORT} did not accept a connection: {err}"))?;
|
||||
tcp.set_read_timeout(Some(timeout))?;
|
||||
tcp.set_write_timeout(Some(timeout))?;
|
||||
let mut tls = connector
|
||||
.connect(host, tcp)
|
||||
.map_err(|err| anyhow::anyhow!("TLS handshake with the camera failed: {err}"))?;
|
||||
tls.write_all(&auth_packet(access_code))?;
|
||||
tls.flush()?;
|
||||
read_frame(&mut tls)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = (payload.len() as u32).to_le_bytes().to_vec();
|
||||
out.extend_from_slice(&[0; 12]);
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_login_packet_has_the_documented_layout() {
|
||||
let p = auth_packet("12345678");
|
||||
assert_eq!(&p[0..8], &[0x40, 0, 0, 0, 0, 0x30, 0, 0]);
|
||||
assert_eq!(&p[8..16], &[0; 8]);
|
||||
assert_eq!(&p[16..20], b"bblp");
|
||||
assert!(p[20..48].iter().all(|b| *b == 0));
|
||||
assert_eq!(&p[48..56], b"12345678");
|
||||
assert!(p[56..].iter().all(|b| *b == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_jpeg_frame_is_returned() {
|
||||
let jpeg = [0xFF, 0xD8, 1, 2, 3, 0xFF, 0xD9];
|
||||
let mut input = frame(&jpeg).into_iter().chain([9, 9, 9]).collect::<Vec<_>>();
|
||||
assert_eq!(read_frame(&mut input.as_slice()).unwrap(), jpeg);
|
||||
input.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonsense_is_refused_not_returned() {
|
||||
// not a JPEG
|
||||
assert!(read_frame(&mut frame(&[1, 2, 3, 4]).as_slice()).is_err());
|
||||
// length beyond the cap
|
||||
let mut huge = (u32::MAX).to_le_bytes().to_vec();
|
||||
huge.extend_from_slice(&[0; 12]);
|
||||
assert!(read_frame(&mut huge.as_slice()).is_err());
|
||||
// truncated
|
||||
let mut cut = frame(&[0xFF, 0xD8, 0, 0xFF, 0xD9]);
|
||||
cut.truncate(cut.len() - 2);
|
||||
assert!(read_frame(&mut cut.as_slice()).is_err());
|
||||
// nothing at all (what a wrong access code looks like)
|
||||
assert!(read_frame(&mut [].as_slice()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -160,11 +160,7 @@ impl PrintReport {
|
||||
self.envelope = newer.envelope;
|
||||
self.msg = newer.msg;
|
||||
|
||||
// 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 = newer.ams.or(self.ams.take());
|
||||
self.ams_exist_bits = newer.ams_exist_bits.or(self.ams_exist_bits.take());
|
||||
self.insert_flag = newer.insert_flag.or(self.insert_flag.take());
|
||||
self.power_on_flag = newer.power_on_flag.or(self.power_on_flag.take());
|
||||
@@ -248,10 +244,6 @@ pub struct LightReport {
|
||||
/// actually sends.
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct AmsState {
|
||||
/// Absent from the partial `ams` objects a P1P pushes mid-print (found on
|
||||
/// the first real dispatch, 2026-10-29: every update was dropped as
|
||||
/// "missing field `ams`" and the job could never complete).
|
||||
#[serde(default)]
|
||||
pub ams: Vec<AmsUnit>,
|
||||
}
|
||||
|
||||
|
||||
+10
-33
@@ -76,9 +76,6 @@ use super::PrinterError;
|
||||
type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>;
|
||||
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long a publish may wait for room in the MQTT request queue.
|
||||
const PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Both rumqttc clients default to a 10KB max packet size in *both*
|
||||
/// directions - fine for a request, nowhere near enough for a reply:
|
||||
/// confirmed live against a real H2C, whose `get_version` reply alone
|
||||
@@ -362,38 +359,18 @@ impl BambuMqttSession {
|
||||
/// `publish` above and `GenericPrinter::publish` (a raw pass-through,
|
||||
/// no envelope opinion at all) are built on.
|
||||
pub async fn publish_raw(&self, payload: String) -> Result<(), PrinterError> {
|
||||
self.publish_with_qos(payload, true).await
|
||||
}
|
||||
|
||||
/// Like `publish`, but QoS 0: for requests that need no acknowledgement
|
||||
/// (a status refresh). QoS 1 publishes the printer never acknowledges
|
||||
/// fill rumqttc's in-flight window, after which every publish blocks
|
||||
/// forever - found on the first real dispatch, where that froze the whole
|
||||
/// proxy about 4.5 minutes in, because callers hold the fleet lock.
|
||||
pub async fn publish_unacked(&self, category: &str, body: &Value) -> Result<(), PrinterError> {
|
||||
self.publish_with_qos(serde_json::json!({ category: body }).to_string(), false).await
|
||||
}
|
||||
|
||||
async fn publish_with_qos(&self, payload: String, acked: bool) -> Result<(), PrinterError> {
|
||||
let topic = format!("device/{}/request", self.sn);
|
||||
|
||||
// Bounded: a publish waits for room in rumqttc's request queue, and an
|
||||
// unbounded wait here wedges every caller that holds the fleet lock.
|
||||
let publishing = async {
|
||||
match &self.client {
|
||||
BambuMqttClient::V1(client) => client
|
||||
.publish(topic, if acked { QoS::AtLeastOnce } else { QoS::AtMostOnce }, false, payload)
|
||||
.await
|
||||
.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)))?
|
||||
match &self.client {
|
||||
BambuMqttClient::V1(client) => client
|
||||
.publish(topic, QoS::AtLeastOnce, false, payload)
|
||||
.await
|
||||
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
|
||||
BambuMqttClient::V2(client) => client
|
||||
.publish(topic, QoSV5::AtLeastOnce, false, payload)
|
||||
.await
|
||||
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current in-memory `print.push_status` object, if at least one
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
+40
-269
@@ -1,304 +1,75 @@
|
||||
//! 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";
|
||||
use super::{GenericPrinter, PrinterBase, PrinterError};
|
||||
|
||||
pub struct KlipperPrinter {
|
||||
base: PrinterBase,
|
||||
http: Http,
|
||||
feed: Option<StatusFeed>,
|
||||
}
|
||||
|
||||
impl KlipperPrinter {
|
||||
pub fn new(id: &str, name: &str, host: &str) -> Self {
|
||||
Self {
|
||||
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
|
||||
http: Http::new(host, MOONRAKER_PORT, None),
|
||||
feed: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Option<PrinterStatus> {
|
||||
self.feed.as_ref().and_then(StatusFeed::latest)
|
||||
}
|
||||
}
|
||||
|
||||
/// Moonraker's `print_stats.state` in the vocabulary the cloud already
|
||||
/// understands (the strings Bambu reports; see `mapStatus` in the backend).
|
||||
fn map_state(state: &str) -> Option<&'static str> {
|
||||
match state {
|
||||
"standby" => Some("IDLE"),
|
||||
"printing" => Some("RUNNING"),
|
||||
"paused" => Some("PAUSE"),
|
||||
"complete" => Some("FINISH"),
|
||||
// A cancelled print leaves nothing running; the machine is free.
|
||||
"cancelled" => Some("IDLE"),
|
||||
"error" => Some("FAILED"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `result.status` object of an `objects/query` answer.
|
||||
fn parse_status(answer: &Value) -> Option<PrinterStatus> {
|
||||
let status = answer.get("result")?.get("status")?;
|
||||
let stats = status.get("print_stats")?;
|
||||
let state = stats.get("state")?.as_str()?;
|
||||
let number = |v: Option<&Value>| v.and_then(Value::as_f64).map(|n| n as f32);
|
||||
|
||||
let progress = number(status.get("virtual_sdcard").and_then(|s| s.get("progress"))).unwrap_or(0.0);
|
||||
let percent = match state {
|
||||
"complete" => 100,
|
||||
"printing" | "paused" => (progress * 100.0).round() as i32,
|
||||
_ => 0,
|
||||
};
|
||||
let duration = number(stats.get("print_duration")).unwrap_or(0.0);
|
||||
// Klipper has no estimate of its own; this is elapsed time scaled by
|
||||
// progress, which is wrong early in a print and settles as it goes.
|
||||
let remaining_minutes = (matches!(state, "printing" | "paused") && progress > 0.01)
|
||||
.then(|| ((duration / progress - duration) / 60.0).round() as i32);
|
||||
let layer_info = stats.get("info");
|
||||
let int = |v: Option<&Value>| v.and_then(Value::as_i64).map(|n| n as i32);
|
||||
|
||||
Some(PrinterStatus {
|
||||
state: map_state(state).map(str::to_string),
|
||||
percent: Some(percent),
|
||||
layer: int(layer_info.and_then(|i| i.get("current_layer"))),
|
||||
total_layers: int(layer_info.and_then(|i| i.get("total_layer"))),
|
||||
remaining_minutes,
|
||||
job_name: stats
|
||||
.get("filename")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|f| !f.is_empty())
|
||||
.map(str::to_string),
|
||||
nozzle_temp: number(status.get("extruder").and_then(|e| e.get("temperature"))),
|
||||
nozzle_target: number(status.get("extruder").and_then(|e| e.get("target"))),
|
||||
bed_temp: number(status.get("heater_bed").and_then(|e| e.get("temperature"))),
|
||||
bed_target: number(status.get("heater_bed").and_then(|e| e.get("target"))),
|
||||
print_error: (state == "error").then_some(1),
|
||||
..PrinterStatus::default()
|
||||
})
|
||||
}
|
||||
|
||||
async fn poll(http: &Http) -> Option<PrinterStatus> {
|
||||
match http.get_json(STATUS_QUERY).await {
|
||||
Ok(answer) => parse_status(&answer),
|
||||
// Moonraker answers 503 to object queries while Klipper itself is
|
||||
// down, but still answers `/printer/info` - which is the
|
||||
// difference between "off" and "faulted".
|
||||
Err(_) => {
|
||||
let info = http.get_json("/printer/info").await.ok()?;
|
||||
let state = info.get("result")?.get("state")?.as_str()?;
|
||||
matches!(state, "error" | "shutdown").then(|| PrinterStatus {
|
||||
state: Some("FAILED".into()),
|
||||
print_error: Some(1),
|
||||
..PrinterStatus::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GenericPrinter for KlipperPrinter {
|
||||
/// Note Klipper calls it "cancel" where Bambu calls it "stop".
|
||||
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> {
|
||||
/// OVERRIDE: real version is `POST /printer/print/pause`, `/resume` or
|
||||
/// `/cancel` on Moonraker - note Klipper calls it "cancel" where Bambu
|
||||
/// calls it "stop".
|
||||
async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> {
|
||||
let endpoint = match action {
|
||||
PrintControl::Pause => "pause",
|
||||
PrintControl::Resume => "resume",
|
||||
PrintControl::Stop => "cancel",
|
||||
super::PrintControl::Pause => "pause",
|
||||
super::PrintControl::Resume => "resume",
|
||||
super::PrintControl::Stop => "cancel",
|
||||
};
|
||||
self.http.post_empty(&format!("/printer/print/{endpoint}")).await.map(|_| ())
|
||||
println!("[{}] POST /printer/print/{endpoint}", self.base.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn base(&self) -> &PrinterBase {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// Checks Moonraker answers, then starts the status poller. Klipper
|
||||
/// itself being down is not a connect failure: Moonraker is up and
|
||||
/// will report it.
|
||||
/// OVERRIDE: a third, again-different body - Moonraker's real feed is a
|
||||
/// WebSocket (see `adapters::moonraker`). This also shows the `Err`
|
||||
/// path: an empty `host` fails immediately, same as any other vendor
|
||||
/// would if it couldn't reach its printer.
|
||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
||||
if self.base.host.trim().is_empty() {
|
||||
if self.base.host.is_empty() {
|
||||
return Err(PrinterError(format!("{} has no host configured", 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 }
|
||||
}));
|
||||
println!("[{}] connecting over WebSocket (Moonraker)", self.base.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Nothing to publish to: Moonraker is request/response.
|
||||
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
|
||||
Err(PrinterError("Klipper has no publish channel".into()))
|
||||
}
|
||||
|
||||
/// `M106` works on Klipper for the part-cooling fan, same as elsewhere.
|
||||
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 to the printer's `gcodes` root and starts it (`print=true`),
|
||||
/// like Bambu's upload-then-`project_file`. Klipper runs plain G-code,
|
||||
/// so a slicer project (`.3mf`) is refused here rather than uploaded
|
||||
/// and left to fail on the machine.
|
||||
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
||||
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")));
|
||||
}
|
||||
// TODO: add publishing via HTTP
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OVERRIDE: Moonraker's own **hypothetical** native API, not gcode injection - no
|
||||
/// percent-to-PWM conversion needed since it already takes a
|
||||
/// percentage directly. Real version:
|
||||
/// `POST /printer/gcode/script?script=SET_FAN_SPEED SPEED=0.NN`.
|
||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
||||
println!("[{}] Moonraker: SET_FAN_SPEED SPEED={:.2}", self.base.name, percent as f32 / 100.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OVERRIDE: real version is a multipart `POST /server/files/upload`
|
||||
/// with `data` as the file part - Moonraker stores it on the printer's
|
||||
/// virtual SD card, same upload-then-start model as Bambu/PrusaLink.
|
||||
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());
|
||||
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> {
|
||||
self.http.post_json("/printer/gcode/script", &json!({ "script": gcode_line })).await.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::printer::mock_http::MockServer;
|
||||
|
||||
const PRINTING: &str = r#"{"result":{"status":{
|
||||
"print_stats":{"state":"printing","filename":"cube.gcode","print_duration":600.0,"info":{"current_layer":12,"total_layer":60}},
|
||||
"virtual_sdcard":{"progress":0.25},
|
||||
"extruder":{"temperature":214.8,"target":215.0},
|
||||
"heater_bed":{"temperature":59.9,"target":60.0}}}}"#;
|
||||
|
||||
#[test]
|
||||
fn a_printing_machine_maps_to_the_shared_vocabulary() {
|
||||
let status = parse_status(&serde_json::from_str(PRINTING).unwrap()).unwrap();
|
||||
assert_eq!(status.state.as_deref(), Some("RUNNING"));
|
||||
assert_eq!(status.percent, Some(25));
|
||||
assert_eq!((status.layer, status.total_layers), (Some(12), Some(60)));
|
||||
// 600 s for a quarter: 1800 s more.
|
||||
assert_eq!(status.remaining_minutes, Some(30));
|
||||
assert_eq!(status.job_name.as_deref(), Some("cube.gcode"));
|
||||
assert_eq!(status.nozzle_temp, Some(214.8));
|
||||
assert_eq!(status.bed_target, Some(60.0));
|
||||
assert_eq!(status.print_error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_is_100_percent_and_error_carries_a_code() {
|
||||
let done = r#"{"result":{"status":{"print_stats":{"state":"complete","filename":"a.gcode"},"virtual_sdcard":{"progress":1.0}}}}"#;
|
||||
let status = parse_status(&serde_json::from_str(done).unwrap()).unwrap();
|
||||
assert_eq!((status.state.as_deref(), status.percent), (Some("FINISH"), Some(100)));
|
||||
|
||||
let failed = r#"{"result":{"status":{"print_stats":{"state":"error"}}}}"#;
|
||||
let status = parse_status(&serde_json::from_str(failed).unwrap()).unwrap();
|
||||
assert_eq!((status.state.as_deref(), status.print_error), (Some("FAILED"), Some(1)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_gcode_and_fan_go_to_the_right_endpoints() {
|
||||
let mock = MockServer::start().await;
|
||||
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
|
||||
mock.answer("POST /printer/print/cancel", 200, r#"{"result":"ok"}"#);
|
||||
mock.answer("POST /printer/gcode/script", 200, r#"{"result":"ok"}"#);
|
||||
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
||||
printer.connect().await.unwrap();
|
||||
|
||||
printer.control_print(PrintControl::Stop).await.unwrap();
|
||||
assert_eq!(mock.sent("POST", "/printer/print/cancel").len(), 1);
|
||||
|
||||
printer.set_fan_speed(50).await.unwrap();
|
||||
let sent = mock.sent("POST", "/printer/gcode/script");
|
||||
assert_eq!(sent[0].text(), r#"{"script":"M106 S127"}"#);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_fails_when_nothing_answers_as_moonraker() {
|
||||
let mock = MockServer::start().await; // every route is a 404
|
||||
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
||||
assert!(printer.connect().await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_poller_feeds_status_and_clears_it_when_the_printer_goes() {
|
||||
let mock = MockServer::start().await;
|
||||
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
|
||||
mock.answer("GET /printer/objects/query", 200, PRINTING);
|
||||
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
||||
printer.connect().await.unwrap();
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(printer.status().unwrap().percent, Some(25));
|
||||
|
||||
// Klipper shuts down: queries 503, /printer/info says why.
|
||||
mock.answer("GET /printer/objects/query", 503, "{}");
|
||||
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"shutdown"}}"#);
|
||||
tokio::time::sleep(Duration::from_millis(2500)).await;
|
||||
assert_eq!(printer.status().unwrap().state.as_deref(), Some("FAILED"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_sends_the_file_with_print_true_and_refuses_a_project() {
|
||||
let mock = MockServer::start().await;
|
||||
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
|
||||
mock.answer("POST /server/files/upload", 201, r#"{"print_started":true}"#);
|
||||
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
||||
printer.connect().await.unwrap();
|
||||
|
||||
printer.upload_project_file("jobs/cube.gcode".into(), b"G28\nG1 X1\n".to_vec()).await.unwrap();
|
||||
let sent = mock.sent("POST", "/server/files/upload");
|
||||
assert_eq!(sent.len(), 1);
|
||||
let body = sent[0].text();
|
||||
assert!(body.contains("name=\"print\"\r\n\r\ntrue"));
|
||||
assert!(body.contains("filename=\"cube.gcode\""));
|
||||
assert!(body.contains("G28\nG1 X1\n"));
|
||||
assert!(sent[0].header("content-type").unwrap().starts_with("multipart/form-data; boundary="));
|
||||
|
||||
let err = printer.upload_project_file("x.3mf".into(), vec![1]).await.unwrap_err();
|
||||
assert!(err.0.contains("not G-code"), "{err}");
|
||||
assert_eq!(mock.sent("POST", "/server/files/upload").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_upload_is_refused_while_a_print_runs() {
|
||||
let mock = MockServer::start().await;
|
||||
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
|
||||
mock.answer("GET /printer/objects/query", 200, PRINTING);
|
||||
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
||||
printer.connect().await.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
let err = printer.upload_project_file("b.gcode".into(), vec![1]).await.unwrap_err();
|
||||
assert!(err.0.contains("already printing"), "{err}");
|
||||
assert!(mock.sent("POST", "/server/files/upload").is_empty());
|
||||
println!("[{}] POST /printer/gcode/script?script={gcode_line}", self.base.name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
//! 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;
|
||||
}
|
||||
}
|
||||
+5
-81
@@ -24,32 +24,26 @@
|
||||
//! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's
|
||||
//! protocol, `bambu_ftps.rs` for getting files onto a Bambu printer,
|
||||
//! `marlin_serial.rs` for a real Marlin serial connection - see each
|
||||
//! module's own doc comment. Klipper (`klipper.rs`) and OctoPrint/PrusaLink
|
||||
//! (`octoprint.rs`) are real HTTP clients, tested against mock servers only.
|
||||
//! module's own doc comment. PrusaLink/Klipper's HTTP clients are still
|
||||
//! stubs; that's the next piece to build past this module.
|
||||
|
||||
mod bambu;
|
||||
mod bambu_camera;
|
||||
mod bambu_commands;
|
||||
mod bambu_ftps;
|
||||
mod bambu_mqtt;
|
||||
mod http;
|
||||
mod klipper;
|
||||
mod marlin_serial;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod mock_http;
|
||||
mod octoprint;
|
||||
mod polled;
|
||||
mod prusa;
|
||||
|
||||
// Reading a `.3mf` well enough to know which filament a plate wants. Kept
|
||||
// free of printer types so it is testable without a printer.
|
||||
mod project_file;
|
||||
pub use project_file::{
|
||||
map_to_slot, map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament,
|
||||
map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament,
|
||||
ProjectFileError,
|
||||
};
|
||||
|
||||
pub use bambu::{SnapshotJob,
|
||||
pub use bambu::{
|
||||
BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource,
|
||||
LightState, CHAMBER_LIGHT,
|
||||
};
|
||||
@@ -59,54 +53,7 @@ pub use bambu_commands::{
|
||||
XcamSettings,
|
||||
};
|
||||
pub use klipper::KlipperPrinter;
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use prusa::{OctoPrintFlavour, OctoPrintPrinter, PrusaSerialPrinter};
|
||||
|
||||
/// A tiny error type: just a message. `String` would work too - this exists
|
||||
/// mainly so `?` has something concrete to convert into everywhere.
|
||||
@@ -247,29 +194,6 @@ pub enum PrinterHandle {
|
||||
}
|
||||
|
||||
impl PrinterHandle {
|
||||
/// A pending camera-frame read, for printers whose camera is a JPEG
|
||||
/// stream rather than RTSP (Bambu P1/A1).
|
||||
pub fn snapshot_job(&self) -> Result<bambu::SnapshotJob, PrinterError> {
|
||||
match self {
|
||||
PrinterHandle::Bambu(p) => p.snapshot_job(),
|
||||
_ => Err(PrinterError("this printer has no snapshot camera".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The latest vendor-neutral status, if the printer has one. Bambu's
|
||||
/// comes from its MQTT cache, the HTTP vendors' from their pollers; the
|
||||
/// serial printer has none.
|
||||
pub async fn status(&self) -> Option<PrinterStatus> {
|
||||
match self {
|
||||
PrinterHandle::Bambu(p) => p.print_status().await.as_ref().map(PrinterStatus::from),
|
||||
PrinterHandle::OctoPrint(p) => p.status(),
|
||||
PrinterHandle::Klipper(p) => p.status(),
|
||||
PrinterHandle::PrusaSerial(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bambu's full report, for what is Bambu-specific (camera, AMS).
|
||||
///
|
||||
/// The latest status this process has for the printer, if it has one.
|
||||
///
|
||||
/// An inherent method rather than a `GenericPrinter` trait method,
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! Status for vendors that have no push feed: a background task asks the
|
||||
//! printer on an interval and keeps the last answer for `status()` to read.
|
||||
//! (Bambu pushes over MQTT and keeps its own cache.)
|
||||
use std::future::Future;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use super::PrinterStatus;
|
||||
|
||||
pub struct StatusFeed {
|
||||
latest: Arc<Mutex<Option<PrinterStatus>>>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl StatusFeed {
|
||||
/// `fetch` returning `None` means "no usable answer" (printer
|
||||
/// unreachable, or not ready), and clears the cache: a stale status
|
||||
/// shown as current is worse than none.
|
||||
pub fn start<F, Fut>(interval: Duration, fetch: F) -> Self
|
||||
where
|
||||
F: Fn() -> Fut + Send + 'static,
|
||||
Fut: Future<Output = Option<PrinterStatus>> + Send,
|
||||
{
|
||||
let latest = Arc::new(Mutex::new(None));
|
||||
let shared = latest.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let status = fetch().await;
|
||||
*shared.lock().unwrap() = status;
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
});
|
||||
Self { latest, task }
|
||||
}
|
||||
|
||||
pub fn latest(&self) -> Option<PrinterStatus> {
|
||||
self.latest.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StatusFeed {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
@@ -71,8 +71,6 @@ pub enum ProjectFileError {
|
||||
/// material or none.
|
||||
#[error("no loaded tray matches {wanted} (filament index {index}); loaded: {loaded}")]
|
||||
NoMatchingTray { index: usize, wanted: String, loaded: String },
|
||||
#[error("cannot use AMS slot {0}: {1}")]
|
||||
BadSlot(i32, String),
|
||||
}
|
||||
|
||||
/// Normalises a colour to six uppercase hex digits.
|
||||
@@ -273,36 +271,6 @@ pub fn map_to_trays(
|
||||
}
|
||||
|
||||
/// Match quality, lower is better; `None` means no match.
|
||||
/// An explicit choice of slot instead of the colour-based pick: the operator
|
||||
/// said which spool to use. Deliberately narrow - a single-filament project
|
||||
/// only (which filament goes where is ambiguous otherwise), onto a slot that
|
||||
/// is loaded and holds the same *material type*, because the type decides
|
||||
/// the temperatures. A different colour is allowed: that is the point.
|
||||
pub fn map_to_slot(
|
||||
filaments: &[ProjectFilament],
|
||||
trays: &[AvailableTray],
|
||||
slot: i32,
|
||||
) -> Result<Vec<i32>, ProjectFileError> {
|
||||
let [filament] = filaments else {
|
||||
return Err(ProjectFileError::BadSlot(
|
||||
slot,
|
||||
format!("the project uses {} filaments; a chosen slot is only for single-filament projects", filaments.len()),
|
||||
));
|
||||
};
|
||||
let tray = trays.iter().find(|t| t.slot == slot).ok_or_else(|| {
|
||||
ProjectFileError::BadSlot(slot, "no filament is loaded in that slot".into())
|
||||
})?;
|
||||
if !tray.filament_type.eq_ignore_ascii_case(&filament.filament_type) {
|
||||
return Err(ProjectFileError::BadSlot(
|
||||
slot,
|
||||
format!("it holds {} but the project was sliced for {}", tray.filament_type, filament.filament_type),
|
||||
));
|
||||
}
|
||||
let mut mapping = vec![-1; filament.index + 1];
|
||||
mapping[filament.index] = slot;
|
||||
Ok(mapping)
|
||||
}
|
||||
|
||||
fn tier(filament: &ProjectFilament, tray: &AvailableTray) -> Option<u8> {
|
||||
let same_product = filament.tray_info_idx.eq_ignore_ascii_case(&tray.tray_info_idx);
|
||||
let same_color = filament.color == tray.color;
|
||||
@@ -320,18 +288,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn a_chosen_slot_overrides_the_colour_pick_but_not_the_material() {
|
||||
let f = [filament(0, "GFG99", "PETG", "161616")];
|
||||
let t = [tray(2, "GFG99", "PETG", "161616"), tray(3, "GFG99", "PETG", "2850E0"), tray(1, "GFL99", "PLA", "D3C5A3")];
|
||||
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![2]);
|
||||
assert_eq!(map_to_slot(&f, &t, 3).unwrap(), vec![3]);
|
||||
assert!(map_to_slot(&f, &t, 1).is_err(), "PLA slot for a PETG project");
|
||||
assert!(map_to_slot(&f, &t, 4).is_err(), "empty slot");
|
||||
let two = [filament(0, "GFG99", "PETG", "161616"), filament(1, "GFG99", "PETG", "2850E0")];
|
||||
assert!(map_to_slot(&two, &t, 3).is_err(), "multi-filament project");
|
||||
}
|
||||
|
||||
const REAL: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<header>
|
||||
|
||||
@@ -1,4 +1,110 @@
|
||||
use super::{GenericPrinter, PrinterBase, PrinterError};
|
||||
|
||||
/****************** OctoPrint API family ******************/
|
||||
|
||||
/// Which implementation of the OctoPrint REST API is on the other end.
|
||||
///
|
||||
/// PrusaLink deliberately implements OctoPrint's API, so one client serves
|
||||
/// both - but not identically. PrusaLink adds its own `/api/v1/*` (`info`,
|
||||
/// `status`, `job`) which is where the useful detail lives, while OctoPrint
|
||||
/// proper has the older `/api/printer` and `/api/job` and a plugin surface
|
||||
/// PrusaLink does not have.
|
||||
///
|
||||
/// This is recorded from config now, while the client is still a stub,
|
||||
/// because **no probe can settle it**: both answer `/api/version`, and every
|
||||
/// PrusaLink identity endpoint needs a credential, so from outside the two
|
||||
/// are indistinguishable (see `discovery::net::classify_octoprint_api`). It
|
||||
/// is the operator's answer to a question only they can answer, so it needs
|
||||
/// somewhere to live the moment they are asked - otherwise the real client
|
||||
/// would have to ask them a second time.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OctoPrintFlavour {
|
||||
/// Prusa's own firmware-hosted implementation, on an MK4/XL/MINI.
|
||||
PrusaLink,
|
||||
/// OctoPrint itself, usually on a Raspberry Pi in front of any printer.
|
||||
Generic,
|
||||
}
|
||||
|
||||
impl OctoPrintFlavour {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
OctoPrintFlavour::PrusaLink => "PrusaLink",
|
||||
OctoPrintFlavour::Generic => "OctoPrint",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OctoPrintPrinter {
|
||||
base: PrinterBase,
|
||||
pub api_key: String,
|
||||
pub flavour: OctoPrintFlavour,
|
||||
}
|
||||
|
||||
impl OctoPrintPrinter {
|
||||
pub fn new(id: &str, name: &str, host: &str, api_key: &str, flavour: OctoPrintFlavour) -> Self {
|
||||
Self {
|
||||
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
|
||||
api_key: api_key.into(),
|
||||
flavour,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GenericPrinter for OctoPrintPrinter {
|
||||
/// OVERRIDE: real version is `PUT /api/v1/job/{id}/pause`, `/resume`, or
|
||||
/// `DELETE /api/v1/job/{id}` to cancel - PrusaLink addresses the running
|
||||
/// job by id rather than the printer, so a real implementation has to
|
||||
/// fetch the current job first.
|
||||
async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> {
|
||||
println!("[{}] {} job control: {}", self.base.name, self.flavour.as_str(), action.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn base(&self) -> &PrinterBase {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// OVERRIDE: a completely different body from Bambu's - PrusaLink is a
|
||||
/// plain REST API (real version: `GET /api/v1/status` with the API
|
||||
/// key, see `adapters::prusalink`), no persistent session to hold.
|
||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
||||
println!("[{}] connecting over HTTP ({})", self.base.name, self.flavour.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
|
||||
// TODO: add publishing via HTTP
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OVERRIDE: gcode via PrusaLink's command-injection endpoint - same
|
||||
/// underlying M106 as Bambu, reusing the shared percent->PWM
|
||||
/// conversion, but sent over HTTP instead of MQTT.
|
||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
||||
let pwm = super::percent_to_pwm(percent);
|
||||
println!("[{}] POST /api/printer/command {{\"commands\":[\"M106 S{pwm}\"]}}", self.base.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OVERRIDE: real version is `PUT /api/v1/files/local/{file_name}` with
|
||||
/// `data` as the request body - PrusaLink stores it on the printer's own
|
||||
/// SD card, same "upload to storage the firmware owns" model as Bambu.
|
||||
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
||||
println!("[{}] PUT /api/v1/files/local/{file_name} ({} bytes)", self.base.name, data.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OVERRIDE: PrusaLink's command-injection endpoint, one line per call -
|
||||
/// same endpoint `set_fan_speed` above already uses for M106, just with
|
||||
/// an arbitrary line instead of a hardcoded one.
|
||||
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||
println!("[{}] POST /api/printer/command {{\"commands\":[\"{gcode_line}\"]}}", self.base.name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/****************** PrusaSerial ******************/
|
||||
|
||||
pub struct PrusaSerialPrinter {
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
//! 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
@@ -1,338 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
+1
-21
@@ -26,26 +26,6 @@ pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
/// up the whole budget above.
|
||||
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Dev-only escape hatch for a local object store (MinIO) that speaks plain
|
||||
/// HTTP: needs `CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true` AND a loopback
|
||||
/// or private-network (RFC 1918) address literal, so a presigned URL can never
|
||||
/// cross a public network in the clear.
|
||||
fn insecure_local_allowed(url: &reqwest::Url) -> bool {
|
||||
use std::net::IpAddr;
|
||||
if url.scheme() != "http"
|
||||
|| !std::env::var("CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS").is_ok_and(|v| v == "true")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(ip) = url.host_str().and_then(|h| h.trim_matches(['[', ']']).parse::<IpAddr>().ok()) else {
|
||||
return false;
|
||||
};
|
||||
match ip {
|
||||
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private(),
|
||||
IpAddr::V6(v6) => v6.is_loopback(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads `url` fully into memory, or says why not.
|
||||
///
|
||||
/// The error is a `String` naming the failure; the caller prefixes the stage.
|
||||
@@ -59,7 +39,7 @@ pub async fn fetch_job_file_capped(url: &str, max_bytes: u64) -> Result<Vec<u8>,
|
||||
let parsed = reqwest::Url::parse(url).map_err(|err| format!("not a valid URL: {err}"))?;
|
||||
// A presigned URL carries its authority in the query string; sending it
|
||||
// over plain HTTP would expose it to anyone on the path.
|
||||
if parsed.scheme() != "https" && !insecure_local_allowed(&parsed) {
|
||||
if parsed.scheme() != "https" {
|
||||
return Err(format!("refusing non-HTTPS URL (scheme '{}')", parsed.scheme()));
|
||||
}
|
||||
|
||||
|
||||
+32
-238
@@ -26,14 +26,12 @@
|
||||
//! readings for no consumer. See PLAN.md for what would actually be worth
|
||||
//! buffering.
|
||||
|
||||
mod auth;
|
||||
mod download;
|
||||
mod webrtc;
|
||||
pub mod protocol;
|
||||
mod protocol;
|
||||
|
||||
pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage};
|
||||
pub use protocol::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage};
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -44,9 +42,6 @@ use tokio_tungstenite::tungstenite::handshake::client::{Request, generate_key};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::provision;
|
||||
use crate::store::Store;
|
||||
use protocol::ProvisionPrinter;
|
||||
use crate::printer::{GenericPrinter, PrinterHandle};
|
||||
|
||||
/// The websocket, once connected. Named because `connect` has to hand it to
|
||||
@@ -83,8 +78,6 @@ const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
|
||||
/// a lot of traffic to describe a machine whose state changes on the scale of
|
||||
/// a layer, so this samples the cached report instead of chasing every push.
|
||||
const TELEMETRY_INTERVAL: Duration = Duration::from_secs(5);
|
||||
/// How often the job-event outbox is looked at for anything new to send.
|
||||
const OUTBOX_INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// The printer fleet, shared between `main` (which owns it and keeps every
|
||||
/// connection alive) and this module (which reads status and dispatches
|
||||
@@ -126,30 +119,13 @@ fn host_header(url: &str) -> anyhow::Result<String> {
|
||||
Ok(authority.to_string())
|
||||
}
|
||||
|
||||
/// The queue a task elsewhere puts a message on to have the uplink send it.
|
||||
///
|
||||
/// Created by the caller rather than inside [`run`] so that a task which is
|
||||
/// not the uplink - printer discovery, today - can hold the sender without
|
||||
/// the uplink having to know what those tasks are. The property that matters
|
||||
/// is unchanged either way: it outlives any one connection, so a message
|
||||
/// produced while the socket is down waits for the next socket instead of
|
||||
/// being dropped, and the sender living for the whole program is what stops
|
||||
/// `recv()` from ever returning `None`.
|
||||
pub fn channel() -> (
|
||||
tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
tokio::sync::mpsc::Receiver<ClientMessage>,
|
||||
) {
|
||||
tokio::sync::mpsc::channel(OUTBOUND_CAPACITY)
|
||||
}
|
||||
|
||||
/// Runs forever: connect, serve the connection until it drops, wait, retry.
|
||||
pub async fn run(
|
||||
config: Config,
|
||||
fleet: SharedFleet,
|
||||
store: Option<Arc<Store>>,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
mut inbox: tokio::sync::mpsc::Receiver<ClientMessage>,
|
||||
) {
|
||||
pub async fn run(config: Config, fleet: SharedFleet) {
|
||||
// Created out here, not per connection, so a message produced while the
|
||||
// socket is down waits for the next one instead of being dropped. The
|
||||
// sender is kept for the whole program's life, which is also what stops
|
||||
// `recv()` below from ever returning `None`.
|
||||
let (outbound, mut inbox) = tokio::sync::mpsc::channel::<ClientMessage>(OUTBOUND_CAPACITY);
|
||||
let mut delay = RETRY_DELAY;
|
||||
|
||||
loop {
|
||||
@@ -164,7 +140,7 @@ pub async fn run(
|
||||
// would not distinguish a connection that lived for an hour
|
||||
// from one that failed to open.
|
||||
delay = RETRY_DELAY;
|
||||
if let Err(err) = serve(ws_stream, &config, &fleet, store.as_ref(), &outbound, &mut inbox).await {
|
||||
if let Err(err) = serve(ws_stream, &config, &fleet, &outbound, &mut inbox).await {
|
||||
warn!(?err, "uplink connection dropped, reconnecting in {delay:?}");
|
||||
}
|
||||
}
|
||||
@@ -180,10 +156,6 @@ pub async fn run(
|
||||
/// handshake from a connection that opened and later dropped; they want
|
||||
/// different retry behaviour.
|
||||
async fn connect(config: &Config) -> anyhow::Result<WsStream> {
|
||||
// A fresh edge JWT per attempt: it lives an hour, so one fetched at
|
||||
// startup would lock a long-running gateway out on its first reconnect.
|
||||
let edge_token = auth::fetch_edge_token(config).await?;
|
||||
|
||||
// The edge token goes on the upgrade request, not in the first message.
|
||||
// The backend verifies it before the socket exists, so a bad token is an
|
||||
// HTTP 401 here rather than a connection that opens and is then closed -
|
||||
@@ -192,7 +164,7 @@ async fn connect(config: &Config) -> anyhow::Result<WsStream> {
|
||||
// those tools already know to redact.
|
||||
let request = Request::builder()
|
||||
.uri(&config.uplink_url)
|
||||
.header("Authorization", format!("Bearer {edge_token}"))
|
||||
.header("Authorization", format!("Bearer {}", config.gateway_token))
|
||||
// tokio-tungstenite fills these in for a plain URL, but not once we
|
||||
// hand it a request we built ourselves - and a handshake missing any
|
||||
// of them is rejected by the server rather than ignored.
|
||||
@@ -212,7 +184,6 @@ async fn serve(
|
||||
ws_stream: WsStream,
|
||||
config: &Config,
|
||||
fleet: &SharedFleet,
|
||||
store: Option<&Arc<Store>>,
|
||||
outbound: &tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
inbox: &mut tokio::sync::mpsc::Receiver<ClientMessage>,
|
||||
) -> anyhow::Result<()> {
|
||||
@@ -228,34 +199,9 @@ async fn serve(
|
||||
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
|
||||
let mut telemetry = tokio::time::interval(TELEMETRY_INTERVAL);
|
||||
let mut seq: u64 = 0;
|
||||
// Outbox events already written to THIS socket. Per connection on
|
||||
// purpose: after a reconnect everything the cloud has not acknowledged
|
||||
// goes out again, which is the point of keeping them.
|
||||
let mut outbox_tick = tokio::time::interval(OUTBOX_INTERVAL);
|
||||
let mut sent: HashSet<i64> = HashSet::new();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = outbox_tick.tick() => {
|
||||
let Some(store) = store else { continue };
|
||||
let pending = match store.outbox_pending() {
|
||||
Ok(pending) => pending,
|
||||
Err(err) => { warn!(%err, "could not read the job-event outbox"); continue }
|
||||
};
|
||||
let unsent: Vec<_> = pending.into_iter().filter(|e| !sent.contains(&e.id)).collect();
|
||||
for event in unsent {
|
||||
let msg = ClientMessage::JobEvent {
|
||||
event_id: event.id,
|
||||
job_id: event.job_id,
|
||||
event: event.event,
|
||||
detail: event.detail,
|
||||
at_ms: event.at_ms,
|
||||
};
|
||||
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
|
||||
sent.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
_ = heartbeat.tick() => {
|
||||
seq += 1;
|
||||
let msg = ClientMessage::Heartbeat { seq };
|
||||
@@ -303,7 +249,6 @@ async fn serve(
|
||||
command,
|
||||
command_id.clone(),
|
||||
outbound.clone(),
|
||||
store.cloned(),
|
||||
);
|
||||
ClientMessage::CommandResult {
|
||||
command_id, ok: true, error: None,
|
||||
@@ -345,39 +290,13 @@ async fn serve(
|
||||
outbound.clone(),
|
||||
);
|
||||
}
|
||||
// Spawned: a connect, a TLS handshake and a frame
|
||||
// from the printer take seconds, and awaiting that
|
||||
// here would stall heartbeats and telemetry.
|
||||
Ok(ServerMessage::CameraSnapshotRequest { request_id, printer_id }) => {
|
||||
spawn_snapshot(fleet.clone(), request_id, printer_id, outbound.clone());
|
||||
}
|
||||
// Spawned: connecting to the new printer can take
|
||||
// many seconds, and awaiting it here would stall
|
||||
// heartbeats and telemetry for the whole farm.
|
||||
Ok(ServerMessage::ProvisionPrinter { request_id, printer }) => {
|
||||
spawn_provision(fleet.clone(), store.cloned(), request_id, Some(printer), outbound.clone());
|
||||
}
|
||||
Ok(ServerMessage::JobEventAck { event_id }) => {
|
||||
sent.remove(&event_id);
|
||||
if let Some(store) = store
|
||||
&& let Err(err) = store.outbox_ack(event_id)
|
||||
{
|
||||
warn!(%err, event_id, "could not clear an acknowledged job event");
|
||||
}
|
||||
}
|
||||
Ok(ServerMessage::RemovePrinter { request_id, printer_id }) => {
|
||||
spawn_remove(fleet.clone(), store.cloned(), request_id, printer_id, outbound.clone());
|
||||
}
|
||||
Ok(other) => info!(?other, "received from cloud"),
|
||||
// Loudly. This used to be an `if let Ok(..)` with
|
||||
// no else, which is the same silent-drop bug that
|
||||
// hid a real `print.push_status` failure for
|
||||
// weeks - and here it would mean commands
|
||||
// vanishing with the cloud never being told.
|
||||
// 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"),
|
||||
Err(err) => warn!(%err, payload = %text, "unparseable message from cloud, ignoring"),
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => return Ok(()),
|
||||
@@ -411,28 +330,29 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
|
||||
|
||||
for printer in fleet.iter() {
|
||||
let base = printer.base();
|
||||
let Some(status) = printer.status().await else {
|
||||
let Some(report) = printer.print_status().await else {
|
||||
continue;
|
||||
};
|
||||
// Only Bambu has a camera report to derive these from.
|
||||
let report = printer.print_status().await;
|
||||
|
||||
out.push(PrinterTelemetry {
|
||||
printer_id: base.id.clone(),
|
||||
name: base.name.clone(),
|
||||
state: status.state,
|
||||
percent: status.percent,
|
||||
stage: status.stage,
|
||||
layer: status.layer,
|
||||
total_layers: status.total_layers,
|
||||
remaining_minutes: status.remaining_minutes,
|
||||
job_name: status.job_name,
|
||||
nozzle_temp: status.nozzle_temp,
|
||||
nozzle_target: status.nozzle_target,
|
||||
bed_temp: status.bed_temp,
|
||||
bed_target: status.bed_target,
|
||||
print_error: status.print_error,
|
||||
active_alerts: status.active_alerts,
|
||||
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()),
|
||||
// Derived from the same reported URL `camera::sources` builds
|
||||
// the go2rtc config from, so a printer is advertised as
|
||||
// streamable here only if it is also actually being served
|
||||
@@ -440,15 +360,9 @@ async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
|
||||
// the rule means the two cannot drift apart.
|
||||
camera_stream: crate::camera::source_for(
|
||||
printer,
|
||||
report.as_ref().and_then(|r| r.ipcam.as_ref()).and_then(|c| c.rtsp_url.as_deref()),
|
||||
report.ipcam.as_ref().and_then(|c| c.rtsp_url.as_deref()),
|
||||
)
|
||||
.map(|source| source.stream_name),
|
||||
camera_snapshot: crate::camera::wants_snapshot(
|
||||
printer,
|
||||
report.as_ref().is_some_and(|r| r.ipcam.is_some()),
|
||||
report.as_ref().and_then(|r| r.ipcam.as_ref()).and_then(|c| c.rtsp_url.as_deref()),
|
||||
)
|
||||
.then_some(true),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -497,31 +411,10 @@ fn spawn_long_command(
|
||||
command: PrinterCommand,
|
||||
command_id: String,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
store: Option<Arc<Store>>,
|
||||
) {
|
||||
// The job is followed from here on, so its outcome is reported even if
|
||||
// the cloud is unreachable when it happens (see `jobs`).
|
||||
let job_id = match (&command, &store) {
|
||||
(PrinterCommand::StartPrint { job_id, .. }, Some(store)) => {
|
||||
crate::jobs::accepted(store, &printer_id, job_id);
|
||||
Some(job_id.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
match dispatch(&fleet, &printer_id, command).await {
|
||||
Ok(()) => info!(%printer_id, "long-running command finished"),
|
||||
Err(err) if job_id.is_some() => {
|
||||
// Still logged locally; the cloud hears of it from the
|
||||
// outbox, which survives it being down right now.
|
||||
tracing::error!(
|
||||
%printer_id, %err,
|
||||
"long-running command failed after the cloud was already told it was accepted"
|
||||
);
|
||||
if let (Some(store), Some(job_id)) = (&store, &job_id) {
|
||||
crate::jobs::start_failed(store, &printer_id, job_id, &err);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Still logged locally, and still at error level: the queue
|
||||
// only reaches a cloud that is up, and this is the copy that
|
||||
@@ -547,54 +440,6 @@ fn spawn_long_command(
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds a printer off the connection's task and always reports how it went:
|
||||
/// the console is holding a form open waiting for exactly this.
|
||||
fn spawn_provision(
|
||||
fleet: SharedFleet,
|
||||
store: Option<Arc<Store>>,
|
||||
request_id: String,
|
||||
request: Option<ProvisionPrinter>,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let result = match (store, request) {
|
||||
(Some(store), Some(request)) => provision::add(&fleet, &store, request).await,
|
||||
(None, _) => Err("this gateway has no local store, so it cannot add printers".to_string()),
|
||||
(_, None) => Err("no printer in the request".to_string()),
|
||||
};
|
||||
report(request_id, result, &outbound).await;
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_remove(
|
||||
fleet: SharedFleet,
|
||||
store: Option<Arc<Store>>,
|
||||
request_id: String,
|
||||
printer_id: String,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let result = match store {
|
||||
Some(store) => provision::remove(&fleet, &store, &printer_id).await,
|
||||
None => Err("this gateway has no local store".to_string()),
|
||||
};
|
||||
report(request_id, result, &outbound).await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn report(request_id: String, result: Result<(), String>, outbound: &tokio::sync::mpsc::Sender<ClientMessage>) {
|
||||
let msg = match result {
|
||||
Ok(()) => ClientMessage::CommandResult { command_id: request_id, ok: true, error: None },
|
||||
Err(error) => {
|
||||
warn!(%error, "printer request failed");
|
||||
ClientMessage::CommandResult { command_id: request_id, ok: false, error: Some(error) }
|
||||
}
|
||||
};
|
||||
if outbound.send(msg).await.is_err() {
|
||||
warn!("uplink is shutting down; the result was not reported");
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers one WebRTC offer, off this connection's task.
|
||||
///
|
||||
/// Always answers, success or failure: the cloud is holding a browser's
|
||||
@@ -624,48 +469,6 @@ fn spawn_webrtc_negotiation(
|
||||
});
|
||||
}
|
||||
|
||||
/// Reads one camera frame off this connection's task and reports it.
|
||||
///
|
||||
/// The fleet lock is held only to copy out what the read needs (the access
|
||||
/// code never leaves this process in the message), never across the network.
|
||||
fn spawn_snapshot(
|
||||
fleet: SharedFleet,
|
||||
request_id: String,
|
||||
printer_id: String,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let job = {
|
||||
let fleet = fleet.lock().await;
|
||||
match fleet.iter().find(|p| p.base().id == printer_id) {
|
||||
Some(printer) => printer.snapshot_job().map_err(|err| err.to_string()),
|
||||
None => Err(format!("no printer '{printer_id}' in this gateway's fleet")),
|
||||
}
|
||||
};
|
||||
let result = match job {
|
||||
Ok(job) => job.run().await.map_err(|err| err.to_string()),
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
let reply = match result {
|
||||
Ok(jpeg) => {
|
||||
use base64::Engine;
|
||||
ClientMessage::CameraSnapshot {
|
||||
request_id,
|
||||
jpeg_base64: Some(base64::engine::general_purpose::STANDARD.encode(jpeg)),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(%printer_id, %err, "could not read a camera frame");
|
||||
ClientMessage::CameraSnapshot { request_id, jpeg_base64: None, error: Some(err) }
|
||||
}
|
||||
};
|
||||
if outbound.send(reply).await.is_err() {
|
||||
warn!(%printer_id, "uplink is shutting down; the viewer gets no picture");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Finds the printer by our own id and runs the command. The error is a
|
||||
/// `String` rather than a typed error because its only destination is a
|
||||
/// `CommandResult` going back to the cloud as text.
|
||||
@@ -709,7 +512,7 @@ async fn dispatch(fleet: &SharedFleet, printer_id: &str, command: PrinterCommand
|
||||
PrinterCommand::ControlPrint { control } => {
|
||||
printer.control_print(control).await.map_err(|err| err.to_string())
|
||||
}
|
||||
PrinterCommand::StartPrint { job_id, file_name, ams_slot, .. } => {
|
||||
PrinterCommand::StartPrint { job_id, file_name, .. } => {
|
||||
let bytes = downloaded.ok_or_else(|| "internal error: start_print reached dispatch with no downloaded file".to_string())?;
|
||||
let byte_count = bytes.len();
|
||||
info!(%printer_id, %job_id, %file_name, byte_count, "job file downloaded, sending to printer");
|
||||
@@ -718,19 +521,10 @@ async fn dispatch(fleet: &SharedFleet, printer_id: &str, command: PrinterCommand
|
||||
// all as one error - so the stage here is "printer", not finer.
|
||||
// The printer's own message says which part (mapping, upload of
|
||||
// the file, or start) it was.
|
||||
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
|
||||
printer
|
||||
.upload_project_file(file_name, bytes)
|
||||
.await
|
||||
.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
|
||||
.map_err(|err| format!("printer upload/start failed (download of {byte_count} bytes succeeded): {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-130
@@ -32,17 +32,6 @@ pub enum ClientMessage {
|
||||
stream_base_url: Option<String>,
|
||||
},
|
||||
Heartbeat { seq: u64 },
|
||||
/// What happened to a job the cloud handed this gateway. Kept in the
|
||||
/// store and re-sent on every connection until a `JobEventAck`.
|
||||
JobEvent {
|
||||
event_id: i64,
|
||||
job_id: String,
|
||||
/// `started`, `finished` or `failed`.
|
||||
event: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
at_ms: i64,
|
||||
},
|
||||
/// One snapshot per printer, on a timer. Sent as a batch rather than a
|
||||
/// message per printer: a farm has one uplink and many printers, and the
|
||||
/// cloud wants a coherent picture of the farm at an instant more than it
|
||||
@@ -67,68 +56,6 @@ pub enum ClientMessage {
|
||||
/// waiting on this and a viewer otherwise watches a video element that
|
||||
/// never loads with nothing anywhere saying why.
|
||||
WebrtcError { session_id: String, message: String },
|
||||
/// The answer to a `CameraSnapshotRequest`: one JPEG frame, base64, or
|
||||
/// the reason there is none. Always sent, success or failure - the cloud
|
||||
/// is holding a viewer's request open for it.
|
||||
CameraSnapshot {
|
||||
request_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
jpeg_base64: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
/// Printers found on this gateway's own network and USB ports that are
|
||||
/// *not* in its fleet file, so the console can offer them instead of
|
||||
/// making an operator walk to a machine and read its IP address off the
|
||||
/// screen.
|
||||
///
|
||||
/// A whole scan result that replaces the last one, not a set of
|
||||
/// additions: a printer that has gone is reported by its absence. An
|
||||
/// empty list is therefore meaningful - it says the gateway looked and
|
||||
/// found nothing, which is different from never having sent one of these
|
||||
/// at all, and a gateway with discovery off (the default) never does.
|
||||
DiscoveredPrinters {
|
||||
printers: Vec<DiscoveredPrinter>,
|
||||
scanned_at: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// One candidate, carrying no credential - and it is worth being precise
|
||||
/// about why not, because this is the field a reader looks for.
|
||||
///
|
||||
/// It is not omitted for tidiness. No probe can learn one: a Bambu access
|
||||
/// code is displayed on the printer's own screen and a PrusaLink API key is
|
||||
/// in its web UI, and every identity endpoint that would reveal either needs
|
||||
/// the credential first. So discovery shortens the operator's job to typing
|
||||
/// one secret, rather than a secret plus an address plus a serial number -
|
||||
/// and the backend's schema refuses a candidate that carries a credential,
|
||||
/// so this staying empty is enforced at the far end too.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct DiscoveredPrinter {
|
||||
/// The `vendor` string this would need in a fleet file.
|
||||
pub vendor: &'static str,
|
||||
/// An IP or hostname, or a device path like `/dev/ttyACM0` for a serial
|
||||
/// candidate. Named `address` rather than `host` because half of them
|
||||
/// are not hosts.
|
||||
pub address: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub serial: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
/// A vendor this could equally be; the operator settles it. Today only
|
||||
/// `prusa_link` against `octoprint`, which nothing observable separates.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ambiguous_with: Option<&'static str>,
|
||||
pub source: &'static str,
|
||||
/// Whether this build can actually drive the vendor. False is still
|
||||
/// worth reporting - it answers "what is on this network?" - but the
|
||||
/// console has to say so, or an entry looks finished when adding it
|
||||
/// would produce a printer that connects and does nothing.
|
||||
pub driver_ready: bool,
|
||||
}
|
||||
|
||||
/// A flattened snapshot of one printer, in the terms an operator console
|
||||
@@ -212,11 +139,6 @@ pub struct PrinterTelemetry {
|
||||
/// `crate::camera`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub camera_stream: Option<String>,
|
||||
/// True when the camera can only be read as single JPEG frames (a P1/A1)
|
||||
/// and this gateway will answer a `CameraSnapshotRequest` for it. See
|
||||
/// `crate::camera::wants_snapshot`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub camera_snapshot: Option<bool>,
|
||||
}
|
||||
|
||||
/// What `continuum-backend` sends back over the same connection.
|
||||
@@ -224,8 +146,6 @@ pub struct PrinterTelemetry {
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerMessage {
|
||||
HelloAck { session_id: String },
|
||||
/// The cloud has dealt with a `JobEvent`; the gateway can forget it.
|
||||
JobEventAck { event_id: i64 },
|
||||
/// The handshake was rejected - a bad, expired or wrong-farm token. The
|
||||
/// backend closes the connection right after sending this.
|
||||
///
|
||||
@@ -253,48 +173,6 @@ pub enum ServerMessage {
|
||||
/// and checks its password before CORS, so the unauthenticated preflight
|
||||
/// a browser always sends is refused - measured, not assumed.
|
||||
WebrtcOffer { session_id: String, camera_stream: String, sdp: String },
|
||||
/// Read one JPEG frame from a P1/A1-style camera and answer with a
|
||||
/// `CameraSnapshot`. `printer_id` is our own id.
|
||||
CameraSnapshotRequest { request_id: String, printer_id: String },
|
||||
/// Add a printer to this gateway. Carries the printer's credential, sent
|
||||
/// once by the console and kept only here - see `provision`. Answered
|
||||
/// with a `CommandResult` carrying the same id.
|
||||
ProvisionPrinter { request_id: String, printer: ProvisionPrinter },
|
||||
/// Forget a printer. Answered like `ProvisionPrinter`.
|
||||
RemovePrinter { request_id: String, printer_id: String },
|
||||
}
|
||||
|
||||
/// What the console may tell a gateway about a printer to add.
|
||||
///
|
||||
/// Deliberately narrower than `fleet::PrinterEntry`: no `ca_cert_path` and no
|
||||
/// serial `port`, because both name something on the gateway's own disk or
|
||||
/// USB bus, and the cloud has no business choosing those.
|
||||
///
|
||||
/// `Debug` is by hand so the credential cannot reach a log line - the
|
||||
/// `Ok(other) => info!(?other, ...)` arm in the uplink formats whole messages.
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct ProvisionPrinter {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub vendor: String,
|
||||
pub host: Option<String>,
|
||||
pub access_code: Option<String>,
|
||||
pub sn: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProvisionPrinter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ProvisionPrinter")
|
||||
.field("id", &self.id)
|
||||
.field("name", &self.name)
|
||||
.field("vendor", &self.vendor)
|
||||
.field("host", &self.host)
|
||||
.field("access_code", &self.access_code.as_ref().map(|_| "<redacted>"))
|
||||
.field("sn", &self.sn)
|
||||
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The closed set of things the cloud is allowed to ask a printer to do.
|
||||
@@ -342,14 +220,7 @@ pub enum PrinterCommand {
|
||||
/// hides it. `file_name` is what the file is called on the printer;
|
||||
/// `.3mf` is treated as a sliced project, anything else as bare gcode.
|
||||
/// `job_id` is only for lining gateway logs up against a cloud job.
|
||||
StartPrint {
|
||||
job_id: String,
|
||||
file_name: String,
|
||||
url: String,
|
||||
/// Use this AMS slot (0-based, all units) instead of the project's own pick.
|
||||
#[serde(default)]
|
||||
ams_slot: Option<u8>,
|
||||
},
|
||||
StartPrint { job_id: String, file_name: String, url: String },
|
||||
}
|
||||
|
||||
impl PrinterCommand {
|
||||
|
||||
@@ -134,124 +134,3 @@ fn an_entry_with_the_credential_left_blank_is_refused_by_name() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The other half of the loop: a printer the operator has already dealt with
|
||||
/// must stop being offered as a fresh find.
|
||||
///
|
||||
/// Same coupling-by-a-string risk as the rest of this file, in the other
|
||||
/// direction. `fleet::configured` reads the fields discovery writes, so a
|
||||
/// rename on either side silently turns the filter off - and a filter that
|
||||
/// silently stops filtering looks exactly like a working one, except the
|
||||
/// console offers every printer the farm already runs.
|
||||
mod already_configured {
|
||||
use super::*;
|
||||
|
||||
fn configured_from(toml: &str) -> fleet::Configured {
|
||||
let mut file = tempfile();
|
||||
file.write_all(toml.as_bytes()).expect("write the fleet file");
|
||||
fleet::configured(&file.path).expect("parse the fleet file")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rendered_candidate_is_recognised_in_the_file_it_was_pasted_into() {
|
||||
for vendor in [Vendor::Bambu, Vendor::Klipper, Vendor::Octoprint, Vendor::PrusaLink, Vendor::PrusaSerial] {
|
||||
let mut candidate = Candidate::new("192.168.0.77", vendor, Source::HttpApi);
|
||||
if vendor == Vendor::PrusaSerial {
|
||||
candidate.host = "/dev/ttyACM0".to_string();
|
||||
}
|
||||
if vendor == Vendor::Bambu {
|
||||
candidate.serial = Some("00M09A382100000".to_string());
|
||||
}
|
||||
let toml = with_credentials(&candidate.to_toml_entry("p"));
|
||||
let configured = configured_from(&toml);
|
||||
assert!(
|
||||
configured.mentions(Some(&candidate.host), candidate.serial.as_deref()),
|
||||
"a pasted {vendor:?} entry was not recognised as configured:\n{toml}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_whose_credential_is_still_blank_counts_as_configured() {
|
||||
// The exact moment this matters: the operator has pasted in what
|
||||
// discovery suggested and has not yet typed the access code off the
|
||||
// printer's screen. `fleet::load` refuses that entry - and must -
|
||||
// but re-offering the printer as a new find while they are mid-way
|
||||
// through adding it is noise at the worst possible time.
|
||||
let mut candidate = Candidate::new("192.168.0.78", Vendor::Bambu, Source::Certificate);
|
||||
candidate.serial = Some("00M09A382100001".to_string());
|
||||
let toml = candidate.to_toml_entry("p");
|
||||
assert!(load(&toml).is_err(), "a blank access_code should still fail to load");
|
||||
assert!(configured_from(&toml).mentions(Some("192.168.0.78"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_printer_at_a_new_address_is_recognised_by_its_serial() {
|
||||
// DHCP moved it. The serial is the machine; the address is a lease.
|
||||
let configured = configured_from(
|
||||
"[[printer]]\nid = \"p\"\nname = \"P1P\"\nvendor = \"bambu\"\n\
|
||||
host = \"192.168.0.20\"\nsn = \"00M09A382100000\"\naccess_code = \"12345678\"\n",
|
||||
);
|
||||
assert!(configured.mentions(Some("192.168.0.99"), Some("00M09A382100000")));
|
||||
// Case is not a difference: a serial read out of a TLS certificate
|
||||
// has no guaranteed case.
|
||||
assert!(configured.mentions(None, Some("00m09a382100000")));
|
||||
// And something genuinely new is still new.
|
||||
assert!(!configured.mentions(Some("192.168.0.99"), Some("00M09A382100002")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_fleet_file_means_nothing_is_configured_rather_than_an_error() {
|
||||
// A gateway with no fleet yet is the case discovery exists for, so
|
||||
// this is the normal path on a new box and not an edge case.
|
||||
let configured = fleet::configured(std::path::Path::new("/nonexistent/printers.toml"))
|
||||
.expect("a missing fleet file is not an error");
|
||||
assert!(!configured.mentions(Some("192.168.0.20"), Some("00M09A382100000")));
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire form a gateway reports, against the vendor and source strings the
|
||||
/// backend's `discovered_printer.schema.json` declares as enums.
|
||||
///
|
||||
/// Those are a contract, not labels: an unrecognised value does not degrade
|
||||
/// gracefully at the far end, it makes the backend reject the frame.
|
||||
#[test]
|
||||
fn every_vendor_and_source_renders_a_value_the_backend_schema_accepts() {
|
||||
const VENDORS: [&str; 5] = ["bambu", "klipper", "prusa_link", "octoprint", "prusa_serial"];
|
||||
const SOURCES: [&str; 5] = ["ssdp", "certificate", "http_api", "usb_descriptor", "serial_handshake"];
|
||||
|
||||
for vendor in [Vendor::Bambu, Vendor::Klipper, Vendor::PrusaLink, Vendor::Octoprint, Vendor::PrusaSerial] {
|
||||
for source in [Source::Ssdp, Source::Certificate, Source::HttpApi, Source::UsbDescriptor, Source::SerialHandshake] {
|
||||
let mut candidate = Candidate::new("192.168.0.20", vendor, source);
|
||||
candidate.ambiguous_with = Some(Vendor::PrusaLink);
|
||||
let wire = candidate.to_wire();
|
||||
assert!(VENDORS.contains(&wire.vendor), "vendor {:?} renders {:?}", vendor, wire.vendor);
|
||||
assert!(SOURCES.contains(&wire.source), "source {:?} renders {:?}", source, wire.source);
|
||||
assert_eq!(wire.ambiguous_with, Some("prusa_link"));
|
||||
assert_eq!(wire.driver_ready, vendor.driver_is_implemented());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate must not carry a credential up to the cloud, and the check is
|
||||
/// on the serialised bytes rather than the struct's fields.
|
||||
///
|
||||
/// Reading the fields would only prove what the type already says. The risk
|
||||
/// is someone later adding a field to `Candidate` - an access code recovered
|
||||
/// from somewhere, a pinned certificate - and it reaching the wire because
|
||||
/// `to_wire` was never the place anyone thought to look.
|
||||
#[test]
|
||||
fn no_credential_shaped_field_reaches_the_wire() {
|
||||
let mut candidate = Candidate::new("192.168.0.20", Vendor::Bambu, Source::Certificate);
|
||||
candidate.serial = Some("00M09A382100000".to_string());
|
||||
candidate.name = Some("P1P".to_string());
|
||||
candidate.bind_state = Some("free".to_string());
|
||||
let json = serde_json::to_string(&candidate.to_wire()).expect("serialise");
|
||||
|
||||
for forbidden in ["access_code", "api_key", "password", "token", "secret", "bind_state"] {
|
||||
assert!(!json.contains(forbidden), "{forbidden} reached the wire: {json}");
|
||||
}
|
||||
// And the fields that should be there, are - or this test would pass on
|
||||
// an empty object.
|
||||
assert!(json.contains("00M09A382100000") && json.contains("\"vendor\":\"bambu\""), "{json}");
|
||||
}
|
||||
|
||||
@@ -144,13 +144,3 @@ fn merge_keeps_stage_and_spool_when_a_partial_update_omits_them() {
|
||||
assert_eq!(base.stg, Some(vec![54, 14, 13]));
|
||||
assert!(base.vt_tray.is_some());
|
||||
}
|
||||
|
||||
/// A P1P mid-print pushes `ams` objects without the inner unit list. That
|
||||
/// used to fail the whole report ("missing field `ams`"), silencing telemetry
|
||||
/// for the rest of the print - found on the first real dispatch.
|
||||
#[test]
|
||||
fn a_partial_ams_object_parses_and_keeps_known_units() {
|
||||
let partial = serde_json::json!({"command":"push_status","sequence_id":"1","msg":1,"gcode_state":"RUNNING","ams":{"tray_now":"2"}});
|
||||
let report: PrintReport = serde_json::from_value(partial).expect("a partial ams object should parse");
|
||||
assert!(report.ams.as_ref().is_some_and(|a| a.ams.is_empty()));
|
||||
}
|
||||
|
||||
@@ -18,12 +18,10 @@ use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
|
||||
|
||||
use continuum_proxy::config::Config;
|
||||
use continuum_proxy::uplink;
|
||||
@@ -46,10 +44,6 @@ fn config(port: u16) -> Config {
|
||||
go2rtc_api_listen: "127.0.0.1:1984".into(),
|
||||
stream_base_url: None,
|
||||
printers_path: "/nonexistent/printers.toml".into(),
|
||||
store_path: "/nonexistent/continuum.db".into(),
|
||||
// Off, which is the default and also the only safe thing in a test:
|
||||
// an enabled one would sweep whatever network the test host is on.
|
||||
discovery: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,60 +66,16 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the cloud's `/auth/edge-token`: reads one HTTP request, checks that
|
||||
/// the gateway sent its farm and long-lived token, and answers with a JWT.
|
||||
/// Returns the request text so the caller can assert on it.
|
||||
async fn answer_token_exchange(listener: &TcpListener) -> String {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let n = tokio::time::timeout(WAIT, stream.read(&mut buf)).await.unwrap().unwrap();
|
||||
assert!(n > 0, "the gateway hung up before finishing its token request");
|
||||
request.extend_from_slice(&buf[..n]);
|
||||
let text = String::from_utf8_lossy(&request).into_owned();
|
||||
if let Some((head, body)) = text.split_once("\r\n\r\n") {
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap()))
|
||||
.unwrap_or(0);
|
||||
if body.len() >= length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = r#"{"token":"minted-edge-jwt","expiresInSeconds":3600}"#;
|
||||
stream
|
||||
.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&request).into_owned()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_long_command_that_fails_reports_its_reason_after_the_fact() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
|
||||
let (outbound, inbox) = uplink::channel();
|
||||
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}");
|
||||
tokio::spawn(uplink::run(config(port), fleet));
|
||||
|
||||
let (stream, _) = listener.accept().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 mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
|
||||
|
||||
let hello = next_json(&mut ws).await;
|
||||
assert_eq!(hello["type"], "hello", "a gateway introduces itself first");
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
//! Job outcomes survive the cloud being away: an event in the gateway's store
|
||||
//! is sent on connect, sent again after a reconnect if nobody acknowledged it,
|
||||
//! and gone once acknowledged. A start that fails after being accepted is
|
||||
//! queued the same way instead of being sent once to whoever is listening.
|
||||
//!
|
||||
//! Real `uplink::run` against a real websocket server; no printer is touched
|
||||
//! (the fleet is empty, so the start fails on its lookup before any download).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
|
||||
|
||||
use continuum_proxy::config::Config;
|
||||
use continuum_proxy::store::Store;
|
||||
use continuum_proxy::uplink;
|
||||
|
||||
/// Long enough for a loopback round trip, short enough that a regression
|
||||
/// fails the test instead of hanging the suite.
|
||||
const WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
fn config(port: u16) -> Config {
|
||||
Config {
|
||||
gateway_id: "test-gw".into(),
|
||||
farm_id: "test-farm".into(),
|
||||
gateway_token: "test-token".into(),
|
||||
uplink_url: format!("ws://127.0.0.1:{port}/ws/edge/v1"),
|
||||
// Nothing here starts go2rtc or reads the fleet file - `uplink::run`
|
||||
// is handed its fleet rather than loading one - but Config has no
|
||||
// Default, so these have to say something.
|
||||
go2rtc_bin: "/nonexistent-go2rtc".into(),
|
||||
go2rtc_config: "/nonexistent/go2rtc.yaml".into(),
|
||||
go2rtc_api_listen: "127.0.0.1:1984".into(),
|
||||
stream_base_url: None,
|
||||
printers_path: "/nonexistent/printers.toml".into(),
|
||||
store_path: "/nonexistent/continuum.db".into(),
|
||||
// Off, which is the default and also the only safe thing in a test:
|
||||
// an enabled one would sweep whatever network the test host is on.
|
||||
discovery: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The next frame as JSON. Heartbeats and telemetry share this socket, so
|
||||
/// callers filter; `interval` fires on its first tick, which means a
|
||||
/// heartbeat arrives immediately after the hello.
|
||||
async fn next_json<S>(ws: &mut WebSocketStream<S>) -> Value
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
loop {
|
||||
let frame = tokio::time::timeout(WAIT, ws.next())
|
||||
.await
|
||||
.expect("timed out waiting for a frame from the gateway")
|
||||
.expect("the gateway closed the connection")
|
||||
.expect("websocket error");
|
||||
if let Message::Text(text) = frame {
|
||||
return serde_json::from_str(&text).expect("the gateway sent invalid JSON");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the cloud's `/auth/edge-token`: reads one HTTP request, checks that
|
||||
/// the gateway sent its farm and long-lived token, and answers with a JWT.
|
||||
/// Returns the request text so the caller can assert on it.
|
||||
async fn answer_token_exchange(listener: &TcpListener) -> String {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let n = tokio::time::timeout(WAIT, stream.read(&mut buf)).await.unwrap().unwrap();
|
||||
assert!(n > 0, "the gateway hung up before finishing its token request");
|
||||
request.extend_from_slice(&buf[..n]);
|
||||
let text = String::from_utf8_lossy(&request).into_owned();
|
||||
if let Some((head, body)) = text.split_once("\r\n\r\n") {
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap()))
|
||||
.unwrap_or(0);
|
||||
if body.len() >= length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = r#"{"token":"minted-edge-jwt","expiresInSeconds":3600}"#;
|
||||
stream
|
||||
.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&request).into_owned()
|
||||
}
|
||||
|
||||
|
||||
/// Answers the token exchange and accepts the websocket, returning it after
|
||||
/// the gateway's hello.
|
||||
async fn accept(listener: &TcpListener) -> WebSocketStream<tokio::net::TcpStream> {
|
||||
answer_token_exchange(listener).await;
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut ws = tokio_tungstenite::accept_hdr_async(stream, |_: &Request, response: Response| Ok(response)).await.unwrap();
|
||||
let hello = next_json(&mut ws).await;
|
||||
assert_eq!(hello["type"], "hello");
|
||||
ws
|
||||
}
|
||||
|
||||
/// Reads frames until `want` job events have arrived, returning them by job id.
|
||||
async fn job_events(ws: &mut WebSocketStream<tokio::net::TcpStream>, want: usize) -> Vec<Value> {
|
||||
let mut events = Vec::new();
|
||||
while events.len() < want {
|
||||
let msg = next_json(ws).await;
|
||||
if msg["type"] == "job_event" {
|
||||
events.push(msg);
|
||||
}
|
||||
}
|
||||
events.sort_by_key(|e| e["event_id"].as_i64());
|
||||
events
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unacknowledged_job_events_are_resent_after_a_reconnect_and_stop_once_acknowledged() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("continuum-outbox-test-{}", std::process::id()));
|
||||
let store = Arc::new(Store::open(&dir.join("s.db")).unwrap());
|
||||
// An outcome reached while the cloud was away.
|
||||
store.outbox_push("11111111-1111-4111-8111-111111111111", "finished", None, 1_000).unwrap();
|
||||
|
||||
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
|
||||
let (outbound, inbox) = uplink::channel();
|
||||
tokio::spawn(uplink::run(config(port), fleet, Some(store.clone()), outbound, inbox));
|
||||
|
||||
let mut ws = accept(&listener).await;
|
||||
let first = job_events(&mut ws, 1).await;
|
||||
assert_eq!(first[0]["job_id"], "11111111-1111-4111-8111-111111111111");
|
||||
assert_eq!(first[0]["event"], "finished");
|
||||
assert_eq!(first[0]["at_ms"], 1_000);
|
||||
|
||||
// A start that fails after being accepted goes through the same outbox.
|
||||
ws.send(Message::Text(
|
||||
json!({
|
||||
"type": "command", "command_id": "cmd-1", "printer_id": "not-in-this-fleet",
|
||||
"command": { "action": "start_print", "job_id": "22222222-2222-4222-8222-222222222222",
|
||||
"file_name": "cube.gcode", "url": "http://127.0.0.1:1/never-fetched" }
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
// Only the new one: the first was already sent on this connection.
|
||||
let mut both = first.clone();
|
||||
both.extend(job_events(&mut ws, 1).await);
|
||||
assert_eq!(both[1]["job_id"], "22222222-2222-4222-8222-222222222222");
|
||||
assert_eq!(both[1]["event"], "failed");
|
||||
assert!(both[1]["detail"].as_str().unwrap().contains("not-in-this-fleet"), "{}", both[1]);
|
||||
|
||||
// The cloud never answers; the connection drops.
|
||||
drop(ws);
|
||||
|
||||
let mut ws = accept(&listener).await;
|
||||
let again = job_events(&mut ws, 2).await;
|
||||
assert_eq!(
|
||||
again.iter().map(|e| e["event_id"].clone()).collect::<Vec<_>>(),
|
||||
both.iter().map(|e| e["event_id"].clone()).collect::<Vec<_>>(),
|
||||
"after a reconnect everything unacknowledged is sent again"
|
||||
);
|
||||
|
||||
ws.send(Message::Text(json!({ "type": "job_event_ack", "event_id": again[0]["event_id"] }).to_string()))
|
||||
.await
|
||||
.unwrap();
|
||||
let deadline = tokio::time::Instant::now() + WAIT;
|
||||
while store.outbox_pending().unwrap().len() != 1 {
|
||||
assert!(tokio::time::Instant::now() < deadline, "the acknowledged event was not cleared");
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
assert_eq!(store.outbox_pending().unwrap()[0].job_id, "22222222-2222-4222-8222-222222222222");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -106,7 +106,7 @@ fn a_vendor_specific_control_verb_is_rejected() {
|
||||
fn a_start_print_command_arrives_with_its_fields_intact() {
|
||||
let raw = r#"{"type":"command","command_id":"c","printer_id":"p1p","command":{"action":"start_print","job_id":"5b0e7c1a-0000-4000-8000-000000000000","file_name":"plate_1.3mf","url":"https://objects.example.com/jobs/abc.3mf?X-Amz-Signature=deadbeef&X-Amz-Expires=300"}}"#;
|
||||
match serde_json::from_str::<ServerMessage>(raw).expect("should parse") {
|
||||
ServerMessage::Command { command: PrinterCommand::StartPrint { job_id, file_name, url, .. }, .. } => {
|
||||
ServerMessage::Command { command: PrinterCommand::StartPrint { job_id, file_name, url }, .. } => {
|
||||
assert_eq!(job_id, "5b0e7c1a-0000-4000-8000-000000000000");
|
||||
assert_eq!(file_name, "plate_1.3mf");
|
||||
assert_eq!(
|
||||
@@ -188,7 +188,6 @@ fn empty_telemetry_fields_are_omitted_not_null() {
|
||||
// The common case, and the third field that must be omitted: a
|
||||
// P1P reports that it has a camera without giving a URL for it.
|
||||
camera_stream: None,
|
||||
camera_snapshot: None,
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -227,7 +226,6 @@ fn a_zero_stage_is_sent_rather_than_treated_as_absent() {
|
||||
print_error: Some(0),
|
||||
active_alerts: Some(0),
|
||||
camera_stream: Some("p1p".into()),
|
||||
camera_snapshot: None,
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -301,28 +299,3 @@ fn a_hello_carries_no_credential() {
|
||||
assert!(!json.contains("token"), "the hello must carry no token, got:\n {json}");
|
||||
assert_eq!(json, r#"{"type":"hello","gateway_id":"gw-1","farm_id":"farm-1"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_request_is_readable_and_its_answer_matches_the_schema() {
|
||||
let request = r#"{"type":"camera_snapshot_request","request_id":"r1","printer_id":"p1p"}"#;
|
||||
assert!(matches!(
|
||||
serde_json::from_str::<ServerMessage>(request),
|
||||
Ok(ServerMessage::CameraSnapshotRequest { .. })
|
||||
));
|
||||
|
||||
let ok = serde_json::to_string(&ClientMessage::CameraSnapshot {
|
||||
request_id: "r1".into(),
|
||||
jpeg_base64: Some("/9j/".into()),
|
||||
error: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(ok, r#"{"type":"camera_snapshot","request_id":"r1","jpeg_base64":"/9j/"}"#);
|
||||
|
||||
let failed = serde_json::to_string(&ClientMessage::CameraSnapshot {
|
||||
request_id: "r1".into(),
|
||||
jpeg_base64: None,
|
||||
error: Some("no frame".into()),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(failed, r#"{"type":"camera_snapshot","request_id":"r1","error":"no frame"}"#);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user