Compare commits
55
Commits
a220bf56cc
...
6c60bd1f70
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c60bd1f70
|
||
|
|
f33b9092e8
|
||
|
|
9061d39684
|
||
|
|
970db780be
|
||
|
|
32dba6af2c
|
||
|
|
7124335fb7
|
||
|
|
815131b951
|
||
|
|
e0a6ccd2c1
|
||
|
|
29c85fa449
|
||
|
|
07f49f0c5b
|
||
|
|
ecbec11336
|
||
|
|
af8bac4bf3
|
||
|
|
c6cf9bcb5e
|
||
|
|
1cd8ce25c3
|
||
|
|
ad55645ec9
|
||
|
|
64d46dbc49
|
||
|
|
da28597eeb
|
||
|
|
a401773406
|
||
|
|
e4344b208b
|
||
|
|
042cf430ff
|
||
|
|
1a0037af36
|
||
|
|
d13284b585
|
||
|
|
70763108f8
|
||
|
|
7a0201c0de
|
||
|
|
f88103a510
|
||
|
|
930d117c4e
|
||
|
|
51ff768153
|
||
|
|
ad150cd29e
|
||
|
|
3b93a1b4e1
|
||
|
|
6a0d80a381
|
||
|
|
901d5e9748
|
||
|
|
76b3894ca9
|
||
|
|
df6dfca3ca
|
||
|
|
7d4cf41999
|
||
|
|
13bfec97fe
|
||
|
|
254798e8cb
|
||
|
|
c22ccf5d22
|
||
|
|
431dd849da
|
||
|
|
0077e15490
|
||
|
|
939c35fb91
|
||
|
|
588ca85842
|
||
|
|
ca3846b7c7
|
||
|
|
91fbcaf665
|
||
|
|
9a314b2b9f
|
||
|
|
0ab602ed79
|
||
|
|
f3f6e21012
|
||
|
|
305c41993d
|
||
|
|
23f157d07a
|
||
|
|
256463e29c
|
||
|
|
6ce60542c6
|
||
|
|
835a4ae778
|
||
|
|
4add7c1248
|
||
|
|
416ef6db07
|
||
|
|
9643c8b685
|
||
|
|
b352fd618d
|
+30
-7
@@ -1,20 +1,43 @@
|
||||
# Identity
|
||||
CONTINUUM_GATEWAY_ID=gw-farm01-rpi01
|
||||
CONTINUUM_FARM_ID=replace-with-farm-uuid
|
||||
# The edge JWT from POST /auth/edge-token. Sent as an Authorization: Bearer
|
||||
# header on the uplink's WebSocket upgrade - not in a message and not in the
|
||||
# URL - so the backend verifies it before any socket exists.
|
||||
CONTINUUM_GATEWAY_TOKEN=replace-with-provisioning-token
|
||||
|
||||
# Cloud uplink
|
||||
CONTINUUM_UPLINK_URL=wss://api.continuum.local/ws/edge/v1
|
||||
|
||||
# go2rtc process management
|
||||
# Camera restreaming. The config file is GENERATED by this daemon from what
|
||||
# the printers themselves report - don't write it by hand, and keep it out of
|
||||
# version control: it holds printer access codes.
|
||||
CONTINUUM_GO2RTC_BIN=/usr/local/bin/go2rtc
|
||||
CONTINUUM_GO2RTC_CONFIG=./go2rtc.yaml
|
||||
CONTINUUM_GO2RTC_API_LISTEN=:1984
|
||||
|
||||
# Bambu MQTTS certificate trust. Most printers verify fine against the
|
||||
# bundled shared CA (certs/bambu_ca2.pem) and need nothing here. For a
|
||||
# printer that doesn't chain to it (P1P, at least), add "printer_id=path"
|
||||
# entries, comma-separated: CONTINUUM_BAMBU_CA_CERT_OVERRIDES=p1=./certs/p1p.pem
|
||||
CONTINUUM_BAMBU_CA_CERT_OVERRIDES=
|
||||
CONTINUUM_BAMBU_REQUIRE_VALID_CERT=true
|
||||
# The go2rtc API serves WebRTC, and the listen address above is every
|
||||
# interface, so it is always password-protected: leave this unset and the
|
||||
# daemon generates a random password each run and writes it into the config
|
||||
# file above, which is already a secret. Set it only if you need a stable one
|
||||
# (to open go2rtc's own web UI, say). The username is always "continuum".
|
||||
#
|
||||
# go2rtc exempts loopback from this, so a check from the gateway itself always
|
||||
# succeeds - test from another machine if you want to confirm it is on.
|
||||
# CONTINUUM_GO2RTC_PASSWORD=
|
||||
|
||||
# What a browser can reach the streams at, reported to the cloud. A bind
|
||||
# address is not one, which is why this is separate from the listen address
|
||||
# above; only you know this gateway's reachable name. Note this is a LAN
|
||||
# address: a browser on this network can play the stream, one outside cannot.
|
||||
# Leave it unset to advertise no streams at all, which is the safe default -
|
||||
# a wrong URL here makes the UI offer a player that cannot connect.
|
||||
# CONTINUUM_STREAM_BASE_URL=http://gateway.local:1984
|
||||
|
||||
# Fleet config - see printers.example.toml. Bambu MQTTS certificate trust
|
||||
# (per-printer ca_cert_path/require_valid_cert, and automatic
|
||||
# trust-on-first-connect pinning otherwise) is configured per printer in
|
||||
# that file, not here.
|
||||
CONTINUUM_PRINTERS_PATH=printers.toml
|
||||
|
||||
RUST_LOG=info,continuum_proxy=debug
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# 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" }
|
||||
+7
-1
@@ -1,5 +1,4 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
*.db
|
||||
*.db-journal
|
||||
*.sqlite
|
||||
@@ -8,3 +7,10 @@ Cargo.lock
|
||||
*.log
|
||||
printers.toml
|
||||
/certs/pinned/
|
||||
|
||||
# Generated by the daemon from what the printers report, and it holds their
|
||||
# access codes.
|
||||
go2rtc.yaml
|
||||
go2rtc.yaml.tmp
|
||||
continuum.toml
|
||||
go2rtc.yaml.pid
|
||||
|
||||
Generated
+2562
File diff suppressed because it is too large
Load Diff
+50
-12
@@ -3,7 +3,10 @@ name = "continuum-proxy"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Continuum edge gateway daemon - runs on-site, bridges printers to the cloud control plane"
|
||||
license = "UNLICENSED"
|
||||
# Proprietary: deliberately not an SPDX id, so cargo-deny treats it as
|
||||
# first-party. See docs/LICENSES.md in continuum-common.
|
||||
license = "LicenseRef-Proprietary"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "continuum_proxy"
|
||||
@@ -21,10 +24,6 @@ path = "examples/printer_polymorphism.rs"
|
||||
name = "test_bambu_certs"
|
||||
path = "examples/test_bambu_certs.rs"
|
||||
|
||||
[[example]]
|
||||
name = "fetch_bambu_cert"
|
||||
path = "examples/fetch_bambu_cert.rs"
|
||||
|
||||
[[example]]
|
||||
name = "auto_connect_bambu"
|
||||
path = "examples/auto_connect_bambu.rs"
|
||||
@@ -33,7 +32,27 @@ path = "examples/auto_connect_bambu.rs"
|
||||
name = "bambu_commands_demo"
|
||||
path = "examples/bambu_commands_demo.rs"
|
||||
|
||||
[[example]]
|
||||
name = "live_reports"
|
||||
path = "examples/live_reports.rs"
|
||||
|
||||
[[example]]
|
||||
name = "bambu_idle_checks"
|
||||
path = "examples/bambu_idle_checks.rs"
|
||||
|
||||
[dependencies]
|
||||
# Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/
|
||||
# Moonraker, FTPS for gcode transfer) go here once you're ready to build
|
||||
# past the stubs in src/printer/ - see that module's doc comment.
|
||||
continuum-rust-types = { path = "../continuum-schemas/packages/rust-types" }
|
||||
# FOR NON-LOCAL DEV
|
||||
# continuum-rust-types = { git = "https://git.octoturge.com/Continuum/continuum-schemas.git" }
|
||||
|
||||
# Builds a JSON instance straight from the schema files in
|
||||
# continuum-schemas/schemas/ - used for Bambu's raw MQTT JSON messages
|
||||
# instead of hand-written serde structs (see bambu.rs).
|
||||
continuum-schema-instance = { path = "../continuum-common/packages/rust-schema-instance" }
|
||||
|
||||
tokio = { version = "1.40", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
@@ -47,10 +66,29 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
dotenvy = "0.15"
|
||||
|
||||
# Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/
|
||||
# Moonraker, FTPS for gcode transfer) go here once you're ready to build
|
||||
# past the stubs in src/printer/ - see that module's doc comment.
|
||||
continuum-rust-types = { path = "../continuum-schemas/packages/rust-types" }
|
||||
# FOR NON-LOCAL DEV
|
||||
# continuum-rust-types = { git = "https://git.octoturge.com/Continuum/continuum-schemas.git" }
|
||||
tokio-serial = { version = "5.5.0", default-features = false }
|
||||
rumqttc = { version = "0.25.1", default-features = false, features = ["use-native-tls"] }
|
||||
# Blocking FTPS client for pushing project files to a printer's own
|
||||
# storage (see src/printer/bambu_ftps.rs for why the blocking one, not
|
||||
# the async one). `deprecated` is what gates `connect_secure_implicit`:
|
||||
# Bambu speaks implicit FTPS on 990, which the RFCs deprecated in favour
|
||||
# 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"] }
|
||||
# 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
|
||||
# to the build.
|
||||
# Apache-2.0 - checked against docs/LICENSES.md before adding.
|
||||
openssl = "0.10"
|
||||
flate2 = "1.1.10"
|
||||
# HTTPS GET of a presigned job-file URL for the `start_print` command (see
|
||||
# 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"
|
||||
|
||||
@@ -4,62 +4,140 @@ Edge gateway daemon for the Continuum print farm platform. Runs on a Linux
|
||||
SBC on-site, talks to printers over the LAN, and keeps a connection to the
|
||||
cloud control plane (`continuum-backend`).
|
||||
|
||||
## This is a learning-stage boilerplate
|
||||
## What's real right now
|
||||
|
||||
This repo is deliberately minimal right now - real printer protocol clients
|
||||
(Bambu MQTT+FTPS, PrusaLink REST, Klipper/Moonraker WebSocket) are **not**
|
||||
implemented yet. Each vendor is a stub that just prints what it would do
|
||||
(`src/printer/bambu.rs`, `prusa.rs`, `klipper.rs`). The idea is to learn
|
||||
Rust's polymorphism pattern (trait + enum, since Rust has no class
|
||||
inheritance) on something simple before adding real networking on top.
|
||||
- **Bambu MQTT** - a real client for both protocol generations (MQTT
|
||||
v3.1.1+BambuMQTT v1 on older firmware, MQTT v5+BambuMQTT v2 on current
|
||||
firmware), auto-detected per printer on connect (a firmware update can
|
||||
move the same physical printer between them, so this isn't something
|
||||
config can hard-code once and forget - see `printer/bambu.rs`'s
|
||||
`BambuProtocolPreference::Auto`). Request/reply correlation via
|
||||
`sequence_id`, an in-memory `print.push_status` cache that merges v1's
|
||||
partial updates and replaces on v2's full ones, and TLS trust that
|
||||
auto-pins a printer's certificate via trust-on-first-connect the first
|
||||
time the bundled CA doesn't verify (`printer/bambu.rs`'s
|
||||
`ensure_trusted`) - see `printer/bambu_mqtt.rs`'s doc comment for the
|
||||
full protocol write-up.
|
||||
- **Marlin/RepRap serial** - a real held-open serial connection that paces
|
||||
gcode lines by Marlin's own "ok" acknowledgment (`printer/marlin_serial.rs`).
|
||||
- **Wire shapes from schemas, not hand duplication** - Bambu's raw MQTT
|
||||
JSON is built straight from `continuum-schemas`' JSON Schema files via
|
||||
`continuum-schema-instance`, instead of a second hand-written copy of
|
||||
every request shape drifting out of sync with the schema.
|
||||
- **Cloud uplink** - a WebSocket client to `continuum-backend` that
|
||||
connects, sends a heartbeat on a timer, and reconnects on drop
|
||||
(`src/uplink/`).
|
||||
- **Camera restreaming** - generates go2rtc's configuration from the camera
|
||||
URLs the printers report, and restarts go2rtc when it changes
|
||||
(`src/camera.rs` + `src/go2rtc.rs`). 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 a camera with no URL
|
||||
for it. A P1/A1 camera needs Bambu's proprietary port-6000 JPEG stream,
|
||||
which this crate does not speak, so those printers advertise no stream
|
||||
rather than one that will not play. Note the stream is reachable on the
|
||||
gateway's LAN only - there is no relay.
|
||||
- **Bambu file upload** - implicit FTPS (port 990) into the printer's own
|
||||
`cache/` directory (`src/printer/bambu_ftps.rs`, via
|
||||
`BambuPrinter::upload_file`). **Confirmed live against a P1P on
|
||||
2026-10-03**: login, binary mode, and the transfer all work, byte count
|
||||
matched exactly.
|
||||
- **Print status** - `print.push_status` parsed into `PrintReport` with real
|
||||
job progress, temperatures, fans and HMS entries. Confirmed live on both
|
||||
generations (idle P1P on v1, H2C 20% into a job on v2); regression
|
||||
fixtures from those captures are in `tests/fixtures/`.
|
||||
|
||||
## Examples worth knowing
|
||||
|
||||
- `cargo run --example live_reports [printer-id]` - **read-only**, safe
|
||||
against a printer that is mid-print. Connects, reports firmware, and
|
||||
watches the status stream. Redirect stderr to capture raw payloads.
|
||||
- `cargo run --example bambu_idle_checks -- <printer-id>` - **writes**
|
||||
(FTPS upload, fan speed). Requires an explicit printer id and refuses to
|
||||
run unless that printer reports `gcode_state: "IDLE"`.
|
||||
|
||||
## What routes between the cloud and the printers
|
||||
|
||||
The uplink and the printer fleet are wired together, in both directions
|
||||
(`uplink/mod.rs`):
|
||||
|
||||
- **Up:** every 5s, one `PrinterTelemetry` snapshot per printer that has
|
||||
reported, batched into a single `Telemetry` message. Verified live against
|
||||
a P1P and an H2C.
|
||||
- **Down:** a `Command` from the cloud is looked up by our own printer id,
|
||||
executed, and answered with a `CommandResult`. The set of commands is
|
||||
deliberately small - a fan speed and a single gcode line - and deliberately
|
||||
has no "start a print" variant; see `PrinterCommand`.
|
||||
|
||||
One quirk worth knowing: a Bambu on protocol v1 only sends a *complete*
|
||||
status when asked, so each telemetry tick also fires a `pushing.pushall` for
|
||||
the next one. A v1 printer's telemetry is therefore one tick behind, which
|
||||
is why it shows up on the second frame rather than the first.
|
||||
|
||||
## What's still a stub
|
||||
|
||||
`start_uploaded_file` - the MQTT command that actually *starts* a print from
|
||||
an uploaded file - is written but has deliberately never been fired, since
|
||||
that commits a machine to hours of work. Its doc comment lists the three
|
||||
details to confirm on the first real attempt. `upload_file` is proven, but
|
||||
only with a small text file, not a multi-megabyte sliced `.3mf`.
|
||||
|
||||
PrusaLink (REST) and Klipper/Moonraker (WebSocket) - `printer/prusa.rs`
|
||||
and `printer/klipper.rs` just `println!` what they'd send. Also not built
|
||||
yet: local buffering for telemetry across connectivity gaps (so telemetry
|
||||
produced while the uplink is down is simply lost), LAN printer discovery
|
||||
(SSDP/mDNS), and the mechanical plate-changer interface.
|
||||
|
||||
## Getting started
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cargo run # the daemon: cloud uplink + go2rtc watchdog
|
||||
cargo run --example printer_polymorphism # standalone demo, no network/env needed
|
||||
cargo run # the daemon: connects every configured printer, then runs the cloud uplink + go2rtc watchdog
|
||||
|
||||
cp printers.example.toml printers.toml # fill in your real printers (gitignored)
|
||||
cargo run --example test_bambu_certs # real TLS handshake test against each Bambu printer
|
||||
cargo run --example auto_connect_bambu # checks/auto-pins each Bambu printer's certificate, no MQTT needed
|
||||
cargo run --example test_bambu_certs # narrower: just the raw TLS handshake, no auto-pinning
|
||||
cargo run --example printer_polymorphism # standalone trait+enum demo, fake data, no network/env needed
|
||||
cargo run --example bambu_commands_demo # round-trips hand-written structs against real captured Bambu JSON
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs Runs the uplink and the go2rtc watchdog side by side
|
||||
config.rs Loads settings from environment variables
|
||||
fleet.rs Loads printers.toml into ready-to-use PrinterHandles
|
||||
uplink/ WebSocket client to continuum-backend: connect, heartbeat, reconnect on drop
|
||||
printer/ GenericPrinter trait + PrinterBase + PrinterHandle enum + one stub per vendor
|
||||
go2rtc.rs Restarts the go2rtc camera-restreaming process if it dies
|
||||
main.rs Loads+connects the printer fleet, then runs the uplink, camera config, watchdog and completion watcher side by side
|
||||
config.rs Loads settings from environment variables
|
||||
fleet.rs Loads printers.toml into ready-to-use PrinterHandles
|
||||
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
|
||||
bambu.rs The real Bambu printer type: cert trust, protocol version auto-detect, commands
|
||||
bambu_mqtt.rs The real MQTT wiring underneath bambu.rs - see its doc comment for the protocol details
|
||||
bambu_commands/ Hand-written serde structs mirroring continuum-schemas' bambulab/ shapes
|
||||
marlin_serial.rs A real held-open Marlin/RepRap serial connection
|
||||
prusa.rs, klipper.rs Still stubs - see "What's still a stub" above
|
||||
examples/
|
||||
printer_polymorphism.rs Runs all five printer stubs through one `connect()` call site
|
||||
test_bambu_certs.rs Loads printers.toml, does a real TLS handshake to each Bambu printer
|
||||
auto_connect_bambu.rs Checks/auto-pins every Bambu printer's certificate (no MQTT)
|
||||
test_bambu_certs.rs Narrower: just a raw TLS handshake per Bambu printer
|
||||
printer_polymorphism.rs Standalone trait+enum demo, five fake printers, no network needed
|
||||
bambu_commands_demo.rs Round-trips bambu_commands' structs against real captured Bambu JSON
|
||||
```
|
||||
|
||||
`src/printer/` is where the "inheritance" question lives - see that
|
||||
module's doc comment for the trait+enum pattern this project uses instead
|
||||
of class inheritance, and run `printer_polymorphism` to see it work.
|
||||
|
||||
`printers.toml` (gitignored - copy from `printers.example.toml`) holds real
|
||||
per-printer connection details: host, access code, and - since not every
|
||||
Bambu printer trusts the same certificate (see `certs/README.md`) - an
|
||||
optional per-printer CA override. `src/fleet.rs` loads it; nothing in
|
||||
`main.rs` uses it yet, but `test_bambu_certs` does, as a real (if narrow -
|
||||
just the TLS handshake, no MQTT) way to check a printer's certificate
|
||||
without needing the full MQTT client built yet.
|
||||
per-printer connection details: host, access code/API key/serial port
|
||||
depending on vendor, and - for Bambu - an optional per-printer CA
|
||||
certificate override (see `certs/README.md`; most printers don't need one,
|
||||
since `connect()` auto-pins automatically).
|
||||
|
||||
## What's not here yet (on purpose)
|
||||
## Learning Rust through this repo
|
||||
|
||||
- Real MQTT/FTPS/HTTP/WebSocket printer clients - `src/printer/*.rs` has a
|
||||
`println!` where each of these will go.
|
||||
- Local SQLite buffering for telemetry across connectivity gaps.
|
||||
- LAN printer discovery (SSDP/mDNS).
|
||||
- The mechanical plate-changer interface (serial/GPIO).
|
||||
|
||||
Add these back in one at a time as you get comfortable with the Rust
|
||||
underneath them - each is its own small lesson (async I/O, a new crate's
|
||||
API, error handling for a real protocol) rather than something to absorb
|
||||
all at once.
|
||||
`src/printer/mod.rs`'s doc comment is where the "how do I do inheritance
|
||||
without classes" question lives - trait (shared behavior) + struct
|
||||
(shared fields) + enum (the closed set of concrete types), instead of
|
||||
`GenericPrinter -> BambuPrinter/PrusaLinkPrinter/...` class inheritance.
|
||||
Run `printer_polymorphism` to see it work end to end without needing any
|
||||
real printer on the network.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# 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
|
||||
@@ -28,3 +28,16 @@ certificates at the same time.
|
||||
If a printer's connection ever fails certificate verification against this
|
||||
file, re-fetch from the source URL above (BambuStudio ships whatever the
|
||||
current fleet needs) and update the fingerprint here.
|
||||
|
||||
## `pinned/`
|
||||
|
||||
Not checked in (gitignored) - where `BambuPrinter::ensure_trusted` saves a
|
||||
certificate it auto-pinned via trust-on-first-connect, one file per
|
||||
printer, named `<serial number>.pem`. This only happens for a printer
|
||||
whose certificate doesn't verify against `bambu_ca2.pem` above (P1P, at
|
||||
least) and that doesn't already have an explicit `ca_cert_path` set in
|
||||
`printers.toml` - see that method's doc comment in `src/printer/bambu.rs`
|
||||
for the full trust-on-first-connect flow, and the SSH-host-key analogy it's
|
||||
built on. Delete a printer's file here to force it to re-pin (a real
|
||||
error, not silent, if the certificate it captures the second time still
|
||||
doesn't verify) the next time it connects.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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"
|
||||
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
|
||||
@@ -0,0 +1,43 @@
|
||||
# Licence policy for a closed-source commercial product.
|
||||
# See docs/LICENSES.md in continuum-common for the rationale and the
|
||||
# non-cargo dependencies (external binaries, ML models) this file cannot see.
|
||||
#
|
||||
# Run: cargo deny check licenses
|
||||
|
||||
[licenses]
|
||||
# Permissive only. Anything copyleft reaching a shipped binary would make
|
||||
# the product undistributable without releasing our source.
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"Zlib",
|
||||
"Unicode-3.0",
|
||||
"Unlicense",
|
||||
"MPL-2.0",
|
||||
"CC0-1.0",
|
||||
"BlueOak-1.0.0",
|
||||
]
|
||||
confidence-threshold = 0.9
|
||||
|
||||
# Our own crates declare `LicenseRef-Proprietary`, which is deliberately not
|
||||
# an SPDX identifier and must never appear in the allow list above - a
|
||||
# third-party crate claiming it would be a real finding. They are recognised
|
||||
# as first-party by `publish = false` in their manifests.
|
||||
private = { ignore = true }
|
||||
|
||||
[bans]
|
||||
deny = []
|
||||
|
||||
[advisories]
|
||||
yanked = "deny"
|
||||
|
||||
# NOT VISIBLE TO THIS FILE: src/go2rtc.rs spawns go2rtc as an external
|
||||
# process found on PATH (go2rtc itself is MIT). go2rtc shells out to ffmpeg
|
||||
# for transcoding - ffmpeg is LGPL-2.1+ only when built without
|
||||
# `--enable-gpl`. If we ever ship either binary rather than requiring the
|
||||
# operator to install it, the build flags become our problem. See
|
||||
# docs/LICENSES.md.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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,12 +1,13 @@
|
||||
//! Run with: cargo run --example auto_connect_bambu
|
||||
//!
|
||||
//! The "smooth, no manual steps" version of test_bambu_certs +
|
||||
//! fetch_bambu_cert combined: for each Bambu printer in printers.toml,
|
||||
//! makes sure it has a working certificate, auto-pinning one via
|
||||
//! trust-on-first-connect if the bundled CA doesn't verify. Safe to run
|
||||
//! repeatedly - a printer that's already pinned (certs/pinned/<serial
|
||||
//! number>.pem) just gets re-verified against its pin, no network trust
|
||||
//! decision is made again.
|
||||
//! For each Bambu printer in printers.toml, makes sure it has a working
|
||||
//! certificate, auto-pinning one via trust-on-first-connect if the
|
||||
//! bundled CA doesn't verify - the exact same `ensure_trusted` step
|
||||
//! `BambuPrinter::connect()` runs before opening MQTT, isolated here so
|
||||
//! you can check/establish trust without needing a full MQTT handshake to
|
||||
//! succeed too. Safe to run repeatedly - a printer that's already pinned
|
||||
//! (certs/pinned/<serial number>.pem) just gets re-verified against its
|
||||
//! pin, no network trust decision is made again.
|
||||
|
||||
use continuum_proxy::fleet;
|
||||
use continuum_proxy::printer::PrinterHandle;
|
||||
@@ -16,10 +17,8 @@ fn main() -> anyhow::Result<()> {
|
||||
let cert_dir = std::path::Path::new("certs/pinned");
|
||||
|
||||
for printer in &mut printers {
|
||||
match printer {
|
||||
PrinterHandle::BambuV1(bambu) => report(&bambu.ensure_trusted(cert_dir)),
|
||||
PrinterHandle::BambuV2(bambu) => report(&bambu.ensure_trusted(cert_dir)),
|
||||
_ => {}
|
||||
if let PrinterHandle::Bambu(bambu) = printer {
|
||||
report(&bambu.ensure_trusted(cert_dir))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Run with: cargo run --example bambu_cache_list -- <printer-id> [dir]
|
||||
//!
|
||||
//! `dir` defaults to `cache`; pass `/` to see the SD card root, which is
|
||||
//! how to find out what a file's real absolute path is.
|
||||
//!
|
||||
//! **Read-only.** Connects, reads one `print.push_status`, and lists the
|
||||
//! printer's `cache/` directory over FTPS. It sends no commands that change
|
||||
//! anything - the `pushall` it issues is a request for a status report, which
|
||||
//! is the one write-shaped thing a read-only tool has to do on a v1 printer
|
||||
//! to learn anything at all.
|
||||
//!
|
||||
//! The point of it: `start_uploaded_file` can only print a file that is
|
||||
//! already in `cache/`, so this answers "is there anything there, and what is
|
||||
//! it called?" before any print command is sent. It is also how to find a
|
||||
//! real slicer-produced `.3mf` to test that command against, which is worth
|
||||
//! a great deal more than a hand-built one - if a hand-built file fails there
|
||||
//! is no way to tell whether the command was wrong or the file was.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use continuum_proxy::fleet;
|
||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const STATUS_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let Some(target) = std::env::args().nth(1) else {
|
||||
anyhow::bail!("usage: cargo run --example bambu_cache_list -- <printer-id> [dir]");
|
||||
};
|
||||
let dir = std::env::args().nth(2).unwrap_or_else(|| "cache".to_string());
|
||||
|
||||
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 - this example is Bambu-specific");
|
||||
};
|
||||
|
||||
println!("=== {} ===", bambu.display_name());
|
||||
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => anyhow::bail!("connect failed: {err}"),
|
||||
Err(_) => anyhow::bail!("connect timed out - printer off or unreachable?"),
|
||||
}
|
||||
|
||||
bambu.pushall().await.ok();
|
||||
|
||||
let deadline = tokio::time::Instant::now() + STATUS_TIMEOUT;
|
||||
loop {
|
||||
if let Some(report) = bambu.print_status().await
|
||||
&& let Some(state) = report.gcode_state.clone()
|
||||
{
|
||||
println!(" gcode_state: {state}");
|
||||
println!(" subtask_name: {}", report.subtask_name.clone().unwrap_or_default());
|
||||
println!(
|
||||
" layer: {}/{}",
|
||||
report.layer_num.unwrap_or_default(),
|
||||
report.total_layer_num.unwrap_or_default()
|
||||
);
|
||||
println!(" percent: {}", report.mc_percent.unwrap_or_default());
|
||||
// Temperatures and remaining time, because state plus layer
|
||||
// number cannot tell a print that is working from one that is
|
||||
// stalled: a P1P sits at layer 0 for minutes during bed
|
||||
// levelling and purge, and the only thing moving in that
|
||||
// window is the heat.
|
||||
println!(
|
||||
" nozzle: {:.1} -> {:.1} C",
|
||||
report.nozzle_temper.unwrap_or_default(),
|
||||
report.nozzle_target_temper.unwrap_or_default()
|
||||
);
|
||||
println!(
|
||||
" bed: {:.1} -> {:.1} C",
|
||||
report.bed_temper.unwrap_or_default(),
|
||||
report.bed_target_temper.unwrap_or_default()
|
||||
);
|
||||
println!(" remaining: {} min", report.mc_remaining_time.unwrap_or_default());
|
||||
break;
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
println!(" (no gcode_state within {STATUS_TIMEOUT:?} - listing cache/ anyway)");
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
println!("\n {dir} over FTPS:");
|
||||
match bambu.list_dir(&dir).await {
|
||||
Ok(entries) if entries.is_empty() => println!(" (empty)"),
|
||||
Ok(entries) => {
|
||||
for entry in &entries {
|
||||
println!(" {entry}");
|
||||
}
|
||||
println!("\n {} entr(ies)", entries.len());
|
||||
}
|
||||
Err(err) => println!(" FAILED: {err}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -36,8 +36,14 @@ fn main() {
|
||||
// --- print report: arrives under "print", NOT "pushing" (pushall
|
||||
// triggers this stream, it isn't a direct reply to it) - includes the
|
||||
// full ams/xcam example, with one empty tray and one loaded tray, to
|
||||
// exercise the untagged Empty/Loaded split.
|
||||
// exercise the untagged Empty/Loaded split. sequence_id/command added
|
||||
// by hand here (the original real capture this was built from was
|
||||
// itself a truncated example that happened to omit them, along with
|
||||
// dozens of other real fields the schema defines - not proof the real
|
||||
// message lacks them, see this struct's own doc comment).
|
||||
let real_print_report = r#"{
|
||||
"sequence_id": "1",
|
||||
"command": "push_status",
|
||||
"ams": {"ams": [{"humidity":"4","id":"0","temp":"22.7","tray":[
|
||||
{"id":"0"},
|
||||
{"bed_temp":"0","bed_temp_type":"0","cols":["000000FF"],"drying_temp":"0","drying_time":"0","id":"1","nozzle_temp_max":"240","nozzle_temp_min":"190","remain":0,"tag_uid":"0000000000000000","tray_color":"000000FF","tray_diameter":"0.00","tray_id_name":"","tray_info_idx":"GFA00","tray_sub_brands":"","tray_type":"PLA","tray_uuid":"00000000000000000000000000000000","tray_weight":"0","xcam_info":"000000000000000000000000"}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Run with: cargo run --example bambu_idle_checks -- <printer-id>
|
||||
//!
|
||||
//! **This one writes.** Unlike `live_reports`, it sends commands that change
|
||||
//! the printer: an FTPS file upload and a fan-speed change. So it does two
|
||||
//! things before touching anything:
|
||||
//!
|
||||
//! 1. Requires an explicit printer id from the command line. No "do this to
|
||||
//! every printer in the fleet" mode - that's not a thing you want to
|
||||
//! discover you ran.
|
||||
//! 2. Refuses to continue unless that printer reports `gcode_state: "IDLE"`.
|
||||
//! It connects, waits for a real `print.push_status`, and bails out if the
|
||||
//! printer turns out to be mid-job. Sending a fan command to a running
|
||||
//! print would corrupt it.
|
||||
//!
|
||||
//! What it deliberately does **not** do, even on an idle printer:
|
||||
//!
|
||||
//! - **Start a print.** The upload here goes through `upload_file`, which is
|
||||
//! FTPS only. `upload_project_file` (upload *then* print) and
|
||||
//! `start_uploaded_file` are left alone - committing a machine to hours of
|
||||
//! physical work needs a human deciding to.
|
||||
//! - **Calibrate.** `get_calibration` runs a real multi-minute bed-levelling
|
||||
//! / vibration-compensation sequence. Available, intentionally not wired
|
||||
//! in here.
|
||||
//! - **Heat anything.** No temperature commands.
|
||||
//!
|
||||
//! The fan is set and then put back to 0 before exiting.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use continuum_proxy::fleet;
|
||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
/// How long to wait for the first `push_status` before giving up on knowing
|
||||
/// whether the printer is idle. v2 pushes within about a second; v1 needs the
|
||||
/// `pushall` this example sends first.
|
||||
const STATUS_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let Some(target) = std::env::args().nth(1) else {
|
||||
anyhow::bail!(
|
||||
"usage: cargo run --example bambu_idle_checks -- <printer-id>\n\
|
||||
(the id from printers.toml - required on purpose, this example writes to the printer)"
|
||||
);
|
||||
};
|
||||
|
||||
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 - this example is Bambu-specific");
|
||||
};
|
||||
|
||||
println!("=== {} ===", bambu.display_name());
|
||||
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => anyhow::bail!("connect failed: {err}"),
|
||||
Err(_) => anyhow::bail!("connect timed out - printer off or unreachable?"),
|
||||
}
|
||||
|
||||
// --- Safety gate: is it actually idle? ---
|
||||
// v1 only sends a full object after a pushall; v2 sends one unprompted.
|
||||
bambu.pushall().await.ok();
|
||||
|
||||
let deadline = tokio::time::Instant::now() + STATUS_TIMEOUT;
|
||||
let state = loop {
|
||||
if let Some(report) = bambu.print_status().await
|
||||
&& let Some(state) = report.gcode_state.clone()
|
||||
{
|
||||
break state;
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
anyhow::bail!(
|
||||
"no print.push_status with a gcode_state within {STATUS_TIMEOUT:?} - refusing to \
|
||||
send anything, because there's no way to tell whether this printer is busy"
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
};
|
||||
|
||||
println!(" gcode_state: {state}");
|
||||
if state != "IDLE" {
|
||||
anyhow::bail!(
|
||||
"printer reports '{state}', not IDLE - refusing to send write commands to a printer \
|
||||
that may be mid-job. Use the `live_reports` example instead, it's read-only."
|
||||
);
|
||||
}
|
||||
println!(" idle - safe to continue\n");
|
||||
|
||||
// --- 1. FTPS upload (no print start) ---
|
||||
// The real point of this example: the FTPS path in bambu_ftps.rs had
|
||||
// never touched hardware. A tiny text file is enough to prove login,
|
||||
// binary mode, cwd into cache/, and the transfer itself.
|
||||
let file_name = "continuum-ftps-check.txt";
|
||||
let body = format!(
|
||||
"written by continuum-proxy bambu_idle_checks at {:?}\n",
|
||||
std::time::SystemTime::now()
|
||||
);
|
||||
let expected = body.len() as u64;
|
||||
|
||||
println!(" uploading {file_name} ({expected} bytes) over FTPS to cache/ ...");
|
||||
match bambu.upload_file(file_name, body.into_bytes()).await {
|
||||
Ok(written) if written == expected => println!(" OK - {written} bytes, matches what we sent"),
|
||||
Ok(written) => println!(" PARTIAL - printer accepted {written} of {expected} bytes"),
|
||||
Err(err) => println!(" FAILED: {err}"),
|
||||
}
|
||||
|
||||
// --- 2. Fan speed (an MQTT gcode_line publish under the hood) ---
|
||||
// Audible and visible on the machine, and harmless on an idle printer.
|
||||
println!("\n setting part-cooling fan to 50% ...");
|
||||
match bambu.set_fan_speed(50).await {
|
||||
Ok(()) => println!(" sent (M106 P1 S127) - listen for the fan"),
|
||||
Err(err) => println!(" FAILED: {err}"),
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
println!(" putting the fan back to 0% ...");
|
||||
match bambu.set_fan_speed(0).await {
|
||||
Ok(()) => println!(" sent"),
|
||||
Err(err) => println!(" FAILED: {err}"),
|
||||
}
|
||||
|
||||
// Give the printer a moment to report the change back, so the fan
|
||||
// reading below reflects the command rather than racing it.
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
if let Some(report) = bambu.print_status().await {
|
||||
println!(
|
||||
"\n cooling_fan_speed now reads: {:?}",
|
||||
report.cooling_fan_speed.as_deref().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
|
||||
println!("\ndone - nothing was printed, calibrated, or heated");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
//! Run with: cargo run --example bambu_print_probe -- <printer-id> <step> [arg]
|
||||
//!
|
||||
//! Probes the print-start and print-control commands against a real printer,
|
||||
//! one deliberate step at a time. **Some of these steps commit a physical
|
||||
//! machine to work**, so nothing here happens implicitly: every step is named
|
||||
//! on the command line, and the ones that can start a print additionally
|
||||
//! require `--yes-start-a-real-print`.
|
||||
//!
|
||||
//! Steps, in the order worth running them:
|
||||
//!
|
||||
//! - `status` read-only. Connect, print state, list `cache/`.
|
||||
//! - `probe-missing` **zero physical risk.** Sends `print.gcode_file`
|
||||
//! pointing at a file that does not exist. The printer
|
||||
//! cannot start anything, so this isolates one
|
||||
//! question: does it accept and parse the command shape
|
||||
//! at all? A clean rejection naming the file means the
|
||||
//! shape is right and only the target was wrong. A
|
||||
//! parse error, or silence, means the shape is wrong.
|
||||
//! - `print-project <local> [name]`
|
||||
//! **starts a real print.** The whole production path in
|
||||
//! one call - derive the AMS mapping from the archive,
|
||||
//! upload, start - via
|
||||
//! `GenericPrinter::upload_project_file`. Gated on a
|
||||
//! terminal state and `--yes-start-a-real-print`. Takes
|
||||
//! no `--ams`: the point is that it reads the mapping
|
||||
//! itself.
|
||||
//! - `upload <local> [name]`
|
||||
//! **inert.** FTPS-transfers a local file onto the card
|
||||
//! and stops - nothing prints until told to. Reports
|
||||
//! the byte count the printer accepted and the rate.
|
||||
//! - `delete <path>` removes one named file from the card.
|
||||
//! - `blink` runs `completion::celebrate`, the real ten-second
|
||||
//! finished-print blink, and then checks the light
|
||||
//! ended off. Twenty light commands in ten seconds is
|
||||
//! a rate the printer may refuse, which is the thing
|
||||
//! this answers.
|
||||
//! - `light on|off|flash`
|
||||
//! **no physical risk** beyond a light changing. `on`
|
||||
//! and `off` go through `BambuPrinter::set_light`, the
|
||||
//! same call `completion::run` blinks with, so this
|
||||
//! confirms the shipped path rather than a hand-built
|
||||
//! body. `flash` instead sends the schema's `flashing`
|
||||
//! mode raw, to settle what `loop_times` and
|
||||
//! `interval_time` actually do - the self-driven blink
|
||||
//! exists because that was unconfirmed.
|
||||
//! - `start <file>` sends `print.gcode_file`/`print.project_file` for a
|
||||
//! file in `cache/`. Gated on `gcode_state: IDLE` and
|
||||
//! on the explicit flag.
|
||||
//! - `pause` / `resume` / `stop`
|
||||
//! the control commands. `stop` cancels - it is the
|
||||
//! undo for `start`, and the reason `start` is worth
|
||||
//! testing at all: without it a cloud-initiated print
|
||||
//! could only be ended at the machine.
|
||||
//!
|
||||
//! After whatever it sends, it watches the `print.push_status` stream and
|
||||
//! reports every change to the fields that matter. Run with
|
||||
//! `RUST_LOG=continuum_proxy::printer::bambu_mqtt=trace` to see the raw
|
||||
//! payloads too - which is where a rejection that `PrintReport` does not
|
||||
//! model will show up.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use continuum_proxy::fleet;
|
||||
use continuum_proxy::printer::{
|
||||
FilamentSource, GenericPrinter, LightState, PrintControl, PrinterHandle, CHAMBER_LIGHT,
|
||||
};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const STATUS_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// How long to watch the report stream after sending something.
|
||||
const WATCH: Duration = Duration::from_secs(20);
|
||||
/// How long to wait for a light change to show up in `lights_report`. A v1
|
||||
/// printer only reports when asked, so this polls `pushall` rather than
|
||||
/// waiting on an organic push that is not coming.
|
||||
const LIGHT_READBACK: Duration = Duration::from_secs(12);
|
||||
/// Only for the message - `completion.rs` owns the real value.
|
||||
const BLINK_FOR_DISPLAY: &str = "10s";
|
||||
|
||||
/// A file name that cannot exist in `cache/`, for `probe-missing`.
|
||||
const MISSING_FILE: &str = "continuum-probe-no-such-file.gcode";
|
||||
|
||||
/// Every tray a P1P with one AMS has: 4 AMS slots plus the external spool.
|
||||
/// `ams_mapping` has to be padded to this length - see the schema.
|
||||
const P1P_TRAY_COUNT: usize = 5;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Without this there is no subscriber, so the `trace!` of every raw
|
||||
// report in bambu_mqtt.rs goes nowhere and RUST_LOG appears to do
|
||||
// nothing - which is exactly how the first run of this probe came back
|
||||
// with no evidence either way.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let (Some(target), Some(step)) = (args.get(1).cloned(), args.get(2).cloned()) else {
|
||||
anyhow::bail!(
|
||||
"usage: cargo run --example bambu_print_probe -- <printer-id> <step> [arg]\n\
|
||||
steps: status | probe-missing | light on|off|flash | start <file-in-cache> \
|
||||
| pause | resume | stop"
|
||||
);
|
||||
};
|
||||
let confirmed = args.iter().any(|a| a == "--yes-start-a-real-print");
|
||||
|
||||
// Handled before the fleet below is borrowed, because the real blink
|
||||
// wants the whole fleet behind a lock rather than one printer.
|
||||
if step == "blink" {
|
||||
return blink_probe(&target).await;
|
||||
}
|
||||
|
||||
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 - this example is Bambu-specific");
|
||||
};
|
||||
|
||||
println!("=== {} / step: {step} ===", bambu.display_name());
|
||||
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => anyhow::bail!("connect failed: {err}"),
|
||||
Err(_) => anyhow::bail!("connect timed out - printer off or unreachable?"),
|
||||
}
|
||||
|
||||
// v1 answers only when asked; v2 pushes unprompted.
|
||||
bambu.pushall().await.ok();
|
||||
let state = wait_for_state(bambu).await?;
|
||||
println!(" state before: {state}");
|
||||
|
||||
match step.as_str() {
|
||||
"status" => {
|
||||
match bambu.list_cache().await {
|
||||
Ok(entries) => {
|
||||
println!("\n cache/ ({} entries):", entries.len());
|
||||
for entry in &entries {
|
||||
println!(" {entry}");
|
||||
}
|
||||
}
|
||||
Err(err) => println!(" cache list FAILED: {err}"),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
"probe-missing" => {
|
||||
// Cannot start anything: the file is not there. This asks only
|
||||
// whether the command shape is understood.
|
||||
println!("\n sending print.gcode_file for a file that does not exist:");
|
||||
println!(" param -> /cache/{MISSING_FILE}");
|
||||
match bambu
|
||||
.start_uploaded_file(MISSING_FILE, &FilamentSource::ExternalSpool)
|
||||
.await
|
||||
{
|
||||
Ok(()) => println!(" the printer ACCEPTED it (result: success)"),
|
||||
Err(err) => anyhow::bail!("the printer answered: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
"start" => {
|
||||
let Some(file) = args.get(3).filter(|a| !a.starts_with("--")).cloned() else {
|
||||
anyhow::bail!("`start` needs a file name that is already in cache/ - run `status` to list them");
|
||||
};
|
||||
// The guard's job is "don't start a print on a machine that may
|
||||
// already be working", and IDLE is not the only state that
|
||||
// satisfies it. FINISH and FAILED are both terminal - FAILED is
|
||||
// what a Bambu reports after an operator cancellation, so
|
||||
// demanding IDLE made the harness refuse every print following a
|
||||
// cancelled one, which is the normal case when testing. PREPARE,
|
||||
// RUNNING, PAUSE and SLICING all mean the machine is committed,
|
||||
// and anything unrecognised is refused rather than assumed idle.
|
||||
const READY_TO_START: [&str; 3] = ["IDLE", "FINISH", "FAILED"];
|
||||
if !READY_TO_START.contains(&state.as_str()) {
|
||||
anyhow::bail!(
|
||||
"printer reports '{state}', which is not one of {READY_TO_START:?} - \
|
||||
refusing to start a print on a machine that may already be working"
|
||||
);
|
||||
}
|
||||
if !confirmed {
|
||||
anyhow::bail!(
|
||||
"refusing to start a real print without --yes-start-a-real-print.\n\
|
||||
This commits the machine to physical work. Be at the printer."
|
||||
);
|
||||
}
|
||||
// `--ams=3` or `--ams=3,-1,0` picks AMS slots per project
|
||||
// filament index; without it, the external spool. There is no
|
||||
// default that is safe for both, so the flag has to be explicit.
|
||||
let filament = match args.iter().find_map(|a| a.strip_prefix("--ams=")) {
|
||||
Some(list) => {
|
||||
let slots = list
|
||||
.split(',')
|
||||
.map(|slot| slot.trim().parse::<i32>())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|err| anyhow::anyhow!("--ams needs a comma-separated list of slot numbers: {err}"))?;
|
||||
// 4 AMS slots + 1 external spool on a P1P with one AMS.
|
||||
// See the padding rule in the ams_mapping schema.
|
||||
FilamentSource::ams(slots, P1P_TRAY_COUNT)
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?
|
||||
}
|
||||
None => FilamentSource::ExternalSpool,
|
||||
};
|
||||
match &filament {
|
||||
FilamentSource::ExternalSpool => {
|
||||
println!("\n starting print of '{file}' from the EXTERNAL SPOOL ...")
|
||||
}
|
||||
FilamentSource::Ams(slots) => {
|
||||
println!("\n starting print of '{file}' with ams_mapping {slots:?} ...")
|
||||
}
|
||||
}
|
||||
match bambu.start_uploaded_file(&file, &filament).await {
|
||||
Ok(()) => println!(" accepted"),
|
||||
Err(err) => anyhow::bail!("the printer answered: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
"pause" | "resume" | "stop" => {
|
||||
let action = match step.as_str() {
|
||||
"pause" => PrintControl::Pause,
|
||||
"resume" => PrintControl::Resume,
|
||||
_ => PrintControl::Stop,
|
||||
};
|
||||
println!("\n sending print.{} ...", action.as_str());
|
||||
match bambu.control_print(action).await {
|
||||
Ok(()) => println!(" accepted"),
|
||||
Err(err) => anyhow::bail!("the printer answered: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
// `light on|off|flash`. The first two deliberately go through
|
||||
// `set_light` rather than a raw body: that method had never once
|
||||
// reached a printer, and `completion::run` blinks a finished print's
|
||||
// chamber light with it, so the thing worth testing is it and not a
|
||||
// body written here to match.
|
||||
"light" => {
|
||||
let mode = args.get(3).map(String::as_str).unwrap_or("on");
|
||||
match mode {
|
||||
"on" | "off" => {
|
||||
let state = if mode == "on" { LightState::On } else { LightState::Off };
|
||||
println!("\n set_light({CHAMBER_LIGHT}, {mode}):");
|
||||
match bambu.set_light(CHAMBER_LIGHT, state).await {
|
||||
Ok(()) => println!(" accepted"),
|
||||
Err(err) => println!(" FAILED: {err}"),
|
||||
}
|
||||
// "Accepted" is not "it changed" - a ledctrl reply says
|
||||
// success as soon as the command parses. `lights_report`
|
||||
// is the only read-back there is, so poll until the
|
||||
// printer says the light is what we asked for.
|
||||
report_light(bambu, mode).await;
|
||||
}
|
||||
// Raw, because there is no typed call for it yet - that is
|
||||
// the point of the probe. Deliberately short: 3 cycles of
|
||||
// 500ms on / 500ms off, matching what `completion::run`
|
||||
// drives by hand, so the two are comparable by eye.
|
||||
"flash" => {
|
||||
let body = serde_json::json!({
|
||||
"command": "ledctrl",
|
||||
"led_node": CHAMBER_LIGHT,
|
||||
"led_mode": "flashing",
|
||||
"led_on_time": 500,
|
||||
"led_off_time": 500,
|
||||
"loop_times": 3,
|
||||
"interval_time": 1000,
|
||||
});
|
||||
println!("\n sending system.ledctrl flashing (3x 500/500, interval 1000):");
|
||||
match bambu.request_raw("system", body).await {
|
||||
Ok(reply) => println!(" reply: {reply}"),
|
||||
Err(err) => println!(" no usable reply: {err}"),
|
||||
}
|
||||
println!(
|
||||
" watch the printer: 3 blinks means loop_times is cycles; \n\
|
||||
anything else means the field means something other than that"
|
||||
);
|
||||
// And read it back, which says whether `flashing` is a
|
||||
// firmware *mode* the printer holds and reports, or a
|
||||
// one-shot that leaves the reported mode untouched.
|
||||
report_light(bambu, "flashing").await;
|
||||
}
|
||||
other => anyhow::bail!("`light` takes on, off or flash - not '{other}'"),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `shape <json>` sends one `print` command verbatim as a correlated
|
||||
// request and prints the reply. For settling an unconfirmed command
|
||||
// shape against the hardware - see `BambuPrinter::request_raw`.
|
||||
"shape" => {
|
||||
let Some(raw) = args.get(3) else {
|
||||
anyhow::bail!("`shape` needs a JSON body for the `print` category");
|
||||
};
|
||||
let body: serde_json::Value = serde_json::from_str(raw)
|
||||
.map_err(|err| anyhow::anyhow!("that is not valid JSON: {err}"))?;
|
||||
println!("\n sending print {} ...", body["command"]);
|
||||
match bambu.request_raw("print", body).await {
|
||||
Ok(reply) => println!(" reply: {reply}"),
|
||||
Err(err) => println!(" no usable reply: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
// `print-project <local> [name]` is the whole path in one call:
|
||||
// derive the filament source from the archive, upload, start. Calls
|
||||
// `GenericPrinter::upload_project_file`, so it tests the production
|
||||
// entry point rather than the three pieces in sequence.
|
||||
"print-project" => {
|
||||
let Some(local) = args.get(3) else {
|
||||
anyhow::bail!("`print-project` needs a local .3mf path");
|
||||
};
|
||||
const READY_TO_START: [&str; 3] = ["IDLE", "FINISH", "FAILED"];
|
||||
if !READY_TO_START.contains(&state.as_str()) {
|
||||
anyhow::bail!(
|
||||
"printer reports '{state}', which is not one of {READY_TO_START:?} - \
|
||||
refusing to start a print on a machine that may already be working"
|
||||
);
|
||||
}
|
||||
if !confirmed {
|
||||
anyhow::bail!(
|
||||
"refusing to start a real print without --yes-start-a-real-print.\n\
|
||||
This uploads a project and commits the machine to it. Be at the printer."
|
||||
);
|
||||
}
|
||||
let data = std::fs::read(local)?;
|
||||
let name = args
|
||||
.get(4)
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("continuum-probe-{}", file_stem(local)));
|
||||
println!("\n upload_project_file({name}, {} bytes):", data.len());
|
||||
println!(" no --ams flag: the mapping comes out of the archive, not this harness");
|
||||
let started = std::time::Instant::now();
|
||||
match bambu.upload_project_file(name.clone(), data).await {
|
||||
Ok(()) => println!(" accepted after {:?} - watching", started.elapsed()),
|
||||
Err(err) => {
|
||||
// The interesting failures are here: an unmappable
|
||||
// project should fail *before* the upload, leaving
|
||||
// nothing on the card.
|
||||
println!(" FAILED after {:?}: {err}", started.elapsed());
|
||||
println!(" if that was the mapping, the card should be untouched - check with `status`");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
watch(bambu, state).await;
|
||||
println!("\n the file is still on the card as {name} - `delete` it when done");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `upload <local> [name]` puts a file on the card and stops there.
|
||||
// Inert: a file on the SD card does nothing until something is told
|
||||
// to print it. The only proven transfer before this was 102 bytes,
|
||||
// and real projects on the card run to 62 MB - a size at which
|
||||
// buffering, timeouts and the FTPS data connection all start to
|
||||
// matter in ways a 102-byte write cannot show.
|
||||
"upload" => {
|
||||
let Some(local) = args.get(3) else {
|
||||
anyhow::bail!("`upload` needs a local file path");
|
||||
};
|
||||
let data = std::fs::read(local)?;
|
||||
let name = args
|
||||
.get(4)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("continuum-probe-{}", file_stem(local)));
|
||||
println!("\n uploading {} bytes as {name} ...", data.len());
|
||||
let started = std::time::Instant::now();
|
||||
match bambu.upload_file(&name, data.clone()).await {
|
||||
Ok(written) => {
|
||||
let elapsed = started.elapsed();
|
||||
println!(" printer accepted {written} bytes in {elapsed:?}");
|
||||
if written == data.len() as u64 {
|
||||
let rate = data.len() as f64 / elapsed.as_secs_f64() / 1_048_576.0;
|
||||
println!(" byte count matches exactly ({rate:.1} MiB/s)");
|
||||
} else {
|
||||
println!(" MISMATCH: sent {}, printer took {written}", data.len());
|
||||
}
|
||||
}
|
||||
Err(err) => println!(" upload FAILED: {err}"),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `delete <path>` removes one file. Named in full on the command
|
||||
// line every time - there is no pattern form on purpose.
|
||||
"delete" => {
|
||||
let Some(path) = args.get(3) else {
|
||||
anyhow::bail!("`delete` needs the exact path of a file on the card");
|
||||
};
|
||||
println!("\n deleting {path} ...");
|
||||
match bambu.delete_file(path).await {
|
||||
Ok(()) => println!(" gone"),
|
||||
Err(err) => println!(" delete FAILED: {err}"),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// `fetch <path> <dest>` copies one file off the card. Read-only.
|
||||
"fetch" => {
|
||||
let (Some(path), Some(dest)) = (args.get(3), args.get(4)) else {
|
||||
anyhow::bail!("`fetch` needs a path on the printer and a local destination");
|
||||
};
|
||||
match bambu.download_file(path).await {
|
||||
Ok(bytes) => {
|
||||
std::fs::write(dest, &bytes)?;
|
||||
println!(" {} bytes -> {dest}", bytes.len());
|
||||
}
|
||||
Err(err) => anyhow::bail!("fetch failed: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
other => anyhow::bail!("unknown step '{other}'"),
|
||||
}
|
||||
|
||||
watch(bambu, state).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The file name without its directory, for naming an upload after its
|
||||
/// source. Not `Path::file_stem`, which would strip only the last extension
|
||||
/// and turn `tray.stl.gcode.3mf` into something that is no longer a `.3mf`.
|
||||
fn file_stem(path: &str) -> String {
|
||||
path.rsplit('/').next().unwrap_or(path).to_string()
|
||||
}
|
||||
|
||||
/// Runs `completion::celebrate` - the actual ten-second blink a finished print
|
||||
/// triggers - against a real printer. The question it answers is a rate one:
|
||||
/// `celebrate` sends twenty `ledctrl` commands half a second apart, and a
|
||||
/// printer is entitled to refuse that. It stops at the first failure, so if
|
||||
/// the rate is too high this prints one warning and ends dark.
|
||||
async fn blink_probe(target: &str) -> anyhow::Result<()> {
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
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");
|
||||
};
|
||||
println!("=== {} / step: blink ===", bambu.display_name());
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => anyhow::bail!("connect failed: {err}"),
|
||||
Err(_) => anyhow::bail!("connect timed out - printer off or unreachable?"),
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n running completion::celebrate for {BLINK_FOR_DISPLAY} - watch the printer");
|
||||
let fleet: continuum_proxy::uplink::SharedFleet = Arc::new(Mutex::new(printers));
|
||||
let started = std::time::Instant::now();
|
||||
continuum_proxy::completion::celebrate(fleet.clone(), target.to_string()).await;
|
||||
println!(" returned after {:?}", started.elapsed());
|
||||
|
||||
// It is specified to end dark, which is the part worth checking: a blink
|
||||
// that leaves the light on looks like a printer that is still busy.
|
||||
let fleet = fleet.lock().await;
|
||||
if let Some(PrinterHandle::Bambu(bambu)) = fleet.iter().find(|p| p.base().id == target) {
|
||||
report_light(bambu, "off").await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Polls `lights_report` until the chamber light reads `want`, printing every
|
||||
/// change with the time it took. Gives up rather than hanging: a light that
|
||||
/// never reports what was asked for is the result, not a reason to wait.
|
||||
async fn report_light(bambu: &continuum_proxy::printer::BambuPrinter, want: &str) {
|
||||
let started = std::time::Instant::now();
|
||||
let mut last = String::new();
|
||||
while started.elapsed() < LIGHT_READBACK {
|
||||
let mode = bambu
|
||||
.print_status()
|
||||
.await
|
||||
.and_then(|r| r.lights_report)
|
||||
.and_then(|lights| {
|
||||
lights
|
||||
.into_iter()
|
||||
.find(|l| l.node.as_deref() == Some(CHAMBER_LIGHT))
|
||||
.and_then(|l| l.mode)
|
||||
});
|
||||
let mode = mode.unwrap_or_else(|| "<not reported>".into());
|
||||
if mode != last {
|
||||
println!(" lights_report after {:?}: {CHAMBER_LIGHT} = {mode}", started.elapsed());
|
||||
last = mode.clone();
|
||||
}
|
||||
if mode == want {
|
||||
println!(" confirmed: the printer reports '{want}', not just that it accepted it");
|
||||
return;
|
||||
}
|
||||
bambu.pushall().await.ok();
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
}
|
||||
println!(" gave up after {LIGHT_READBACK:?}: last reported '{last}', asked for '{want}'");
|
||||
}
|
||||
|
||||
async fn wait_for_state(bambu: &continuum_proxy::printer::BambuPrinter) -> anyhow::Result<String> {
|
||||
let deadline = tokio::time::Instant::now() + STATUS_TIMEOUT;
|
||||
loop {
|
||||
if let Some(report) = bambu.print_status().await
|
||||
&& let Some(state) = report.gcode_state.clone()
|
||||
{
|
||||
return Ok(state);
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
anyhow::bail!(
|
||||
"no print.push_status with a gcode_state within {STATUS_TIMEOUT:?} - refusing to \
|
||||
go further, because there is no way to tell what this printer is doing"
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports every change to the fields that would show a command landing.
|
||||
async fn watch(bambu: &continuum_proxy::printer::BambuPrinter, before: String) {
|
||||
println!("\n watching the report stream for {WATCH:?} ...");
|
||||
let deadline = tokio::time::Instant::now() + WATCH;
|
||||
let mut last = before;
|
||||
let mut last_error: Option<i32> = None;
|
||||
let mut last_task = String::new();
|
||||
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
// v1 needs asking each time for a complete object.
|
||||
bambu.pushall().await.ok();
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
|
||||
if let Some(report) = bambu.print_status().await {
|
||||
if let Some(state) = report.gcode_state.clone()
|
||||
&& state != last
|
||||
{
|
||||
println!(" gcode_state: {last} -> {state}");
|
||||
last = state;
|
||||
}
|
||||
if report.print_error != last_error {
|
||||
println!(" print_error: {:?} -> {:?}", last_error, report.print_error);
|
||||
last_error = report.print_error;
|
||||
}
|
||||
let task = report.subtask_name.clone().unwrap_or_default();
|
||||
if task != last_task {
|
||||
println!(" subtask_name: '{last_task}' -> '{task}'");
|
||||
last_task = task;
|
||||
}
|
||||
if let Some(hms) = &report.hms
|
||||
&& !hms.is_empty()
|
||||
{
|
||||
println!(" hms (printer alerts): {}", serde_json::to_string(hms).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
println!(" final state: {last}");
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Finds printers and prints `printers.toml` blocks for them.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run --example discover_printers -- all [a.b.c.0/24] # everything safe
|
||||
//! cargo run --example discover_printers -- listen [seconds] # passive SSDP (Bambu)
|
||||
//! cargo run --example discover_printers -- raw [seconds] # ... and dump every datagram
|
||||
//! 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
|
||||
//! ```
|
||||
//!
|
||||
//! Everything except `serial-probe` is **read-only and sends no credentials**.
|
||||
//! `listen` only receives. `identify` and `sweep` open a connection and read
|
||||
//! what the far end volunteers - a TLS certificate, an HTTP identity endpoint
|
||||
//! - without a session or a key. `serial` reads sysfs and opens no device.
|
||||
//!
|
||||
//! **`serial-probe` resets the printer**, because opening a serial port
|
||||
//! toggles DTR on an Arduino-based board. That ruins a print in progress, so
|
||||
//! it refuses to run without `--yes-reset-the-printer`, the same shape as
|
||||
//! `bambu_print_probe`'s `--yes-start-a-real-print`.
|
||||
//!
|
||||
//! Which network subcommand to use depends on where the gateway sits.
|
||||
//! `listen` learns the most about a Bambu printer - the model and the
|
||||
//! operator's own name come only from SSDP - but multicast does not cross a
|
||||
//! router, so it finds nothing unless this host is on the printers' segment.
|
||||
//! `sweep` works from anywhere that can route to them, and covers Bambu,
|
||||
//! Klipper and PrusaLink. `all` does both and merges the result.
|
||||
//!
|
||||
//! It deliberately does not write `printers.toml`: a Bambu access code is on
|
||||
//! the printer's screen and a PrusaLink API key is in its web UI, so every
|
||||
//! block printed here is incomplete by construction. `fleet::load` rejects an
|
||||
//! entry whose credential is empty, which means pasting one in and forgetting
|
||||
//! to finish it fails loudly at the next start rather than quietly.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use continuum_proxy::discovery::{self, Candidate, net, serial};
|
||||
|
||||
/// Long enough to catch Bambu's announcement interval a few times over.
|
||||
const LISTEN_SECONDS: u64 = 15;
|
||||
|
||||
/// Per-host probe budget. Generous for a LAN, but a sweep spends it on every
|
||||
/// address that is not there, so the batching in `sweep_hosts` matters more.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// A serial handshake has to wait out a board reset before it can even ask.
|
||||
const SERIAL_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let Some(command) = args.first().map(String::as_str) else {
|
||||
anyhow::bail!(usage());
|
||||
};
|
||||
|
||||
let found = match command {
|
||||
"all" => {
|
||||
let mut found = listen(LISTEN_SECONDS, false).await?;
|
||||
match args.get(1) {
|
||||
Some(subnet) => found.extend(sweep(subnet).await?),
|
||||
// Without a subnet the sweep is skipped rather than guessed:
|
||||
// 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
|
||||
}
|
||||
"listen" | "raw" => {
|
||||
let seconds = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(LISTEN_SECONDS);
|
||||
listen(seconds, command == "raw").await?
|
||||
}
|
||||
"identify" => {
|
||||
let hosts = &args[1..];
|
||||
if hosts.is_empty() {
|
||||
anyhow::bail!("identify needs at least one host");
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
for host in hosts {
|
||||
let candidates = net::identify_any(host, PROBE_TIMEOUT).await;
|
||||
if candidates.is_empty() {
|
||||
// Named explicitly, unlike in a sweep: the operator asked
|
||||
// about this address, so silence is the wrong answer.
|
||||
eprintln!("{host}: nothing answered as a Bambu, Klipper or PrusaLink printer");
|
||||
}
|
||||
for candidate in &candidates {
|
||||
println!("{host}: {} - {}", candidate.vendor.toml_value(), describe(candidate));
|
||||
}
|
||||
found.extend(candidates);
|
||||
}
|
||||
found
|
||||
}
|
||||
"sweep" => {
|
||||
let subnet = args.get(1).ok_or_else(|| anyhow::anyhow!("sweep needs a subnet, e.g. 192.168.0.0/24"))?;
|
||||
sweep(subnet).await?
|
||||
}
|
||||
"serial" => list_serial()?,
|
||||
"serial-probe" => {
|
||||
let port = args.get(1).ok_or_else(|| anyhow::anyhow!("serial-probe needs a port, e.g. /dev/ttyACM0"))?;
|
||||
let baud = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(serial::DEFAULT_BAUD_RATE);
|
||||
if !args.iter().any(|a| a == "--yes-reset-the-printer") {
|
||||
anyhow::bail!(
|
||||
"opening {port} will RESET the printer on it - on an Arduino-based board the \
|
||||
port open toggles DTR, which reboots the machine and ruins any print in \
|
||||
progress. Re-run with --yes-reset-the-printer if nothing is printing. \
|
||||
`serial` (no probe) lists ports without opening any."
|
||||
);
|
||||
}
|
||||
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()),
|
||||
};
|
||||
|
||||
report(discovery::merge(found));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn sweep(subnet: &str) -> anyhow::Result<Vec<Candidate>> {
|
||||
// Expanded before anything is printed, so a refused subnet says so
|
||||
// instead of first claiming to be probing it.
|
||||
let hosts = net::hosts_in_subnet(subnet)?;
|
||||
println!(
|
||||
"probing {} addresses in {subnet} - Bambu on {}, Klipper on {}, PrusaLink on {} ...",
|
||||
hosts.len(),
|
||||
net::BAMBU_MQTT_PORT,
|
||||
net::MOONRAKER_PORT,
|
||||
net::PRUSALINK_PORT
|
||||
);
|
||||
Ok(net::sweep_hosts(&hosts, PROBE_TIMEOUT).await)
|
||||
}
|
||||
|
||||
async fn listen(seconds: u64, raw: bool) -> anyhow::Result<Vec<Candidate>> {
|
||||
let window = Duration::from_secs(seconds);
|
||||
println!("listening {seconds}s on {:?} for {} ...", discovery::SSDP_PORTS, discovery::SSDP_GROUP);
|
||||
|
||||
// Both ports at once, because which one a printer announces on depends on
|
||||
// its firmware and the operator has no way to know which.
|
||||
let listeners = discovery::SSDP_PORTS.map(|port| discovery::listen_ssdp(port, window, raw));
|
||||
let mut found: Vec<Candidate> = Vec::new();
|
||||
for result in futures_util::future::join_all(listeners).await {
|
||||
match result {
|
||||
Ok(candidates) => found.extend(candidates),
|
||||
// One port being unavailable - held by another process, or a
|
||||
// kernel that will not join the group - must not lose the other.
|
||||
Err(err) => eprintln!("warning: {err}"),
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn list_serial() -> anyhow::Result<Vec<Candidate>> {
|
||||
let ports = serial::list_ports()?;
|
||||
if ports.is_empty() {
|
||||
println!("no USB/serial ports found.");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
println!("{} serial port(s):", ports.len());
|
||||
for port in &ports {
|
||||
let confidence = if port.is_probably_a_printer() { "printer" } else { "maybe" };
|
||||
println!(" {} [{confidence}] {}", port.path, port.to_candidate().detail.unwrap_or_default());
|
||||
}
|
||||
// USB descriptors describe the bridge chip, so for anything but a Prusa
|
||||
// this is as far as passive detection goes - and the operator should know
|
||||
// that the next step has a cost.
|
||||
if ports.iter().any(|port| !port.is_probably_a_printer()) {
|
||||
println!(
|
||||
"\nUSB alone cannot tell a printer from any other serial device. `serial-probe <port>`\n\
|
||||
asks the board directly with M115, but opening the port RESETS it - don't while printing."
|
||||
);
|
||||
}
|
||||
Ok(ports.iter().map(serial::SerialDevice::to_candidate).collect())
|
||||
}
|
||||
|
||||
fn describe(candidate: &Candidate) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(serial) = &candidate.serial {
|
||||
parts.push(format!("serial {serial}"));
|
||||
}
|
||||
if let Some(name) = &candidate.name {
|
||||
parts.push(name.clone());
|
||||
}
|
||||
if let Some(detail) = &candidate.detail {
|
||||
parts.push(detail.clone());
|
||||
}
|
||||
if parts.is_empty() { "no details available".to_string() } else { parts.join(", ") }
|
||||
}
|
||||
|
||||
fn report(found: Vec<Candidate>) {
|
||||
if found.is_empty() {
|
||||
println!(
|
||||
"\nNo printers found.\n\
|
||||
If that was `listen`: multicast does not cross a router, so a gateway on a\n\
|
||||
different subnet from its printers will never hear them - use\n\
|
||||
`sweep <their subnet>/24` instead. If it was `sweep`: check the subnet, and\n\
|
||||
that the printers have LAN mode (Bambu) or their API enabled."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n{} printer(s) found. Paste into printers.toml and add each credential:\n", found.len());
|
||||
let mut used: HashSet<String> = HashSet::new();
|
||||
for candidate in &found {
|
||||
// Two of the same model suggest the same id, and a duplicate `id`
|
||||
// would silently give the fleet two printers it cannot tell apart.
|
||||
let base = candidate.suggested_id();
|
||||
let mut id = base.clone();
|
||||
let mut n = 2;
|
||||
while !used.insert(id.clone()) {
|
||||
id = format!("{base}-{n}");
|
||||
n += 1;
|
||||
}
|
||||
println!("{}", candidate.to_toml_entry(&id));
|
||||
}
|
||||
|
||||
if found.iter().any(|c| !c.vendor.driver_is_implemented()) {
|
||||
println!(
|
||||
"Note: some of these vendors' clients in printer/ are still stubs - those entries\n\
|
||||
are marked, and a gateway will connect to them and do nothing useful yet."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
//! Run with: cargo run --example fetch_bambu_cert -- <printer_id> <output_path>
|
||||
//!
|
||||
//! "Trust on first connect": connects to one printer from printers.toml
|
||||
//! with certificate verification disabled, captures whatever certificate
|
||||
//! it presents, and saves it as PEM. Meant for a printer like P1P that
|
||||
//! doesn't chain to the bundled CA - after running this, point that
|
||||
//! printer's `ca_cert_path` in printers.toml at the saved file and
|
||||
//! `test_bambu_certs` should report it as verified from then on.
|
||||
//!
|
||||
//! This trusts whoever answers on the network *right now* - only run it on
|
||||
//! a network you trust, ideally right after unboxing the printer.
|
||||
|
||||
use continuum_proxy::fleet;
|
||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let printer_id = args.next().ok_or_else(|| anyhow::anyhow!("usage: fetch_bambu_cert <printer_id> <output_path>"))?;
|
||||
let output_path = args.next().ok_or_else(|| anyhow::anyhow!("usage: fetch_bambu_cert <printer_id> <output_path>"))?;
|
||||
|
||||
let printers = fleet::load(std::path::Path::new("printers.toml"))?;
|
||||
|
||||
let pem = printers
|
||||
.iter()
|
||||
.find_map(|printer| match printer {
|
||||
PrinterHandle::BambuV1(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()),
|
||||
PrinterHandle::BambuV2(bambu) if bambu.base().id == printer_id => Some(bambu.fetch_certificate()),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("no Bambu printer with id '{printer_id}' in printers.toml"))??;
|
||||
|
||||
std::fs::write(&output_path, &pem)?;
|
||||
println!("saved {printer_id}'s certificate to {output_path}");
|
||||
println!("now set ca_cert_path = \"{output_path}\" for {printer_id} in printers.toml");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Run with: cargo run --example live_reports
|
||||
//!
|
||||
//! **Read-only. Safe to run against a printer that is mid-print.**
|
||||
//!
|
||||
//! Connects to every Bambu printer in printers.toml, asks each one what
|
||||
//! firmware it's running, then watches its `print.push_status` stream for a
|
||||
//! while. Everything it sends either reads state or asks the printer to
|
||||
//! re-send what it already knows:
|
||||
//!
|
||||
//! - `get_version` - read-only.
|
||||
//! - `pushall` - "re-send the full status object". A request for data,
|
||||
//! not a state change. v1 only; a no-op on v2.
|
||||
//! - `print_status` - reads this process's own in-memory cache, no traffic.
|
||||
//!
|
||||
//! It deliberately sends **no** `gcode_line`, **no** `set_fan_speed`, **no**
|
||||
//! `calibration` (that one starts a physical calibration sequence and would
|
||||
//! wreck a running print), no upload, and nothing that starts or stops a
|
||||
//! job. Keep it that way - the whole point of this example is that it can
|
||||
//! be pointed at a working printer without a second thought. Use
|
||||
//! `bambu_idle_checks` for anything that writes.
|
||||
//!
|
||||
//! The raw payloads are the real output. `PrintReport` models only a small
|
||||
//! fraction of what a real `push_status` carries (see its doc comment), so
|
||||
//! this turns on the `trace`-level raw-payload log in `bambu_mqtt.rs` and
|
||||
//! lets you capture the untouched JSON:
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --example live_reports 2> capture.jsonl
|
||||
//! ```
|
||||
//!
|
||||
//! Then grow `PrintReport` and `continuum-schemas`' `push_status.schema.json`
|
||||
//! from what's actually in there, rather than from guesses.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use continuum_proxy::fleet;
|
||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// How long to sit and watch each printer's stream before moving on.
|
||||
const WATCH_FOR: Duration = Duration::from_secs(25);
|
||||
/// How often to print a one-line summary of the cached report.
|
||||
const POLL_EVERY: Duration = Duration::from_secs(2);
|
||||
/// A printer that's switched off shouldn't hold the whole run up - the TLS
|
||||
/// handshake inside `connect` has its own 5s timeout, but the MQTT half
|
||||
/// doesn't, so this bounds the lot.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Default to trace for the raw-payload target specifically, so a plain
|
||||
// run captures payloads without needing RUST_LOG set by hand. An
|
||||
// explicit RUST_LOG still wins.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
EnvFilter::new("info,continuum_proxy::printer::bambu_mqtt=trace")
|
||||
}))
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let mut printers = fleet::load(std::path::Path::new("printers.toml"))?;
|
||||
|
||||
// Optional: a printer id to look at just that one. No argument means the
|
||||
// whole fleet. Read-only either way, so there's nothing to guard here.
|
||||
let only: Option<String> = std::env::args().nth(1);
|
||||
|
||||
for printer in &mut printers {
|
||||
if let Some(id) = &only
|
||||
&& &printer.base().id != id
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let PrinterHandle::Bambu(bambu) = printer else {
|
||||
println!("skipping {} - not a Bambu printer", printer.display_name());
|
||||
continue;
|
||||
};
|
||||
|
||||
println!("\n=== {} ===", bambu.display_name());
|
||||
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, bambu.connect()).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => {
|
||||
println!(" connect failed: {err}");
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
println!(" connect timed out after {CONNECT_TIMEOUT:?} - printer off or unreachable?");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match bambu.get_version().await {
|
||||
Ok(version) => {
|
||||
println!(" get_version OK: {} modules", version.module.len());
|
||||
for module in &version.module {
|
||||
println!(" {:<12} sw {:<14} hw {}", module.name, module.sw_ver, module.hw_ver);
|
||||
}
|
||||
}
|
||||
Err(err) => println!(" get_version failed: {err}"),
|
||||
}
|
||||
|
||||
// v1 needs this to get a full object at all; it's a no-op on v2 (see
|
||||
// `pushall`'s doc comment). Harmless either way.
|
||||
if let Err(err) = bambu.pushall().await {
|
||||
println!(" pushall failed: {err}");
|
||||
}
|
||||
|
||||
println!(" watching print.push_status for {WATCH_FOR:?}...");
|
||||
let deadline = tokio::time::Instant::now() + WATCH_FOR;
|
||||
// Keyed on the *contents*, not on `sequence_id`. v2 leaves
|
||||
// `sequence_id` pinned at one value while the report genuinely
|
||||
// changes underneath it (confirmed live - see `PrintStatusCache`),
|
||||
// so keying on it reports one update for a whole print.
|
||||
let mut last_seen: Option<String> = None;
|
||||
let mut updates = 0u32;
|
||||
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
tokio::time::sleep(POLL_EVERY).await;
|
||||
|
||||
match bambu.print_status().await {
|
||||
Some(report) => {
|
||||
let seq = report.envelope.sequence_id.clone();
|
||||
let fingerprint = format!(
|
||||
"{:?}|{:?}|{:?}|{:?}|{:?}|{:?}",
|
||||
report.gcode_state,
|
||||
report.mc_percent,
|
||||
report.layer_num,
|
||||
report.mc_remaining_time,
|
||||
report.nozzle_temper,
|
||||
report.bed_temper
|
||||
);
|
||||
let changed = last_seen.as_ref() != Some(&fingerprint);
|
||||
if changed {
|
||||
updates += 1;
|
||||
last_seen = Some(fingerprint);
|
||||
}
|
||||
println!(
|
||||
" seq={seq:<6} {:<8} {:>3}% layer {}/{} {} min left nozzle {:.1}/{:.1}C bed {:.1}/{:.1}C{}",
|
||||
report.gcode_state.as_deref().unwrap_or("?"),
|
||||
report.mc_percent.unwrap_or(0),
|
||||
report.layer_num.unwrap_or(0),
|
||||
report.total_layer_num.unwrap_or(0),
|
||||
report.mc_remaining_time.unwrap_or(0),
|
||||
report.nozzle_temper.unwrap_or(0.0),
|
||||
report.nozzle_target_temper.unwrap_or(0.0),
|
||||
report.bed_temper.unwrap_or(0.0),
|
||||
report.bed_target_temper.unwrap_or(0.0),
|
||||
if changed { "" } else { " (unchanged)" }
|
||||
);
|
||||
if let Some(job) = report.subtask_name.as_deref().filter(|s| !s.is_empty()) {
|
||||
println!(" job: {job}");
|
||||
}
|
||||
if let Some(hms) = report.hms.as_ref().filter(|h| !h.is_empty()) {
|
||||
println!(" hms: {} active entr(y/ies)", hms.len());
|
||||
}
|
||||
println!(
|
||||
" fans: cooling={} heatbreak={} big1={} big2={} wifi={}",
|
||||
report.cooling_fan_speed.as_deref().unwrap_or("?"),
|
||||
report.heatbreak_fan_speed.as_deref().unwrap_or("?"),
|
||||
report.big_fan1_speed.as_deref().unwrap_or("?"),
|
||||
report.big_fan2_speed.as_deref().unwrap_or("?"),
|
||||
report.wifi_signal.as_deref().unwrap_or("?")
|
||||
);
|
||||
}
|
||||
// Reaching here now means genuinely nothing arrived. It used
|
||||
// to also mean "something arrived and failed to parse",
|
||||
// which is why a mid-print H2C once looked completely
|
||||
// silent - see `handle_incoming_report`.
|
||||
None => println!(" no push_status received yet"),
|
||||
}
|
||||
}
|
||||
|
||||
println!(" {updates} distinct push_status update(s) in {WATCH_FOR:?}");
|
||||
}
|
||||
|
||||
println!("\ndone - raw payloads went to stderr (redirect it to capture them)");
|
||||
Ok(())
|
||||
}
|
||||
@@ -4,36 +4,46 @@
|
||||
//! - `connect()` is called the same way regardless of vendor (polymorphism),
|
||||
//! but each variant's `impl GenericPrinter` runs completely different
|
||||
//! code (override).
|
||||
//! - `dispatch_gcode()` only exists on the two Bambu structs (extension) -
|
||||
//! you have to `match` the enum back down to the concrete type to reach
|
||||
//! it, which is the trade-off for not having implicit downcasting.
|
||||
//! - `dispatch_gcode()` only exists on the Bambu struct (extension) - you
|
||||
//! have to `match` the enum back down to the concrete type to reach it,
|
||||
//! which is the trade-off for not having implicit downcasting.
|
||||
|
||||
use continuum_proxy::printer::{
|
||||
BambuTls, BambuV1Printer, BambuV2Printer, GenericPrinter, KlipperPrinter, PrinterHandle, PrusaLinkPrinter,
|
||||
PrusaSerialPrinter,
|
||||
BambuPrinter, BambuProtocolPreference, BambuTls, GenericPrinter, KlipperPrinter, PrinterHandle,
|
||||
OctoPrintFlavour, OctoPrintPrinter, PrusaSerialPrinter,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let mut fleet: Vec<PrinterHandle> = vec![
|
||||
PrinterHandle::BambuV1(BambuV1Printer::new(
|
||||
// Forced: a known-old printer.
|
||||
PrinterHandle::Bambu(BambuPrinter::new(
|
||||
"p1",
|
||||
"P1S",
|
||||
"192.168.1.49",
|
||||
"87654321",
|
||||
"01P00A000000000",
|
||||
BambuTls::BundledCa,
|
||||
BambuProtocolPreference::ForceV1,
|
||||
)),
|
||||
PrinterHandle::BambuV2(BambuV2Printer::new(
|
||||
// Auto: the recommended default - see bambu.rs's doc comment.
|
||||
PrinterHandle::Bambu(BambuPrinter::new(
|
||||
"p2",
|
||||
"X1 Carbon",
|
||||
"192.168.1.50",
|
||||
"12345678",
|
||||
"00M00A000000000",
|
||||
BambuTls::BundledCa,
|
||||
BambuProtocolPreference::Auto,
|
||||
)),
|
||||
PrinterHandle::PrusaLink(PrusaLinkPrinter::new("p3", "MK4", "192.168.1.51", "prusa-api-key")),
|
||||
PrinterHandle::PrusaSerial(PrusaSerialPrinter::new("p4", "MK3S+", 0)),
|
||||
PrinterHandle::OctoPrint(OctoPrintPrinter::new(
|
||||
"p3",
|
||||
"MK4",
|
||||
"192.168.1.51",
|
||||
"prusa-api-key",
|
||||
OctoPrintFlavour::PrusaLink,
|
||||
)),
|
||||
PrinterHandle::PrusaSerial(PrusaSerialPrinter::new("p4", "MK3S+", "/dev/ttyACM0", 115_200)),
|
||||
PrinterHandle::Klipper(KlipperPrinter::new("p5", "Voron 2.4", "192.168.1.52")),
|
||||
// No host configured - this one will hit the Err path.
|
||||
PrinterHandle::Klipper(KlipperPrinter::new("p6", "Broken Voron", "")),
|
||||
@@ -50,16 +60,15 @@ async fn main() {
|
||||
}
|
||||
|
||||
// Vendor-only extension: reachable only after matching the concrete
|
||||
// variant back out of the enum. BambuV1Printer and BambuV2Printer are
|
||||
// separate types now (each *has* a BambuGenericPrinter rather than
|
||||
// being the same struct), so - unlike before the split - this needs
|
||||
// two arms instead of one `A(x) | B(x)` pattern: an or-pattern requires
|
||||
// every alternative to bind the same type, and these no longer do.
|
||||
// variant back out of the enum - one arm now that Bambu is a single
|
||||
// type regardless of which protocol version `connect()` settled on.
|
||||
// `print_status()` is real Bambu-only behavior with no equivalent on
|
||||
// `GenericPrinter` (no other vendor here has an in-memory report
|
||||
// cache) - none of these connected above (fake IPs), so it's always
|
||||
// `None`, but the point is the downcast, not a live reading.
|
||||
for printer in &fleet {
|
||||
match printer {
|
||||
PrinterHandle::BambuV1(bambu) => bambu.dispatch_gcode("part.gcode.3mf"),
|
||||
PrinterHandle::BambuV2(bambu) => bambu.dispatch_gcode("part.gcode.3mf"),
|
||||
_ => {}
|
||||
if let PrinterHandle::Bambu(bambu) = printer {
|
||||
println!(" {}: cached print_status = {:?}", bambu.sn, bambu.print_status().await)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,19 +7,14 @@
|
||||
//! this trust question.
|
||||
|
||||
use continuum_proxy::fleet;
|
||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||
use continuum_proxy::printer::PrinterHandle;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let printers = fleet::load(std::path::Path::new("printers.toml"))?;
|
||||
|
||||
// BambuV1Printer and BambuV2Printer are separate types (each *has* a
|
||||
// BambuGenericPrinter rather than being the same struct), so this
|
||||
// needs one arm per variant rather than a single `A(x) | B(x)` pattern.
|
||||
for printer in &printers {
|
||||
match printer {
|
||||
PrinterHandle::BambuV1(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()),
|
||||
PrinterHandle::BambuV2(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()),
|
||||
_ => {}
|
||||
if let PrinterHandle::Bambu(bambu) = printer {
|
||||
report(bambu.base().name.as_str(), bambu.test_tls_handshake())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+42
-7
@@ -1,24 +1,52 @@
|
||||
# Copy this file to printers.toml (gitignored) and fill in your real
|
||||
# printers. See src/fleet.rs for the loader, examples/test_bambu_certs.rs
|
||||
# for a way to test a Bambu printer's certificate against this config.
|
||||
#
|
||||
# Rather than reading these fields off each printer's screen:
|
||||
#
|
||||
# cargo run --example discover_printers -- all 192.168.0.0/24
|
||||
#
|
||||
# finds the printers on your network and on your USB ports and prints blocks
|
||||
# like the ones below, with the addresses and serials already filled in. It
|
||||
# covers Bambu, Klipper/Moonraker, PrusaLink and Marlin-over-serial. Also
|
||||
# `listen` (Bambu on this subnet only), `sweep <subnet>` (works through a
|
||||
# router), `identify <host>`, and `serial`.
|
||||
#
|
||||
# It cannot find a credential - a Bambu access code is on the printer's
|
||||
# screen and a PrusaLink API key is in its web UI, which is the point of both
|
||||
# - so every block it prints still needs that one field before this file will
|
||||
# load. Note that only `bambu` has a working client today; the others are
|
||||
# found and reported, but their drivers in src/printer/ are still stubs.
|
||||
#
|
||||
# One thing discovery cannot answer for you: `octoprint` vs `prusa_link`.
|
||||
# PrusaLink implements OctoPrint's API, so from the network the two are
|
||||
# identical - discovery suggests `octoprint` (the protocol it actually saw)
|
||||
# and leaves a comment offering the other. Change it if you know it is a
|
||||
# Prusa serving its own API.
|
||||
|
||||
[[printer]]
|
||||
id = "p1p"
|
||||
name = "P1P"
|
||||
vendor = "bambu_v1" # bambu_v1 | bambu_v2 | prusa_link | prusa_serial | klipper
|
||||
# Forced instead of "bambu" here since this one's a known-old printer -
|
||||
# see fleet.rs's PrinterEntry doc comment for the full vendor list.
|
||||
vendor = "bambu_v1"
|
||||
host = "192.168.1.50"
|
||||
access_code = "REPLACE_WITH_ACCESS_CODE"
|
||||
sn = "REPLACE_WITH_SERIAL_NUMBER" # printer screen > Settings > Device (used for MQTT topics later)
|
||||
# P1P doesn't chain to the bundled CA - point this at a cert you've
|
||||
# downloaded from the printer itself. Leave unset to use the bundled CA.
|
||||
# `cargo run --example fetch_bambu_cert -- p1p ./certs/p1p.pem` can fetch it
|
||||
# for you (trust-on-first-connect - see that example's doc comment).
|
||||
ca_cert_path = "./certs/p1p.pem"
|
||||
# No ca_cert_path needed even though P1P doesn't chain to the bundled CA -
|
||||
# `connect()` auto-pins a certificate via trust-on-first-connect the first
|
||||
# time it can't verify against the bundled one, and saves it under
|
||||
# certs/pinned/<sn>.pem for every run after that (see BambuPrinter::
|
||||
# ensure_trusted's doc comment). Only set ca_cert_path explicitly if you
|
||||
# want to point at a certificate you've captured/vetted some other way.
|
||||
|
||||
[[printer]]
|
||||
id = "h2c"
|
||||
name = "H2C"
|
||||
vendor = "bambu_v2"
|
||||
# "bambu" auto-detects MQTT v5-vs-v3.1.1 (BambuMQTT v2-vs-v1) on connect -
|
||||
# the recommended default, since a firmware update can change which one a
|
||||
# given printer speaks without you knowing ahead of time.
|
||||
vendor = "bambu"
|
||||
host = "192.168.1.51"
|
||||
access_code = "REPLACE_WITH_ACCESS_CODE"
|
||||
sn = "REPLACE_WITH_SERIAL_NUMBER"
|
||||
@@ -37,3 +65,10 @@ sn = "REPLACE_WITH_SERIAL_NUMBER"
|
||||
# vendor = "prusa_link"
|
||||
# host = "192.168.1.53"
|
||||
# api_key = "REPLACE_WITH_API_KEY"
|
||||
|
||||
# [[printer]]
|
||||
# id = "mk3s"
|
||||
# name = "MK3S+"
|
||||
# vendor = "prusa_serial"
|
||||
# port = "/dev/ttyACM0"
|
||||
# baud_rate = 115200 # optional, defaults to 115200
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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)
|
||||
@@ -0,0 +1,274 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
+586
@@ -0,0 +1,586 @@
|
||||
//! Turns the printers' own camera URLs into a go2rtc configuration.
|
||||
//!
|
||||
//! `go2rtc.rs` has supervised a go2rtc process since before this module
|
||||
//! existed, and go2rtc has never had anything to serve: nothing wrote its
|
||||
//! config file. `CONTINUUM_GO2RTC_CONFIG` pointed at `./go2rtc.yaml`, which
|
||||
//! did not exist in the repository or anywhere else. This writes it.
|
||||
//!
|
||||
//! ## Where the URL comes from
|
||||
//!
|
||||
//! Not from `printers.toml`. A printer reports its own camera URL in
|
||||
//! `print.push_status`'s `ipcam` block, so asking an operator to copy it into
|
||||
//! a config file would be asking them to keep a second copy of something the
|
||||
//! machine already tells us - and to update it by hand when the printer's
|
||||
//! address changes.
|
||||
//!
|
||||
//! ## What does not work, which is most printers
|
||||
//!
|
||||
//! Only the X1/H2 class publishes an `rtsp_url`. Confirmed against two real
|
||||
//! machines: an H2C reports `rtsps://<ip>:322/streaming/live/1`, and a P1P
|
||||
//! reports an `ipcam` block with no URL in it at all. A P1/A1 camera is
|
||||
//! reachable only over Bambu's proprietary port-6000 JPEG stream, which this
|
||||
//! crate does not speak, so those printers get no stream here and say so
|
||||
//! rather than advertising one that will not play.
|
||||
//!
|
||||
//! 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
|
||||
//! *refuses* 322 and listens on 6000. So a P1P has no RTSP endpoint to
|
||||
//! restream, and no amount of guessing a URL would produce one.
|
||||
//!
|
||||
//! That is also why this module reads the *reported* URL and never builds one
|
||||
//! from the printer's IP: the fact that a printer has a camera and the fact
|
||||
//! that the camera has an RTSP endpoint are different facts, and only the
|
||||
//! second one is useful.
|
||||
//!
|
||||
//! ## What is confirmed to work
|
||||
//!
|
||||
//! Measured end to end against a live H2C with go2rtc 1.9.14 (2026-10-18):
|
||||
//! this module's generated config is enough for go2rtc to connect to
|
||||
//! `rtsps://bblp:<access_code>@<ip>:322/streaming/live/1` and pull video -
|
||||
//! 984,604 bytes over `rtsp+tcp`, no certificate complaint and no
|
||||
//! authentication failure. The printer runs LIVE555 and sends **H.264 High
|
||||
//! profile, level 4.1**, which a browser plays natively over WebRTC, so no
|
||||
//! transcoding step (and no ffmpeg on the gateway) is needed. The
|
||||
//! `rtspx://` prefix that go2rtc documents for skipping TLS verification is
|
||||
//! not required here.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Notify;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::printer::{GenericPrinter, PrinterHandle};
|
||||
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);
|
||||
|
||||
/// 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
|
||||
/// authenticate with.
|
||||
const CAMERA_USER: &str = "bblp";
|
||||
|
||||
/// One printer's playable stream.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CameraSource {
|
||||
/// Our own printer id, which doubles as the go2rtc stream name. Using it
|
||||
/// for both means the cloud can name a stream without the gateway having
|
||||
/// to report a mapping: `camera_stream` in telemetry is this id.
|
||||
pub stream_name: String,
|
||||
/// The full source URL, credentials included.
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Builds the source URL for one printer, or `None` if it has no stream.
|
||||
///
|
||||
/// The credentials go in the URL because that is the only place go2rtc takes
|
||||
/// them for an RTSP source. The consequence is that the printer's access code
|
||||
/// ends up in a file on disk - which is not a new exposure (`printers.toml`
|
||||
/// already holds it, and that file is gitignored for this reason) but it does
|
||||
/// mean the generated config deserves the same treatment, and must never be
|
||||
/// logged.
|
||||
pub fn source_for(printer: &PrinterHandle, report_url: Option<&str>) -> Option<CameraSource> {
|
||||
let PrinterHandle::Bambu(bambu) = printer else {
|
||||
// Nothing else has a camera path here yet. A Prusa's is a separate
|
||||
// product, and a Klipper camera is whatever the user configured.
|
||||
return None;
|
||||
};
|
||||
|
||||
let url = report_url?;
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// `rtsps://1.2.3.4:322/streaming/live/1` -> `rtsps://bblp:code@1.2.3.4:322/...`
|
||||
let with_credentials = match url.split_once("://") {
|
||||
Some((scheme, rest)) => {
|
||||
// A URL that already carries credentials is left alone rather
|
||||
// than having a second set spliced in.
|
||||
if rest.contains('@') {
|
||||
url.to_string()
|
||||
} else {
|
||||
format!("{scheme}://{CAMERA_USER}:{}@{rest}", bambu.access_code)
|
||||
}
|
||||
}
|
||||
// Not a URL shape this understands. Refused rather than guessed at:
|
||||
// a malformed source makes go2rtc fail to load the whole file, which
|
||||
// would take every *working* stream down with it.
|
||||
None => {
|
||||
warn!(
|
||||
printer = %bambu.display_name(),
|
||||
"the printer reported a camera URL with no scheme; ignoring it"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(CameraSource {
|
||||
stream_name: printer.base().id.clone(),
|
||||
url: with_credentials,
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders a go2rtc configuration.
|
||||
///
|
||||
/// Hand-written YAML rather than a serializer, for the same reason the
|
||||
/// `.3mf` reader is hand-rolled: it is a fixed, tiny shape, and a dependency
|
||||
/// for ten lines of output is a dependency to license and audit. The values
|
||||
/// are quoted and the keys are our own printer ids, which `fleet.rs`
|
||||
/// constrains.
|
||||
///
|
||||
/// `listen` is explicit rather than left to go2rtc's defaults, because the
|
||||
/// cloud is told this address and a default that changes between go2rtc
|
||||
/// versions would change where the stream lives without anything saying so.
|
||||
/// Basic-auth credentials for go2rtc's API listener.
|
||||
///
|
||||
/// The API is what serves WebRTC, so without this anyone who can reach
|
||||
/// `http://<gateway>:1984` can watch any printer with a camera - and the
|
||||
/// listener defaults to `:1984`, every interface. Measured against go2rtc
|
||||
/// 1.9.14, `username`/`password` under `api:` really does enforce it: from a
|
||||
/// non-loopback address, no credentials and wrong credentials both get 401
|
||||
/// and the correct ones get 200, on `/api/webrtc`, `/api/ws`,
|
||||
/// `/api/frame.jpeg` and the web UI alike.
|
||||
///
|
||||
/// **Loopback is exempt**, which is a deliberate convenience of go2rtc's and
|
||||
/// worth knowing twice over: it is why a probe from the gateway itself still
|
||||
/// works with no credentials, and it is why a probe from the gateway itself
|
||||
/// cannot be used to check that auth is on. Test from another host.
|
||||
pub struct ApiAuth {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl ApiAuth {
|
||||
/// The operator's password if they set one, otherwise a fresh random one.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// A generated password is never logged. It is written into the config
|
||||
/// file, which already holds every printer's access code and is already
|
||||
/// documented as a secret, so that is where an operator reads it from -
|
||||
/// one secret store rather than two.
|
||||
pub fn new() -> anyhow::Result<Self> {
|
||||
let password = match std::env::var("CONTINUUM_GO2RTC_PASSWORD") {
|
||||
Ok(set) if !set.is_empty() => set,
|
||||
// `||` semantics, not `??`: an empty value in a deployment
|
||||
// template is "unset", not "the empty password".
|
||||
_ => random_secret()?,
|
||||
};
|
||||
Ok(Self { username: "continuum".to_string(), password })
|
||||
}
|
||||
}
|
||||
|
||||
/// 32 bytes of kernel randomness, base64url so it is safe unquoted in YAML.
|
||||
///
|
||||
/// Read from `/dev/urandom` with the standard library rather than pulling in
|
||||
/// a crate for it: the gateway is Linux, this is the one place that needs
|
||||
/// randomness, and a new dependency would need a licence review (see
|
||||
/// `continuum-common/docs/LICENSES.md`).
|
||||
fn random_secret() -> anyhow::Result<String> {
|
||||
use base64::Engine;
|
||||
use std::io::Read;
|
||||
|
||||
let mut bytes = [0u8; 32];
|
||||
std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
|
||||
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
/// Escapes a value for a YAML double-quoted scalar.
|
||||
///
|
||||
/// Only needed for an operator-supplied password - a generated one is
|
||||
/// base64url and has nothing to escape - but a password containing a quote
|
||||
/// would otherwise produce a config go2rtc cannot parse, and the symptom
|
||||
/// would be a camera that silently never appears.
|
||||
fn yaml_quoted(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
pub fn render_config(api_listen: &str, sources: &[CameraSource], auth: &ApiAuth) -> String {
|
||||
let mut out = String::from(
|
||||
"# Generated by continuum-proxy. Do not edit: it is rewritten from\n\
|
||||
# what the printers themselves report, so an edit here is lost on\n\
|
||||
# the next rebuild. Contains printer access codes - treat it as a\n\
|
||||
# secret and keep it out of version control.\n",
|
||||
);
|
||||
|
||||
// Sorted, so an unchanged fleet renders byte-identical output and the
|
||||
// change detection below does not restart go2rtc on every rebuild.
|
||||
let ordered: BTreeMap<&str, &str> = sources
|
||||
.iter()
|
||||
.map(|s| (s.stream_name.as_str(), s.url.as_str()))
|
||||
.collect();
|
||||
|
||||
out.push_str("streams:\n");
|
||||
if ordered.is_empty() {
|
||||
// An empty mapping, written explicitly. `streams:` with nothing
|
||||
// under it is a null in YAML, and go2rtc is entitled to treat that
|
||||
// differently from an empty map.
|
||||
out.push_str(" {}\n");
|
||||
}
|
||||
for (name, url) in &ordered {
|
||||
out.push_str(&format!(" {name}: \"{url}\"\n"));
|
||||
}
|
||||
|
||||
out.push_str(&format!("api:\n listen: \"{api_listen}\"\n"));
|
||||
// Not optional, and there is no flag to turn it off. The alternative is
|
||||
// an unauthenticated video feed of someone's workshop reachable by
|
||||
// anything on their network, which is not a thing to leave behind a
|
||||
// setting. Nothing in this crate calls go2rtc's API, so nothing here
|
||||
// breaks; a browser cannot use these credentials directly either way -
|
||||
// see the CORS note in the module docs.
|
||||
out.push_str(&format!(
|
||||
" username: \"{}\"\n password: \"{}\"\n",
|
||||
yaml_quoted(&auth.username),
|
||||
yaml_quoted(&auth.password),
|
||||
));
|
||||
|
||||
// Closes go2rtc's own RTSP server, and this has to be explicit.
|
||||
//
|
||||
// Leaving the key out does NOT leave the server off - go2rtc defaults to
|
||||
// :8554. Measured against go2rtc 1.9.14 with a config that omitted it:
|
||||
// `rtsp://<gateway>:8554/<printer>` answered a DESCRIBE carrying a real
|
||||
// video SDP, with no credentials sent at all. Every printer's camera was
|
||||
// republished to anything that could reach the port.
|
||||
//
|
||||
// Nothing needs it: a browser is served over WebRTC through the API
|
||||
// listener above. An empty `listen` is how go2rtc is told not to bind.
|
||||
out.push_str("rtsp:\n listen: \"\"\n");
|
||||
|
||||
// `webrtc:` is deliberately left alone, and with TURN in the picture that
|
||||
// is now a decision worth stating rather than an omission.
|
||||
//
|
||||
// go2rtc keeps its default media port and its default candidate
|
||||
// gathering, which produces a host candidate and - measured against
|
||||
// 1.9.14 - a server-reflexive one it discovers by STUN on its own. That
|
||||
// is everything this end needs. No `ice_servers` here, and in particular
|
||||
// **no TURN credentials on the gateway**: the viewer's browser is pinned
|
||||
// to relay-only, so the browser allocates the relay and go2rtc simply
|
||||
// sends to it from its own candidate. Only one side of a WebRTC session
|
||||
// has to hold a relay for the media to traverse one.
|
||||
//
|
||||
// That asymmetry is what makes this tractable at all, because go2rtc
|
||||
// reads its config exactly once at startup: TURN credentials are
|
||||
// short-lived, so rotating them here would mean restarting go2rtc and
|
||||
// dropping every viewer on a timer. Keeping them browser-side moves the
|
||||
// rotation to a request that is already per-viewer.
|
||||
//
|
||||
// This is not a second open stream server either way: a WebRTC session
|
||||
// exists only after an SDP offer through the API, and that API now
|
||||
// requires a password.
|
||||
out
|
||||
}
|
||||
|
||||
/// Collects every printer that currently has a playable stream.
|
||||
pub async fn sources(fleet: &SharedFleet) -> Vec<CameraSource> {
|
||||
let fleet = fleet.lock().await;
|
||||
let mut found = Vec::new();
|
||||
|
||||
for printer in fleet.iter() {
|
||||
let report_url = printer
|
||||
.print_status()
|
||||
.await
|
||||
.and_then(|report| report.ipcam)
|
||||
.and_then(|ipcam| ipcam.rtsp_url);
|
||||
|
||||
if let Some(source) = source_for(printer, report_url.as_deref()) {
|
||||
found.push(source);
|
||||
}
|
||||
}
|
||||
|
||||
found
|
||||
}
|
||||
|
||||
/// Rewrites the config whenever what the printers report changes, and asks
|
||||
/// the watchdog to restart go2rtc when it does.
|
||||
///
|
||||
/// Restart rather than reload: go2rtc can reload some of its configuration
|
||||
/// over its own API, but a restart is the behaviour we can be sure of, and
|
||||
/// the watchdog was already built to restart it. A stream interruption at the
|
||||
/// moment a printer's camera URL changes is acceptable; a stream that is
|
||||
/// silently serving the old address is not.
|
||||
pub async fn run(
|
||||
fleet: SharedFleet,
|
||||
config_path: String,
|
||||
api_listen: String,
|
||||
auth: ApiAuth,
|
||||
restart: std::sync::Arc<Notify>,
|
||||
) {
|
||||
let mut ticker = tokio::time::interval(REBUILD_INTERVAL);
|
||||
let mut last_written: Option<String> = None;
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
|
||||
// Collected once and kept, so the count logged below describes the
|
||||
// config actually written rather than a second, later reading of the
|
||||
// fleet that could disagree with it.
|
||||
let found = sources(&fleet).await;
|
||||
let desired = render_config(&api_listen, &found, &auth);
|
||||
if last_written.as_deref() == Some(desired.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compare against the file too, not just our own memory of it: on
|
||||
// startup `last_written` is `None`, and rewriting an identical file
|
||||
// would restart go2rtc - and therefore drop every live viewer - for
|
||||
// no reason, every time the daemon restarts.
|
||||
if last_written.is_none()
|
||||
&& let Ok(existing) = tokio::fs::read_to_string(&config_path).await
|
||||
&& existing == desired
|
||||
{
|
||||
last_written = Some(desired);
|
||||
continue;
|
||||
}
|
||||
|
||||
match write_atomically(&config_path, &desired).await {
|
||||
Ok(()) => {
|
||||
// Counted from the sources, not from the rendered text. It
|
||||
// used to be `matches("\n ").count() - 1`, which reported
|
||||
// one stream for a config with none - the `{}` of an empty
|
||||
// mapping indents exactly like a stream does. A log line
|
||||
// that says a camera is being served when none is makes the
|
||||
// one thing this task is for impossible to check.
|
||||
let count = found.len();
|
||||
// The content is never logged: it holds access codes.
|
||||
info!(path = %config_path, streams = count, "wrote go2rtc config, restarting it");
|
||||
last_written = Some(desired);
|
||||
restart.notify_one();
|
||||
}
|
||||
Err(err) => warn!(path = %config_path, ?err, "could not write the go2rtc config"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes through a temporary file and renames.
|
||||
///
|
||||
/// go2rtc may be reading this file at any moment - the watchdog restarts it
|
||||
/// on a five-second loop - and a partially written config is a config that
|
||||
/// fails to parse, which takes every working stream down. A rename is atomic
|
||||
/// 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::rename(&temp, path).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::printer::{BambuPrinter, BambuProtocolPreference, BambuTls};
|
||||
|
||||
fn printer(id: &str) -> PrinterHandle {
|
||||
PrinterHandle::Bambu(BambuPrinter::new(
|
||||
id,
|
||||
"Test Printer",
|
||||
"192.168.0.21",
|
||||
"12345678",
|
||||
"SN123",
|
||||
BambuTls::Insecure,
|
||||
BambuProtocolPreference::Auto,
|
||||
))
|
||||
}
|
||||
|
||||
/// A fixed credential, so the byte-identical assertions below stay
|
||||
/// meaningful. A real one is 32 random bytes and would make every
|
||||
/// rendered config differ from every other.
|
||||
fn test_auth() -> ApiAuth {
|
||||
ApiAuth { username: "continuum".into(), password: "test-password".into() }
|
||||
}
|
||||
|
||||
/// The URL shape confirmed from a real H2C's `ipcam` block.
|
||||
#[test]
|
||||
fn a_reported_rtsps_url_gets_the_printer_credentials_spliced_in() {
|
||||
let source = source_for(&printer("h2c"), Some("rtsps://192.168.0.21:322/streaming/live/1"))
|
||||
.expect("an rtsps URL is playable");
|
||||
assert_eq!(source.stream_name, "h2c");
|
||||
assert_eq!(source.url, "rtsps://bblp:12345678@192.168.0.21:322/streaming/live/1");
|
||||
}
|
||||
|
||||
/// What a P1P reports: an `ipcam` block that says a camera exists without
|
||||
/// giving anything to connect to. Advertising a stream here would make a
|
||||
/// UI offer a player that can never load.
|
||||
#[test]
|
||||
fn a_printer_that_reports_no_url_has_no_stream() {
|
||||
assert_eq!(source_for(&printer("p1p"), None), None);
|
||||
assert_eq!(source_for(&printer("p1p"), Some("")), None);
|
||||
}
|
||||
|
||||
/// Splicing a second set of credentials into a URL that already has some
|
||||
/// would produce `bblp:code@user:pass@host`, which is not a host.
|
||||
#[test]
|
||||
fn a_url_that_already_carries_credentials_is_left_alone() {
|
||||
let source = source_for(&printer("h2c"), Some("rtsps://someone:else@10.0.0.5:322/live"))
|
||||
.expect("still playable");
|
||||
assert_eq!(source.url, "rtsps://someone:else@10.0.0.5:322/live");
|
||||
}
|
||||
|
||||
/// Refused rather than guessed at, because one bad entry makes go2rtc
|
||||
/// fail to load the whole file and takes every working stream with it.
|
||||
#[test]
|
||||
fn a_url_with_no_scheme_is_refused_rather_than_repaired() {
|
||||
assert_eq!(source_for(&printer("h2c"), Some("192.168.0.21:322/streaming/live/1")), None);
|
||||
}
|
||||
|
||||
/// `streams:` with nothing under it is YAML for null, not for an empty
|
||||
/// map, and a gateway with no streamable camera is the normal state of a
|
||||
/// P1-only farm - so this is the output that must be valid, not a corner.
|
||||
#[test]
|
||||
fn an_empty_fleet_renders_an_explicit_empty_map() {
|
||||
let config = render_config(":1984", &[], &test_auth());
|
||||
assert!(config.contains("streams:\n {}\n"), "got:\n{config}");
|
||||
assert!(config.contains("listen: \":1984\""), "got:\n{config}");
|
||||
}
|
||||
|
||||
/// The change detection in `run` compares rendered output, so an
|
||||
/// unchanged fleet in a different order must render identically or
|
||||
/// go2rtc gets restarted - dropping every live viewer - every 30s.
|
||||
#[test]
|
||||
fn the_same_fleet_in_a_different_order_renders_identically() {
|
||||
let a = CameraSource { stream_name: "aaa".into(), url: "rtsps://one".into() };
|
||||
let b = CameraSource { stream_name: "zzz".into(), url: "rtsps://two".into() };
|
||||
assert_eq!(
|
||||
render_config(":1984", &[a.clone(), b.clone()], &test_auth()),
|
||||
render_config(":1984", &[b, a], &test_auth()),
|
||||
);
|
||||
}
|
||||
|
||||
/// The config must *actively* close go2rtc's RTSP server.
|
||||
///
|
||||
/// This test used to assert the opposite thing - that the rendered
|
||||
/// config contained no `rtsp:` key - and concluded from that that no
|
||||
/// RTSP server was opened. It passed, and it was worthless: go2rtc
|
||||
/// defaults to :8554 when the key is absent. Measured against go2rtc
|
||||
/// 1.9.14, a config with no `rtsp:` block answered an unauthenticated
|
||||
/// DESCRIBE on `rtsp://<gateway>:8554/h2c` with a real video SDP.
|
||||
///
|
||||
/// A test that asserts a key is missing cannot tell "off" from
|
||||
/// "defaulted on". This one asserts the value that turns it off.
|
||||
#[test]
|
||||
fn the_generated_config_closes_the_rtsp_server() {
|
||||
let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() };
|
||||
let config = render_config(":1984", &[source], &test_auth());
|
||||
assert!(config.contains("rtsp:\n listen: \"\"\n"), "got:\n{config}");
|
||||
}
|
||||
|
||||
/// An empty fleet must still close it - a gateway with no cameras is the
|
||||
/// common case, and it is the config most likely to be left running.
|
||||
#[test]
|
||||
fn an_empty_config_closes_the_rtsp_server_too() {
|
||||
assert!(render_config(":1984", &[], &test_auth()).contains("rtsp:\n listen: \"\"\n"));
|
||||
}
|
||||
|
||||
/// The API listener is what serves WebRTC, and it defaults to every
|
||||
/// interface, so a config without this is a video feed of someone's
|
||||
/// workshop open to their whole network.
|
||||
///
|
||||
/// Asserts the values, for the same reason as the RTSP test above: a
|
||||
/// `username:` key with the wrong content would pass an existence check
|
||||
/// and authenticate nobody.
|
||||
#[test]
|
||||
fn the_generated_config_demands_a_password_on_the_api() {
|
||||
let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() };
|
||||
let config = render_config(":1984", &[source], &test_auth());
|
||||
assert!(
|
||||
config.contains(" username: \"continuum\"\n password: \"test-password\"\n"),
|
||||
"got:\n{config}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Even with no cameras. A gateway that acquires one later keeps the
|
||||
/// config it was already running, and an unauthenticated listener with
|
||||
/// no streams is one printer away from an unauthenticated listener with
|
||||
/// one.
|
||||
#[test]
|
||||
fn an_empty_config_demands_a_password_too() {
|
||||
assert!(render_config(":1984", &[], &test_auth()).contains(" password: \"test-password\"\n"));
|
||||
}
|
||||
|
||||
/// Byte-identical output for the same inputs, which is what stops the
|
||||
/// rebuild loop restarting go2rtc.
|
||||
///
|
||||
/// The credential is the live hazard here: it is generated once per run
|
||||
/// precisely so this holds. Were it re-derived per render, every 30s tick
|
||||
/// would see a changed config, restart go2rtc and drop every viewer -
|
||||
/// forever, and the only symptom would be video that dies every half
|
||||
/// minute.
|
||||
#[test]
|
||||
fn the_same_fleet_and_credential_render_identically() {
|
||||
let source = CameraSource { stream_name: "h2c".into(), url: "rtsps://x".into() };
|
||||
assert_eq!(
|
||||
render_config(":1984", std::slice::from_ref(&source), &test_auth()),
|
||||
render_config(":1984", &[source], &test_auth()),
|
||||
);
|
||||
}
|
||||
|
||||
/// An operator-supplied password is not guaranteed to be YAML-safe, and
|
||||
/// a config go2rtc cannot parse means a camera that silently never
|
||||
/// appears - the hardest kind of failure to trace back to a password.
|
||||
#[test]
|
||||
fn an_awkward_operator_password_is_escaped() {
|
||||
let auth = ApiAuth {
|
||||
username: "continuum".into(),
|
||||
password: r#"he said "hi"\ok"#.into(),
|
||||
};
|
||||
let config = render_config(":1984", &[], &auth);
|
||||
assert!(
|
||||
config.contains(r#" password: "he said \"hi\"\\ok""#),
|
||||
"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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Noticing that a print has finished, and saying so at the machine.
|
||||
//!
|
||||
//! Its own task, polling the fleet, rather than a branch inside the uplink's
|
||||
//! telemetry loop. The uplink's loop only ticks while the cloud connection is
|
||||
//! up, and a light that only comes on when the internet is working is not
|
||||
//! much of a notification: the person this is for is standing next to the
|
||||
//! printer, and the printer is on the same LAN as this process. Nothing about
|
||||
//! telling them should route through a datacentre.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::printer::{GenericPrinter, LightState, PrinterHandle, CHAMBER_LIGHT};
|
||||
use crate::uplink::SharedFleet;
|
||||
|
||||
/// How long the lights blink for when a print finishes.
|
||||
const BLINK_FOR: Duration = Duration::from_secs(10);
|
||||
|
||||
/// On for this long, then off for this long. 500ms reads as a deliberate
|
||||
/// blink from across a room; much faster looks like a fault.
|
||||
const BLINK_HALF_CYCLE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How often to look for a print that has just finished.
|
||||
///
|
||||
/// A notification a few seconds late is fine. What matters more is that this
|
||||
/// is frequent enough not to miss a state: `FINISH` persists until the next
|
||||
/// print starts, so there is no race to lose - but the first poll that sees
|
||||
/// it is the one that fires.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Bambu's word for a print that ran to completion.
|
||||
///
|
||||
/// Deliberately only this one. `FAILED` is what a Bambu reports for a print
|
||||
/// that was cancelled or went wrong - including every `print.stop` this
|
||||
/// software sends - and celebrating a cancellation would be worse than
|
||||
/// staying dark.
|
||||
const FINISHED: &str = "FINISH";
|
||||
|
||||
/// Remembers each printer's last known state, so a finish can be told from a
|
||||
/// printer that has simply been sitting in `FINISH` for an hour.
|
||||
#[derive(Default)]
|
||||
pub struct CompletionWatcher {
|
||||
last_state: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl CompletionWatcher {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The printers that have *just* finished a print.
|
||||
///
|
||||
/// Two rules, both of which exist to avoid a false celebration:
|
||||
///
|
||||
/// - It must be a transition. `FINISH` persists until the next print
|
||||
/// starts, so without this every poll for the next hour would fire.
|
||||
/// - A printer seen for the first time never fires, even if it is
|
||||
/// already `FINISH`. Otherwise restarting the gateway blinks every
|
||||
/// printer that happens to be sitting in `FINISH`, announcing prints
|
||||
/// that finished yesterday.
|
||||
pub fn newly_finished<I, S>(&mut self, states: I) -> Vec<String>
|
||||
where
|
||||
I: IntoIterator<Item = (String, S)>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
let mut finished = Vec::new();
|
||||
|
||||
for (printer_id, state) in states {
|
||||
let state = state.as_ref();
|
||||
let previous = self.last_state.insert(printer_id.clone(), state.to_string());
|
||||
|
||||
if state == FINISHED && previous.is_some_and(|prev| prev != FINISHED) {
|
||||
finished.push(printer_id);
|
||||
}
|
||||
}
|
||||
|
||||
finished
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches the fleet and blinks a printer's lights when it finishes a print.
|
||||
pub async fn run(fleet: SharedFleet) {
|
||||
let mut watcher = CompletionWatcher::new();
|
||||
let mut ticker = tokio::time::interval(POLL_INTERVAL);
|
||||
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
|
||||
let states = {
|
||||
let fleet = fleet.lock().await;
|
||||
// `print_status` is a cached read, but `refresh_status` below
|
||||
// publishes, so this does hold the lock across one MQTT publish
|
||||
// per printer. Same shape as the uplink's own `refresh_fleet`,
|
||||
// and at a handful of printers it is not worth more machinery;
|
||||
// the blink itself is what had to stay out of the lock, because
|
||||
// that one takes ten seconds.
|
||||
let mut states = Vec::new();
|
||||
for printer in fleet.iter() {
|
||||
// v1 printers only push a status when asked. Doing it here as
|
||||
// well as in the uplink means this keeps working with the
|
||||
// cloud connection down, which is the whole point of the
|
||||
// separate task.
|
||||
if let Err(err) = printer.refresh_status().await {
|
||||
warn!(printer = %printer.base().id, %err, "could not request a status refresh");
|
||||
}
|
||||
if let Some(state) = printer.print_status().await.and_then(|r| r.gcode_state) {
|
||||
states.push((printer.base().id.clone(), state));
|
||||
}
|
||||
}
|
||||
states
|
||||
};
|
||||
|
||||
for printer_id in watcher.newly_finished(states) {
|
||||
info!(printer = %printer_id, "print finished; blinking the lights");
|
||||
// Spawned, not awaited: this takes ten seconds, and nothing else
|
||||
// in the fleet should wait for it.
|
||||
tokio::spawn(celebrate(fleet.clone(), printer_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blinks one printer's chamber light for `BLINK_FOR`, then leaves it off.
|
||||
///
|
||||
/// The light is left off whatever happens, including when a blink fails
|
||||
/// part-way: a printer abandoned mid-blink with its light on looks like a
|
||||
/// machine still working.
|
||||
/// Public so `examples/bambu_print_probe.rs` can run the real blink against a
|
||||
/// real printer. Twenty light commands in ten seconds is a rate a printer can
|
||||
/// refuse, and a blink reimplemented in the probe to match would not have
|
||||
/// told us whether this one works.
|
||||
pub async fn celebrate(fleet: SharedFleet, printer_id: String) {
|
||||
let deadline = tokio::time::Instant::now() + BLINK_FOR;
|
||||
let mut state = LightState::On;
|
||||
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if let Err(err) = set_light(&fleet, &printer_id, state).await {
|
||||
// Stop at the first failure rather than logging twenty times,
|
||||
// but still fall through to the final Off below.
|
||||
warn!(printer = %printer_id, %err, "stopped blinking after a failed light command");
|
||||
break;
|
||||
}
|
||||
|
||||
state = match state {
|
||||
LightState::On => LightState::Off,
|
||||
LightState::Off => LightState::On,
|
||||
};
|
||||
tokio::time::sleep(BLINK_HALF_CYCLE).await;
|
||||
}
|
||||
|
||||
if let Err(err) = set_light(&fleet, &printer_id, LightState::Off).await {
|
||||
warn!(printer = %printer_id, %err, "could not turn the light back off");
|
||||
}
|
||||
}
|
||||
|
||||
/// One light command, holding the fleet lock only for as long as it takes.
|
||||
///
|
||||
/// Locking per command rather than once around the whole blink: the fleet
|
||||
/// mutex is also what telemetry and cloud command dispatch need, and holding
|
||||
/// it for ten seconds would stall both for every printer on the farm.
|
||||
async fn set_light(
|
||||
fleet: &SharedFleet,
|
||||
printer_id: &str,
|
||||
state: LightState,
|
||||
) -> Result<(), String> {
|
||||
let fleet = fleet.lock().await;
|
||||
let printer = fleet
|
||||
.iter()
|
||||
.find(|p| p.base().id == printer_id)
|
||||
.ok_or_else(|| format!("no printer with id '{printer_id}' in this gateway's fleet"))?;
|
||||
|
||||
// Bambu-only for now. A Prusa has no addressable light and Klipper's
|
||||
// depends entirely on the user's own config, so there is nothing
|
||||
// vendor-neutral to put on `GenericPrinter` yet - and inventing a
|
||||
// trait method that three of four implementations refuse is worse than
|
||||
// matching here.
|
||||
match printer {
|
||||
PrinterHandle::Bambu(bambu) => {
|
||||
bambu.set_light(CHAMBER_LIGHT, state).await.map_err(|err| err.to_string())
|
||||
}
|
||||
other => Err(format!(
|
||||
"{} has no light this software knows how to control",
|
||||
other.display_name()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn states(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
pairs.iter().map(|(id, s)| (id.to_string(), s.to_string())).collect()
|
||||
}
|
||||
|
||||
/// The regression that matters most: a gateway restarting while a
|
||||
/// printer sits in FINISH must not announce a print that finished
|
||||
/// yesterday.
|
||||
#[test]
|
||||
fn a_printer_first_seen_already_finished_does_not_fire() {
|
||||
let mut watcher = CompletionWatcher::new();
|
||||
assert!(watcher.newly_finished(states(&[("p1p", "FINISH")])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transition_into_finish_fires_once_and_only_once() {
|
||||
let mut watcher = CompletionWatcher::new();
|
||||
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
|
||||
|
||||
assert_eq!(watcher.newly_finished(states(&[("p1p", "FINISH")])), vec!["p1p"]);
|
||||
// FINISH persists until the next print starts; without the
|
||||
// transition check this would fire every poll for an hour.
|
||||
assert!(watcher.newly_finished(states(&[("p1p", "FINISH")])).is_empty());
|
||||
}
|
||||
|
||||
/// A Bambu reports an operator cancellation as FAILED - including every
|
||||
/// `print.stop` this software sends. Celebrating that would be worse
|
||||
/// than staying dark.
|
||||
#[test]
|
||||
fn a_cancelled_print_never_fires() {
|
||||
let mut watcher = CompletionWatcher::new();
|
||||
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
|
||||
assert!(watcher.newly_finished(states(&[("p1p", "FAILED")])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_print_fires_again() {
|
||||
let mut watcher = CompletionWatcher::new();
|
||||
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
|
||||
watcher.newly_finished(states(&[("p1p", "FINISH")]));
|
||||
watcher.newly_finished(states(&[("p1p", "RUNNING")]));
|
||||
assert_eq!(watcher.newly_finished(states(&[("p1p", "FINISH")])), vec!["p1p"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printers_are_tracked_independently() {
|
||||
let mut watcher = CompletionWatcher::new();
|
||||
watcher.newly_finished(states(&[("p1p", "RUNNING"), ("h2c", "RUNNING")]));
|
||||
|
||||
let finished = watcher.newly_finished(states(&[("p1p", "FINISH"), ("h2c", "RUNNING")]));
|
||||
assert_eq!(finished, vec!["p1p"]);
|
||||
}
|
||||
|
||||
/// Ten seconds of 500ms half-cycles is 20 commands, ending on Off. Worth
|
||||
/// pinning: the ask was "blink for ten seconds then turn the lights off",
|
||||
/// and a half-cycle that does not divide the duration would end mid-blink.
|
||||
#[test]
|
||||
fn the_blink_divides_evenly_into_the_duration() {
|
||||
assert_eq!(BLINK_FOR.as_millis() % BLINK_HALF_CYCLE.as_millis(), 0);
|
||||
assert_eq!(BLINK_FOR.as_millis() / BLINK_HALF_CYCLE.as_millis(), 20);
|
||||
}
|
||||
}
|
||||
+607
-35
@@ -1,6 +1,49 @@
|
||||
use std::collections::HashMap;
|
||||
//! 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::path::PathBuf;
|
||||
use std::fmt;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
@@ -10,50 +53,579 @@ pub struct Config {
|
||||
pub uplink_url: String,
|
||||
pub go2rtc_bin: String,
|
||||
pub go2rtc_config: String,
|
||||
/// What go2rtc's API listens on, which is also what serves WebRTC to a
|
||||
/// browser. Written into the generated config rather than left to
|
||||
/// go2rtc's default, because the cloud is told this address and a
|
||||
/// default that moves between go2rtc versions would move the stream
|
||||
/// without anything saying so.
|
||||
pub go2rtc_api_listen: String,
|
||||
/// The base URL a browser can reach this gateway's streams at, reported
|
||||
/// to the cloud in `hello`.
|
||||
///
|
||||
/// Not derived from `go2rtc_api_listen`: that is a bind address, often
|
||||
/// `:1984` or `0.0.0.0:1984`, and neither is something a browser can
|
||||
/// open. Only the operator knows the gateway's reachable name. Unset
|
||||
/// means "do not advertise a stream", which is the right default - a
|
||||
/// wrong URL here is worse than none, because a UI would offer a player
|
||||
/// that cannot connect.
|
||||
pub stream_base_url: Option<String>,
|
||||
/// Path to the fleet config file - see `fleet.rs` and
|
||||
/// `printers.example.toml` for the shape. Bambu certificate trust
|
||||
/// (per-printer `ca_cert_path`/`require_valid_cert`, and automatic
|
||||
/// trust-on-first-connect pinning otherwise - see
|
||||
/// `BambuPrinter::ensure_trusted`) lives entirely in that file now,
|
||||
/// not as a separate fleet-wide env-configured policy here.
|
||||
pub printers_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,
|
||||
}
|
||||
|
||||
/// Per-printer CA certificate overrides, keyed by printer ID. Most
|
||||
/// Bambu printers chain to the bundled shared CA (`certs/bambu_ca2.pem`)
|
||||
/// and don't need an entry here - but some models (P1P, at least) ship
|
||||
/// their own certificate instead of one signed by that shared CA, with
|
||||
/// no single file covering all of them. Look up a printer's own entry
|
||||
/// (if any) when constructing it; there's no fleet-wide "the" override.
|
||||
pub bambu_ca_cert_overrides: HashMap<String, PathBuf>,
|
||||
/// When `false`, Bambu MQTTS connections skip certificate verification
|
||||
/// entirely instead of checking against a CA. `true` (the default) is
|
||||
/// the secure choice; only flip this fleet-wide policy if you have
|
||||
/// printers you can't get to verify any other way.
|
||||
pub bambu_require_valid_cert: bool,
|
||||
/// 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>,
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
Ok(Self {
|
||||
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"),
|
||||
Self::layered(None, &|key| env::var(key).ok())
|
||||
}
|
||||
|
||||
bambu_ca_cert_overrides: parse_cert_overrides(&env_or("CONTINUUM_BAMBU_CA_CERT_OVERRIDES", "")),
|
||||
bambu_require_valid_cert: env_or("CONTINUUM_BAMBU_REQUIRE_VALID_CERT", "true") == "true",
|
||||
/// 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"),
|
||||
discovery: discovery_settings(file.discovery.as_ref(), env)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `"printer_id=path,printer_id2=path2"` into a lookup map. Empty
|
||||
/// input (the common case - no overrides needed) yields an empty map.
|
||||
fn parse_cert_overrides(raw: &str) -> HashMap<String, PathBuf> {
|
||||
raw.split(',')
|
||||
.filter_map(|entry| entry.split_once('='))
|
||||
.map(|(id, path)| (id.trim().to_string(), PathBuf::from(path.trim())))
|
||||
.collect()
|
||||
/// 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
@@ -0,0 +1,878 @@
|
||||
//! Finding printers, so setting up a farm does not mean reading five fields
|
||||
//! off each machine's screen and retyping them into `printers.toml`.
|
||||
//!
|
||||
//! `printers.toml` stays the source of truth. Discovery deliberately does not
|
||||
//! write it and does not add printers to a running fleet, because **it cannot
|
||||
//! learn a credential** - a Bambu access code is on the printer's own screen
|
||||
//! and a PrusaLink API key is in its web UI, which is the point of both. What
|
||||
//! discovery produces is a *candidate*: everything except the secret,
|
||||
//! rendered as a `[[printer]]` block for a human to complete. `fleet::load`
|
||||
//! rejects an entry whose credential is empty, which is why this prints
|
||||
//! rather than appends - writing a half-finished entry into the file would
|
||||
//! stop the gateway starting.
|
||||
//!
|
||||
//! # What can be found, and how much
|
||||
//!
|
||||
//! Detection splits into two tiers, and keeping them apart is what makes this
|
||||
//! honest rather than optimistic:
|
||||
//!
|
||||
//! - **"something at this address speaks X"** needs no credential for any
|
||||
//! vendor, and is most of the value: it yields the address and the vendor,
|
||||
//! which is two of the three or four fields an entry needs.
|
||||
//! - **the details** - serial, model, firmware, the operator's own name for
|
||||
//! the machine - only come from vendors that publish them unauthenticated.
|
||||
//!
|
||||
//! | Vendor | Probe | Tier 1 | Details |
|
||||
//! |---|---|---|---|
|
||||
//! | Bambu | TLS certificate on 8883 | yes | **serial number**, free |
|
||||
//! | Bambu | SSDP on 2021/1990 | yes | model, name, bind state |
|
||||
//! | Klipper | `GET :7125/printer/info` | yes | hostname + Klipper version, if its authorization allows |
|
||||
//! | PrusaLink | `GET :80/api/version` | yes | none - every identity endpoint needs digest auth |
|
||||
//! | Marlin over serial | USB descriptors in sysfs | yes | vendor/product strings, USB serial |
|
||||
//! | Marlin over serial | `M115` handshake | yes | firmware and machine type - **but see the warning below** |
|
||||
//!
|
||||
//! # The three things worth knowing before extending this
|
||||
//!
|
||||
//! **Multicast does not cross a router.** SSDP finds the most about a Bambu
|
||||
//! printer, but a gateway on a different VLAN from its printers hears
|
||||
//! nothing, which is an ordinary way to run a workshop. Active probing is
|
||||
//! what works from anywhere that can route to the machines, and that is why
|
||||
//! the certificate read - not SSDP - is the dependable Bambu mechanism.
|
||||
//!
|
||||
//! **Opening a serial port resets the board.** On Arduino-based boards
|
||||
//! toggling DTR, which opening the port does, reboots the printer -
|
||||
//! `printer/marlin_serial.rs` documents this too. So a `M115` handshake on a
|
||||
//! machine that happens to be printing *ruins the print*. Serial detection is
|
||||
//! therefore passive by default: it reads USB descriptors out of sysfs and
|
||||
//! opens nothing. The handshake is opt-in, per port, and says so.
|
||||
//!
|
||||
//! **Finding a printer is not being able to drive it.** The Klipper and
|
||||
//! PrusaLink clients in `printer/` are stubs. Discovery still reports those
|
||||
//! machines, because knowing what is on the network is useful before the
|
||||
//! client exists, but a rendered entry for one carries a warning - otherwise
|
||||
//! an operator pastes it in and gets a gateway that connects and does
|
||||
//! nothing.
|
||||
//!
|
||||
//! # Verified, and not
|
||||
//!
|
||||
//! Verified live on 2026-10-19 against the only hardware available, two
|
||||
//! Bambu printers: a P1P presented `CN = 01S09B441900143` (issuer `BBL CA`,
|
||||
//! TLS 1.2) and an H2C `CN = 31B8BP5A1800585` (`BBL Device CA O1C2-V2`,
|
||||
//! TLS 1.3), each an exact match for `printers.toml`, and a blind sweep of
|
||||
//! their `/24` found exactly those two in 24s.
|
||||
//!
|
||||
//! **Unverified, and untestable from here:** SSDP (this host is routed to the
|
||||
//! printers rather than on their segment, so their multicast cannot arrive),
|
||||
//! the Klipper and PrusaLink probes (no such hardware), and all serial
|
||||
//! detection (no serial device is attached, and `/sys/class/tty` holds
|
||||
//! nothing but virtual consoles). Every parser those paths depend on is unit
|
||||
//! tested against recorded protocol text, and each probe is tolerant by
|
||||
//! design - a missing field costs a field, not the printer. `discover_printers
|
||||
//! raw` dumps whatever actually arrives, so the first run on real hardware
|
||||
//! 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};
|
||||
|
||||
/// The SSDP multicast group, which is the standard one - Bambu's discovery is
|
||||
/// ordinary SSDP with vendor headers, not a protocol of its own.
|
||||
pub const SSDP_GROUP: Ipv4Addr = Ipv4Addr::new(239, 255, 255, 250);
|
||||
|
||||
/// Both ports Bambu has been seen to announce on. 2021 is the current one;
|
||||
/// 1990 is older firmware, and listening on both costs a socket.
|
||||
pub const SSDP_PORTS: [u16; 2] = [2021, 1990];
|
||||
|
||||
/// Which kind of printer a candidate is, which decides what its entry needs.
|
||||
///
|
||||
/// These map one-to-one onto the `vendor` strings `fleet.rs` accepts, and the
|
||||
/// mapping is the whole reason this enum exists rather than a string: a typo
|
||||
/// in a rendered `vendor` produces a config file that fails to load, and the
|
||||
/// compiler can prevent that.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Vendor {
|
||||
Bambu,
|
||||
Klipper,
|
||||
/// Prusa's own implementation of the OctoPrint API, on an MK4/XL/MINI.
|
||||
PrusaLink,
|
||||
/// OctoPrint itself, or anything else serving its API.
|
||||
///
|
||||
/// This is the honest default for a printer found by probing, because
|
||||
/// what a probe observes is "something here speaks the OctoPrint API" -
|
||||
/// never which implementation. Reporting `prusa_link` would assert a
|
||||
/// product nothing on the wire names.
|
||||
Octoprint,
|
||||
/// Marlin-family firmware over a raw serial port. Named for Prusa
|
||||
/// because that is what `fleet.rs` calls it, but
|
||||
/// `printer/marlin_serial.rs` drives any Marlin board the same way -
|
||||
/// there is nothing Prusa-specific in the transport.
|
||||
PrusaSerial,
|
||||
}
|
||||
|
||||
impl Vendor {
|
||||
/// The exact string `fleet.rs` expects in `vendor`.
|
||||
pub fn toml_value(self) -> &'static str {
|
||||
match self {
|
||||
// Never a guessed `bambu_v1`/`bambu_v2`: this value auto-detects
|
||||
// the MQTT dialect on connect, and since a firmware update can
|
||||
// move a physical printer between them, a guess written into a
|
||||
// file goes stale silently.
|
||||
Vendor::Bambu => "bambu",
|
||||
Vendor::Klipper => "klipper",
|
||||
Vendor::PrusaLink => "prusa_link",
|
||||
Vendor::Octoprint => "octoprint",
|
||||
Vendor::PrusaSerial => "prusa_serial",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `printer/` can actually drive this vendor yet.
|
||||
///
|
||||
/// Only Bambu is real. Reporting a printer the gateway cannot drive is
|
||||
/// still worth doing - it answers "what is on this network?" - but it has
|
||||
/// to be said out loud, or the entry looks finished when it is not.
|
||||
pub fn driver_is_implemented(self) -> bool {
|
||||
match self {
|
||||
Vendor::Bambu => true,
|
||||
// `printer/klipper.rs` and `printer/prusa.rs` are stubs whose
|
||||
// methods return without doing anything.
|
||||
Vendor::Klipper | Vendor::PrusaLink | Vendor::Octoprint => false,
|
||||
// `marlin_serial.rs` is a real transport, but
|
||||
// `PrusaSerialPrinter`'s command methods on top of it are not.
|
||||
Vendor::PrusaSerial => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a candidate was found, which bounds how much it can know.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// A Bambu SSDP announcement.
|
||||
Ssdp,
|
||||
/// The subject CN of the TLS certificate on Bambu's MQTT port.
|
||||
Certificate,
|
||||
/// An unauthenticated HTTP identity endpoint.
|
||||
HttpApi,
|
||||
/// USB descriptors read out of sysfs, with no port opened.
|
||||
UsbDescriptor,
|
||||
/// An `M115` handshake, which required opening the port.
|
||||
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 {
|
||||
/// What goes in `host` - or in `port`, for a serial candidate, where it
|
||||
/// is a device path like `/dev/ttyACM0`.
|
||||
pub host: String,
|
||||
pub vendor: Vendor,
|
||||
/// The printer's serial number. Bambu's MQTT topics are addressed by it,
|
||||
/// so for that vendor it is required rather than decorative.
|
||||
pub serial: Option<String>,
|
||||
pub model: Option<String>,
|
||||
/// The name the operator gave the printer, where the vendor reports one.
|
||||
pub name: Option<String>,
|
||||
/// `DevBind.bambu.com`: whether the printer is bound to a Bambu cloud
|
||||
/// account. Worth surfacing rather than acting on - a bound printer can
|
||||
/// still be driven over the LAN if LAN mode is on, and "bound" is the
|
||||
/// likeliest explanation for a printer that is visible here and then
|
||||
/// refuses to connect.
|
||||
pub bind_state: Option<String>,
|
||||
/// Firmware or software version, free-form, for an operator to recognise
|
||||
/// the machine by. Not rendered into the TOML - nothing in `fleet.rs`
|
||||
/// takes a version, and a stale one in a config file would be a lie.
|
||||
pub detail: Option<String>,
|
||||
/// Another vendor this could equally be, when the probe genuinely cannot
|
||||
/// tell - which today means only OctoPrint versus PrusaLink.
|
||||
///
|
||||
/// Carried rather than resolved by guessing, because the operator knows
|
||||
/// what they own and the network does not. It renders as a comment above
|
||||
/// `vendor`, so changing it is a one-word edit in the file they are
|
||||
/// already pasting into.
|
||||
pub ambiguous_with: Option<Vendor>,
|
||||
pub source: Source,
|
||||
}
|
||||
|
||||
impl Candidate {
|
||||
pub fn new(host: impl Into<String>, vendor: Vendor, source: Source) -> Self {
|
||||
Self {
|
||||
host: host.into(),
|
||||
vendor,
|
||||
serial: None,
|
||||
model: None,
|
||||
name: None,
|
||||
bind_state: None,
|
||||
detail: None,
|
||||
ambiguous_with: None,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// others, since an unused key is a key someone later has to work out the
|
||||
/// meaning of. The credential, where there is one, comes last, empty, and
|
||||
/// labelled - it is the one thing discovery cannot supply, and the entry
|
||||
/// does not load until a human supplies it.
|
||||
pub fn to_toml_entry(&self, id: &str) -> String {
|
||||
let name = self
|
||||
.name
|
||||
.clone()
|
||||
.or_else(|| self.model.clone())
|
||||
.unwrap_or_else(|| id.to_string());
|
||||
|
||||
let mut out = String::new();
|
||||
if let Some(detail) = &self.detail {
|
||||
out.push_str(&format!("# {detail}\n"));
|
||||
}
|
||||
if !self.vendor.driver_is_implemented() {
|
||||
out.push_str(&format!(
|
||||
"# WARNING: continuum-proxy's {} client is still a stub - it will connect\n\
|
||||
# and do nothing. Found and reported so you know it is there.\n",
|
||||
self.vendor.toml_value()
|
||||
));
|
||||
}
|
||||
out.push_str("[[printer]]\n");
|
||||
out.push_str(&format!("id = {}\n", toml_string(id)));
|
||||
out.push_str(&format!("name = {}\n", toml_string(&name)));
|
||||
if let Some(other) = self.ambiguous_with {
|
||||
out.push_str(&format!(
|
||||
"# vendor: change to {:?} if that is what this is - nothing on the network\n\
|
||||
# distinguishes them, so this is a guess only you can settle.\n",
|
||||
other.toml_value()
|
||||
));
|
||||
}
|
||||
out.push_str(&format!("vendor = {}\n", toml_string(self.vendor.toml_value())));
|
||||
|
||||
match self.vendor {
|
||||
Vendor::Bambu => {
|
||||
out.push_str(&format!("host = {}\n", toml_string(&self.host)));
|
||||
match &self.serial {
|
||||
Some(sn) => out.push_str(&format!("sn = {}\n", toml_string(sn))),
|
||||
// Required by `fleet.rs`, so a blank would not load
|
||||
// either - but it must not be left looking filled in.
|
||||
None => out.push_str("# sn = \"\" # NOT FOUND - printer screen > Settings > Device\n"),
|
||||
}
|
||||
out.push_str("access_code = \"\" # REQUIRED: printer screen > Settings > Network (LAN mode)\n");
|
||||
}
|
||||
// The only field `fleet.rs` wants, and Moonraker needs no
|
||||
// credential from us - it authorizes by client address.
|
||||
Vendor::Klipper => out.push_str(&format!("host = {}\n", toml_string(&self.host))),
|
||||
// One client, two flavours - same required fields.
|
||||
Vendor::PrusaLink | Vendor::Octoprint => {
|
||||
out.push_str(&format!("host = {}\n", toml_string(&self.host)));
|
||||
out.push_str("api_key = \"\" # REQUIRED: the printer's or OctoPrint's web UI > API key\n");
|
||||
}
|
||||
Vendor::PrusaSerial => {
|
||||
out.push_str(&format!("port = {}\n", toml_string(&self.host)));
|
||||
// Stated rather than left to the default, because a serial
|
||||
// port that is wrong about its baud rate does not fail - it
|
||||
// returns noise, which is far harder to diagnose than a
|
||||
// refusal.
|
||||
out.push_str("baud_rate = 115200 # Marlin's usual default; check your firmware\n");
|
||||
}
|
||||
}
|
||||
if let Some(bind) = &self.bind_state
|
||||
&& bind != "free"
|
||||
{
|
||||
out.push_str(&format!("# note: this printer reports DevBind = {bind:?} (bound to a Bambu account).\n"));
|
||||
out.push_str("# LAN mode has to be on for a gateway to drive it.\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A plausible `id` for the entry: short, stable, and filename-safe.
|
||||
///
|
||||
/// Derived from the model or the operator's name rather than the serial,
|
||||
/// because this id is what appears in logs and in the cloud, and
|
||||
/// `01S09B441900143` is not something anyone recognises. Falls back to
|
||||
/// the serial's tail only when there is nothing else.
|
||||
pub fn suggested_id(&self) -> String {
|
||||
let basis = self
|
||||
.model
|
||||
.as_deref()
|
||||
.or(self.name.as_deref())
|
||||
.map(slug)
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(slug) = basis {
|
||||
return slug;
|
||||
}
|
||||
match &self.serial {
|
||||
// The tail, not the head: serials share a model prefix, so the
|
||||
// last characters are what distinguishes two of the same machine.
|
||||
Some(sn) if sn.len() > 6 => format!("printer-{}", slug(&sn[sn.len() - 6..])),
|
||||
Some(sn) => format!("printer-{}", slug(sn)),
|
||||
None => {
|
||||
// A serial port has no identity but its path, and
|
||||
// `/dev/ttyACM0` makes a better id than `printer`.
|
||||
let from_path = self.host.rsplit('/').next().map(slug).filter(|s| !s.is_empty());
|
||||
from_path.unwrap_or_else(|| "printer".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn slug(input: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for ch in input.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
} else if !out.ends_with('-') && !out.is_empty() {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
out.trim_end_matches('-').to_string()
|
||||
}
|
||||
|
||||
/// Quotes a value for TOML, escaping what a basic string must escape.
|
||||
///
|
||||
/// None of these characters can appear in a serial or an IP address, but the
|
||||
/// name comes from whatever the operator typed into their slicer, and a stray
|
||||
/// quote in it would otherwise produce a block that does not parse.
|
||||
fn toml_string(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 2);
|
||||
out.push('"');
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// Rejects anything that is not shaped like a printer serial number.
|
||||
///
|
||||
/// The point is not to validate a vendor's format - it is that this value
|
||||
/// gets written into a config file as `sn`, and a `USN` carrying a UPnP UUID
|
||||
/// or a human-readable device description would produce an entry that looks
|
||||
/// filled in and then addresses a topic no printer publishes. Better to leave
|
||||
/// it blank and say so.
|
||||
pub(crate) fn valid_serial(value: String) -> Option<String> {
|
||||
let ok = (8..=32).contains(&value.len()) && value.chars().all(|c| c.is_ascii_alphanumeric());
|
||||
ok.then_some(value)
|
||||
}
|
||||
|
||||
/// Parses one SSDP datagram, or `None` if it is not a Bambu announcement.
|
||||
///
|
||||
/// `from` is the sender's address, used when the message carries no usable
|
||||
/// `Location` - which matters because the address a packet came from is the
|
||||
/// 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".
|
||||
pub fn parse_ssdp(message: &str, from: Ipv4Addr) -> Option<Candidate> {
|
||||
let mut lines = message.lines();
|
||||
let start = lines.next()?.trim();
|
||||
|
||||
// A NOTIFY is an unsolicited announcement; a `200 OK` is the reply to an
|
||||
// M-SEARCH. Anything else is rejected, which is what keeps a listener
|
||||
// from parsing the M-SEARCH queries it hears - including its own, which
|
||||
// arrive back on the same socket.
|
||||
let upper = start.to_ascii_uppercase();
|
||||
if !upper.starts_with("NOTIFY") && !upper.contains("200 OK") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut headers: Vec<(String, &str)> = Vec::new();
|
||||
for line in lines {
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
headers.push((key.trim().to_ascii_lowercase(), value.trim()));
|
||||
}
|
||||
}
|
||||
let get = |name: &str| {
|
||||
headers
|
||||
.iter()
|
||||
.find(|(key, _)| key == name)
|
||||
.map(|(_, value)| *value)
|
||||
.filter(|value| !value.is_empty())
|
||||
};
|
||||
|
||||
// Only claim a Bambu printer. Every UPnP device on a LAN - televisions,
|
||||
// routers, printers of the paper kind - announces on this same group, and
|
||||
// offering an operator a TOML block for their TV would be worse than
|
||||
// finding nothing.
|
||||
let device_type = get("nt").or_else(|| get("st")).unwrap_or("");
|
||||
let looks_bambu = device_type.to_ascii_lowercase().contains("bambulab")
|
||||
|| headers.iter().any(|(key, _)| key.ends_with(".bambu.com"));
|
||||
if !looks_bambu {
|
||||
debug!(%from, %device_type, "ignoring a non-Bambu SSDP announcement");
|
||||
return None;
|
||||
}
|
||||
|
||||
let host = get("location").map(host_from_location).unwrap_or_else(|| from.to_string());
|
||||
let mut candidate = Candidate::new(host, Vendor::Bambu, Source::Ssdp);
|
||||
candidate.serial = get("usn").map(|usn| usn.trim_start_matches("uuid:").trim().to_string()).and_then(valid_serial);
|
||||
candidate.model = get("devmodel.bambu.com").map(str::to_string);
|
||||
candidate.name = get("devname.bambu.com").map(str::to_string);
|
||||
candidate.bind_state = get("devbind.bambu.com").map(str::to_string);
|
||||
candidate.detail = get("devversion.bambu.com").map(|v| format!("Bambu firmware {v}"));
|
||||
Some(candidate)
|
||||
}
|
||||
|
||||
/// Bambu's `Location` is a bare address, but SSDP's is a URL. Accept either.
|
||||
fn host_from_location(location: &str) -> String {
|
||||
let without_scheme = location.split_once("://").map(|(_, rest)| rest).unwrap_or(location);
|
||||
let host = without_scheme.split('/').next().unwrap_or(without_scheme);
|
||||
// Strip a port, while leaving a bracketed IPv6 literal alone.
|
||||
match host.rsplit_once(':') {
|
||||
Some((before, after)) if !before.ends_with(']') && after.chars().all(|c| c.is_ascii_digit()) => {
|
||||
before.to_string()
|
||||
}
|
||||
_ => host.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Listens for SSDP announcements on one port for `window`, de-duplicated.
|
||||
///
|
||||
/// `raw` logs every datagram that arrives, Bambu or not, at info level. That
|
||||
/// is here because this path cannot be tested from the development
|
||||
/// environment: the printers are on `192.168.0.0/24` and the only host
|
||||
/// available is routed to them from another subnet, so their multicast never
|
||||
/// arrives - a router does not forward it. The first run on a real segment is
|
||||
/// therefore also the first real test, and it should print what it saw rather
|
||||
/// than only what it understood.
|
||||
pub async fn listen_ssdp(port: u16, window: Duration, raw: bool) -> anyhow::Result<Vec<Candidate>> {
|
||||
let socket = bind_ssdp(port)?;
|
||||
let mut found: Vec<Candidate> = Vec::new();
|
||||
let mut buf = vec![0u8; 65_535];
|
||||
let deadline = tokio::time::Instant::now() + window;
|
||||
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
let received = match tokio::time::timeout(remaining, socket.recv_from(&mut buf)).await {
|
||||
Ok(Ok(received)) => received,
|
||||
// The window closed, which is the ordinary way out.
|
||||
Err(_) => break,
|
||||
// One malformed or oversized datagram must not end the scan.
|
||||
Ok(Err(err)) => {
|
||||
warn!(?err, "SSDP receive failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (len, from) = received;
|
||||
let text = String::from_utf8_lossy(&buf[..len]);
|
||||
let sender = match from {
|
||||
SocketAddr::V4(v4) => *v4.ip(),
|
||||
SocketAddr::V6(_) => continue,
|
||||
};
|
||||
if raw {
|
||||
tracing::info!(%from, bytes = len, "SSDP datagram:\n{}", text.trim());
|
||||
}
|
||||
if let Some(candidate) = parse_ssdp(&text, sender)
|
||||
&& !found.iter().any(|existing| existing.host == candidate.host)
|
||||
{
|
||||
found.push(candidate);
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Binds the SSDP port and joins the group on every IPv4 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.
|
||||
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.set_nonblocking(true)?;
|
||||
Ok(UdpSocket::from_std(socket)?)
|
||||
}
|
||||
|
||||
/// Drops candidates already covered by another, so one printer found twice is
|
||||
/// reported once.
|
||||
///
|
||||
/// A sweep and an SSDP listen overlap on every Bambu printer, and the two
|
||||
/// know different things - SSDP has the model and the operator's name, the
|
||||
/// certificate has the serial - so the merge keeps whichever field is
|
||||
/// present rather than preferring a source.
|
||||
pub fn merge(mut found: Vec<Candidate>) -> Vec<Candidate> {
|
||||
let mut merged: Vec<Candidate> = Vec::new();
|
||||
for candidate in found.drain(..) {
|
||||
let existing = merged
|
||||
.iter_mut()
|
||||
.find(|other| other.host == candidate.host && other.vendor == candidate.vendor);
|
||||
match existing {
|
||||
Some(other) => {
|
||||
other.serial = other.serial.take().or(candidate.serial);
|
||||
other.model = other.model.take().or(candidate.model);
|
||||
other.name = other.name.take().or(candidate.name);
|
||||
other.bind_state = other.bind_state.take().or(candidate.bind_state);
|
||||
other.detail = other.detail.take().or(candidate.detail);
|
||||
}
|
||||
None => merged.push(candidate),
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Bambu's announcement shape, with the vendor headers it is worth
|
||||
/// extracting. Not a capture - see `parse_ssdp`'s note.
|
||||
const NOTIFY: &str = "NOTIFY * HTTP/1.1\r\n\
|
||||
HOST: 239.255.255.250:2021\r\n\
|
||||
Server: UPnP/1.0\r\n\
|
||||
Location: 192.168.0.21\r\n\
|
||||
NT: urn:bambulab-com:device:3dprinter:1\r\n\
|
||||
USN: 31B8BP5A1800585\r\n\
|
||||
Cache-Control: max-age=1800\r\n\
|
||||
DevModel.bambu.com: O1C2\r\n\
|
||||
DevName.bambu.com: H2C\r\n\
|
||||
DevSignal.bambu.com: -48\r\n\
|
||||
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");
|
||||
assert_eq!(found.host, "192.168.0.21");
|
||||
assert_eq!(found.serial.as_deref(), Some("31B8BP5A1800585"));
|
||||
assert_eq!(found.model.as_deref(), Some("O1C2"));
|
||||
assert_eq!(found.name.as_deref(), Some("H2C"));
|
||||
assert_eq!(found.bind_state.as_deref(), Some("free"));
|
||||
assert_eq!(found.vendor, Vendor::Bambu);
|
||||
assert_eq!(found.source, Source::Ssdp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_vendor_header_costs_one_field_and_not_the_printer() {
|
||||
// The whole reason the parser is tolerant: the header spelling is
|
||||
// unverified, so an announcement that spells `DevModel` differently
|
||||
// must still produce a usable candidate.
|
||||
let trimmed = NOTIFY.replace("DevModel.bambu.com: O1C2\r\n", "");
|
||||
let found = parse_ssdp(&trimmed, Ipv4Addr::new(10, 0, 0, 1)).expect("still a printer");
|
||||
assert_eq!(found.model, None);
|
||||
assert_eq!(found.serial.as_deref(), Some("31B8BP5A1800585"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sender_address_stands_in_for_a_missing_location() {
|
||||
let without = NOTIFY.replace("Location: 192.168.0.21\r\n", "");
|
||||
let found = parse_ssdp(&without, Ipv4Addr::new(192, 168, 0, 21)).expect("a printer");
|
||||
assert_eq!(found.host, "192.168.0.21");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_location_is_reduced_to_a_host() {
|
||||
for (location, expected) in [
|
||||
("http://192.168.0.21:80/description.xml", "192.168.0.21"),
|
||||
("http://192.168.0.21/", "192.168.0.21"),
|
||||
("192.168.0.21", "192.168.0.21"),
|
||||
] {
|
||||
let message = NOTIFY.replace("Location: 192.168.0.21", &format!("Location: {location}"));
|
||||
let found = parse_ssdp(&message, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||
assert_eq!(found.host, expected, "for {location}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_query_is_not_an_announcement() {
|
||||
// A listener hears its own M-SEARCH back off the multicast group, and
|
||||
// an M-SEARCH names a device type without naming a device - parsing
|
||||
// one would invent a printer at whatever address asked the question.
|
||||
let query = "M-SEARCH * HTTP/1.1\r\n\
|
||||
HOST: 239.255.255.250:2021\r\n\
|
||||
MAN: \"ssdp:discover\"\r\n\
|
||||
MX: 1\r\n\
|
||||
ST: urn:bambulab-com:device:3dprinter:1\r\n";
|
||||
assert!(parse_ssdp(query, Ipv4Addr::new(10, 200, 0, 4)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_vendors_upnp_device_is_ignored() {
|
||||
let television = "NOTIFY * HTTP/1.1\r\n\
|
||||
HOST: 239.255.255.250:1900\r\n\
|
||||
Location: http://192.168.0.9:8080/desc.xml\r\n\
|
||||
NT: urn:schemas-upnp-org:device:MediaRenderer:1\r\n\
|
||||
USN: uuid:4d696361-736f-6674-2d77-696e646f7773\r\n";
|
||||
assert!(parse_ssdp(television, Ipv4Addr::new(192, 168, 0, 9)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_usn_that_is_not_a_serial_is_dropped_rather_than_written_out() {
|
||||
let uuid = NOTIFY.replace("USN: 31B8BP5A1800585", "USN: uuid:4d696361-736f-6674-2d77-696e646f7773");
|
||||
let found = parse_ssdp(&uuid, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||
assert_eq!(found.serial, None, "a UUID is not a serial and must not be written into `sn`");
|
||||
assert!(found.to_toml_entry("h2c").contains("# sn = \"\" # NOT FOUND"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bambu_entry_parses_as_toml_and_names_the_access_code() {
|
||||
let found = parse_ssdp(NOTIFY, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||
let entry = found.to_toml_entry("h2c");
|
||||
let parsed: toml::Value = toml::from_str(&entry).expect("the emitted block must be valid TOML");
|
||||
let printer = &parsed["printer"][0];
|
||||
assert_eq!(printer["id"].as_str(), Some("h2c"));
|
||||
assert_eq!(printer["host"].as_str(), Some("192.168.0.21"));
|
||||
assert_eq!(printer["sn"].as_str(), Some("31B8BP5A1800585"));
|
||||
assert_eq!(printer["name"].as_str(), Some("H2C"));
|
||||
// Auto-detecting, not a guess from the TLS version - see `toml_value`.
|
||||
assert_eq!(printer["vendor"].as_str(), Some("bambu"));
|
||||
// Present and empty: the operator has to fill it in, and `fleet::load`
|
||||
// refuses the entry until they do.
|
||||
assert_eq!(printer["access_code"].as_str(), Some(""));
|
||||
// Bambu is the one vendor that works, so no stub warning.
|
||||
assert!(!entry.contains("WARNING"), "{entry}");
|
||||
}
|
||||
|
||||
/// Each vendor's entry has to carry exactly the fields `fleet.rs`
|
||||
/// requires of it - this is the test that fails if those two drift apart.
|
||||
#[test]
|
||||
fn every_vendors_entry_carries_the_fields_its_loader_demands() {
|
||||
let cases = [
|
||||
(Vendor::Bambu, "192.168.0.21", vec!["host", "sn", "access_code"]),
|
||||
(Vendor::Klipper, "192.168.0.50", vec!["host"]),
|
||||
(Vendor::PrusaLink, "192.168.0.51", vec!["host", "api_key"]),
|
||||
(Vendor::Octoprint, "192.168.0.60", vec!["host", "api_key"]),
|
||||
(Vendor::PrusaSerial, "/dev/ttyACM0", vec!["port", "baud_rate"]),
|
||||
];
|
||||
for (vendor, host, expected) in cases {
|
||||
let mut candidate = Candidate::new(host, vendor, Source::HttpApi);
|
||||
candidate.serial = Some("31B8BP5A1800585".to_string());
|
||||
let entry = candidate.to_toml_entry("x");
|
||||
let parsed: toml::Value = toml::from_str(&entry).unwrap_or_else(|err| panic!("{vendor:?}: {err}\n{entry}"));
|
||||
let printer = &parsed["printer"][0];
|
||||
assert_eq!(printer["vendor"].as_str(), Some(vendor.toml_value()));
|
||||
for key in &expected {
|
||||
assert!(printer.get(key).is_some(), "{vendor:?} must render {key}:\n{entry}");
|
||||
}
|
||||
// No stray keys: an unused one is a key someone later has to
|
||||
// work out the meaning of.
|
||||
for absent in ["host", "sn", "access_code", "api_key", "port", "baud_rate"] {
|
||||
if !expected.contains(&absent) {
|
||||
assert!(printer.get(absent).is_none(), "{vendor:?} must not render {absent}:\n{entry}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vendor_with_a_stub_client_says_so_in_its_entry() {
|
||||
for vendor in [Vendor::Klipper, Vendor::PrusaLink, Vendor::Octoprint, Vendor::PrusaSerial] {
|
||||
let entry = Candidate::new("192.168.0.50", vendor, Source::HttpApi).to_toml_entry("x");
|
||||
assert!(entry.contains("WARNING"), "{vendor:?} is a stub and must say so:\n{entry}");
|
||||
assert!(entry.contains("stub"), "{vendor:?}:\n{entry}");
|
||||
// Still valid TOML, because the operator is meant to paste it.
|
||||
toml::from_str::<toml::Value>(&entry).expect("a warning must be a comment, not a syntax error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_with_a_quote_in_it_still_renders_parseable_toml() {
|
||||
let awkward = NOTIFY.replace("DevName.bambu.com: H2C", "DevName.bambu.com: Bob\"s \\printer");
|
||||
let found = parse_ssdp(&awkward, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||
let parsed: toml::Value = toml::from_str(&found.to_toml_entry("h2c")).expect("valid TOML");
|
||||
assert_eq!(parsed["printer"][0]["name"].as_str(), Some("Bob\"s \\printer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_printer_is_flagged_rather_than_hidden() {
|
||||
let bound = NOTIFY.replace("DevBind.bambu.com: free", "DevBind.bambu.com: bound");
|
||||
let found = parse_ssdp(&bound, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||
let entry = found.to_toml_entry("h2c");
|
||||
assert!(entry.contains("DevBind"), "a bound printer needs the explanation: {entry}");
|
||||
assert!(entry.contains("LAN mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_firmware_version_is_a_comment_and_never_a_field() {
|
||||
// Nothing in `fleet.rs` takes a version, and one written into a
|
||||
// config file would be a lie the moment the printer updates.
|
||||
let versioned = NOTIFY.replace("DevBind.bambu.com: free", "DevVersion.bambu.com: 01.08.00.00");
|
||||
let found = parse_ssdp(&versioned, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||
assert_eq!(found.detail.as_deref(), Some("Bambu firmware 01.08.00.00"));
|
||||
let entry = found.to_toml_entry("h2c");
|
||||
assert!(entry.contains("# Bambu firmware 01.08.00.00"));
|
||||
let parsed: toml::Value = toml::from_str(&entry).expect("valid TOML");
|
||||
assert!(parsed["printer"][0].get("detail").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id_is_suggested_from_the_model_then_the_name_then_the_serial() {
|
||||
let found = parse_ssdp(NOTIFY, Ipv4Addr::LOCALHOST).expect("a printer");
|
||||
assert_eq!(found.suggested_id(), "o1c2");
|
||||
|
||||
let no_model = parse_ssdp(&NOTIFY.replace("DevModel.bambu.com: O1C2\r\n", ""), Ipv4Addr::LOCALHOST).unwrap();
|
||||
assert_eq!(no_model.suggested_id(), "h2c");
|
||||
|
||||
let mut bare = Candidate::new("192.168.0.20", Vendor::Bambu, Source::Certificate);
|
||||
bare.serial = Some("01S09B441900143".to_string());
|
||||
// The tail distinguishes two of the same model; the prefix does not.
|
||||
assert_eq!(bare.suggested_id(), "printer-900143");
|
||||
|
||||
// A serial port has no identity but its path, which still beats
|
||||
// calling it "printer".
|
||||
let port = Candidate::new("/dev/ttyACM0", Vendor::PrusaSerial, Source::UsbDescriptor);
|
||||
assert_eq!(port.suggested_id(), "ttyacm0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_operators_name_becomes_a_safe_id() {
|
||||
for (name, expected) in [("Bob's P1P!", "bob-s-p1p"), (" spaced out ", "spaced-out"), ("!!!", "")] {
|
||||
assert_eq!(slug(name), expected, "for {name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_printer_found_by_two_mechanisms_is_reported_once_with_both_halves() {
|
||||
// SSDP knows the model and the operator's name; the certificate
|
||||
// knows the serial. Neither is complete, and an operator should not
|
||||
// have to merge two blocks by hand.
|
||||
let mut from_ssdp = Candidate::new("192.168.0.21", Vendor::Bambu, Source::Ssdp);
|
||||
from_ssdp.model = Some("O1C2".to_string());
|
||||
from_ssdp.name = Some("H2C".to_string());
|
||||
let mut from_cert = Candidate::new("192.168.0.21", Vendor::Bambu, Source::Certificate);
|
||||
from_cert.serial = Some("31B8BP5A1800585".to_string());
|
||||
|
||||
let merged = merge(vec![from_ssdp, from_cert]);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].model.as_deref(), Some("O1C2"));
|
||||
assert_eq!(merged[0].serial.as_deref(), Some("31B8BP5A1800585"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_vendors_at_one_address_stay_separate() {
|
||||
// A Raspberry Pi can run Moonraker and have PrusaLink reachable
|
||||
// through it; merging those into one entry would invent a printer
|
||||
// that is neither.
|
||||
let merged = merge(vec![
|
||||
Candidate::new("192.168.0.50", Vendor::Klipper, Source::HttpApi),
|
||||
Candidate::new("192.168.0.50", Vendor::PrusaLink, Source::HttpApi),
|
||||
]);
|
||||
assert_eq!(merged.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_certificate_authoritys_name_is_not_a_serial_number() {
|
||||
// The failure mode this guards: an issuer CN sits earlier in the DER
|
||||
// than the subject's, so a scan for "the first CN" reports `BBL CA`.
|
||||
assert!(valid_serial("BBL CA".to_string()).is_none(), "a space disqualifies it");
|
||||
assert!(valid_serial("BBL Device CA O1C2-V2".to_string()).is_none());
|
||||
// Both real printers, as read off their certificates on 2026-10-19.
|
||||
assert_eq!(valid_serial("01S09B441900143".to_string()).as_deref(), Some("01S09B441900143"));
|
||||
assert_eq!(valid_serial("31B8BP5A1800585".to_string()).as_deref(), Some("31B8BP5A1800585"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
//! Active probes: ask an address what it is, rather than waiting to be told.
|
||||
//!
|
||||
//! This is the half of discovery that works from anywhere that can route to
|
||||
//! the printers, which is why it carries the weight - SSDP finds more about a
|
||||
//! Bambu printer but only on its own broadcast domain.
|
||||
//!
|
||||
//! Each probe answers two different questions, and they are worth keeping
|
||||
//! apart. "Is there a printer of this kind here?" needs no credential from
|
||||
//! any of the three. "What is it called, what firmware does it run?" only has
|
||||
//! an answer where the vendor publishes one unauthenticated - which Bambu
|
||||
//! does, Moonraker usually does, and PrusaLink never does.
|
||||
//!
|
||||
//! That is why **an HTTP 401 counts as a successful detection.** It is not a
|
||||
//! failure to work around: a password prompt from `/api/version` is proof
|
||||
//! that something is serving `/api/version`, which is the question being
|
||||
//! asked. Treating it as a find also makes the Moonraker probe correct
|
||||
//! without having to know how a given host configured `[authorization]`,
|
||||
//! which is not knowable from outside.
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
use super::{Candidate, Source, Vendor, valid_serial};
|
||||
|
||||
/// Bambu's MQTT port, which is also where its identifying certificate is.
|
||||
pub const BAMBU_MQTT_PORT: u16 = 8883;
|
||||
|
||||
/// Moonraker's default HTTP port (`[server] port`, documented default 7125).
|
||||
pub const MOONRAKER_PORT: u16 = 7125;
|
||||
|
||||
/// PrusaLink serves its OctoPrint-compatible API on plain HTTP.
|
||||
pub const PRUSALINK_PORT: u16 = 80;
|
||||
|
||||
/// Reads a Bambu printer's serial number out of the certificate it presents
|
||||
/// on 8883, sending no credentials and opening no MQTT session.
|
||||
///
|
||||
/// This accepts an unverifiable certificate, which deserves saying plainly:
|
||||
/// the certificate is being read as a *name tag*, not trusted. There is
|
||||
/// nothing to verify it against yet - the serial is exactly what is being
|
||||
/// learned, and a P1P does not chain to the bundled Bambu CA at all. Trust is
|
||||
/// still decided later and elsewhere, by `BambuTls::resolve` and the
|
||||
/// trust-on-first-connect pinning in `BambuPrinter::ensure_trusted`, at the
|
||||
/// point where credentials are actually sent. Nothing secret is transmitted
|
||||
/// here, so a host impersonating a printer learns nothing and gains only the
|
||||
/// chance to have its made-up serial printed to a terminal.
|
||||
pub async fn identify_bambu(host: &str, timeout: Duration) -> anyhow::Result<Candidate> {
|
||||
let host = host.to_string();
|
||||
// A blocking handshake on its own thread, the same shape as
|
||||
// `printer/bambu_ftps.rs` and for the same reason: `native_tls` is the
|
||||
// blocking API, and this is a brief one-shot.
|
||||
tokio::task::spawn_blocking(move || -> anyhow::Result<Candidate> {
|
||||
let addr = resolve(&host, BAMBU_MQTT_PORT)?;
|
||||
let tcp = std::net::TcpStream::connect_timeout(&addr, timeout)
|
||||
.map_err(|err| anyhow::anyhow!("{host}:{BAMBU_MQTT_PORT} did not accept a connection: {err}"))?;
|
||||
tcp.set_read_timeout(Some(timeout))?;
|
||||
tcp.set_write_timeout(Some(timeout))?;
|
||||
|
||||
let connector = native_tls::TlsConnector::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.build()?;
|
||||
let stream = connector
|
||||
.connect(&host, tcp)
|
||||
.map_err(|err| anyhow::anyhow!("TLS handshake with {host} failed: {err}"))?;
|
||||
|
||||
let certificate = stream
|
||||
.peer_certificate()?
|
||||
.ok_or_else(|| anyhow::anyhow!("{host} completed a TLS handshake but presented no certificate"))?;
|
||||
let serial = serial_from_der(&certificate.to_der()?)?;
|
||||
|
||||
let mut candidate = Candidate::new(&host, Vendor::Bambu, Source::Certificate);
|
||||
candidate.serial = Some(serial);
|
||||
Ok(candidate)
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Resolves a host to one address, so a name works as well as a literal.
|
||||
fn resolve(host: &str, port: u16) -> anyhow::Result<SocketAddr> {
|
||||
use std::net::ToSocketAddrs;
|
||||
(host, port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|err| anyhow::anyhow!("could not resolve {host}: {err}"))?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("{host} resolved to no addresses"))
|
||||
}
|
||||
|
||||
/// Pulls the subject CN out of a DER certificate and checks it is a serial.
|
||||
pub fn serial_from_der(der: &[u8]) -> anyhow::Result<String> {
|
||||
let cert = openssl::x509::X509::from_der(der)
|
||||
.map_err(|err| anyhow::anyhow!("could not parse the presented certificate: {err}"))?;
|
||||
// `entries_by_nid` on the *subject*, never the whole certificate: a Bambu
|
||||
// certificate is issued by "BBL CA", whose own CN sits earlier in the
|
||||
// DER, so anything that scans for the first CN it can find reports the
|
||||
// certificate authority as the printer.
|
||||
let cn = cert
|
||||
.subject_name()
|
||||
.entries_by_nid(openssl::nid::Nid::COMMONNAME)
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("the certificate's subject has no CN, so it names no printer"))?
|
||||
.data()
|
||||
// `to_string` and not `as_utf8`: that one truncates at the first
|
||||
// interior NUL, so a CN of `01S09B441900143\0anything` would read as
|
||||
// a valid serial. `valid_serial` would reject the untruncated form,
|
||||
// which is the point of checking the shape rather than trusting a
|
||||
// name read off an unverified certificate.
|
||||
.to_string()
|
||||
.map_err(|err| anyhow::anyhow!("the certificate's CN is not text: {err}"))?;
|
||||
|
||||
valid_serial(cn.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!("the certificate's CN {cn:?} is not shaped like a Bambu serial number"))
|
||||
}
|
||||
|
||||
/// One HTTP GET with a deadline, returning the status and the body.
|
||||
///
|
||||
/// Deliberately not `reqwest`'s JSON support: this crate builds reqwest with
|
||||
/// `default-features = false` so that a presigned download pulls in no more
|
||||
/// than it has to, and `serde_json` on the bytes costs nothing extra.
|
||||
async fn http_get(url: &str, timeout: Duration) -> anyhow::Result<(u16, Vec<u8>, Option<String>)> {
|
||||
let client = reqwest::Client::builder().timeout(timeout).build()?;
|
||||
let response = client.get(url).send().await.map_err(|err| anyhow::anyhow!("{url}: {err}"))?;
|
||||
let status = response.status().as_u16();
|
||||
let challenge = response
|
||||
.headers()
|
||||
.get(reqwest::header::WWW_AUTHENTICATE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = response.bytes().await.map_err(|err| anyhow::anyhow!("{url}: {err}"))?.to_vec();
|
||||
Ok((status, body, challenge))
|
||||
}
|
||||
|
||||
/// Probes for Moonraker, i.e. a Klipper host.
|
||||
///
|
||||
/// `GET /printer/info` is Moonraker's own identity endpoint and returns
|
||||
/// `hostname` and `software_version` among others. A 401 still identifies
|
||||
/// Moonraker - see this module's note - so the probe does not need to know
|
||||
/// whether the host restricts `trusted_clients`, which it cannot know anyway.
|
||||
pub async fn probe_moonraker(host: &str, timeout: Duration) -> anyhow::Result<Candidate> {
|
||||
let url = format!("http://{host}:{MOONRAKER_PORT}/printer/info");
|
||||
let (status, body, _) = http_get(&url, timeout).await?;
|
||||
|
||||
match status {
|
||||
200 => parse_moonraker_info(host, &body),
|
||||
401 | 403 => {
|
||||
let mut candidate = Candidate::new(host, Vendor::Klipper, Source::HttpApi);
|
||||
candidate.detail =
|
||||
Some("Moonraker is here but would not say more without authorization (trusted_clients)".to_string());
|
||||
Ok(candidate)
|
||||
}
|
||||
other => anyhow::bail!("{host} answered {url} with HTTP {other}, which is not Moonraker"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a `/printer/info` body. Separate from the request so it can be
|
||||
/// tested, which matters because no Klipper host was available to try it on.
|
||||
pub fn parse_moonraker_info(host: &str, body: &[u8]) -> anyhow::Result<Candidate> {
|
||||
let parsed: serde_json::Value = serde_json::from_slice(body)
|
||||
.map_err(|err| anyhow::anyhow!("{host} answered /printer/info with something that is not JSON: {err}"))?;
|
||||
// Moonraker wraps every HTTP reply in `result`. If that is missing, this
|
||||
// is some other service that happens to serve the path, and claiming it
|
||||
// as a printer would be worse than missing it.
|
||||
let result = parsed
|
||||
.get("result")
|
||||
.ok_or_else(|| anyhow::anyhow!("{host} answered /printer/info without a `result` - not Moonraker"))?;
|
||||
|
||||
let mut candidate = Candidate::new(host, Vendor::Klipper, Source::HttpApi);
|
||||
candidate.name = result.get("hostname").and_then(|v| v.as_str()).map(str::to_string);
|
||||
let version = result.get("software_version").and_then(|v| v.as_str());
|
||||
let state = result.get("state").and_then(|v| v.as_str());
|
||||
candidate.detail = Some(match (version, state) {
|
||||
(Some(version), Some(state)) => format!("Klipper {version}, state {state}"),
|
||||
(Some(version), None) => format!("Klipper {version}"),
|
||||
// Reached only if Moonraker ever drops both fields. Still a
|
||||
// Moonraker, so still a find.
|
||||
_ => "Klipper (Moonraker gave no version)".to_string(),
|
||||
});
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
/// Probes for PrusaLink.
|
||||
///
|
||||
/// There is no unauthenticated identity endpoint: PrusaLink's own OpenAPI
|
||||
/// specification puts `digestAuth` on every operation, including
|
||||
/// `/api/version` and `/api/v1/info`, so a 401 is the *expected* and best
|
||||
/// available answer. Which is enough - it establishes the address and the
|
||||
/// vendor, and the one field that is still missing is the API key, which no
|
||||
/// probe could ever supply.
|
||||
///
|
||||
/// **What this detects is the OctoPrint API, not a product.** PrusaLink
|
||||
/// deliberately implements that API, so without a credential the two are
|
||||
/// indistinguishable - and `vendor = "octoprint"` is what gets reported,
|
||||
/// because that is what was actually observed. Naming `prusa_link` would
|
||||
/// assert a product nothing on the wire mentions. The candidate carries
|
||||
/// `ambiguous_with` so the rendered entry offers the other choice as a
|
||||
/// one-word edit.
|
||||
///
|
||||
/// The exception is a body that names itself: `/api/version`'s `text` field
|
||||
/// reads `PrusaLink 0.7.0` or `OctoPrint 1.9.3`, so on the rare
|
||||
/// unauthenticated 200 the answer is known rather than guessed.
|
||||
pub async fn probe_prusalink(host: &str, timeout: Duration) -> anyhow::Result<Candidate> {
|
||||
let url = format!("http://{host}:{PRUSALINK_PORT}/api/version");
|
||||
let (status, body, challenge) = http_get(&url, timeout).await?;
|
||||
classify_octoprint_api(host, status, &body, challenge)
|
||||
}
|
||||
|
||||
/// The decision `probe_prusalink` makes once the response is in hand, split
|
||||
/// out so the 401 path can be tested without a printer.
|
||||
pub fn classify_octoprint_api(
|
||||
host: &str,
|
||||
status: u16,
|
||||
body: &[u8],
|
||||
challenge: Option<String>,
|
||||
) -> anyhow::Result<Candidate> {
|
||||
match status {
|
||||
// Some builds answer unauthenticated. Take the detail if so.
|
||||
200 => parse_octoprint_version(host, body),
|
||||
401 => {
|
||||
// A bare 401 is *not* enough. Plenty of things on a LAN password
|
||||
// protect port 80 - routers, NASes, cameras - and accepting any
|
||||
// of them would hand the operator a TOML block for their router.
|
||||
// PrusaLink's specification puts `digestAuth` on every operation,
|
||||
// so requiring a Digest challenge keeps the Basic-auth crowd out
|
||||
// while still matching the only answer a real PrusaLink gives.
|
||||
let challenge = challenge.ok_or_else(|| {
|
||||
anyhow::anyhow!("{host} asked for credentials on /api/version but sent no challenge - not PrusaLink")
|
||||
})?;
|
||||
if !challenge.to_ascii_lowercase().contains("digest") {
|
||||
anyhow::bail!(
|
||||
"{host} wants {challenge:?} on /api/version, not Digest - some other password-protected web server, not PrusaLink"
|
||||
);
|
||||
}
|
||||
// Reported as the generic flavour: a digest challenge proves the
|
||||
// OctoPrint API is here and proves nothing about which
|
||||
// implementation serves it.
|
||||
let mut candidate = Candidate::new(host, Vendor::Octoprint, Source::HttpApi);
|
||||
candidate.ambiguous_with = Some(Vendor::PrusaLink);
|
||||
// The challenge is worth quoting back: `Digest realm="Printer API"`
|
||||
// is what an operator can check against what they expect there.
|
||||
candidate.detail =
|
||||
Some(format!("an OctoPrint-compatible API needing a key - OctoPrint or PrusaLink ({challenge})"));
|
||||
Ok(candidate)
|
||||
}
|
||||
other => anyhow::bail!("{host} answered /api/version with HTTP {other}, which is not PrusaLink"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads an `/api/version` body, for the builds that answer without auth.
|
||||
///
|
||||
/// This is the one path that can name the implementation rather than the
|
||||
/// protocol, because `text` says which it is.
|
||||
pub fn parse_octoprint_version(host: &str, body: &[u8]) -> anyhow::Result<Candidate> {
|
||||
let parsed: serde_json::Value = serde_json::from_slice(body)
|
||||
.map_err(|err| anyhow::anyhow!("{host} answered /api/version with something that is not JSON: {err}"))?;
|
||||
// `api` is the field OctoPrint's contract guarantees; without it this is
|
||||
// some other web server that happens to answer /api/version.
|
||||
if parsed.get("api").is_none() {
|
||||
anyhow::bail!("{host} answered /api/version without an `api` field - not PrusaLink");
|
||||
}
|
||||
let text = parsed.get("text").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let lowered = text.to_ascii_lowercase();
|
||||
// `text` names the implementation when it is there, so this is the only
|
||||
// place the distinction is known rather than left to the operator.
|
||||
let (vendor, ambiguous_with) = if lowered.contains("prusalink") {
|
||||
(Vendor::PrusaLink, None)
|
||||
} else if lowered.contains("octoprint") {
|
||||
(Vendor::Octoprint, None)
|
||||
} else {
|
||||
(Vendor::Octoprint, Some(Vendor::PrusaLink))
|
||||
};
|
||||
|
||||
let mut candidate = Candidate::new(host, vendor, Source::HttpApi);
|
||||
candidate.ambiguous_with = ambiguous_with;
|
||||
let firmware = parsed.get("firmware").and_then(|v| v.as_str());
|
||||
let described = if text.is_empty() { "an OctoPrint-compatible API" } else { text };
|
||||
candidate.detail = Some(match firmware {
|
||||
Some(firmware) => format!("{described}, firmware {firmware}"),
|
||||
None => described.to_string(),
|
||||
});
|
||||
candidate.name = parsed.get("hostname").and_then(|v| v.as_str()).map(str::to_string);
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
/// Runs every network probe against one address, concurrently, and returns
|
||||
/// whatever answered.
|
||||
///
|
||||
/// Concurrent rather than sequential so that one host costs one timeout
|
||||
/// instead of three - which is the difference between a `/24` sweep taking
|
||||
/// 24 seconds and taking a minute and a half. A host can legitimately answer
|
||||
/// more than one probe: a Raspberry Pi running Moonraker may also have
|
||||
/// something on port 80.
|
||||
pub async fn identify_any(host: &str, timeout: Duration) -> Vec<Candidate> {
|
||||
let (bambu, moonraker, prusalink) = tokio::join!(
|
||||
identify_bambu(host, timeout),
|
||||
probe_moonraker(host, timeout),
|
||||
probe_prusalink(host, timeout),
|
||||
);
|
||||
let mut found = Vec::new();
|
||||
for result in [bambu, moonraker, prusalink] {
|
||||
match result {
|
||||
Ok(candidate) => found.push(candidate),
|
||||
// Expected for all but a handful of addresses in any subnet, so
|
||||
// this is debug and not a warning.
|
||||
Err(err) => debug!("{err}"),
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Expands `a.b.c.d/prefix` into the hosts worth probing.
|
||||
///
|
||||
/// Refuses anything larger than a `/24`. That is a guard rather than a
|
||||
/// limitation: 256 addresses is every printer anyone has on one segment, and
|
||||
/// a mistyped `/8` would be sixteen million connection attempts across
|
||||
/// somebody's network - which is not a thing this tool should be able to do
|
||||
/// by accident.
|
||||
pub fn hosts_in_subnet(subnet: &str) -> anyhow::Result<Vec<Ipv4Addr>> {
|
||||
let (base, prefix) = subnet
|
||||
.split_once('/')
|
||||
.ok_or_else(|| anyhow::anyhow!("{subnet:?} is not a subnet - write it as 192.168.0.0/24"))?;
|
||||
let base: Ipv4Addr = base.parse().map_err(|err| anyhow::anyhow!("{base:?} is not an IPv4 address: {err}"))?;
|
||||
let prefix: u32 = prefix.parse().map_err(|err| anyhow::anyhow!("{prefix:?} is not a prefix length: {err}"))?;
|
||||
if !(24..=32).contains(&prefix) {
|
||||
anyhow::bail!(
|
||||
"/{prefix} is too large to sweep - /24 (256 addresses) is the limit, \
|
||||
and anything bigger is more likely a typo than an intention"
|
||||
);
|
||||
}
|
||||
|
||||
let mask = if prefix == 32 { u32::MAX } else { !(u32::MAX >> prefix) };
|
||||
let network = u32::from(base) & mask;
|
||||
let broadcast = network | !mask;
|
||||
// A /32 is a single host, and it is neither a network nor a broadcast
|
||||
// address - naming one address has to probe that address.
|
||||
if prefix == 32 {
|
||||
return Ok(vec![Ipv4Addr::from(network)]);
|
||||
}
|
||||
Ok((network + 1..broadcast).map(Ipv4Addr::from).collect())
|
||||
}
|
||||
|
||||
/// Probes every host in a subnet and returns the printers that answered.
|
||||
pub async fn sweep(subnet: &str, timeout: Duration) -> anyhow::Result<Vec<Candidate>> {
|
||||
Ok(sweep_hosts(&hosts_in_subnet(subnet)?, timeout).await)
|
||||
}
|
||||
|
||||
/// The probing half of `sweep`, for a caller that has already expanded a
|
||||
/// subnet - so it can say how many addresses it is about to try before
|
||||
/// trying them, rather than announcing work that validation then refuses.
|
||||
///
|
||||
/// Probes run in batches rather than all at once: 254 hosts times three
|
||||
/// protocols is a burst that a consumer router notices, and the point is to
|
||||
/// find printers without making the network worse while doing it.
|
||||
pub async fn sweep_hosts(hosts: &[Ipv4Addr], timeout: Duration) -> Vec<Candidate> {
|
||||
const BATCH: usize = 32;
|
||||
let mut found = Vec::new();
|
||||
for batch in hosts.chunks(BATCH) {
|
||||
let probes = batch.iter().map(|host| {
|
||||
let host = host.to_string();
|
||||
async move { identify_any(&host, timeout).await }
|
||||
});
|
||||
for candidates in futures_util::future::join_all(probes).await {
|
||||
found.extend(candidates);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_subnet_expands_to_its_usable_hosts() {
|
||||
let hosts = hosts_in_subnet("192.168.0.0/24").expect("a /24");
|
||||
assert_eq!(hosts.len(), 254);
|
||||
assert_eq!(hosts[0], Ipv4Addr::new(192, 168, 0, 1));
|
||||
assert_eq!(hosts[253], Ipv4Addr::new(192, 168, 0, 254));
|
||||
|
||||
// A host address, not just a network address, names its own subnet.
|
||||
let from_host = hosts_in_subnet("192.168.0.20/24").expect("a /24");
|
||||
assert_eq!(from_host, hosts);
|
||||
|
||||
assert_eq!(hosts_in_subnet("192.168.0.20/32").expect("one host"), vec![Ipv4Addr::new(192, 168, 0, 20)]);
|
||||
assert_eq!(hosts_in_subnet("192.168.0.0/30").expect("a /30").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sweep_refuses_a_subnet_big_enough_to_be_a_typo() {
|
||||
for too_big in ["10.0.0.0/8", "192.168.0.0/16", "192.168.0.0/23"] {
|
||||
let err = hosts_in_subnet(too_big).expect_err("must refuse");
|
||||
assert!(err.to_string().contains("too large"), "for {too_big}: {err}");
|
||||
}
|
||||
assert!(hosts_in_subnet("192.168.0.0").is_err(), "a bare address is not a subnet");
|
||||
assert!(hosts_in_subnet("not-an-ip/24").is_err());
|
||||
}
|
||||
|
||||
/// Moonraker's own documented `/printer/info` response.
|
||||
const MOONRAKER_INFO: &[u8] = br#"{"result": {
|
||||
"state": "ready",
|
||||
"state_message": "Printer is ready",
|
||||
"hostname": "voron-pi",
|
||||
"software_version": "v0.12.0-191-g2ba5e0b2",
|
||||
"cpu_info": "4 core ARMv7 Processor rev 4 (v7l)",
|
||||
"klipper_path": "/home/pi/klipper",
|
||||
"python_path": "/home/pi/klippy-env/bin/python",
|
||||
"log_file": "/tmp/klippy.log",
|
||||
"config_file": "/home/pi/printer.cfg"
|
||||
}}"#;
|
||||
|
||||
/// PrusaLink's own documented `/api/version` response.
|
||||
const PRUSALINK_VERSION: &[u8] = br#"{
|
||||
"api": "2.0.0", "version": "2.1.2", "printer": "1.3.1",
|
||||
"text": "PrusaLink 0.7.0", "firmware": "3.10.1-4697", "sdk": "0.7.0"
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn a_moonraker_reply_names_the_host_and_the_klipper_version() {
|
||||
let found = parse_moonraker_info("192.168.0.50", MOONRAKER_INFO).expect("Moonraker");
|
||||
assert_eq!(found.vendor, Vendor::Klipper);
|
||||
assert_eq!(found.name.as_deref(), Some("voron-pi"));
|
||||
assert_eq!(found.detail.as_deref(), Some("Klipper v0.12.0-191-g2ba5e0b2, state ready"));
|
||||
// Klipper needs only a host, and Moonraker authorizes by client
|
||||
// address rather than a key we could paste in.
|
||||
let entry = found.to_toml_entry("voron");
|
||||
let parsed: toml::Value = toml::from_str(&entry).expect("valid TOML");
|
||||
assert_eq!(parsed["printer"][0]["host"].as_str(), Some("192.168.0.50"));
|
||||
assert!(parsed["printer"][0].get("api_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn something_else_serving_printer_info_is_not_a_printer() {
|
||||
// Moonraker wraps every reply in `result`; a bare object does not.
|
||||
let err = parse_moonraker_info("192.168.0.50", br#"{"state":"ready"}"#).expect_err("must refuse");
|
||||
assert!(err.to_string().contains("not Moonraker"), "{err}");
|
||||
assert!(parse_moonraker_info("192.168.0.50", b"<html>hello</html>").is_err());
|
||||
}
|
||||
|
||||
/// `text` names the implementation, so this is the one case where the
|
||||
/// product is known rather than inferred - and it must be taken, not
|
||||
/// flattened into the generic answer.
|
||||
#[test]
|
||||
fn a_body_that_names_itself_prusalink_is_reported_as_prusalink() {
|
||||
let found = parse_octoprint_version("192.168.0.51", PRUSALINK_VERSION).expect("PrusaLink");
|
||||
assert_eq!(found.vendor, Vendor::PrusaLink);
|
||||
assert_eq!(found.ambiguous_with, None, "`text` settled it - nothing to offer the operator");
|
||||
assert_eq!(found.detail.as_deref(), Some("PrusaLink 0.7.0, firmware 3.10.1-4697"));
|
||||
// The API key is the field no probe can supply, so it must be
|
||||
// rendered empty and labelled rather than omitted.
|
||||
let parsed: toml::Value = toml::from_str(&found.to_toml_entry("mk4")).expect("valid TOML");
|
||||
assert_eq!(parsed["printer"][0]["api_key"].as_str(), Some(""));
|
||||
assert_eq!(parsed["printer"][0]["vendor"].as_str(), Some("prusa_link"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_that_names_itself_octoprint_is_reported_as_octoprint() {
|
||||
let body = br#"{"api":"0.1","server":"1.9.3","text":"OctoPrint 1.9.3"}"#;
|
||||
let found = parse_octoprint_version("192.168.0.60", body).expect("OctoPrint");
|
||||
assert_eq!(found.vendor, Vendor::Octoprint);
|
||||
assert_eq!(found.ambiguous_with, None);
|
||||
}
|
||||
|
||||
/// The common case: the API answers but names no product, so the vendor
|
||||
/// reported is the protocol that was observed and the alternative is
|
||||
/// offered rather than guessed away.
|
||||
#[test]
|
||||
fn an_unnamed_octoprint_api_reports_the_protocol_and_offers_the_choice() {
|
||||
let body = br#"{"api":"2.0.0","version":"2.1.2"}"#;
|
||||
let found = parse_octoprint_version("192.168.0.51", body).expect("the API");
|
||||
assert_eq!(found.vendor, Vendor::Octoprint);
|
||||
assert_eq!(found.ambiguous_with, Some(Vendor::PrusaLink));
|
||||
|
||||
let entry = found.to_toml_entry("mk4");
|
||||
assert_eq!(
|
||||
toml::from_str::<toml::Value>(&entry).expect("valid TOML")["printer"][0]["vendor"].as_str(),
|
||||
Some("octoprint")
|
||||
);
|
||||
// The other choice has to be a one-word edit in the file the
|
||||
// operator is already pasting into.
|
||||
assert!(entry.contains("prusa_link"), "the alternative must be offered:\n{entry}");
|
||||
assert!(entry.contains("only you can settle"), "{entry}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_server_that_is_not_octoprint_shaped_is_refused() {
|
||||
let err = parse_octoprint_version("192.168.0.51", br#"{"version":"1.0"}"#).expect_err("must refuse");
|
||||
assert!(err.to_string().contains("not PrusaLink"), "{err}");
|
||||
}
|
||||
|
||||
/// Captured from the real segment on 2026-10-19. Three devices there
|
||||
/// answer `/api/version` with **HTTP 200** and a body that is not JSON -
|
||||
/// a router, a device with a logon page, and one serving gzip. Without
|
||||
/// the `api`-field guard every one of them would be reported as a
|
||||
/// printer, so these are the cases that guard exists for.
|
||||
#[test]
|
||||
fn the_things_that_actually_answer_api_version_on_a_real_lan_are_not_printers() {
|
||||
let real_bodies: [&[u8]; 4] = [
|
||||
br#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title></title>"#,
|
||||
br#"<script>
|
||||
var logonInfo = new Array(
|
||||
0,
|
||||
0,0);
|
||||
var g_Lan = 394;"#,
|
||||
// gzip, which is not even text.
|
||||
&[0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a],
|
||||
b"",
|
||||
];
|
||||
for body in real_bodies {
|
||||
let err = classify_octoprint_api("192.168.0.1", 200, body, None).expect_err("not a printer");
|
||||
assert!(err.to_string().contains("not JSON") || err.to_string().contains("not PrusaLink"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_password_protected_router_is_not_a_printer() {
|
||||
// The false positive that a bare "401 means PrusaLink" would create.
|
||||
// Routers, NASes and cameras all password protect port 80.
|
||||
let basic = classify_octoprint_api("192.168.0.1", 401, b"", Some(r#"Basic realm="RouterOS""#.into()));
|
||||
let err = basic.expect_err("Basic auth is not PrusaLink");
|
||||
assert!(err.to_string().contains("not Digest"), "{err}");
|
||||
|
||||
let silent = classify_octoprint_api("192.168.0.1", 401, b"", None);
|
||||
assert!(silent.is_err(), "a 401 with no challenge names nothing");
|
||||
|
||||
// What a real PrusaLink answers, which must still be found.
|
||||
let found = classify_octoprint_api("192.168.0.51", 401, b"", Some(r#"Digest realm="Printer API""#.into()))
|
||||
.expect("a digest challenge on /api/version is the OctoPrint API");
|
||||
// Generic, because a challenge proves the API is here and proves
|
||||
// nothing about which implementation serves it.
|
||||
assert_eq!(found.vendor, Vendor::Octoprint);
|
||||
assert_eq!(found.ambiguous_with, Some(Vendor::PrusaLink));
|
||||
assert!(found.detail.unwrap().contains("Printer API"));
|
||||
}
|
||||
|
||||
/// The one link between a real socket and `classify_octoprint_api` that
|
||||
/// unit tests on the classifier cannot reach: whether the challenge
|
||||
/// header actually survives the trip. PrusaLink is identified *by* that
|
||||
/// header, so if `http_get` dropped it every PrusaLink would be reported
|
||||
/// as "no challenge - not PrusaLink". Port 80 cannot be bound without
|
||||
/// root here, so this stands in for the real probe's plumbing.
|
||||
#[tokio::test]
|
||||
async fn a_digest_challenge_survives_the_round_trip_and_identifies_the_api() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
|
||||
let port = listener.local_addr().expect("an address").port();
|
||||
std::thread::spawn(move || {
|
||||
use std::io::{Read, Write};
|
||||
let (mut socket, _) = listener.accept().expect("one connection");
|
||||
let mut scratch = [0u8; 1024];
|
||||
let _ = socket.read(&mut scratch);
|
||||
// Exactly what PrusaLink answers an unauthenticated GET.
|
||||
let _ = socket.write_all(
|
||||
b"HTTP/1.1 401 Unauthorized\r\n\
|
||||
WWW-Authenticate: Digest realm=\"Printer API\", nonce=\"abc\", qop=\"auth\"\r\n\
|
||||
Content-Length: 0\r\n\r\n",
|
||||
);
|
||||
});
|
||||
|
||||
let (status, body, challenge) =
|
||||
http_get(&format!("http://127.0.0.1:{port}/api/version"), Duration::from_secs(5))
|
||||
.await
|
||||
.expect("the stand-in answers");
|
||||
assert_eq!(status, 401);
|
||||
assert!(challenge.as_deref().unwrap_or_default().contains("Digest"), "{challenge:?}");
|
||||
|
||||
// And the classifier turns that into the generic flavour with the
|
||||
// alternative offered - the whole path, end to end.
|
||||
let found = classify_octoprint_api("127.0.0.1", status, &body, challenge).expect("the OctoPrint API");
|
||||
assert_eq!(found.vendor, Vendor::Octoprint);
|
||||
assert_eq!(found.ambiguous_with, Some(Vendor::PrusaLink));
|
||||
assert!(found.detail.unwrap().contains("Printer API"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_garbage_certificate_is_an_error_and_not_a_panic() {
|
||||
let err = serial_from_der(b"not a certificate").expect_err("must not parse");
|
||||
assert!(err.to_string().contains("could not parse"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! 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, 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(configured) => 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! 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
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
//! Finding printers on USB/serial ports - old Prusa machines and any other
|
||||
//! Marlin-family board.
|
||||
//!
|
||||
//! # Why this is passive by default
|
||||
//!
|
||||
//! **Opening a serial port reboots the printer.** On Arduino-based boards the
|
||||
//! act of opening the port toggles DTR, which resets the MCU;
|
||||
//! `printer/marlin_serial.rs` notes the same thing from the other direction,
|
||||
//! which is why it holds one connection open rather than reopening per line.
|
||||
//! So a firmware handshake against a machine that happens to be mid-print
|
||||
//! *ruins the print* - and discovery is exactly the sort of thing someone
|
||||
//! runs while a farm is busy, to add a sixth printer to five working ones.
|
||||
//!
|
||||
//! `list_ports` therefore opens nothing. It reads USB descriptors out of
|
||||
//! sysfs, which is where udev reads them from too, and gets the manufacturer,
|
||||
//! the product name and the USB serial without touching the device. That is
|
||||
//! enough to render an entry, because a serial printer's `[[printer]]` block
|
||||
//! needs only the device path and a baud rate.
|
||||
//!
|
||||
//! `probe_m115` is the opt-in extra: it learns the firmware and the exact
|
||||
//! machine type, which nothing else can, and it costs a reset. It is never
|
||||
//! called unless a caller asks for that specific port.
|
||||
//!
|
||||
//! # Why sysfs rather than libudev
|
||||
//!
|
||||
//! `tokio-serial` can enumerate ports with USB metadata, but only with its
|
||||
//! `libudev` feature, which needs `libudev-dev` at build time. A gateway
|
||||
//! appliance should not need a system library to list its own ports, and
|
||||
//! this container does not have one. Reading the four files that udev would
|
||||
//! read is about thirty lines and no dependency.
|
||||
//!
|
||||
//! # Unverified
|
||||
//!
|
||||
//! None of this has run against hardware: no serial device is attached here
|
||||
//! and `/sys/class/tty` holds nothing but virtual consoles. The sysfs walk
|
||||
//! and the `M115` parser are unit tested against recorded text, and
|
||||
//! `list_ports` on a machine with no ports correctly returns an empty list,
|
||||
//! which is the only part this environment can actually demonstrate.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
use super::{Candidate, Source, Vendor};
|
||||
|
||||
/// Marlin's usual default, and `fleet.rs`'s default too.
|
||||
pub const DEFAULT_BAUD_RATE: u32 = 115_200;
|
||||
|
||||
/// How long to wait for a board to finish booting after the port is opened.
|
||||
///
|
||||
/// The open resets the board, so the first thing on the wire is a bootloader
|
||||
/// pause and then Marlin's startup banner - asking for `M115` before that is
|
||||
/// finished gets no reply.
|
||||
const BOOT_WAIT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// A serial port, as described by USB without being opened.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SerialDevice {
|
||||
/// The device path, e.g. `/dev/ttyACM0`.
|
||||
pub path: String,
|
||||
pub vendor_id: Option<u16>,
|
||||
pub product_id: Option<u16>,
|
||||
pub manufacturer: Option<String>,
|
||||
pub product: Option<String>,
|
||||
/// The USB serial string, which is not a printer serial number - it
|
||||
/// identifies the USB-serial bridge chip, and on a generic CH340 it is
|
||||
/// often absent or shared between every board of that type.
|
||||
pub usb_serial: Option<String>,
|
||||
}
|
||||
|
||||
impl SerialDevice {
|
||||
/// What is known about this port's USB vendor, if anything.
|
||||
pub fn vendor_description(&self) -> Option<&'static str> {
|
||||
describe_usb_vendor(self.vendor_id?)
|
||||
}
|
||||
|
||||
/// Whether this is probably a 3D printer rather than any other serial
|
||||
/// device.
|
||||
///
|
||||
/// Only Prusa's own USB vendor id is a real answer. Everything else is a
|
||||
/// USB-serial bridge that a printer is *likely* to be behind but that a
|
||||
/// GPS, an Arduino project or a label printer could equally be - so this
|
||||
/// is a hint for an operator to confirm, never a basis for connecting to
|
||||
/// something and sending it gcode.
|
||||
pub fn is_probably_a_printer(&self) -> bool {
|
||||
self.vendor_id == Some(PRUSA_USB_VENDOR)
|
||||
}
|
||||
|
||||
pub fn to_candidate(&self) -> Candidate {
|
||||
let mut candidate = Candidate::new(&self.path, Vendor::PrusaSerial, Source::UsbDescriptor);
|
||||
// The product string is the operator-facing name here - a Prusa
|
||||
// reports something like "Original Prusa i3 MK3" - but it describes
|
||||
// the USB interface, so fall back to the vendor rather than invent.
|
||||
candidate.name = self.product.clone().or_else(|| self.manufacturer.clone());
|
||||
candidate.model = self.product.clone();
|
||||
|
||||
let ids = match (self.vendor_id, self.product_id) {
|
||||
(Some(vid), Some(pid)) => format!("USB {vid:04x}:{pid:04x}"),
|
||||
_ => "no USB ids".to_string(),
|
||||
};
|
||||
let vendor = self.vendor_description().unwrap_or("unrecognised USB vendor");
|
||||
candidate.detail = Some(if self.is_probably_a_printer() {
|
||||
format!("{ids} - {vendor}")
|
||||
} else {
|
||||
// Said plainly, because the operator is the one who knows what is
|
||||
// plugged in and this is a guess they need to check.
|
||||
format!("{ids} - {vendor}; could be a printer or any other serial device")
|
||||
});
|
||||
candidate
|
||||
}
|
||||
}
|
||||
|
||||
/// Prusa Research's own USB vendor id, the one strong positive signal.
|
||||
const PRUSA_USB_VENDOR: u16 = 0x2c99;
|
||||
|
||||
/// The USB vendors a Marlin board is plausibly behind.
|
||||
///
|
||||
/// Everything except Prusa here is a generic USB-serial bridge, which is why
|
||||
/// `is_probably_a_printer` does not treat them as evidence. They are listed
|
||||
/// so an operator reading the output can recognise what they plugged in.
|
||||
fn describe_usb_vendor(vendor_id: u16) -> Option<&'static str> {
|
||||
Some(match vendor_id {
|
||||
PRUSA_USB_VENDOR => "Prusa Research",
|
||||
0x0403 => "FTDI serial bridge (common on older RAMPS boards)",
|
||||
0x1a86 => "QinHeng CH340/CH341 serial bridge",
|
||||
0x10c4 => "Silicon Labs CP210x serial bridge",
|
||||
0x2341 => "Arduino",
|
||||
0x2a03 => "Arduino (dog hunter)",
|
||||
0x1b4f => "SparkFun",
|
||||
0x16c0 => "Teensy / Van Ooijen",
|
||||
0x0483 => "STMicroelectronics (many 32-bit printer boards)",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Lists serial ports and what USB says about them, opening nothing.
|
||||
///
|
||||
/// Ports with no USB identity are included only when their name says they are
|
||||
/// USB-attached (`ttyUSB*`/`ttyACM*`). A bare `ttyS0` is skipped, because
|
||||
/// most systems enumerate a row of 8250 ports that are not physically there
|
||||
/// and listing thirty-two phantom devices would bury the two real ones.
|
||||
pub fn list_ports() -> anyhow::Result<Vec<SerialDevice>> {
|
||||
let class = Path::new("/sys/class/tty");
|
||||
if !class.is_dir() {
|
||||
anyhow::bail!("{} does not exist - this is not a Linux host with sysfs", class.display());
|
||||
}
|
||||
let mut ports = Vec::new();
|
||||
for entry in std::fs::read_dir(class)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let device_path = format!("/dev/{name}");
|
||||
if !Path::new(&device_path).exists() {
|
||||
continue;
|
||||
}
|
||||
let usb_dir = usb_parent(&entry.path());
|
||||
let looks_usb = name.starts_with("ttyUSB") || name.starts_with("ttyACM");
|
||||
if usb_dir.is_none() && !looks_usb {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut device = SerialDevice {
|
||||
path: device_path,
|
||||
vendor_id: None,
|
||||
product_id: None,
|
||||
manufacturer: None,
|
||||
product: None,
|
||||
usb_serial: None,
|
||||
};
|
||||
if let Some(dir) = usb_dir {
|
||||
device.vendor_id = read_hex(&dir, "idVendor");
|
||||
device.product_id = read_hex(&dir, "idProduct");
|
||||
device.manufacturer = read_trimmed(&dir, "manufacturer");
|
||||
device.product = read_trimmed(&dir, "product");
|
||||
device.usb_serial = read_trimmed(&dir, "serial");
|
||||
}
|
||||
ports.push(device);
|
||||
}
|
||||
ports.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Ok(ports)
|
||||
}
|
||||
|
||||
/// Walks up from a tty's `device` link to the USB device that owns it.
|
||||
///
|
||||
/// The number of levels differs by driver - a `ttyACM` link points at a USB
|
||||
/// *interface* while a `ttyUSB` link points at a usb-serial port, each a
|
||||
/// different depth below the device that holds `idVendor` - so this climbs
|
||||
/// until it finds the files rather than counting parents.
|
||||
fn usb_parent(tty_dir: &Path) -> Option<PathBuf> {
|
||||
let mut current = std::fs::canonicalize(tty_dir.join("device")).ok()?;
|
||||
loop {
|
||||
if current.join("idVendor").is_file() && current.join("idProduct").is_file() {
|
||||
return Some(current);
|
||||
}
|
||||
// Stops at /sys rather than walking to the filesystem root.
|
||||
if !current.pop() || current.as_os_str().len() <= "/sys".len() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_trimmed(dir: &Path, file: &str) -> Option<String> {
|
||||
let text = std::fs::read_to_string(dir.join(file)).ok()?;
|
||||
let trimmed = text.trim().to_string();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
}
|
||||
|
||||
fn read_hex(dir: &Path, file: &str) -> Option<u16> {
|
||||
u16::from_str_radix(read_trimmed(dir, file)?.trim_start_matches("0x"), 16).ok()
|
||||
}
|
||||
|
||||
/// Asks a board what firmware it runs, by opening the port and sending
|
||||
/// `M115`.
|
||||
///
|
||||
/// **This reboots the printer**, because opening the port toggles DTR on an
|
||||
/// Arduino-based board. Never call it against a machine that might be
|
||||
/// printing. It is separate from `list_ports` for exactly that reason, and
|
||||
/// the caller has to name the port.
|
||||
///
|
||||
/// What it buys: `MACHINE_TYPE` and `FIRMWARE_NAME`, which is the only way to
|
||||
/// tell an MK3S from an MK2 or a Prusa from an Ender - USB descriptors
|
||||
/// describe the bridge chip, not the printer.
|
||||
pub async fn probe_m115(port: &str, baud_rate: u32, timeout: Duration) -> anyhow::Result<Candidate> {
|
||||
use tokio_serial::SerialPortBuilderExt;
|
||||
|
||||
let stream = tokio_serial::new(port, baud_rate)
|
||||
.timeout(timeout)
|
||||
.open_native_async()
|
||||
.map_err(|err| anyhow::anyhow!("could not open {port} at {baud_rate} baud: {err}"))?;
|
||||
let (read_half, mut write_half) = tokio::io::split(stream);
|
||||
let mut lines = BufReader::new(read_half).lines();
|
||||
|
||||
// The open just reset the board; asking before it has booted gets
|
||||
// nothing back.
|
||||
tokio::time::sleep(BOOT_WAIT).await;
|
||||
write_half
|
||||
.write_all(b"M115\n")
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("could not write M115 to {port}: {err}"))?;
|
||||
|
||||
// Collect until Marlin's "ok" terminates the reply, or the budget runs
|
||||
// out. Lines before the reply - the boot banner - are kept, because
|
||||
// `echo:Marlin 2.1.2` identifies the firmware even if `M115` is somehow
|
||||
// unanswered.
|
||||
let mut collected = String::new();
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
match tokio::time::timeout(remaining, lines.next_line()).await {
|
||||
Ok(Ok(Some(line))) => {
|
||||
let is_ok = line.trim() == "ok" || line.trim().starts_with("ok ");
|
||||
collected.push_str(&line);
|
||||
collected.push('\n');
|
||||
if is_ok && collected.contains("FIRMWARE_NAME") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The port closed.
|
||||
Ok(Ok(None)) => break,
|
||||
Ok(Err(err)) => anyhow::bail!("error reading from {port}: {err}"),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
if collected.trim().is_empty() {
|
||||
anyhow::bail!(
|
||||
"{port} opened but said nothing in {timeout:?} - wrong baud rate, \
|
||||
or not a Marlin board (it may have been reset regardless)"
|
||||
);
|
||||
}
|
||||
candidate_from_m115(port, &collected)
|
||||
.ok_or_else(|| anyhow::anyhow!("{port} replied but named no firmware:\n{}", collected.trim()))
|
||||
}
|
||||
|
||||
/// Builds a candidate from an `M115` reply, or `None` if it names no firmware.
|
||||
pub fn candidate_from_m115(port: &str, response: &str) -> Option<Candidate> {
|
||||
let fields = parse_m115(response);
|
||||
let firmware = fields.get("FIRMWARE_NAME");
|
||||
let machine = fields.get("MACHINE_TYPE");
|
||||
// Marlin's boot banner, for a board that answered M115 with nothing
|
||||
// useful but announced itself on reset.
|
||||
let banner = response
|
||||
.lines()
|
||||
.find(|line| line.starts_with("echo:") && line.to_ascii_lowercase().contains("marlin"))
|
||||
.map(|line| line.trim_start_matches("echo:").trim().to_string());
|
||||
if firmware.is_none() && machine.is_none() && banner.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut candidate = Candidate::new(port, Vendor::PrusaSerial, Source::SerialHandshake);
|
||||
candidate.model = machine.cloned();
|
||||
candidate.name = machine.cloned();
|
||||
candidate.detail = firmware.cloned().or(banner);
|
||||
// `UUID` is Marlin's own board identifier. Kept as the serial because it
|
||||
// is the only stable per-machine value a serial printer offers, and it is
|
||||
// what distinguishes two identical MK3S units on one host.
|
||||
candidate.serial = fields.get("UUID").cloned();
|
||||
Some(candidate)
|
||||
}
|
||||
|
||||
/// Splits an `M115` reply into its `KEY:value` fields.
|
||||
///
|
||||
/// The format has no delimiter between fields and values may contain spaces -
|
||||
/// `FIRMWARE_NAME:Prusa-Firmware 3.13.3 based on Marlin FIRMWARE_URL:...` -
|
||||
/// so the only way to find where a value ends is to find where the next key
|
||||
/// begins. A key is a run of capitals, digits and underscores followed by a
|
||||
/// colon, at the start of a line or after a space, which is also what keeps
|
||||
/// Marlin's `Cap:EEPROM:1` capability lines out of the result: `Cap` is not
|
||||
/// all capitals.
|
||||
pub fn parse_m115(response: &str) -> BTreeMap<String, String> {
|
||||
let bytes = response.as_bytes();
|
||||
let is_key_char = |b: u8| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_';
|
||||
|
||||
// Every (key, value-start) pair, in order.
|
||||
let mut keys: Vec<(String, usize)> = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if !is_key_char(bytes[index]) || (index > 0 && !matches!(bytes[index - 1], b' ' | b'\n' | b'\r' | b'\t')) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let start = index;
|
||||
while index < bytes.len() && is_key_char(bytes[index]) {
|
||||
index += 1;
|
||||
}
|
||||
if index < bytes.len() && bytes[index] == b':' && index > start {
|
||||
keys.push((response[start..index].to_string(), index + 1));
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut fields = BTreeMap::new();
|
||||
for (position, (key, value_start)) in keys.iter().enumerate() {
|
||||
// The value runs to the start of the next key...
|
||||
let next_key = keys
|
||||
.get(position + 1)
|
||||
.map(|(next_key, next_start)| next_start - next_key.len() - 1)
|
||||
.unwrap_or(response.len());
|
||||
// ...or to the end of its own line, whichever comes first. These
|
||||
// fields are line-oriented, and without the newline bound the last
|
||||
// value on a line swallows everything after it - which is how `UUID`
|
||||
// came to be read as "cede2a2f-...\nok".
|
||||
let line_end = response[*value_start..].find('\n').map(|at| value_start + at).unwrap_or(response.len());
|
||||
let end = next_key.min(line_end).max(*value_start);
|
||||
let value = response[*value_start..end].trim();
|
||||
if !value.is_empty() {
|
||||
fields.insert(key.clone(), value.to_string());
|
||||
}
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A real MK3S reply's shape: space-separated keys, values with spaces in
|
||||
/// them, and capability lines after.
|
||||
const MK3S: &str = "FIRMWARE_NAME:Prusa-Firmware 3.13.3 based on Marlin \
|
||||
FIRMWARE_URL:https://github.com/prusa3d/Prusa-Firmware \
|
||||
PROTOCOL_VERSION:1.0 MACHINE_TYPE:Prusa i3 MK3S EXTRUDER_COUNT:1 \
|
||||
UUID:00000000-0000-0000-0000-000000000000\n\
|
||||
Cap:EEPROM:1\nCap:AUTOREPORT_TEMP:1\nok\n";
|
||||
|
||||
#[test]
|
||||
fn a_firmware_reply_splits_on_the_next_key_not_on_spaces() {
|
||||
let fields = parse_m115(MK3S);
|
||||
// The thing that makes this format awkward: the value has spaces in
|
||||
// it and ends only where the next key starts.
|
||||
assert_eq!(fields.get("FIRMWARE_NAME").map(String::as_str), Some("Prusa-Firmware 3.13.3 based on Marlin"));
|
||||
assert_eq!(fields.get("MACHINE_TYPE").map(String::as_str), Some("Prusa i3 MK3S"));
|
||||
assert_eq!(fields.get("PROTOCOL_VERSION").map(String::as_str), Some("1.0"));
|
||||
assert_eq!(fields.get("EXTRUDER_COUNT").map(String::as_str), Some("1"));
|
||||
assert_eq!(fields.get("FIRMWARE_URL").map(String::as_str), Some("https://github.com/prusa3d/Prusa-Firmware"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_lines_are_not_fields() {
|
||||
// `Cap:` is mixed case, so the all-capitals rule excludes it - which
|
||||
// is lucky, because `Cap:EEPROM:1` would otherwise parse as a key
|
||||
// whose value is another key.
|
||||
let fields = parse_m115(MK3S);
|
||||
assert!(!fields.contains_key("Cap"), "{fields:?}");
|
||||
assert!(!fields.contains_key("EEPROM"), "{fields:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_becomes_a_candidate_naming_the_machine() {
|
||||
let candidate = candidate_from_m115("/dev/ttyACM0", MK3S).expect("a printer");
|
||||
assert_eq!(candidate.host, "/dev/ttyACM0");
|
||||
assert_eq!(candidate.vendor, Vendor::PrusaSerial);
|
||||
assert_eq!(candidate.model.as_deref(), Some("Prusa i3 MK3S"));
|
||||
assert_eq!(candidate.detail.as_deref(), Some("Prusa-Firmware 3.13.3 based on Marlin"));
|
||||
assert_eq!(candidate.source, Source::SerialHandshake);
|
||||
|
||||
let entry = candidate.to_toml_entry("mk3s");
|
||||
let parsed: toml::Value = toml::from_str(&entry).expect("valid TOML");
|
||||
// A serial printer is addressed by path, not host - the field name
|
||||
// `fleet.rs` wants is `port`.
|
||||
assert_eq!(parsed["printer"][0]["port"].as_str(), Some("/dev/ttyACM0"));
|
||||
assert_eq!(parsed["printer"][0]["vendor"].as_str(), Some("prusa_serial"));
|
||||
assert!(parsed["printer"][0].get("host").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_generic_marlin_board_is_still_identified() {
|
||||
// Not a Prusa, and the point of the handshake: USB descriptors would
|
||||
// have said only "CH340 serial bridge".
|
||||
let ender = "FIRMWARE_NAME:Marlin 2.1.2.1 (Github) SOURCE_CODE_URL:... \
|
||||
PROTOCOL_VERSION:1.0 MACHINE_TYPE:Ender-3 EXTRUDER_COUNT:1 UUID:cede2a2f-41a2-4748-9b12-c55c62f367ff\nok\n";
|
||||
let candidate = candidate_from_m115("/dev/ttyUSB0", ender).expect("a printer");
|
||||
assert_eq!(candidate.model.as_deref(), Some("Ender-3"));
|
||||
assert_eq!(candidate.serial.as_deref(), Some("cede2a2f-41a2-4748-9b12-c55c62f367ff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boot_banner_alone_still_names_the_firmware() {
|
||||
// The open resets the board, so sometimes all that arrives is the
|
||||
// startup banner. That still identifies Marlin, and throwing it away
|
||||
// would mean resetting a printer for nothing.
|
||||
let banner = "start\necho:Marlin 2.1.2.1\necho: Last Updated: 2024-01-01\nok\n";
|
||||
let candidate = candidate_from_m115("/dev/ttyUSB0", banner).expect("a board");
|
||||
assert_eq!(candidate.detail.as_deref(), Some("Marlin 2.1.2.1"));
|
||||
assert_eq!(candidate.model, None, "the banner names firmware, not a machine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_and_noise_are_not_printers() {
|
||||
assert!(candidate_from_m115("/dev/ttyUSB0", "").is_none());
|
||||
assert!(candidate_from_m115("/dev/ttyUSB0", "ok\n").is_none());
|
||||
// A GPS on a CH340 answers, but not with firmware fields.
|
||||
assert!(candidate_from_m115("/dev/ttyUSB0", "$GPGGA,123519,4807.038,N\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_prusas_own_usb_id_counts_as_evidence() {
|
||||
let prusa = SerialDevice {
|
||||
path: "/dev/ttyACM0".into(),
|
||||
vendor_id: Some(0x2c99),
|
||||
product_id: Some(0x0002),
|
||||
manufacturer: Some("Prusa Research (prusa3d.com)".into()),
|
||||
product: Some("Original Prusa i3 MK3".into()),
|
||||
usb_serial: Some("CZPX1234X567XK98765".into()),
|
||||
};
|
||||
assert!(prusa.is_probably_a_printer());
|
||||
assert_eq!(prusa.vendor_description(), Some("Prusa Research"));
|
||||
let candidate = prusa.to_candidate();
|
||||
assert_eq!(candidate.model.as_deref(), Some("Original Prusa i3 MK3"));
|
||||
assert_eq!(candidate.detail.as_deref(), Some("USB 2c99:0002 - Prusa Research"));
|
||||
assert!(!candidate.detail.as_deref().unwrap().contains("could be"));
|
||||
|
||||
// A CH340 is a serial bridge, not a printer. Report it, but say so -
|
||||
// the operator knows what they plugged in and we do not.
|
||||
let ch340 = SerialDevice {
|
||||
path: "/dev/ttyUSB0".into(),
|
||||
vendor_id: Some(0x1a86),
|
||||
product_id: Some(0x7523),
|
||||
manufacturer: None,
|
||||
product: None,
|
||||
usb_serial: None,
|
||||
};
|
||||
assert!(!ch340.is_probably_a_printer());
|
||||
let detail = ch340.to_candidate().detail.unwrap();
|
||||
assert!(detail.contains("CH340"), "{detail}");
|
||||
assert!(detail.contains("could be a printer or any other serial device"), "{detail}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_usb_vendor_is_reported_rather_than_dropped() {
|
||||
let unknown = SerialDevice {
|
||||
path: "/dev/ttyUSB0".into(),
|
||||
vendor_id: Some(0xdead),
|
||||
product_id: Some(0xbeef),
|
||||
manufacturer: None,
|
||||
product: None,
|
||||
usb_serial: None,
|
||||
};
|
||||
assert_eq!(unknown.vendor_description(), None);
|
||||
assert!(unknown.to_candidate().detail.unwrap().contains("unrecognised USB vendor"));
|
||||
}
|
||||
|
||||
/// 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");
|
||||
for port in &ports {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
+140
-25
@@ -3,11 +3,15 @@
|
||||
//! 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;
|
||||
|
||||
use crate::printer::{BambuTls, BambuV1Printer, BambuV2Printer, KlipperPrinter, PrinterHandle, PrusaLinkPrinter, PrusaSerialPrinter};
|
||||
use crate::printer::{
|
||||
BambuPrinter, BambuProtocolPreference, BambuTls, KlipperPrinter, OctoPrintFlavour, OctoPrintPrinter,
|
||||
PrinterHandle, PrusaSerialPrinter,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FleetFile {
|
||||
@@ -19,7 +23,15 @@ struct FleetFile {
|
||||
struct PrinterEntry {
|
||||
id: String,
|
||||
name: String,
|
||||
/// One of: "bambu_v1", "bambu_v2", "prusa_link", "prusa_serial", "klipper".
|
||||
/// 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),
|
||||
/// "prusa_link", "octoprint", "prusa_serial", "klipper".
|
||||
///
|
||||
/// `prusa_link` and `octoprint` are the same REST client with a different
|
||||
/// flavour - see `OctoPrintFlavour`. Pick `octoprint` if it is OctoPrint
|
||||
/// on a Pi and `prusa_link` if it is an MK4/XL/MINI serving its own API;
|
||||
/// discovery cannot tell, so it suggests `octoprint` and says so.
|
||||
vendor: String,
|
||||
host: Option<String>,
|
||||
access_code: Option<String>,
|
||||
@@ -27,7 +39,10 @@ struct PrinterEntry {
|
||||
/// by serial (`device/{sn}/report`), not by our own `id`.
|
||||
sn: Option<String>,
|
||||
api_key: Option<String>,
|
||||
com_port: Option<i16>,
|
||||
/// Serial device path, e.g. `/dev/ttyACM0` on Linux.
|
||||
port: Option<String>,
|
||||
#[serde(default = "default_baud_rate")]
|
||||
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).
|
||||
@@ -40,6 +55,11 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Marlin's usual default on most current Prusa firmware.
|
||||
fn default_baud_rate() -> u32 {
|
||||
115_200
|
||||
}
|
||||
|
||||
/// Reads and parses a printers file into ready-to-use `PrinterHandle`s.
|
||||
pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
|
||||
let text = std::fs::read_to_string(path).map_err(|err| {
|
||||
@@ -52,30 +72,86 @@ pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
|
||||
file.printers.into_iter().map(build_printer).collect()
|
||||
}
|
||||
|
||||
/// 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();
|
||||
for entry in file.printers {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
add(&mut out.hosts, entry.host);
|
||||
add(&mut out.serials, entry.sn);
|
||||
add(&mut out.ports, entry.port);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
|
||||
match entry.vendor.as_str() {
|
||||
"bambu_v1" => {
|
||||
let host = require_field(&entry.id, "host", entry.host)?;
|
||||
let access_code = require_field(&entry.id, "access_code", entry.access_code)?;
|
||||
let sn = require_field(&entry.id, "sn", entry.sn)?;
|
||||
let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?;
|
||||
Ok(PrinterHandle::BambuV1(BambuV1Printer::new(&entry.id, &entry.name, &host, &access_code, &sn, tls)))
|
||||
}
|
||||
"bambu_v2" => {
|
||||
let host = require_field(&entry.id, "host", entry.host)?;
|
||||
let access_code = require_field(&entry.id, "access_code", entry.access_code)?;
|
||||
let sn = require_field(&entry.id, "sn", entry.sn)?;
|
||||
let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?;
|
||||
Ok(PrinterHandle::BambuV2(BambuV2Printer::new(&entry.id, &entry.name, &host, &access_code, &sn, tls)))
|
||||
}
|
||||
"prusa_link" => {
|
||||
let host = require_field(&entry.id, "host", entry.host)?;
|
||||
let api_key = require_field(&entry.id, "api_key", entry.api_key)?;
|
||||
Ok(PrinterHandle::PrusaLink(PrusaLinkPrinter::new(&entry.id, &entry.name, &host, &api_key)))
|
||||
}
|
||||
"bambu" => build_bambu(entry, BambuProtocolPreference::Auto),
|
||||
"bambu_v1" => build_bambu(entry, BambuProtocolPreference::ForceV1),
|
||||
"bambu_v2" => build_bambu(entry, BambuProtocolPreference::ForceV2),
|
||||
// Two names for one client, because PrusaLink implements OctoPrint's
|
||||
// API. The distinction is kept rather than collapsed: the two differ
|
||||
// in which endpoints they offer beyond `/api/version`, and - the
|
||||
// reason it has to come from config - nothing on the network can tell
|
||||
// them apart, since every PrusaLink identity endpoint needs a
|
||||
// credential. So this is the operator's answer, and `octoprint` is
|
||||
// the honest default for a printer found by discovery.
|
||||
"prusa_link" => build_octoprint(entry, OctoPrintFlavour::PrusaLink),
|
||||
"octoprint" => build_octoprint(entry, OctoPrintFlavour::Generic),
|
||||
"prusa_serial" => {
|
||||
let com_port = entry.com_port.ok_or_else(|| anyhow::anyhow!("{}: prusa_serial needs com_port", entry.id))?;
|
||||
Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, com_port)))
|
||||
let port = require_field(&entry.id, "port", entry.port)?;
|
||||
Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, &port, entry.baud_rate)))
|
||||
}
|
||||
"klipper" => {
|
||||
let host = require_field(&entry.id, "host", entry.host)?;
|
||||
@@ -85,6 +161,45 @@ fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_octoprint(entry: PrinterEntry, flavour: OctoPrintFlavour) -> anyhow::Result<PrinterHandle> {
|
||||
let host = require_field(&entry.id, "host", entry.host)?;
|
||||
let api_key = require_field(&entry.id, "api_key", entry.api_key)?;
|
||||
Ok(PrinterHandle::OctoPrint(OctoPrintPrinter::new(&entry.id, &entry.name, &host, &api_key, flavour)))
|
||||
}
|
||||
|
||||
fn build_bambu(entry: PrinterEntry, preference: BambuProtocolPreference) -> anyhow::Result<PrinterHandle> {
|
||||
let host = require_field(&entry.id, "host", entry.host)?;
|
||||
let access_code = require_field(&entry.id, "access_code", entry.access_code)?;
|
||||
let sn = require_field(&entry.id, "sn", entry.sn)?;
|
||||
let tls = BambuTls::resolve(entry.ca_cert_path.as_deref().map(Path::new), entry.require_valid_cert)?;
|
||||
Ok(PrinterHandle::Bambu(BambuPrinter::new(&entry.id, &entry.name, &host, &access_code, &sn, tls, preference)))
|
||||
}
|
||||
|
||||
/// A required field, where **empty counts as missing**.
|
||||
///
|
||||
/// The emptiness check is the load-bearing half. `Option` only catches an
|
||||
/// absent key, so `access_code = ""` arrived here as `Some("")` and loaded
|
||||
/// happily - and an empty credential is not a usable one, it just moves the
|
||||
/// failure somewhere worse. A Bambu printer configured that way reaches MQTT
|
||||
/// and is refused, a PrusaLink one gets a 401 on every poll, and nothing in
|
||||
/// either chain says "that field is blank in printers.toml".
|
||||
///
|
||||
/// It is also what makes a half-finished entry safe. `discovery` prints
|
||||
/// blocks with `access_code = ""`/`api_key = ""` for an operator to
|
||||
/// complete, and pasting one in and forgetting to finish it has to fail at
|
||||
/// startup naming the field - which is only true because of this check.
|
||||
/// `tests/discovery_to_fleet.rs` asserts exactly that, and is how the gap
|
||||
/// was found.
|
||||
///
|
||||
/// This is the same trap as `??`-instead-of-`||` on an environment variable
|
||||
/// that ships empty, which `continuum-backend`'s `auth/edge_secret.ts` and
|
||||
/// `s3/index.ts` both document. It bites identically on a TOML key.
|
||||
fn require_field(printer_id: &str, field: &str, value: Option<String>) -> anyhow::Result<String> {
|
||||
value.ok_or_else(|| anyhow::anyhow!("{printer_id}: missing required field '{field}'"))
|
||||
match value {
|
||||
Some(value) if !value.trim().is_empty() => Ok(value),
|
||||
Some(_) => Err(anyhow::anyhow!(
|
||||
"{printer_id}: required field '{field}' is empty - fill it in (discovery leaves it blank on purpose; an empty value is not a usable one)"
|
||||
)),
|
||||
None => Err(anyhow::anyhow!("{printer_id}: missing required field '{field}'")),
|
||||
}
|
||||
}
|
||||
|
||||
+172
-7
@@ -1,12 +1,97 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::Notify;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Supervises the go2rtc subprocess used for camera restreaming (RTSP/USB -> WebRTC).
|
||||
/// Restarts it with a fixed backoff whenever it exits, for as long as the daemon runs.
|
||||
pub async fn watchdog(bin: String, config_path: String) {
|
||||
/// 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.
|
||||
///
|
||||
/// `restart` is what `camera::run` rings after rewriting the config: go2rtc
|
||||
/// reads its configuration once at startup, so a config that changed while it
|
||||
/// was running would otherwise be ignored until the process happened to die.
|
||||
/// 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");
|
||||
|
||||
@@ -19,10 +104,34 @@ pub async fn watchdog(bin: String, config_path: String) {
|
||||
.spawn();
|
||||
|
||||
match spawned {
|
||||
Ok(mut child) => match child.wait().await {
|
||||
Ok(status) => warn!(%status, "go2rtc exited, restarting after backoff"),
|
||||
Err(err) => error!(?err, "failed to wait on go2rtc process"),
|
||||
},
|
||||
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
|
||||
// of this arm, so the restart path needs no explicit
|
||||
// kill - and cannot leave an orphan serving the old
|
||||
// config.
|
||||
waited = child.wait() => match waited {
|
||||
Ok(status) => warn!(%status, "go2rtc exited, restarting after backoff"),
|
||||
Err(err) => error!(?err, "failed to wait on go2rtc process"),
|
||||
},
|
||||
_ = restart.notified() => {
|
||||
info!("go2rtc config changed, restarting it");
|
||||
// Explicitly, rather than relying on the drop: this
|
||||
// awaits the kill so the next iteration cannot race
|
||||
// the old process for the API port.
|
||||
if let Err(err) = child.kill().await {
|
||||
warn!(?err, "could not stop go2rtc for a config change");
|
||||
}
|
||||
// No backoff on this path - a config change is a
|
||||
// deliberate act, not a crash loop.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "failed to spawn go2rtc, is it installed and on PATH?");
|
||||
}
|
||||
@@ -31,3 +140,59 @@ pub async fn watchdog(bin: String, config_path: String) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -1,7 +1,19 @@
|
||||
//! Library surface for `continuum-proxy`. The daemon itself is a binary
|
||||
//! (`src/main.rs`); this crate root exists so standalone examples (see
|
||||
//! `examples/`) and future integration tests can `use continuum_proxy::...`
|
||||
//! without duplicating module declarations.
|
||||
//! Library surface for `continuum-proxy`. The daemon itself is a thin binary
|
||||
//! (`src/main.rs`) that uses this crate; the examples in `examples/` and the
|
||||
//! integration tests in `tests/` use it too.
|
||||
//!
|
||||
//! Everything the daemon needs lives here rather than in the binary, so it is
|
||||
//! compiled once. `go2rtc` is the one exception: supervising that subprocess
|
||||
//! is the daemon's own concern and nothing else calls it. Deciding *what* it
|
||||
//! 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 discovery;
|
||||
pub mod fleet;
|
||||
pub mod printer;
|
||||
pub mod uplink;
|
||||
|
||||
+187
-13
@@ -1,11 +1,19 @@
|
||||
mod config;
|
||||
// Everything but go2rtc comes from the library crate (src/lib.rs) rather than
|
||||
// being re-declared here. Declaring `mod printer;` in both places compiles the
|
||||
// same files twice, once per crate, and the binary copy warned about every
|
||||
// `pub use` re-export and every method only examples or tests call - "unused"
|
||||
// is true inside a binary, where nothing is externally reachable, and wrong
|
||||
// about the library's actual public API. One copy, no false warnings.
|
||||
mod go2rtc;
|
||||
mod printer;
|
||||
mod uplink;
|
||||
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use crate::config::Config;
|
||||
use continuum_proxy::bootstrap::{self, Bootstrap};
|
||||
use continuum_proxy::config::Config;
|
||||
use continuum_proxy::config_import;
|
||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||
use continuum_proxy::{camera, completion, fleet, uplink};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@@ -14,20 +22,186 @@ async fn main() -> anyhow::Result<()> {
|
||||
.init();
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
let config = Config::from_env()?;
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
tracing::info!(gateway_id = %config.gateway_id, farm_id = %config.farm_id, "starting continuum-proxy");
|
||||
|
||||
// Two things run at once for now: the cloud uplink, and the go2rtc
|
||||
// camera-restreaming process. More will join them later - printer
|
||||
// discovery, the plate changer - as you build past src/printer/'s
|
||||
// stubs. `tokio::select!` runs both and stops as soon as either one
|
||||
// returns; both loop forever below, so in practice this runs until the
|
||||
// process is killed.
|
||||
// Loaded and connected here, once, at startup - nothing yet re-reads
|
||||
// printers.toml on a running daemon, so adding or removing a printer
|
||||
// still means a restart.
|
||||
let mut fleet = fleet::load(std::path::Path::new(&config.printers_path))?;
|
||||
connect_fleet(&mut fleet).await;
|
||||
|
||||
// The fleet is now shared with the uplink rather than just parked here
|
||||
// keeping connections alive: the uplink reads each printer's status to
|
||||
// send telemetry up, and looks printers up by id to dispatch commands
|
||||
// down. `Arc<Mutex<..>>` is how two tasks share one mutable thing -
|
||||
// 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.
|
||||
//
|
||||
// The completion watcher is deliberately its own task rather than part
|
||||
// of the uplink: it must keep working with the cloud connection down,
|
||||
// because the person it is for is standing next to the printer. See
|
||||
// `completion::run`.
|
||||
//
|
||||
// The camera builder and the watchdog are two tasks and not one because
|
||||
// they answer different questions - what should be served, and keeping
|
||||
// the thing that serves it alive. They meet at one `Notify`: the builder
|
||||
// rings it after writing a new config, because go2rtc only reads its
|
||||
// configuration at startup.
|
||||
//
|
||||
// `tokio::select!` stops as soon as any returns; all four loop forever,
|
||||
// so in practice this runs until the process is killed.
|
||||
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
|
||||
// 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()?;
|
||||
info!(
|
||||
user = %camera_auth.username,
|
||||
config = %config.go2rtc_config,
|
||||
"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! {
|
||||
_ = uplink::run(config.clone()) => {}
|
||||
_ = go2rtc::watchdog(config.go2rtc_bin.clone(), config.go2rtc_config.clone()) => {}
|
||||
// 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(), outbound.clone(), inbox) => {}
|
||||
_ = continuum_proxy::discovery::report::run(
|
||||
config.discovery.clone(),
|
||||
config.printers_path.clone(),
|
||||
outbound.clone(),
|
||||
) => {}
|
||||
_ = go2rtc::watchdog(
|
||||
config.go2rtc_bin.clone(),
|
||||
config.go2rtc_config.clone(),
|
||||
restart_go2rtc.clone(),
|
||||
) => {}
|
||||
_ = camera::run(
|
||||
fleet.clone(),
|
||||
config.go2rtc_config.clone(),
|
||||
config.go2rtc_api_listen.clone(),
|
||||
camera_auth,
|
||||
restart_go2rtc.clone(),
|
||||
) => {}
|
||||
_ = completion::run(fleet.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;
|
||||
/// serially, a fleet of twenty would take twenty times as long to start).
|
||||
/// Logs success/failure per printer instead of failing startup over one -
|
||||
/// a farm shouldn't refuse to start because one printer is unplugged.
|
||||
async fn connect_fleet(fleet: &mut [PrinterHandle]) {
|
||||
let results =
|
||||
futures_util::future::join_all(fleet.iter_mut().map(|printer| async {
|
||||
let name = printer.display_name();
|
||||
(name, printer.connect().await)
|
||||
}))
|
||||
.await;
|
||||
|
||||
for (name, result) in results {
|
||||
match result {
|
||||
Ok(()) => tracing::info!(printer = %name, "connected"),
|
||||
Err(err) => tracing::warn!(printer = %name, %err, "failed to connect"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1408
-153
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,10 @@
|
||||
//! continuum-schemas for continuum.v1, which is a schema we designed
|
||||
//! ourselves and that both TS and Rust genuinely need.
|
||||
//!
|
||||
//! One file per Bambu *category* (`get_version.rs` for "info", `pushall.rs`
|
||||
//! + `print.rs` for "pushing"/"print" - see below for why those are two
|
||||
//! files, not one). Copy `get_version.rs`'s shape for your next command.
|
||||
//! One file per Bambu *category* (`get_version.rs` for "info",
|
||||
//! `pushall.rs` + `print.rs` for "pushing"/"print" - see below for why
|
||||
//! those are two files, not one). Copy `get_version.rs`'s shape for your
|
||||
//! next command.
|
||||
//!
|
||||
//! Mirrors continuum-schemas' `schemas/bambulab/` JSON Schemas - same
|
||||
//! category split, same "print vs pushing" correction (see that
|
||||
@@ -25,7 +26,10 @@ mod print;
|
||||
mod pushall;
|
||||
|
||||
pub use get_version::{GetVersionRequest, GetVersionResponse, ModuleInfo};
|
||||
pub use print::{AmsState, AmsTray, AmsUnit, EmptyTray, LoadedTray, PrintReport, XcamSettings};
|
||||
pub use print::{
|
||||
AmsState, AmsTray, AmsUnit, EmptyTray, Ipcam, LightReport, LoadedTray, PrintReport, VtTray,
|
||||
XcamSettings,
|
||||
};
|
||||
pub use pushall::PushallRequest;
|
||||
|
||||
/// Fields every Bambu command/response shares. This is the "inheritance"
|
||||
@@ -49,6 +53,12 @@ pub struct CommandEnvelope {
|
||||
/// category can have more than one possible request shape - if that ever
|
||||
/// happens here, that variant's payload becomes its own small
|
||||
/// `#[serde(tag = "command")]` enum instead of a single struct.
|
||||
///
|
||||
/// The "print" category isn't a variant here at all: unlike `info`/
|
||||
/// `pushing`, it has many possible request shapes (`gcode_line`, `pause`,
|
||||
/// `resume`, ...) in continuum-schemas' own `oneOf`, so
|
||||
/// `bambu.rs`'s `send_gcode_line_impl` builds that one directly via
|
||||
/// `continuum-schema-instance` instead of adding it here.
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BambuRequest {
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
//! The ongoing print-state push (root key "print"). Doesn't carry a
|
||||
//! `CommandEnvelope` - no `sequence_id`/`command` seen on this one in the
|
||||
//! real capture, and it may genuinely not have them (an async push, not a
|
||||
//! reply to a specific request) - revisit if a real capture proves
|
||||
//! otherwise.
|
||||
//! The ongoing print-state push (root key "print"). The one real capture
|
||||
//! this struct was originally built from didn't show `sequence_id`/
|
||||
//! `command` - turned out to be because that capture was itself a
|
||||
//! truncated example (it's also missing dozens of other fields the real
|
||||
//! schema defines), not because the message genuinely lacks them:
|
||||
//! `push_status.schema.json` composes the shared envelope schema like
|
||||
//! every other Bambu message, so it does carry `sequence_id` - which
|
||||
//! matters a lot, see `BambuMqttSession`'s doc comment on how it's used
|
||||
//! both to correlate a `pushall` reply and to detect a gap in the
|
||||
//! ongoing organic stream.
|
||||
use super::CommandEnvelope;
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct PrintReport {
|
||||
#[serde(flatten)]
|
||||
pub envelope: CommandEnvelope,
|
||||
/// BambuMQTT v1 only: `1` on an automatic/organic push (a partial
|
||||
/// update - only the fields that changed), `0` on a forced one (a
|
||||
/// `pushing.pushall`-triggered reply - the full object). This is the
|
||||
/// actual, reliable signal for which kind a given message is -
|
||||
/// confirmed against a real printer, unlike trying to infer it from
|
||||
/// request/reply timing (see `BambuMqttSession`'s `PrintStatusCache`,
|
||||
/// which used to try that and got it wrong twice). Now declared in
|
||||
/// `push_status.schema.json` too.
|
||||
pub msg: Option<i32>,
|
||||
pub ams: Option<AmsState>,
|
||||
pub ams_exist_bits: Option<String>,
|
||||
pub insert_flag: Option<bool>,
|
||||
@@ -14,57 +31,293 @@ pub struct PrintReport {
|
||||
pub tray_is_bbl_bits: Option<String>,
|
||||
pub xcam: Option<XcamSettings>,
|
||||
pub xcam_status: Option<String>,
|
||||
// The real payload has a lot more fields than shown here (this
|
||||
// example was truncated) - add them the same way, one field per line,
|
||||
// `Option<T>` for anything that isn't always present.
|
||||
|
||||
// --- Job progress. The reason this struct exists at all, for a print
|
||||
// --- farm: a real capture has 97 top-level fields and the nine above
|
||||
// --- contained none of the ones an operator actually wants.
|
||||
/// `"IDLE"`, `"RUNNING"`, `"PAUSE"`, `"FINISH"`, `"FAILED"` - observed
|
||||
/// `IDLE` on an idle P1P and `RUNNING` mid-print on an H2C. Treated as a
|
||||
/// string rather than an enum deliberately: an unknown state should show
|
||||
/// up as itself, not fail the whole report.
|
||||
pub gcode_state: Option<String>,
|
||||
/// Percent complete, 0-100. v2 also sends an identical `percent`.
|
||||
pub mc_percent: Option<i32>,
|
||||
/// Minutes remaining. v2 also sends an identical `remain_time`.
|
||||
pub mc_remaining_time: Option<i32>,
|
||||
pub layer_num: Option<i32>,
|
||||
pub total_layer_num: Option<i32>,
|
||||
/// Path on the printer, e.g. `/data/Metadata/plate_1.gcode`.
|
||||
pub gcode_file: Option<String>,
|
||||
/// The human-facing job name, e.g. a model filename.
|
||||
pub subtask_name: Option<String>,
|
||||
/// `0` when nothing is wrong.
|
||||
pub print_error: Option<i32>,
|
||||
|
||||
// --- Temperatures. `f32` throughout, not `i32`: v1 reports an idle
|
||||
// --- target as the integer `0` while v2 reports `35.0`, and serde
|
||||
// --- widens an integer into a float but not the reverse.
|
||||
pub nozzle_temper: Option<f32>,
|
||||
pub nozzle_target_temper: Option<f32>,
|
||||
pub bed_temper: Option<f32>,
|
||||
pub bed_target_temper: Option<f32>,
|
||||
/// Not sent by every model - absent from the H2C capture.
|
||||
pub chamber_temper: Option<f32>,
|
||||
|
||||
// --- Fans and connectivity.
|
||||
/// Fan speeds come back as *strings* holding a **0-15 gear**, not a
|
||||
/// percentage and not the 0-255 PWM value `set_fan_speed` sends. All
|
||||
/// three scales are in play at once, so converting carelessly gives a
|
||||
/// number that looks plausible and is wrong:
|
||||
///
|
||||
/// - we send PWM 0-255 (`M106 S<pwm>`, see `percent_to_pwm`),
|
||||
/// - the operator thinks in percent 0-100,
|
||||
/// - the printer reports gear 0-15.
|
||||
///
|
||||
/// Confirmed live on a P1P: a full-speed heatbreak fan reads `"15"`, and
|
||||
/// `set_fan_speed(50)` (PWM 127) read back as `"7"`. Divide by 15 for a
|
||||
/// fraction, not by 100 and not by 255.
|
||||
pub cooling_fan_speed: Option<String>,
|
||||
pub heatbreak_fan_speed: Option<String>,
|
||||
pub big_fan1_speed: Option<String>,
|
||||
pub big_fan2_speed: Option<String>,
|
||||
pub wifi_signal: Option<String>,
|
||||
|
||||
/// Health-management-system entries - the printer's own error/warning
|
||||
/// list, empty when healthy. Left as raw JSON for now: an H2C mid-print
|
||||
/// reported several `{attr, code}` pairs whose meaning needs decoding
|
||||
/// against Bambu's HMS tables before it's worth a typed shape.
|
||||
pub hms: Option<Vec<serde_json::Value>>,
|
||||
|
||||
// --- Stage. Without these a print that is busy preparing is
|
||||
// --- indistinguishable from one that has stalled.
|
||||
/// The stage the printer is in right now; `0` is "none in particular"
|
||||
/// (idle, or actively printing). Confirmed live on a P1P mid-print: it
|
||||
/// walked `54 -> 14 -> 13 -> 0` while `layer_num` stayed `0` and
|
||||
/// `mc_percent` climbed to 14%. That is the only signal separating "doing
|
||||
/// bed levelling and the like before the first layer" from "stuck at
|
||||
/// layer 0", so a stall detector built on `layer_num` alone would raise
|
||||
/// a false alarm on every healthy print start. Deliberately a bare
|
||||
/// number: the code-to-meaning table is Bambu's, and an unknown code
|
||||
/// should still show up as itself.
|
||||
pub stg_cur: Option<i32>,
|
||||
/// The stages planned for this print, in order - `[54, 14, 13]` on the
|
||||
/// live P1P above, `[]` on an idle one, twelve entries on the H2C
|
||||
/// fixture. `stg_cur` walks through these.
|
||||
pub stg: Option<Vec<i32>>,
|
||||
/// The external spool ("virtual tray"). **Observed on v1 only:** the P1P
|
||||
/// fixture carries it, the H2C one has no `vt_tray` key at all. That is a
|
||||
/// genuine generation divergence, not a gap in the capture, so this must
|
||||
/// stay `Option` - the `tray_temp`/`drying_temp` split on `LoadedTray`
|
||||
/// is the precedent, and making that one required broke every report from
|
||||
/// the other generation.
|
||||
pub vt_tray: Option<VtTray>,
|
||||
/// What each light is actually doing. This is the read-back for
|
||||
/// `set_light`, and the only one there is: a `ledctrl` reply says
|
||||
/// `result: "success"` as soon as the command parses, which on a Bambu
|
||||
/// means "accepted" and not "the light changed" (the same trap as the
|
||||
/// print-control verbs). Confirmed on the P1P (2026-10-15) by setting
|
||||
/// the chamber light on and off and reading
|
||||
/// `[{"node":"chamber_light","mode":"on"}]` / `"off"` back here - which
|
||||
/// is the first time `set_light` was shown to do anything at all.
|
||||
///
|
||||
/// Observed on v1 despite only the v2 schema declaring it; the v1
|
||||
/// schema was simply missing it.
|
||||
pub lights_report: Option<Vec<LightReport>>,
|
||||
/// The printer's camera settings - and, on the models that have one, the
|
||||
/// URL of its stream. See `Ipcam`.
|
||||
pub ipcam: Option<Ipcam>,
|
||||
|
||||
// There are still ~60 more top-level fields in a real v2 payload (see
|
||||
// `tests/fixtures/push_status_v2_h2c_printing.json` for the full set):
|
||||
// the `job`/`device`/`net`/`upload` sub-objects, queue state, the
|
||||
// `2D`/`3D` blocks, and so on. Add them the same way - one field per
|
||||
// line, `Option<T>` unless it was seen on every capture from both
|
||||
// generations. `merge_from` below needs no edit when you do.
|
||||
}
|
||||
|
||||
impl PrintReport {
|
||||
/// Merges `newer`'s fields into `self`, in place - BambuMQTT v1's
|
||||
/// organic `push_status` pushes only carry the fields that changed,
|
||||
/// so any field `newer` doesn't have (`None`) leaves `self`'s
|
||||
/// existing value alone instead of clobbering it. Not used for v2
|
||||
/// (always a full object) or for a `pushall`-triggered reply on
|
||||
/// either version (also guaranteed full) - see `BambuMqttSession`.
|
||||
/// Every field below is the same one-liner:
|
||||
/// `self.x = newer.x.or(self.x.take())`. `Option::or` returns the first
|
||||
/// `Option` if it's `Some` and the second otherwise, so this reads as
|
||||
/// "prefer the new value, fall back to what we had" - exactly the merge
|
||||
/// rule, in the place a TS version would write `newer.x ?? self.x`.
|
||||
/// `take()` is what makes it work for a `String`/`Vec` field too: it
|
||||
/// moves the old value out and leaves `None` behind, since the value
|
||||
/// can't be copied out of `self` and we're overwriting it anyway.
|
||||
///
|
||||
/// This replaced two dozen four-line `if newer.x.is_some()` blocks. The
|
||||
/// point isn't brevity for its own sake - it's that the old shape made
|
||||
/// adding a field a three-place edit (struct, merge, and remembering to),
|
||||
/// and forgetting the merge half produced a field that silently never
|
||||
/// persisted across a partial update.
|
||||
pub fn merge_from(&mut self, newer: PrintReport) {
|
||||
self.envelope = newer.envelope;
|
||||
self.msg = newer.msg;
|
||||
|
||||
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());
|
||||
self.tray_exist_bits = newer.tray_exist_bits.or(self.tray_exist_bits.take());
|
||||
self.tray_is_bbl_bits = newer.tray_is_bbl_bits.or(self.tray_is_bbl_bits.take());
|
||||
self.xcam = newer.xcam.or(self.xcam.take());
|
||||
self.xcam_status = newer.xcam_status.or(self.xcam_status.take());
|
||||
|
||||
self.gcode_state = newer.gcode_state.or(self.gcode_state.take());
|
||||
self.mc_percent = newer.mc_percent.or(self.mc_percent.take());
|
||||
self.mc_remaining_time = newer.mc_remaining_time.or(self.mc_remaining_time.take());
|
||||
self.layer_num = newer.layer_num.or(self.layer_num.take());
|
||||
self.total_layer_num = newer.total_layer_num.or(self.total_layer_num.take());
|
||||
self.gcode_file = newer.gcode_file.or(self.gcode_file.take());
|
||||
self.subtask_name = newer.subtask_name.or(self.subtask_name.take());
|
||||
self.print_error = newer.print_error.or(self.print_error.take());
|
||||
|
||||
self.nozzle_temper = newer.nozzle_temper.or(self.nozzle_temper.take());
|
||||
self.nozzle_target_temper = newer.nozzle_target_temper.or(self.nozzle_target_temper.take());
|
||||
self.bed_temper = newer.bed_temper.or(self.bed_temper.take());
|
||||
self.bed_target_temper = newer.bed_target_temper.or(self.bed_target_temper.take());
|
||||
self.chamber_temper = newer.chamber_temper.or(self.chamber_temper.take());
|
||||
|
||||
self.cooling_fan_speed = newer.cooling_fan_speed.or(self.cooling_fan_speed.take());
|
||||
self.heatbreak_fan_speed = newer.heatbreak_fan_speed.or(self.heatbreak_fan_speed.take());
|
||||
self.big_fan1_speed = newer.big_fan1_speed.or(self.big_fan1_speed.take());
|
||||
self.big_fan2_speed = newer.big_fan2_speed.or(self.big_fan2_speed.take());
|
||||
self.wifi_signal = newer.wifi_signal.or(self.wifi_signal.take());
|
||||
|
||||
self.hms = newer.hms.or(self.hms.take());
|
||||
|
||||
self.stg_cur = newer.stg_cur.or(self.stg_cur.take());
|
||||
self.stg = newer.stg.or(self.stg.take());
|
||||
self.vt_tray = newer.vt_tray.or(self.vt_tray.take());
|
||||
self.lights_report = newer.lights_report.or(self.lights_report.take());
|
||||
self.ipcam = newer.ipcam.or(self.ipcam.take());
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera block, which is where a printer says whether it has a stream
|
||||
/// anything else can connect to.
|
||||
///
|
||||
/// Only `rtsp_url` is modelled of the dozen-odd fields in here, because it is
|
||||
/// the only one that is actionable: the rest are recording and timelapse
|
||||
/// preferences the printer manages itself.
|
||||
///
|
||||
/// **It is absent on most printers, and that is the headline.** Confirmed
|
||||
/// from live captures of two machines: an H2C reports
|
||||
/// `rtsps://<ip>:322/streaming/live/1`, while a P1P's block carries
|
||||
/// `ipcam_dev: "1"`, `ipcam_record: "enable"` and *no URL at all*. So the
|
||||
/// P1/A1 class has a camera that simply cannot be reached this way - it
|
||||
/// needs Bambu's proprietary port-6000 JPEG stream, which this crate does
|
||||
/// not implement. `ipcam_dev: "1"` means "a camera exists", not "a camera
|
||||
/// you can connect to", and reading it as the latter would have the gateway
|
||||
/// advertise streams that never play.
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct Ipcam {
|
||||
/// `rtsps://<ip>:322/streaming/live/1` on the models that have it. Note
|
||||
/// it carries no credentials: a client has to add `bblp:<access_code>`
|
||||
/// itself.
|
||||
pub rtsp_url: Option<String>,
|
||||
}
|
||||
|
||||
/// One light's state, as the printer reports it rather than as we asked for
|
||||
/// it. `mode` is the same vocabulary `ledctrl` accepts (`"on"`, `"off"`,
|
||||
/// `"flashing"`), and `node` the same light name - so a round-trip is
|
||||
/// directly comparable, which is the point of having it.
|
||||
///
|
||||
/// Both fields optional on principle rather than from evidence: every entry
|
||||
/// seen on the P1P carried both, but one light in a list is not a sample of
|
||||
/// every light on every model, and a missing `node` must not cost us the
|
||||
/// whole report.
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct LightReport {
|
||||
pub node: Option<String>,
|
||||
pub mode: Option<String>,
|
||||
}
|
||||
|
||||
/// Bambu's own naming has the outer container and the inner array both
|
||||
/// called `ams` - `print.ams.ams`, not a typo here, just what the printer
|
||||
/// actually sends.
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct AmsState {
|
||||
pub ams: Vec<AmsUnit>,
|
||||
}
|
||||
|
||||
/// One physical AMS unit (Bambu's numbering starts at 0 via `id`).
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
/// One physical AMS unit (Bambu's numbering starts at 0 via `id`). Only
|
||||
/// `id` and `tray` were present on every unit across both captures, so the
|
||||
/// rest are optional - an H2C reports three units of different kinds
|
||||
/// (`n3f`/`n3s`) and they don't all carry the same fields.
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct AmsUnit {
|
||||
pub humidity: String,
|
||||
pub id: String,
|
||||
pub temp: String,
|
||||
pub tray: Vec<AmsTray>,
|
||||
pub humidity: Option<String>,
|
||||
pub temp: Option<String>,
|
||||
pub humidity_raw: Option<String>,
|
||||
pub dry_time: Option<i32>,
|
||||
pub info: Option<String>,
|
||||
}
|
||||
|
||||
/// A tray slot is either empty (just `id`) or loaded (the full field set)
|
||||
/// - two genuinely different shapes, not one shape with optional fields,
|
||||
/// so this is an untagged enum rather than a struct full of `Option<T>`.
|
||||
/// A tray slot is either empty (just `id`) or loaded (the full field set) -
|
||||
/// two genuinely different shapes, not one shape with optional fields, so
|
||||
/// this is an untagged enum rather than a struct full of `Option<T>`.
|
||||
/// `#[serde(deny_unknown_fields)]` on `Empty` is load-bearing: untagged
|
||||
/// enums try variants in order, and without it a *loaded* tray (which has
|
||||
/// every field `Empty` allows, plus more) would incorrectly match `Empty`
|
||||
/// first, since a plain struct doesn't reject extra fields by default.
|
||||
/// Verified this the hard way - see git history.
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum AmsTray {
|
||||
Empty(EmptyTray),
|
||||
Loaded(LoadedTray),
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
/// `deny_unknown_fields` stays - it's what stops a *loaded* tray (23 fields,
|
||||
/// a superset of this one) matching `Empty` first. But an empty slot is not
|
||||
/// always just `{"id": "..."}`: an H2C reports `{"id": "2", "state": 0}` on
|
||||
/// an empty slot, and that two-field shape matched neither variant, which
|
||||
/// failed the entire report (and so every `push_status` the printer sent
|
||||
/// while an AMS slot was empty). Captured in
|
||||
/// `tests/fixtures/push_status_v2_h2c_printing.json`.
|
||||
///
|
||||
/// Anything genuinely new here will fail loudly rather than be quietly
|
||||
/// dropped, which is the point of keeping `deny_unknown_fields`: better a
|
||||
/// visible error naming the field than a report that silently vanishes.
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EmptyTray {
|
||||
pub id: String,
|
||||
/// v2 sends this on an empty slot; v1 omits it entirely.
|
||||
pub state: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
/// Captured from both generations (see `tests/push_status_parsing.rs`), and
|
||||
/// they do **not** agree. A v2 tray carries `drying_temp`/`drying_time`; a
|
||||
/// v1 tray carries `tray_temp`/`tray_time` and neither `drying_*` field.
|
||||
/// Every one of these was `String` (required) before, which meant every
|
||||
/// single v1 push carrying AMS data failed to deserialize - silently, so it
|
||||
/// looked like the printer simply wasn't sending anything. All four are
|
||||
/// `Option` now, and so is anything else only one generation was observed
|
||||
/// to send.
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct LoadedTray {
|
||||
pub id: String,
|
||||
pub bed_temp: String,
|
||||
pub bed_temp_type: String,
|
||||
pub cols: Vec<String>,
|
||||
pub drying_temp: String,
|
||||
pub drying_time: String,
|
||||
/// v2 only.
|
||||
pub drying_temp: Option<String>,
|
||||
/// v2 only.
|
||||
pub drying_time: Option<String>,
|
||||
/// v1 only - the same idea as `drying_temp` under a different name.
|
||||
pub tray_temp: Option<String>,
|
||||
/// v1 only - the same idea as `drying_time` under a different name.
|
||||
pub tray_time: Option<String>,
|
||||
pub nozzle_temp_max: String,
|
||||
pub nozzle_temp_min: String,
|
||||
pub remain: i32,
|
||||
@@ -78,11 +331,58 @@ pub struct LoadedTray {
|
||||
pub tray_uuid: String,
|
||||
pub tray_weight: String,
|
||||
pub xcam_info: String,
|
||||
/// Seen on both, but not in the original truncated example this struct
|
||||
/// was built from.
|
||||
pub state: Option<i32>,
|
||||
pub cali_idx: Option<i32>,
|
||||
pub ctype: Option<i32>,
|
||||
pub total_len: Option<i64>,
|
||||
/// Flow-calibration coefficients - v1 sends these on a loaded tray.
|
||||
pub k: Option<f32>,
|
||||
pub n: Option<i32>,
|
||||
}
|
||||
|
||||
/// The external spool. Kept separate from `LoadedTray` rather than shared:
|
||||
/// the shapes differ (no `cols`/`state`, but `k`/`n`/`cali_idx` are always
|
||||
/// there), and bending `LoadedTray` to fit would loosen its required fields
|
||||
/// for AMS trays, which have been seen on both generations. Only `id` is
|
||||
/// required; the rest come from a single P1P capture, and any of them being
|
||||
/// required would fail the whole report the first time another model or
|
||||
/// firmware omits one - the same silent failure `LoadedTray` already hit.
|
||||
/// Not `deny_unknown_fields`: a new key here should be ignored, not take
|
||||
/// every `push_status` down with it.
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct VtTray {
|
||||
/// `"254"` on the P1P - Bambu's fixed id for the external spool.
|
||||
pub id: String,
|
||||
pub bed_temp: Option<String>,
|
||||
pub bed_temp_type: Option<String>,
|
||||
pub nozzle_temp_max: Option<String>,
|
||||
pub nozzle_temp_min: Option<String>,
|
||||
pub remain: Option<i32>,
|
||||
pub tag_uid: Option<String>,
|
||||
pub tray_color: Option<String>,
|
||||
pub tray_diameter: Option<String>,
|
||||
pub tray_id_name: Option<String>,
|
||||
pub tray_info_idx: Option<String>,
|
||||
pub tray_sub_brands: Option<String>,
|
||||
pub tray_type: Option<String>,
|
||||
pub tray_uuid: Option<String>,
|
||||
pub tray_weight: Option<String>,
|
||||
pub xcam_info: Option<String>,
|
||||
/// v1 spelling; v2 AMS trays call these `drying_temp`/`drying_time`,
|
||||
/// and it is unknown what a v2 external spool would send.
|
||||
pub tray_temp: Option<String>,
|
||||
pub tray_time: Option<String>,
|
||||
/// Flow-calibration profile index (`-1` when none) and coefficients.
|
||||
pub cali_idx: Option<i32>,
|
||||
pub k: Option<f32>,
|
||||
pub n: Option<i32>,
|
||||
}
|
||||
|
||||
/// Camera-based AI print monitoring settings (spaghetti detection,
|
||||
/// first-layer inspection, etc.).
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
#[derive(serde::Deserialize, Debug, Clone)]
|
||||
pub struct XcamSettings {
|
||||
pub allow_skip_parts: bool,
|
||||
pub buildplate_marker_detector: bool,
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Getting a file *onto* a Bambu printer - the other half of
|
||||
//! `upload_project_file`, which `bambu.rs` stubbed with a `println!` until
|
||||
//! now. MQTT can start a print, but it can only name a file the printer
|
||||
//! already has; moving the bytes there is a completely separate protocol.
|
||||
//!
|
||||
//! This is deliberately suppaftp's **blocking** client, not its async one,
|
||||
//! driven from `tokio::task::spawn_blocking` at the single call site in
|
||||
//! `bambu.rs`. Two reasons:
|
||||
//!
|
||||
//! 1. The blocking client takes a plain `native_tls::TlsConnector`, so this
|
||||
//! module reuses `BambuTls::build_connector()` exactly as-is and every
|
||||
//! TLS trust decision for a printer stays in that one method (which has
|
||||
//! a long doc comment explaining some hard-won specifics - duplicating
|
||||
//! that reasoning against a second TLS stack is how it drifts).
|
||||
//! 2. suppaftp's async client needs that second TLS stack
|
||||
//! (`async-native-tls`), whose futures-vs-tokio `AsyncRead` mismatch has
|
||||
//! already cost this project one debugging session.
|
||||
//!
|
||||
//! **Unverified against real hardware.** The port, the username, and
|
||||
//! implicit TLS are well-established for Bambu LAN mode, and the TLS layer
|
||||
//! itself is the same one `test_tls_handshake` already proves against a
|
||||
//! real printer on 8883. But no physical printer has accepted an upload
|
||||
//! from *this* code yet. Expect the first live run to be where the
|
||||
//! remaining details get settled - see `bambu.rs::upload_project_file` for
|
||||
//! the list of specific things to watch.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use suppaftp::types::FileType;
|
||||
use suppaftp::{NativeTlsConnector, NativeTlsFtpStream};
|
||||
|
||||
/// Bambu LAN mode speaks *implicit* FTPS: TLS starts the instant the socket
|
||||
/// opens, with no plaintext `AUTH TLS` negotiation first. That's the older
|
||||
/// of the two FTPS styles and formally deprecated, which is exactly why
|
||||
/// suppaftp hides `connect_secure_implicit` behind a `deprecated` cargo
|
||||
/// feature. The printers only speak this, so the feature is switched on in
|
||||
/// `Cargo.toml`.
|
||||
const FTPS_PORT: u16 = 990;
|
||||
|
||||
/// The same for every Bambu printer in LAN mode. The password is the same
|
||||
/// access code MQTT authenticates with - one shared secret, two protocols.
|
||||
const FTPS_USER: &str = "bblp";
|
||||
|
||||
/// Storage the printer's own firmware owns, and the directory Bambu Studio
|
||||
/// uploads to as well. A `project_file`/`gcode_file` MQTT command can only
|
||||
/// print something that already lives here.
|
||||
pub const CACHE_DIR: &str = "cache";
|
||||
|
||||
/// The SD card's root, which is where an FTPS login lands and - confirmed
|
||||
/// live on a P1P, 2026-10-11 - the only directory a `print.project_file`
|
||||
/// command has been seen to print from.
|
||||
///
|
||||
/// The card's two interesting directories hold different things, and a
|
||||
/// listing of the real P1P is the clearest way to say which:
|
||||
///
|
||||
/// - the root holds 43 `<name>.gcode.3mf` files, the printable project
|
||||
/// archives Bambu Studio sends when it uploads a plate;
|
||||
/// - `cache/` holds 20 `<project>_plate_N.gcode` files with
|
||||
/// `<N>_<project>.gcode.bbl` sidecars - the printer's own extracted
|
||||
/// per-plate working copies.
|
||||
///
|
||||
/// Those extracted copies look like the obvious thing to print and are not:
|
||||
/// `print.gcode_file` with `param: "/cache/Cube_plate_1.gcode"` was
|
||||
/// rejected (`result: "fail"`) for a file that demonstrably exists, while
|
||||
/// `print.project_file` with `url: "ftp:///Cube.gcode.3mf"` against the
|
||||
/// root started a print. So uploads go here, not into `cache/`.
|
||||
pub const SD_ROOT: &str = "/";
|
||||
|
||||
/// Blocking: opens an implicit-FTPS session to `host`, logs in with
|
||||
/// `access_code`, and writes `data` to `cache/{file_name}`. Returns however
|
||||
/// many bytes the server reported accepting.
|
||||
///
|
||||
/// Takes an already-built `connector` rather than a `&BambuTls` so the
|
||||
/// caller resolves trust on the async side and this function can be moved
|
||||
/// wholesale onto a blocking thread - `native_tls::TlsConnector` is `Send`,
|
||||
/// which is what makes that legal.
|
||||
pub fn upload(
|
||||
host: &str,
|
||||
access_code: &str,
|
||||
connector: native_tls::TlsConnector,
|
||||
dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
) -> anyhow::Result<u64> {
|
||||
let mut ftp = NativeTlsFtpStream::connect_secure_implicit(
|
||||
(host, FTPS_PORT),
|
||||
NativeTlsConnector::from(connector),
|
||||
host,
|
||||
)?;
|
||||
|
||||
ftp.login(FTPS_USER, access_code)?;
|
||||
|
||||
// A .3mf is a zip archive and a .gcode has meaningful line endings -
|
||||
// either way, letting the server treat the stream as text is free
|
||||
// corruption.
|
||||
ftp.transfer_type(FileType::Binary)?;
|
||||
// `SD_ROOT` means "don't change directory at all" - the FTPS login
|
||||
// already lands in the card's root, and `cwd("/")` is a needless round
|
||||
// trip that some servers answer less predictably than no request.
|
||||
if dir != SD_ROOT {
|
||||
ftp.cwd(dir)?;
|
||||
}
|
||||
|
||||
// `put_file` wants something that implements `Read`. `data` is already
|
||||
// bytes in memory (see `GenericPrinter::upload_project_file`'s doc
|
||||
// comment for why it's bytes and not a path), and `Cursor` is the
|
||||
// standard way to let something read from a slice - roughly wrapping a
|
||||
// Buffer in a stream in Node.
|
||||
let mut reader = Cursor::new(data);
|
||||
let written = ftp.put_file(file_name, &mut reader)?;
|
||||
|
||||
// Best-effort: the bytes are already delivered and acknowledged, so
|
||||
// failing to say goodbye politely isn't worth failing the upload over.
|
||||
let _ = ftp.quit();
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Blocking: reads one file off the printer's SD card.
|
||||
///
|
||||
/// Read-only. The reason this exists: a `.3mf` already on the card is the
|
||||
/// only record of which filament each of its plates was sliced for, and
|
||||
/// `ams_mapping` cannot be built without that - see
|
||||
/// `schemas/bambulab/v1/common/ams/ams_mapping.schema.json`. Printing a
|
||||
/// project with the wrong mapping is not a cosmetic error; it draws the
|
||||
/// wrong filament, or none.
|
||||
pub fn download(
|
||||
host: &str,
|
||||
access_code: &str,
|
||||
connector: native_tls::TlsConnector,
|
||||
path: &str,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let mut ftp = NativeTlsFtpStream::connect_secure_implicit(
|
||||
(host, FTPS_PORT),
|
||||
NativeTlsConnector::from(connector),
|
||||
host,
|
||||
)?;
|
||||
|
||||
ftp.login(FTPS_USER, access_code)?;
|
||||
// A `.3mf` is a zip archive; letting the server treat it as text is free
|
||||
// corruption, exactly as on the way up.
|
||||
ftp.transfer_type(FileType::Binary)?;
|
||||
|
||||
// `retr_as_buffer` rather than `retr_as_stream`: suppaftp 12's
|
||||
// `TransferStream` finalizes itself on drop, and a project archive is
|
||||
// read whole anyway - `plate_filaments` needs the central directory at
|
||||
// its end before it can read anything.
|
||||
//
|
||||
// Confirmed fine at 4.96 MB on the P1P (2026-10-15, byte-identical
|
||||
// round trip). The qualifier here used to be "a few hundred KB", which
|
||||
// the card itself disproves - the largest project on it is 62 MB, and
|
||||
// that one would be held in memory twice during a round trip. Streaming
|
||||
// is the answer if that ever matters; it has not yet.
|
||||
let bytes = ftp.retr_as_buffer(path)?.into_inner();
|
||||
|
||||
let _ = ftp.quit();
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Blocking: removes one file from the card.
|
||||
///
|
||||
/// The counterpart to `upload`, and the only reason it did not exist before
|
||||
/// is that nothing had uploaded anything worth removing. It is needed now:
|
||||
/// the P1P's card already holds 45 project archives and 47 extracted
|
||||
/// `cache/` entries, so a gateway that uploads a project per job and never
|
||||
/// removes one is a gateway that eventually fills a customer's SD card and
|
||||
/// starts failing prints for a reason nobody will connect to this code.
|
||||
///
|
||||
/// Deliberately takes one explicit path and does no globbing or recursion:
|
||||
/// the obvious convenience here ("clear the old ones") is also the obvious
|
||||
/// way to delete a file somebody printed from last week.
|
||||
pub fn delete(
|
||||
host: &str,
|
||||
access_code: &str,
|
||||
connector: native_tls::TlsConnector,
|
||||
path: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut ftp = NativeTlsFtpStream::connect_secure_implicit(
|
||||
(host, FTPS_PORT),
|
||||
NativeTlsConnector::from(connector),
|
||||
host,
|
||||
)?;
|
||||
|
||||
ftp.login(FTPS_USER, access_code)?;
|
||||
ftp.rm(path)?;
|
||||
|
||||
let _ = ftp.quit();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Blocking: lists `cache/` on the printer.
|
||||
///
|
||||
/// Read-only, and the counterpart to `upload`. The previous version of this
|
||||
/// comment said a `project_file` command "can only print something that is
|
||||
/// already in this directory", which is wrong twice over: the printable
|
||||
/// projects are in the root (see `SD_ROOT`), and this directory's contents
|
||||
/// were rejected when asked for directly. It is still how you find a real
|
||||
/// slicer-produced `.3mf`
|
||||
/// to test against, instead of hand-building one and then not knowing
|
||||
/// whether a failure was the command or the file.
|
||||
///
|
||||
/// Returns the server's raw LIST lines rather than parsed entries: Bambu's
|
||||
/// FTPS server formats them in the usual unix style, but the parsing is the
|
||||
/// caller's problem until something needs more than the file names.
|
||||
pub fn list(
|
||||
host: &str,
|
||||
access_code: &str,
|
||||
connector: native_tls::TlsConnector,
|
||||
dir: &str,
|
||||
) -> anyhow::Result<Vec<String>> {
|
||||
let mut ftp = NativeTlsFtpStream::connect_secure_implicit(
|
||||
(host, FTPS_PORT),
|
||||
NativeTlsConnector::from(connector),
|
||||
host,
|
||||
)?;
|
||||
|
||||
ftp.login(FTPS_USER, access_code)?;
|
||||
ftp.transfer_type(FileType::Binary)?;
|
||||
|
||||
let entries = ftp.list(Some(dir))?;
|
||||
|
||||
let _ = ftp.quit();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
//! The real MQTT wiring for Bambu printers - the piece `connect`/
|
||||
//! `send_gcode_line` in `bambu.rs` used to just stub out with a
|
||||
//! `println!`. Three things make this more than "call `publish`, get a
|
||||
//! `String` back":
|
||||
//!
|
||||
//! 1. MQTT v3.1.1 (BambuMQTT v1) and MQTT v5 (BambuMQTT v2) are two
|
||||
//! entirely separate rumqttc client types (`rumqttc::AsyncClient` vs
|
||||
//! `rumqttc::v5::AsyncClient`) with different event/error types -
|
||||
//! `BambuMqttClient` is the enum that hides which one a connection
|
||||
//! ended up using, same pattern as `PrinterHandle` hides which vendor
|
||||
//! struct you're holding.
|
||||
//! 2. A response to a published request doesn't come back from that same
|
||||
//! call - it arrives later, asynchronously, on a *subscription* to
|
||||
//! `device/{sn}/report`. That needs a background task continuously
|
||||
//! polling the connection (rumqttc doesn't do this for you - nothing
|
||||
//! happens until you poll the `EventLoop` yourself, repeatedly,
|
||||
//! forever) - this is the first real use of `tokio::spawn` in this
|
||||
//! crate, flagged as deferred since `printer/mod.rs`'s very first doc
|
||||
//! comment, and this is why: it only makes sense once there's a
|
||||
//! long-running background job that has to run independently of
|
||||
//! whatever call site is waiting on a response.
|
||||
//!
|
||||
//! Correlating a reply to its request isn't as uniform as it looks -
|
||||
//! confirmed live, not assumed. `info.get_version`'s reply echoes back
|
||||
//! the `sequence_id` you sent; `request` relies on that. A tempting
|
||||
//! generalization - add your own extra field instead of `sequence_id`,
|
||||
//! since most replies are supposed to echo back whatever extra fields
|
||||
//! a request carried - turned out false for this exact command: a real
|
||||
//! H2C's `get_version` reply had precisely `["command", "module",
|
||||
//! "sequence_id"]`, nothing else, so a homemade correlation field never
|
||||
//! survived and every request timed out. `sequence_id` - proven to
|
||||
//! round-trip, not just assumed to - is what's actually used.
|
||||
//! `print.push_status` doesn't echo `sequence_id` either (the printer
|
||||
//! stamps that with its own free-running counter regardless of what
|
||||
//! triggered it) - but it was never a request/reply case to begin with
|
||||
//! (`pushing.pushall` triggers an ongoing *stream*, not a single
|
||||
//! reply) - see `PrintStatusCache` for how that's handled instead.
|
||||
//! 3. `print.push_status` also needs to be kept as running in-memory
|
||||
//! state, not just handed to whoever happens to be waiting at the
|
||||
//! moment it arrives - see `PrintStatusCache` below for why (short
|
||||
//! version: v1 only ever sends partial updates, so something has to
|
||||
//! remember what the fields *were* to produce a coherent whole
|
||||
//! object).
|
||||
//!
|
||||
//! Unverified against a live printer (no way to force it in a test): the
|
||||
//! exact `ConnectReturnCode`/error shape a real Bambu printer's embedded
|
||||
//! broker gives when it doesn't understand MQTT v5's CONNECT at all
|
||||
//! (rather than a clean version-rejection CONNACK) - `open()` below
|
||||
//! treats *any* failure of the v5 attempt as "fall back to v3.1.1", not
|
||||
//! just a precisely-matched version-rejection code, since a broker that
|
||||
//! can't parse a v5 CONNECT packet may just drop the connection instead
|
||||
//! of replying with one.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use rumqttc::v5::mqttbytes::v5::Packet as PacketV5;
|
||||
use rumqttc::v5::mqttbytes::QoS as QoSV5;
|
||||
use rumqttc::{Event, Packet, QoS, Transport, TlsConfiguration};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
|
||||
use super::bambu::BambuTls;
|
||||
use super::bambu_commands::PrintReport;
|
||||
use super::BambuProtocolVersion;
|
||||
use super::PrinterError;
|
||||
|
||||
/// Requests waiting on a report with a matching `sequence_id` - only
|
||||
/// valid for a command empirically confirmed to echo it back, like
|
||||
/// `get_version` (see this module's doc comment - a homemade correlation
|
||||
/// field was tried instead and confirmed *not* to survive on this
|
||||
/// command's reply). Shared between whichever call registered one (in
|
||||
/// `request`) and the background poll task that completes it.
|
||||
type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>;
|
||||
|
||||
const REQUEST_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
|
||||
/// (25625 bytes - it reports a lot of modules: dual AMS, AMS HT, six WTM
|
||||
/// units, ...) blew straight through that limit and killed the whole
|
||||
/// connection outright (a real MQTT v5 protocol error, not a timeout).
|
||||
/// `print.push_status` reports (full AMS/xcam state) can get large too.
|
||||
/// 1MB has real headroom for that.
|
||||
const MAX_PACKET_SIZE: usize = 1024 * 1024;
|
||||
|
||||
/// Every `sequence_id` this crate generates gets this prefix - a printer
|
||||
/// runs its own independent counter for unsolicited messages (confirmed
|
||||
/// live: `print.push_status`'s was already in the 500s at the start of a
|
||||
/// session, climbing steadily), and that counter has only ever been
|
||||
/// observed as a plain unprefixed integer string. An earlier version of
|
||||
/// this just started our own counter from a large offset instead of
|
||||
/// prefixing it, to dodge the same range a printer's counter climbs
|
||||
/// through - confirmed live (with an ISO-timestamp-prefixed sequence_id)
|
||||
/// that `sequence_id` isn't required to be numeric at all, so a
|
||||
/// non-numeric prefix rules out collision outright instead of merely
|
||||
/// making it improbable, and the counter itself can go back to starting
|
||||
/// at 1 each session.
|
||||
const SEQUENCE_ID_PREFIX: &str = "cxproxy-";
|
||||
|
||||
enum BambuMqttClient {
|
||||
V1(rumqttc::AsyncClient),
|
||||
V2(rumqttc::v5::AsyncClient),
|
||||
}
|
||||
|
||||
/// The running in-memory `print.push_status` object, plus enough state to
|
||||
/// know how to fold the next update into it:
|
||||
///
|
||||
/// - BambuMQTT v2 sends the *entire* object every time - always replace.
|
||||
/// - BambuMQTT v1 only sends the fields that changed since last time -
|
||||
/// merge into the existing object, or the picture goes incomplete the
|
||||
/// instant anything not present in the latest push gets treated as
|
||||
/// "unset". The exception is a `pushing.pushall`-triggered push, which
|
||||
/// is always the full object - and v1 self-describes which kind a given
|
||||
/// message is via its own `msg` field (`1` automatic/partial, `0`
|
||||
/// forced/full), confirmed against a real printer. `push_status` also
|
||||
/// doesn't echo `sequence_id` (see this module's doc comment), so it
|
||||
/// couldn't be identified via `request`'s correlation even if the
|
||||
/// timing worked out; `msg` needs none of that: it's on the message
|
||||
/// itself, correct regardless of which client (or nothing at all)
|
||||
/// triggered it, and regardless of timing.
|
||||
/// - **v2 does not use `sequence_id` as a counter here at all.** Confirmed
|
||||
/// live against an H2C mid-print on 2026-10-03: every organic
|
||||
/// `push_status` carried the *same* `sequence_id` (`"2021"`) for minutes
|
||||
/// on end while the contents (percent, layer, remaining time) changed
|
||||
/// normally. So on v2 it identifies nothing and orders nothing - don't
|
||||
/// use it to detect staleness or to deduplicate. The gap check below
|
||||
/// already skips v2 (every v2 message is a full object), which turns out
|
||||
/// to be necessary rather than merely tidy: it would otherwise warn on
|
||||
/// every single message.
|
||||
/// - `last_sequence_id` exists to notice when v1's organic stream skipped
|
||||
/// an update (network hiccup, a message this client wasn't fast enough
|
||||
/// to read): if the incoming `sequence_id` isn't exactly one more than
|
||||
/// the last one seen, the merged object is missing whatever changed in
|
||||
/// the gap. There's no way to know what that baseline number even is
|
||||
/// ahead of time - the printer runs its own counter, not one this
|
||||
/// client started - so the very first `push_status` received after
|
||||
/// connecting just seeds `last_sequence_id` rather than being checked
|
||||
/// against anything.
|
||||
struct PrintStatusCache {
|
||||
report: Option<PrintReport>,
|
||||
last_sequence_id: Option<u64>,
|
||||
}
|
||||
|
||||
impl PrintStatusCache {
|
||||
fn new() -> Self {
|
||||
Self { report: None, last_sequence_id: None }
|
||||
}
|
||||
|
||||
fn apply(&mut self, incoming: PrintReport, version: BambuProtocolVersion) {
|
||||
let incoming_seq = incoming.envelope.sequence_id.parse::<u64>().ok();
|
||||
// A forced/full update isn't missing anything by definition, so a
|
||||
// sequence gap right after one (or on v2, where every message is
|
||||
// full) isn't a real problem worth warning about.
|
||||
let is_full_object = matches!(version, BambuProtocolVersion::V2) || incoming.msg != Some(1);
|
||||
|
||||
// Only reachable for v1 partial updates. Historically this fired
|
||||
// constantly and blamed the network, when the real cause was local:
|
||||
// a push that failed to deserialize never reached this function, so
|
||||
// the next one that did looked like it had skipped ahead. That is now
|
||||
// reported where it happens (see `handle_incoming_report`), which
|
||||
// leaves this warning meaning what it says.
|
||||
if let (Some(last), Some(incoming_seq), false) = (self.last_sequence_id, incoming_seq, is_full_object)
|
||||
&& incoming_seq != last.wrapping_add(1)
|
||||
{
|
||||
eprintln!(
|
||||
"print.push_status sequence gap: expected {}, got {incoming_seq} - the in-memory \
|
||||
object may be missing whatever changed in between",
|
||||
last.wrapping_add(1)
|
||||
);
|
||||
}
|
||||
if let Some(incoming_seq) = incoming_seq {
|
||||
self.last_sequence_id = Some(incoming_seq);
|
||||
}
|
||||
|
||||
match (&mut self.report, is_full_object) {
|
||||
(_, true) => self.report = Some(incoming),
|
||||
(Some(existing), false) => existing.merge_from(incoming),
|
||||
(None, false) => self.report = Some(incoming),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One live MQTT connection to a Bambu printer - whichever protocol
|
||||
/// version `open()` settled on.
|
||||
pub struct BambuMqttSession {
|
||||
client: BambuMqttClient,
|
||||
pending: PendingTable,
|
||||
print_status: Arc<Mutex<PrintStatusCache>>,
|
||||
sn: String,
|
||||
next_sequence_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl BambuMqttSession {
|
||||
/// `ForceV1`/`ForceV2` connect with that protocol directly. `Auto`
|
||||
/// tries v5 first and falls back to v3.1.1 if that attempt fails -
|
||||
/// see this module's doc comment for why "any failure", not just a
|
||||
/// precisely-matched version-rejection code.
|
||||
pub async fn open(
|
||||
host: &str,
|
||||
access_code: &str,
|
||||
sn: &str,
|
||||
tls: &BambuTls,
|
||||
preference: super::bambu::BambuProtocolPreference,
|
||||
) -> Result<(Self, BambuProtocolVersion), PrinterError> {
|
||||
use super::bambu::BambuProtocolPreference as Pref;
|
||||
|
||||
match preference {
|
||||
Pref::ForceV1 => {
|
||||
let session = Self::open_v1(host, access_code, sn, tls).await?;
|
||||
Ok((session, BambuProtocolVersion::V1))
|
||||
}
|
||||
Pref::ForceV2 => {
|
||||
let session = Self::open_v2(host, access_code, sn, tls).await?;
|
||||
Ok((session, BambuProtocolVersion::V2))
|
||||
}
|
||||
Pref::Auto => match Self::open_v2(host, access_code, sn, tls).await {
|
||||
Ok(session) => Ok((session, BambuProtocolVersion::V2)),
|
||||
Err(_) => {
|
||||
let session = Self::open_v1(host, access_code, sn, tls).await?;
|
||||
Ok((session, BambuProtocolVersion::V1))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_v1(host: &str, access_code: &str, sn: &str, tls: &BambuTls) -> Result<Self, PrinterError> {
|
||||
let connector = tls.build_connector().map_err(|err| PrinterError(format!("TLS setup failed: {err}")))?;
|
||||
|
||||
let mut options = rumqttc::MqttOptions::new(format!("continuum-{sn}"), host, 8883);
|
||||
options.set_credentials("bblp", access_code);
|
||||
options.set_transport(Transport::tls_with_config(TlsConfiguration::NativeConnector(connector)));
|
||||
options.set_keep_alive(Duration::from_secs(30));
|
||||
options.set_max_packet_size(MAX_PACKET_SIZE, MAX_PACKET_SIZE);
|
||||
|
||||
let (client, mut event_loop) = rumqttc::AsyncClient::new(options, 10);
|
||||
client
|
||||
.subscribe(format!("device/{sn}/report"), QoS::AtMostOnce)
|
||||
.await
|
||||
.map_err(|err| PrinterError(format!("subscribe failed: {err}")))?;
|
||||
|
||||
// Nothing actually happens on the wire until the eventloop is
|
||||
// polled - this first poll is what makes the TCP+TLS+CONNECT
|
||||
// handshake happen, and its result is the only way to know
|
||||
// whether it worked.
|
||||
match event_loop.poll().await {
|
||||
Ok(_) => {}
|
||||
Err(err) => return Err(PrinterError(format!("MQTT v3.1.1 connect failed: {err}"))),
|
||||
}
|
||||
|
||||
let pending: PendingTable = Arc::new(Mutex::new(HashMap::new()));
|
||||
let print_status = Arc::new(Mutex::new(PrintStatusCache::new()));
|
||||
let sn_owned = sn.to_string();
|
||||
tokio::spawn(poll_loop_v1(event_loop, pending.clone(), print_status.clone(), BambuProtocolVersion::V1, sn_owned));
|
||||
|
||||
Ok(Self {
|
||||
client: BambuMqttClient::V1(client),
|
||||
pending,
|
||||
print_status,
|
||||
sn: sn.to_string(),
|
||||
next_sequence_id: AtomicU64::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
async fn open_v2(host: &str, access_code: &str, sn: &str, tls: &BambuTls) -> Result<Self, PrinterError> {
|
||||
let connector = tls.build_connector().map_err(|err| PrinterError(format!("TLS setup failed: {err}")))?;
|
||||
|
||||
let mut options = rumqttc::v5::MqttOptions::new(format!("continuum-{sn}"), host, 8883);
|
||||
options.set_credentials("bblp", access_code);
|
||||
options.set_transport(Transport::tls_with_config(TlsConfiguration::NativeConnector(connector)));
|
||||
options.set_keep_alive(Duration::from_secs(30));
|
||||
options.set_max_packet_size(Some(MAX_PACKET_SIZE as u32));
|
||||
|
||||
let (client, mut event_loop) = rumqttc::v5::AsyncClient::new(options, 10);
|
||||
client
|
||||
.subscribe(format!("device/{sn}/report"), QoSV5::AtMostOnce)
|
||||
.await
|
||||
.map_err(|err| PrinterError(format!("subscribe failed: {err}")))?;
|
||||
|
||||
match event_loop.poll().await {
|
||||
Ok(_) => {}
|
||||
Err(err) => return Err(PrinterError(format!("MQTT v5 connect failed: {err}"))),
|
||||
}
|
||||
|
||||
let pending: PendingTable = Arc::new(Mutex::new(HashMap::new()));
|
||||
let print_status = Arc::new(Mutex::new(PrintStatusCache::new()));
|
||||
let sn_owned = sn.to_string();
|
||||
tokio::spawn(poll_loop_v2(event_loop, pending.clone(), print_status.clone(), BambuProtocolVersion::V2, sn_owned));
|
||||
|
||||
Ok(Self {
|
||||
client: BambuMqttClient::V2(client),
|
||||
pending,
|
||||
print_status,
|
||||
sn: sn.to_string(),
|
||||
next_sequence_id: AtomicU64::new(1),
|
||||
})
|
||||
}
|
||||
|
||||
/// Publishes `body` under `device/{sn}/request`, wrapped as
|
||||
/// `{category: body}` (Bambu's top-level key names the *category*,
|
||||
/// e.g. "print"/"info" - `body`'s own `command` field says which
|
||||
/// specific one), and waits up to `REQUEST_TIMEOUT` for a report
|
||||
/// whose `sequence_id` matches the one this call sent.
|
||||
///
|
||||
/// This crate tried adding its own correlation field
|
||||
/// (`continuum_correlation_id`) instead of relying on `sequence_id`, on
|
||||
/// the understanding that extra fields survive into most replies.
|
||||
/// Confirmed live that this doesn't hold for `get_version`
|
||||
/// specifically - a real H2C's reply had exactly `["command", "module",
|
||||
/// "sequence_id"]`, nothing extra at all, so the correlation field
|
||||
/// never came back and every `get_version` call just timed out.
|
||||
/// `sequence_id` is what's actually proven to round-trip for it
|
||||
/// (confirmed earlier, live, on both protocol versions), so that's what
|
||||
/// this uses. Whether a custom field survives on some *other* command
|
||||
/// (`calibration`, say) is unverified, not confirmed false - this was
|
||||
/// only ever checked for `get_version`.
|
||||
///
|
||||
/// Not for `print.push_status` - that's not a single reply, see
|
||||
/// `PrintStatusCache`. Not for a command with no reply at all either,
|
||||
/// like `gcode_line` - use `publish` (fire-and-forget) for those.
|
||||
pub async fn request(&self, category: &str, mut body: Value) -> Result<Value, PrinterError> {
|
||||
let sequence_id = format!(
|
||||
"{SEQUENCE_ID_PREFIX}{}",
|
||||
self.next_sequence_id.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
body["sequence_id"] = Value::String(sequence_id.clone());
|
||||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pending.lock().await.insert(sequence_id.clone(), tx);
|
||||
|
||||
if let Err(err) = self.publish(category, &body).await {
|
||||
self.pending.lock().await.remove(&sequence_id);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(_)) => Err(PrinterError(format!("{} closed before replying", self.sn))),
|
||||
Err(_) => {
|
||||
self.pending.lock().await.remove(&sequence_id);
|
||||
Err(PrinterError(format!("{} did not reply within {REQUEST_TIMEOUT:?}", self.sn)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes `body` under `device/{sn}/request`, wrapped as
|
||||
/// `{category: body}`, without waiting for a reply - for commands
|
||||
/// like `gcode_line` and `pushing.pushall` that don't get a direct
|
||||
/// correlated reply (an implicit ack the printer just executes, or an
|
||||
/// ongoing stream instead of a single answer, respectively).
|
||||
pub async fn publish(&self, category: &str, body: &Value) -> Result<(), PrinterError> {
|
||||
self.publish_raw(serde_json::json!({ category: body }).to_string()).await
|
||||
}
|
||||
|
||||
/// Publishes an already-serialized payload under `device/{sn}/request`
|
||||
/// as-is, with no category wrapping - the low-level primitive both
|
||||
/// `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> {
|
||||
let topic = format!("device/{}/request", 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
|
||||
/// has arrived since connecting - `None` until then. Cloned out from
|
||||
/// behind the lock rather than handing out a guard, so callers aren't
|
||||
/// holding the poll loop's own lock while they use it.
|
||||
pub async fn print_status(&self) -> Option<PrintReport> {
|
||||
self.print_status.lock().await.report.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs forever, polling the v3.1.1 eventloop and routing incoming
|
||||
/// reports (pending-request correlation, `print.push_status` caching).
|
||||
/// Exits - loudly, not silently like before (that silence is exactly
|
||||
/// what made a real dropped connection to a live H2C look identical to
|
||||
/// "the printer just isn't sending anything") - once the connection is
|
||||
/// gone for good. Dropping the `BambuMqttSession` drops its `AsyncClient`,
|
||||
/// which is what eventually makes that happen during normal shutdown;
|
||||
/// this also fires on an unexpected disconnect, which is the case this
|
||||
/// logging is actually for. No reconnect logic yet - that's the next
|
||||
/// thing to add here, not something to fake by pretending the loop
|
||||
/// didn't end.
|
||||
async fn poll_loop_v1(
|
||||
mut event_loop: rumqttc::EventLoop,
|
||||
pending: PendingTable,
|
||||
print_status: Arc<Mutex<PrintStatusCache>>,
|
||||
version: BambuProtocolVersion,
|
||||
sn: String,
|
||||
) {
|
||||
loop {
|
||||
match event_loop.poll().await {
|
||||
Ok(Event::Incoming(Packet::Publish(publish))) => {
|
||||
handle_incoming_report(&sn, &pending, &print_status, version, &publish.payload).await;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
eprintln!("[{sn}] MQTT v3.1.1 connection lost, no longer receiving reports: {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll_loop_v2(
|
||||
mut event_loop: rumqttc::v5::EventLoop,
|
||||
pending: PendingTable,
|
||||
print_status: Arc<Mutex<PrintStatusCache>>,
|
||||
version: BambuProtocolVersion,
|
||||
sn: String,
|
||||
) {
|
||||
loop {
|
||||
match event_loop.poll().await {
|
||||
Ok(rumqttc::v5::Event::Incoming(PacketV5::Publish(publish))) => {
|
||||
handle_incoming_report(&sn, &pending, &print_status, version, &publish.payload).await;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
eprintln!("[{sn}] MQTT v5 connection lost, no longer receiving reports: {err}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an incoming report, unwraps its single category key (a report
|
||||
/// is `{category: {"sequence_id": "...", ...}}`, same shape a request is
|
||||
/// published as - `request` publishes `body` and wants `body`-shaped data
|
||||
/// back, not the wrapper around it), and:
|
||||
///
|
||||
/// 1. If its `sequence_id` matches something `request` is waiting on,
|
||||
/// hands it the unwrapped body - but doesn't wake that waiter yet
|
||||
/// (see 3 for why).
|
||||
/// 2. If the unwrapped body is a `print.push_status`, folds it into the
|
||||
/// running `PrintStatusCache` (which reads the message's own `msg`
|
||||
/// field to decide full-vs-merge, not anything to do with step 1 - see
|
||||
/// `PrintStatusCache::apply`'s doc comment for why not).
|
||||
/// 3. Only now - once the cache is guaranteed up to date - actually sends
|
||||
/// on whichever oneshot matched in step 1, waking `request`. The order
|
||||
/// of 2 and 3 is load-bearing, caught live on an earlier version of
|
||||
/// this mechanism: sending on the oneshot first let a waiter's task
|
||||
/// resume and check `print_status()` before this function had gotten
|
||||
/// around to updating it. Nothing here awaits between the cache write
|
||||
/// and the send, so that race stays closed.
|
||||
///
|
||||
/// A report matching nothing pending isn't an error - most Bambu reports
|
||||
/// (including every organic `push_status` push) aren't a reply to
|
||||
/// anything specific.
|
||||
async fn handle_incoming_report(
|
||||
sn: &str,
|
||||
pending: &PendingTable,
|
||||
print_status: &Arc<Mutex<PrintStatusCache>>,
|
||||
version: BambuProtocolVersion,
|
||||
payload: &[u8],
|
||||
) {
|
||||
// The raw bytes, before anything gets parsed or thrown away. `PrintReport`
|
||||
// deliberately models only a fraction of what a real `push_status`
|
||||
// carries (see its doc comment), so without this the rest is
|
||||
// unrecoverable - you can't add a field to that struct if you can never
|
||||
// see what the printer actually sent. At `trace` so it costs nothing
|
||||
// normally; turn it on with
|
||||
// `RUST_LOG=continuum_proxy::printer::bambu_mqtt=trace`.
|
||||
tracing::trace!(target: "continuum_proxy::printer::bambu_mqtt", sn, raw = %String::from_utf8_lossy(payload), "incoming report");
|
||||
|
||||
let Ok(value) = serde_json::from_slice::<Value>(payload) else { return };
|
||||
let Some(object) = value.as_object() else { return };
|
||||
let Some((category, body)) = object.iter().next() else { return };
|
||||
|
||||
let request_sender = match body.get("sequence_id").and_then(Value::as_str) {
|
||||
Some(sequence_id) => pending.lock().await.remove(sequence_id),
|
||||
None => None,
|
||||
};
|
||||
|
||||
if category == "print" && body.get("command").and_then(Value::as_str) == Some("push_status") {
|
||||
match serde_json::from_value::<PrintReport>(body.clone()) {
|
||||
Ok(report) => print_status.lock().await.apply(report, version),
|
||||
// Loudly, not silently. This arm used to be an `if let Ok(..)`
|
||||
// with no `else`, and it cost two misdiagnosed symptoms on real
|
||||
// hardware: `print_status()` stayed `None` forever on an H2C
|
||||
// that was in fact pushing a full report every second (read as
|
||||
// "v2 doesn't push unprompted", which was wrong), and every v1
|
||||
// push carrying AMS data vanished - which the gap check below
|
||||
// then reported as a *sequence gap*, blaming the network for a
|
||||
// local deserialization bug. A report this process can't parse
|
||||
// is a real defect in `PrintReport`, and the only way to fix it
|
||||
// is to be told which field.
|
||||
Err(err) => eprintln!(
|
||||
"[{sn}] print.push_status (sequence_id {}) did not match PrintReport, dropping it: \
|
||||
{err} - add the field to PrintReport and a fixture to \
|
||||
tests/push_status_parsing.rs",
|
||||
body.get("sequence_id").and_then(Value::as_str).unwrap_or("?")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(sender) = request_sender {
|
||||
let _ = sender.send(body.clone());
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,20 @@ impl KlipperPrinter {
|
||||
}
|
||||
|
||||
impl GenericPrinter for KlipperPrinter {
|
||||
/// OVERRIDE: real version is `POST /printer/print/pause`, `/resume` or
|
||||
/// `/cancel` on Moonraker - note Klipper calls it "cancel" where Bambu
|
||||
/// calls it "stop".
|
||||
async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> {
|
||||
let endpoint = match action {
|
||||
super::PrintControl::Pause => "pause",
|
||||
super::PrintControl::Resume => "resume",
|
||||
super::PrintControl::Stop => "cancel",
|
||||
};
|
||||
println!("[{}] POST /printer/print/{endpoint}", self.base.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn base(&self) -> &PrinterBase {
|
||||
&self.base
|
||||
}
|
||||
@@ -29,6 +43,11 @@ impl GenericPrinter for KlipperPrinter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
|
||||
// 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:
|
||||
@@ -37,4 +56,20 @@ impl GenericPrinter for KlipperPrinter {
|
||||
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> {
|
||||
println!("[{}] POST /printer/gcode/script?script={gcode_line}", self.base.name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! A held-open serial connection to Marlin/RepRap-family firmware - usable
|
||||
//! by any printer connected over a raw serial port, since there's no
|
||||
//! printer-side storage to bulk-upload to the way there is for Bambu/
|
||||
//! PrusaLink/Klipper (see `prusa.rs`'s `PrusaSerialPrinter`).
|
||||
//!
|
||||
//! Marlin only ever buffers a handful of commands at once, so lines have
|
||||
//! to be paced by its own "ok" acknowledgment - send faster than it acks
|
||||
//! and you overrun that buffer, you don't just queue up harmlessly. That
|
||||
//! write-a-line/wait-for-"ok" pattern is the one new idea here. Splitting
|
||||
//! the connection into a read half and a write half so both can be used at
|
||||
//! once isn't new - `uplink/mod.rs` already does the same thing
|
||||
//! (`ws_stream.split()`) for the WebSocket to `continuum-backend`.
|
||||
//!
|
||||
//! The connection has to stay open across many `send_line` calls, not
|
||||
//! reopen per line: opening/closing per line would be wasteful, and on
|
||||
//! Arduino-based boards toggling the serial line's DTR signal (which
|
||||
//! opening the port does) resets the printer.
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf};
|
||||
use tokio_serial::{SerialPortBuilderExt, SerialStream};
|
||||
|
||||
use super::PrinterError;
|
||||
|
||||
pub struct MarlinSerialConnection {
|
||||
write_half: WriteHalf<SerialStream>,
|
||||
lines_in: Lines<BufReader<ReadHalf<SerialStream>>>,
|
||||
}
|
||||
|
||||
impl MarlinSerialConnection {
|
||||
/// Opens `port` (a serial device path - e.g. `/dev/ttyACM0` on Linux) at
|
||||
/// `baud_rate`.
|
||||
pub async fn open(port: &str, baud_rate: u32) -> Result<Self, PrinterError> {
|
||||
let stream = tokio_serial::new(port, baud_rate)
|
||||
.open_native_async()
|
||||
.map_err(|err| PrinterError(format!("failed to open {port}: {err}")))?;
|
||||
|
||||
// Same shape as `ws_stream.split()` in uplink/mod.rs: one
|
||||
// connection, used for writing gcode out and reading
|
||||
// acknowledgments back at the same time.
|
||||
let (read_half, write_half) = tokio::io::split(stream);
|
||||
let lines_in = BufReader::new(read_half).lines();
|
||||
|
||||
Ok(Self { write_half, lines_in })
|
||||
}
|
||||
|
||||
/// Sends one gcode line and waits for Marlin's "ok" before returning.
|
||||
pub async fn send_line(&mut self, line: &str) -> Result<(), PrinterError> {
|
||||
self.write_half
|
||||
.write_all(format!("{line}\n").as_bytes())
|
||||
.await
|
||||
.map_err(|err| PrinterError(format!("write failed: {err}")))?;
|
||||
|
||||
// Marlin also sends unsolicited lines between commands
|
||||
// (temperature reports, "echo:" messages) - keep reading until the
|
||||
// real "ok" shows up instead of treating the first response as it.
|
||||
loop {
|
||||
let response = self
|
||||
.lines_in
|
||||
.next_line()
|
||||
.await
|
||||
.map_err(|err| PrinterError(format!("read failed: {err}")))?
|
||||
.ok_or_else(|| PrinterError("serial connection closed".into()))?;
|
||||
|
||||
if response.starts_with("ok") {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+227
-35
@@ -2,12 +2,10 @@
|
||||
//! `GenericPrinter -> BambuV1/BambuV2/PrusaLink/PrusaSerial/Klipper`-style
|
||||
//! inheritance, since Rust structs can't `extend` each other.
|
||||
//!
|
||||
//! Bambu goes one level deeper than the others: `bambu.rs` has
|
||||
//! `BambuGenericPrinter` (fields + behavior every Bambu printer shares -
|
||||
//! access code, CA trust, the TLS handshake test/fetch) which
|
||||
//! `BambuV1Printer`/`BambuV2Printer` each *have* one of, mirroring the
|
||||
//! `GenericPrinter -> BambuGenericPrinter -> BambuV1/V2` chain this project
|
||||
//! actually wanted from the start - see that file's doc comment.
|
||||
//! Bambu is a single `BambuPrinter` type, not a per-version split: which
|
||||
//! MQTT/BambuMQTT protocol generation (v1 vs v2) a given printer uses is
|
||||
//! something `connect()` discovers (or is told to force), not something
|
||||
//! config picks a *type* for - see `bambu.rs`'s doc comment.
|
||||
//!
|
||||
//! Three pieces, each doing one job an OOP base class would normally do:
|
||||
//!
|
||||
@@ -23,23 +21,39 @@
|
||||
//!
|
||||
//! Run `cargo run --example printer_polymorphism` to see it in action.
|
||||
//!
|
||||
//! Deliberately left out of this module for now, to keep it focused on one
|
||||
//! idea at a time: real MQTT/HTTP/FTPS calls (that's `src/adapters/`) and
|
||||
//! `Send`-bound futures (matters once this is wired into a multi-threaded
|
||||
//! `tokio::spawn`, not yet).
|
||||
//! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's
|
||||
//! protocol, `bambu_ftps.rs` for getting files onto a Bambu printer,
|
||||
//! `marlin_serial.rs` for a real Marlin serial connection - see each
|
||||
//! module's own doc comment. PrusaLink/Klipper's HTTP clients are still
|
||||
//! stubs; that's the next piece to build past this module.
|
||||
|
||||
mod bambu;
|
||||
mod bambu_commands;
|
||||
mod bambu_ftps;
|
||||
mod bambu_mqtt;
|
||||
mod klipper;
|
||||
mod marlin_serial;
|
||||
mod prusa;
|
||||
|
||||
pub use bambu::{BambuGenericPrinter, BambuTls, BambuV1Printer, BambuV2Printer};
|
||||
// Reading a `.3mf` well enough to know which filament a plate wants. Kept
|
||||
// free of printer types so it is testable without a printer.
|
||||
mod project_file;
|
||||
pub use project_file::{
|
||||
map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament,
|
||||
ProjectFileError,
|
||||
};
|
||||
|
||||
pub use bambu::{
|
||||
BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource,
|
||||
LightState, CHAMBER_LIGHT,
|
||||
};
|
||||
pub use bambu_commands::{
|
||||
AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest,
|
||||
GetVersionResponse, LoadedTray, PrintReport, PushallRequest, XcamSettings,
|
||||
GetVersionResponse, Ipcam, LightReport, LoadedTray, ModuleInfo, PrintReport, PushallRequest, VtTray,
|
||||
XcamSettings,
|
||||
};
|
||||
pub use klipper::KlipperPrinter;
|
||||
pub use prusa::{PrusaLinkPrinter, PrusaSerialPrinter};
|
||||
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.
|
||||
@@ -57,6 +71,19 @@ pub struct PrinterBase {
|
||||
|
||||
/// The shared interface - think `interface GenericPrinter` (Java/TS) or an
|
||||
/// ABC (Python). Every vendor's struct implements this.
|
||||
///
|
||||
/// The `allow` is a considered decision, not a brush-off. `async fn` in a
|
||||
/// public trait means callers cannot require the returned futures to be
|
||||
/// `Send`, so such a future cannot be moved to another OS thread. That is
|
||||
/// fine here, and checked: the daemon does run on tokio's multi-threaded
|
||||
/// runtime, but every printer is reached through one `Arc<Mutex<Vec<..>>>`
|
||||
/// from the uplink's own task (see `uplink::SharedFleet`), so these futures
|
||||
/// are created and polled in the same place. Revisit if a printer ever gets
|
||||
/// its own task - the fix is to desugar to
|
||||
/// `fn connect(&mut self) -> impl Future<Output = ..> + Send`, which cannot
|
||||
/// be done without changing every implementation, which is why it is not
|
||||
/// done pre-emptively.
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait GenericPrinter {
|
||||
/// Read-only access to the shared fields.
|
||||
fn base(&self) -> &PrinterBase;
|
||||
@@ -65,19 +92,48 @@ pub trait GenericPrinter {
|
||||
/// so there's nothing sensible to share here. Each `impl` in
|
||||
/// `bambu.rs`/`prusa.rs`/`klipper.rs` provides a totally different
|
||||
/// body - that's the override.
|
||||
///
|
||||
/// `cargo check` will warn about `async fn` in a public trait here.
|
||||
/// That's about a more advanced concern (whether the resulting work can
|
||||
/// safely move across OS threads) that only matters once this is wired
|
||||
/// into the multi-threaded daemon - safe to ignore for now.
|
||||
async fn connect(&mut self) -> Result<(), PrinterError>;
|
||||
|
||||
/// Sends one already-encoded message by whatever transport this vendor
|
||||
/// uses. Lower level than the methods below and mostly a building block
|
||||
/// for them - `bambu.rs` publishes MQTT with it; the stubs only log.
|
||||
async fn publish(&mut self, payload: String) -> Result<(), PrinterError>;
|
||||
|
||||
/// REQUIRED, no default body: `percent` is 0-100, but how it actually
|
||||
/// gets sent - an MQTT gcode-injection command, a REST call, raw bytes
|
||||
/// on a serial port - is different for every vendor, same as
|
||||
/// `connect()`.
|
||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError>;
|
||||
|
||||
/// REQUIRED, no default body: takes the *whole file* already in memory
|
||||
/// (`data`), not a path - whatever called this already had to download
|
||||
/// it from the S3 URL the cloud handed over, so there'd be nothing on
|
||||
/// disk to point a path at anyway. Each vendor does something quite
|
||||
/// different with `data`: Bambu/PrusaLink/Klipper each do one bulk
|
||||
/// transfer to storage the printer's own firmware owns; PrusaSerial
|
||||
/// has no such storage at all - see that impl's doc comment.
|
||||
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError>;
|
||||
|
||||
/// REQUIRED, no default body: sends exactly *one* gcode line, using
|
||||
/// whatever single-command mechanism this vendor has (an MQTT
|
||||
/// `gcode_line` publish for Bambu, an HTTP command-injection endpoint
|
||||
/// for PrusaLink/Klipper, a raw write over the port for PrusaSerial).
|
||||
/// `upload_gcode` below is the generic driver that calls this once per
|
||||
/// line for a whole file - this method is where each vendor's actual
|
||||
/// difference lives, same division of labor as every other method here.
|
||||
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError>;
|
||||
|
||||
/// REQUIRED, no default body: pause, resume or cancel the print that is
|
||||
/// already running. Each vendor has one mechanism for all three - an
|
||||
/// MQTT `print` command, a PrusaLink job endpoint, a Klipper macro, a
|
||||
/// Marlin gcode word - so they arrive here as one call taking which.
|
||||
///
|
||||
/// There is deliberately no "start a print" counterpart on this trait.
|
||||
/// Starting one needs a file that is already on the printer, which is
|
||||
/// `upload_project_file`'s job, and the two have very different
|
||||
/// consequences; see `BambuPrinter::start_uploaded_file`.
|
||||
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError>;
|
||||
|
||||
/// OPTIONAL: a default body every printer gets for free unless it
|
||||
/// writes its own. None of ours do below, so all three vendors share
|
||||
/// this exact implementation - the trait-method equivalent of
|
||||
@@ -88,35 +144,119 @@ pub trait GenericPrinter {
|
||||
}
|
||||
|
||||
/// M106's `S` parameter is a raw 0-255 PWM value, not a percentage. Shared
|
||||
/// by anything that ultimately sends gcode (Bambu, PrusaLink, PrusaSerial)
|
||||
/// - *not* a trait default, since Klipper's hypothetical native fan API
|
||||
/// below takes a percent directly and has no use for this at all. Private,
|
||||
/// but still visible to bambu.rs/prusa.rs: Rust's module privacy reaches
|
||||
/// into child modules, not just the exact file it's defined in.
|
||||
/// by anything that ultimately sends gcode (Bambu, PrusaLink,
|
||||
/// PrusaSerial) - *not* a trait default, since Klipper's hypothetical
|
||||
/// native fan API below takes a percent directly and has no use for this at
|
||||
/// all. Private, but still visible to bambu.rs/prusa.rs: Rust's module
|
||||
/// privacy reaches into child modules, not just the exact file it's defined
|
||||
/// in.
|
||||
fn percent_to_pwm(percent: u8) -> u8 {
|
||||
((percent.min(100) as u16 * 255) / 100) as u8
|
||||
}
|
||||
|
||||
|
||||
/// The three things you can do to a print that is already running.
|
||||
///
|
||||
/// One enum and one trait method rather than three separate methods: every
|
||||
/// vendor implements all three the same way it implements any of them (one
|
||||
/// MQTT command for Bambu, one REST call for PrusaLink, one macro for
|
||||
/// Klipper, one gcode word for Marlin), so splitting them would triple the
|
||||
/// delegation boilerplate without separating anything that differs.
|
||||
///
|
||||
/// `Stop` is Bambu's own word for it and the one the schemas use. It
|
||||
/// cancels the print - it is not a pause you can come back from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PrintControl {
|
||||
Pause,
|
||||
Resume,
|
||||
Stop,
|
||||
}
|
||||
|
||||
impl PrintControl {
|
||||
/// The vendor command name. Bambu's `print` category happens to use
|
||||
/// exactly these three words, so this doubles as the MQTT command.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pause => "pause",
|
||||
Self::Resume => "resume",
|
||||
Self::Stop => "stop",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The closed set of printer kinds this fleet can talk to.
|
||||
pub enum PrinterHandle {
|
||||
BambuV1(BambuV1Printer),
|
||||
BambuV2(BambuV2Printer),
|
||||
PrusaLink(PrusaLinkPrinter),
|
||||
Bambu(BambuPrinter),
|
||||
OctoPrint(OctoPrintPrinter),
|
||||
PrusaSerial(PrusaSerialPrinter),
|
||||
Klipper(KlipperPrinter),
|
||||
}
|
||||
|
||||
impl PrinterHandle {
|
||||
/// The latest status this process has for the printer, if it has one.
|
||||
///
|
||||
/// An inherent method rather than a `GenericPrinter` trait method,
|
||||
/// because right now only Bambu has a real status path - the other three
|
||||
/// return `None` not because they have nothing to say but because their
|
||||
/// protocol clients are still stubs. Putting it on the trait would make
|
||||
/// every vendor claim to implement something three of them don't. Move it
|
||||
/// onto the trait once a second vendor can genuinely answer.
|
||||
pub async fn print_status(&self) -> Option<PrintReport> {
|
||||
match self {
|
||||
PrinterHandle::Bambu(p) => p.print_status().await,
|
||||
PrinterHandle::OctoPrint(_) | PrinterHandle::PrusaSerial(_) | PrinterHandle::Klipper(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the printer to report its full state, for the printers that only
|
||||
/// report when asked.
|
||||
///
|
||||
/// This exists because of one specific vendor quirk that should not leak
|
||||
/// any further than this: a Bambu on protocol v1 pushes only *partial*
|
||||
/// `print.push_status` objects unprompted - often a single temperature -
|
||||
/// and sends a complete one only in response to `pushing.pushall`. Without
|
||||
/// a periodic call here, `print_status()` on a v1 printer stays stuck on
|
||||
/// whatever fragments it happened to volunteer. Found exactly that way: a
|
||||
/// live P1P reached the cloud as a row of nulls with one real bed
|
||||
/// temperature in it, while a v2 H2C beside it was fully populated.
|
||||
///
|
||||
/// Nothing arrives by the time this returns - the refreshed status comes
|
||||
/// back over MQTT as an ordinary push some time later, so a caller that
|
||||
/// refreshes and immediately reads gets the *previous* refresh's data. On
|
||||
/// a timer that is simply a one-tick lag, which is why this is
|
||||
/// fire-and-forget rather than something to await a result from.
|
||||
///
|
||||
/// A no-op for everyone else: `BambuPrinter::pushall` already no-ops on
|
||||
/// v2 (which pushes complete objects unprompted), and the other three
|
||||
/// vendors are still stubs. Same reasoning as `print_status` for why this
|
||||
/// is inherent rather than a `GenericPrinter` method.
|
||||
pub async fn refresh_status(&self) -> Result<(), PrinterError> {
|
||||
match self {
|
||||
PrinterHandle::Bambu(p) => p.pushall().await,
|
||||
PrinterHandle::OctoPrint(_) | PrinterHandle::PrusaSerial(_) | PrinterHandle::Klipper(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hand-written delegation: each trait method just matches on the variant
|
||||
// and forwards to that variant's own implementation. This one block is the
|
||||
// only "boilerplate tax" for not having inheritance - everything else reads
|
||||
// like normal code.
|
||||
impl GenericPrinter for PrinterHandle {
|
||||
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> {
|
||||
match self {
|
||||
PrinterHandle::Bambu(p) => p.control_print(action).await,
|
||||
PrinterHandle::OctoPrint(p) => p.control_print(action).await,
|
||||
PrinterHandle::PrusaSerial(p) => p.control_print(action).await,
|
||||
PrinterHandle::Klipper(p) => p.control_print(action).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn base(&self) -> &PrinterBase {
|
||||
match self {
|
||||
PrinterHandle::BambuV1(p) => p.base(),
|
||||
PrinterHandle::BambuV2(p) => p.base(),
|
||||
PrinterHandle::PrusaLink(p) => p.base(),
|
||||
PrinterHandle::Bambu(p) => p.base(),
|
||||
PrinterHandle::OctoPrint(p) => p.base(),
|
||||
PrinterHandle::PrusaSerial(p) => p.base(),
|
||||
PrinterHandle::Klipper(p) => p.base(),
|
||||
}
|
||||
@@ -124,21 +264,73 @@ impl GenericPrinter for PrinterHandle {
|
||||
|
||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
||||
match self {
|
||||
PrinterHandle::BambuV1(p) => p.connect().await,
|
||||
PrinterHandle::BambuV2(p) => p.connect().await,
|
||||
PrinterHandle::PrusaLink(p) => p.connect().await,
|
||||
PrinterHandle::Bambu(p) => p.connect().await,
|
||||
PrinterHandle::OctoPrint(p) => p.connect().await,
|
||||
PrinterHandle::PrusaSerial(p) => p.connect().await,
|
||||
PrinterHandle::Klipper(p) => p.connect().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||
match self {
|
||||
PrinterHandle::Bambu(p) => p.publish(payload).await,
|
||||
PrinterHandle::OctoPrint(p) => p.publish(payload).await,
|
||||
PrinterHandle::PrusaSerial(p) => p.publish(payload).await,
|
||||
PrinterHandle::Klipper(p) => p.publish(payload).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
||||
match self {
|
||||
PrinterHandle::BambuV1(p) => p.set_fan_speed(percent).await,
|
||||
PrinterHandle::BambuV2(p) => p.set_fan_speed(percent).await,
|
||||
PrinterHandle::PrusaLink(p) => p.set_fan_speed(percent).await,
|
||||
PrinterHandle::Bambu(p) => p.set_fan_speed(percent).await,
|
||||
PrinterHandle::OctoPrint(p) => p.set_fan_speed(percent).await,
|
||||
PrinterHandle::PrusaSerial(p) => p.set_fan_speed(percent).await,
|
||||
PrinterHandle::Klipper(p) => p.set_fan_speed(percent).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
||||
match self {
|
||||
PrinterHandle::Bambu(p) => p.upload_project_file(file_name, data).await,
|
||||
PrinterHandle::OctoPrint(p) => p.upload_project_file(file_name, data).await,
|
||||
PrinterHandle::PrusaSerial(p) => p.upload_project_file(file_name, data).await,
|
||||
PrinterHandle::Klipper(p) => p.upload_project_file(file_name, data).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||
match self {
|
||||
PrinterHandle::Bambu(p) => p.send_gcode_line(gcode_line).await,
|
||||
PrinterHandle::OctoPrint(p) => p.send_gcode_line(gcode_line).await,
|
||||
PrinterHandle::PrusaSerial(p) => p.send_gcode_line(gcode_line).await,
|
||||
PrinterHandle::Klipper(p) => p.send_gcode_line(gcode_line).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits `gcode` into lines and sends each one through `printer`'s own
|
||||
/// `send_gcode_line` - works for *any* vendor, not just serial ones, since
|
||||
/// Bambu/PrusaLink/Klipper each have their own single-line injection
|
||||
/// mechanism too (see each `send_gcode_line` impl). They just don't use
|
||||
/// this for `upload_project_file` - they have real file storage to bulk-
|
||||
/// upload to instead, which is a different, faster path than sending a
|
||||
/// whole file one line at a time.
|
||||
///
|
||||
/// `P: GenericPrinter` makes this a genuine Rust generic: one function
|
||||
/// body, compiled separately for whichever concrete printer type you call
|
||||
/// it with - like a TS function typed `<P extends GenericPrinter>`, except
|
||||
/// Rust generates real, separate machine code per type instead of erasing
|
||||
/// the type at compile time. Call it with any of the five printer structs,
|
||||
/// or with `&mut PrinterHandle` itself (it implements `GenericPrinter` too,
|
||||
/// via the delegation above).
|
||||
pub async fn upload_gcode<P: GenericPrinter>(printer: &mut P, gcode: &str) -> Result<(), PrinterError> {
|
||||
for line in gcode.lines() {
|
||||
let line = line.trim();
|
||||
// Blank lines and full-line comments have nothing to send.
|
||||
if line.is_empty() || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
printer.send_gcode_line(line.to_string()).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
//! Reading a Bambu `.3mf` project archive well enough to know which
|
||||
//! filament each plate wants.
|
||||
//!
|
||||
//! This exists because `use_ams: false` is not a safe default. Hardcoding it
|
||||
//! overrode the slicer's own choice and pointed an AMS-sliced project at the
|
||||
//! external spool; on a real P1P the print started, heated, reached 1% and
|
||||
//! then the printer paused itself with `print_error: 50364420` and
|
||||
//! `tray_now: 255`. See `FilamentSource` in `bambu.rs`.
|
||||
//!
|
||||
//! Deliberately free of any MQTT or printer types: it takes archive bytes and
|
||||
//! a plain list of loaded trays, and returns an `ams_mapping`. That keeps it
|
||||
//! unit-testable without a printer, which is the only way this gets tested at
|
||||
//! all - the alternative is discovering a mapping bug by watching a machine
|
||||
//! print in the wrong colour.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
/// One filament a plate was sliced for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProjectFilament {
|
||||
/// 0-based. This is the position in `ams_mapping`, so it has to come
|
||||
/// from the filament's own `id` minus one and not from the order the
|
||||
/// entries happen to appear in the XML.
|
||||
pub index: usize,
|
||||
/// Bambu's filament identifier, e.g. `GFG99`. The strongest signal for
|
||||
/// matching, because it names the exact product.
|
||||
pub tray_info_idx: String,
|
||||
/// e.g. `PETG`.
|
||||
pub filament_type: String,
|
||||
/// Six uppercase hex digits, no `#`. Normalised on the way in, because
|
||||
/// the project writes `#2850E0` and the printer reports `2850E0FF` - the
|
||||
/// same colour with a trailing alpha byte.
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
/// A tray the printer actually has filament in, flattened to a single slot
|
||||
/// number.
|
||||
///
|
||||
/// Deliberately not `bambu_commands::LoadedTray`, which is the wire shape:
|
||||
/// that carries an `id` scoped to its own AMS unit, so slot 2 of unit 1 and
|
||||
/// slot 2 of unit 0 are both `"2"`. Flattening to a global slot is the
|
||||
/// caller's job, because only the caller knows how many units there are.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AvailableTray {
|
||||
/// The AMS slot number, as it appears in `ams_mapping`.
|
||||
pub slot: i32,
|
||||
pub tray_info_idx: String,
|
||||
pub filament_type: String,
|
||||
/// Same normalisation as `ProjectFilament::color`.
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProjectFileError {
|
||||
#[error("the project archive could not be opened: {0}")]
|
||||
NotAnArchive(String),
|
||||
#[error("the archive has no {0} - it may not be a Bambu project")]
|
||||
MissingEntry(String),
|
||||
#[error("{0} could not be read: {1}")]
|
||||
UnreadableEntry(String, String),
|
||||
#[error("the archive describes no plate {0}")]
|
||||
NoSuchPlate(u32),
|
||||
#[error("plate {0} lists no filaments")]
|
||||
NoFilaments(u32),
|
||||
/// A `<filament>` element that could not be read. An error rather than a
|
||||
/// skip: see the comment at the parse site.
|
||||
#[error("plate {plate} has an unreadable filament: {detail}")]
|
||||
MalformedFilament { plate: u32, detail: String },
|
||||
/// Deliberately an error and never a silent fallback to the external
|
||||
/// spool: an unmatched filament means the print would draw the wrong
|
||||
/// material or none.
|
||||
#[error("no loaded tray matches {wanted} (filament index {index}); loaded: {loaded}")]
|
||||
NoMatchingTray { index: usize, wanted: String, loaded: String },
|
||||
}
|
||||
|
||||
/// Normalises a colour to six uppercase hex digits.
|
||||
///
|
||||
/// Accepts `#2850E0`, `2850E0`, `2850E0FF` and lowercase, because the
|
||||
/// project archive and the printer's own report disagree on all three
|
||||
/// points.
|
||||
///
|
||||
/// Input with fewer than six hex digits comes back uppercased and stripped
|
||||
/// but otherwise untouched. It will not equal any real colour, so the matcher
|
||||
/// fails to match - the safe outcome - rather than this panicking.
|
||||
pub fn normalise_color(raw: &str) -> String {
|
||||
let stripped = raw.trim().trim_start_matches('#').to_ascii_uppercase();
|
||||
let head: String = stripped.chars().take(6).collect();
|
||||
if head.len() == 6 && head.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
head
|
||||
} else {
|
||||
stripped
|
||||
}
|
||||
}
|
||||
|
||||
const SLICE_INFO: &str = "Metadata/slice_info.config";
|
||||
|
||||
/// Reads the filaments one plate was sliced for out of a `.3mf`.
|
||||
///
|
||||
/// `plate_index` is 1-based, matching the archive's own numbering
|
||||
/// (`Metadata/plate_1.gcode`, `<metadata key="index" value="1"/>`).
|
||||
///
|
||||
/// There is no XML crate in the dependency tree, so this scans the text.
|
||||
/// That is tolerable only because `slice_info.config` is machine-written by
|
||||
/// one program in a stable shape; the scanning is still defensive (tag names
|
||||
/// must be followed by whitespace, attribute names must start at a word
|
||||
/// boundary) so that a neighbouring element cannot be mistaken for the one
|
||||
/// we want. Anything unexpected yields a typed error, never a guess.
|
||||
pub fn plate_filaments(
|
||||
archive: &[u8],
|
||||
plate_index: u32,
|
||||
) -> Result<Vec<ProjectFilament>, ProjectFileError> {
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(archive))
|
||||
.map_err(|e| ProjectFileError::NotAnArchive(e.to_string()))?;
|
||||
let mut entry = zip
|
||||
.by_name(SLICE_INFO)
|
||||
.map_err(|_| ProjectFileError::MissingEntry(SLICE_INFO.to_string()))?;
|
||||
let mut xml = String::new();
|
||||
entry
|
||||
.read_to_string(&mut xml)
|
||||
.map_err(|e| ProjectFileError::UnreadableEntry(SLICE_INFO.to_string(), e.to_string()))?;
|
||||
|
||||
let block = plate_blocks(&xml)
|
||||
.into_iter()
|
||||
.find(|block| plate_number(block) == Some(plate_index))
|
||||
.ok_or(ProjectFileError::NoSuchPlate(plate_index))?;
|
||||
|
||||
// Every `<filament>` must parse. A malformed one used to be dropped
|
||||
// here, which is the worst available outcome: a dropped filament leaves
|
||||
// a hole at its index, `map_to_trays` fills the hole with `-1`, and the
|
||||
// printer is told that material is unused - so the plate prints in
|
||||
// whatever remains mapped. Refusing is the only safe answer, because
|
||||
// there is nothing to fall back to that is not a guess about what
|
||||
// someone meant to print.
|
||||
let mut filaments: Vec<ProjectFilament> = tags(block, "filament")
|
||||
.into_iter()
|
||||
.map(|tag| {
|
||||
let missing = |what: &str| ProjectFileError::MalformedFilament {
|
||||
plate: plate_index,
|
||||
detail: format!("{what} in <filament {}>", tag.trim()),
|
||||
};
|
||||
|
||||
// Ids are 1-based, so 0 or a non-number would underflow into a
|
||||
// bogus position.
|
||||
let raw_id = attr(tag, "id").ok_or_else(|| missing("no id"))?;
|
||||
let id: usize = raw_id.parse().map_err(|_| missing("id is not a number"))?;
|
||||
let index = id.checked_sub(1).ok_or_else(|| missing("id is 0, but ids are 1-based"))?;
|
||||
|
||||
Ok(ProjectFilament {
|
||||
index,
|
||||
tray_info_idx: attr(tag, "tray_info_idx")
|
||||
.ok_or_else(|| missing("no tray_info_idx"))?
|
||||
.to_string(),
|
||||
filament_type: attr(tag, "type").ok_or_else(|| missing("no type"))?.to_string(),
|
||||
color: normalise_color(attr(tag, "color").ok_or_else(|| missing("no color"))?),
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, ProjectFileError>>()?;
|
||||
filaments.sort_by_key(|f| f.index);
|
||||
|
||||
if filaments.is_empty() {
|
||||
return Err(ProjectFileError::NoFilaments(plate_index));
|
||||
}
|
||||
Ok(filaments)
|
||||
}
|
||||
|
||||
/// Each `<plate>` element's text, up to the start of the next one.
|
||||
fn plate_blocks(xml: &str) -> Vec<&str> {
|
||||
xml.split("<plate")
|
||||
.skip(1)
|
||||
// `<plate_x` would be a different element; `</plate>` never matches
|
||||
// the split at all because its `<` is followed by `/`.
|
||||
.filter(|rest| rest.starts_with(|c: char| c == '>' || c.is_whitespace()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn plate_number(block: &str) -> Option<u32> {
|
||||
tags(block, "metadata")
|
||||
.into_iter()
|
||||
.find(|tag| attr(tag, "key") == Some("index"))
|
||||
.and_then(|tag| attr(tag, "value")?.parse().ok())
|
||||
}
|
||||
|
||||
/// Every `<name ...>` start tag in `text`, without the angle brackets.
|
||||
/// Requires whitespace after the name so `filament` does not match
|
||||
/// `<filament_settings>`.
|
||||
fn tags<'a>(text: &'a str, name: &str) -> Vec<&'a str> {
|
||||
let open = format!("<{name}");
|
||||
let mut found = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find(&open) {
|
||||
let after = &rest[at + open.len()..];
|
||||
let Some(end) = after.find('>') else { break };
|
||||
if after.starts_with(char::is_whitespace) {
|
||||
found.push(&after[..end]);
|
||||
}
|
||||
rest = &after[end..];
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The value of attribute `name` in a start tag.
|
||||
///
|
||||
/// The name must begin right after whitespace, so asking for `id` cannot
|
||||
/// land inside `identify_id` or `group_id`, and asking for `type` cannot
|
||||
/// land inside `volume_type`. Whitespace before the `=` is tolerated
|
||||
/// (`error_code ="..."` occurs in real files).
|
||||
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
let mut from = 0;
|
||||
while let Some(rel) = tag[from..].find(name) {
|
||||
let at = from + rel;
|
||||
from = at + name.len();
|
||||
let at_boundary = tag[..at].ends_with(char::is_whitespace);
|
||||
let after = tag[from..].trim_start();
|
||||
if at_boundary && let Some(value) = after.strip_prefix('=') {
|
||||
let value = value.trim_start().strip_prefix('"')?;
|
||||
return value.split('"').next();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Builds the `ams_mapping` body for a plate: position = project filament
|
||||
/// index, value = the AMS slot to draw it from.
|
||||
///
|
||||
/// Unused positions are `-1`. The caller pads to the printer's full tray
|
||||
/// count - see `FilamentSource::ams`.
|
||||
///
|
||||
/// Each filament takes the best tray by this order:
|
||||
/// 1. same `tray_info_idx` and same colour - the exact product, as sliced.
|
||||
/// 2. same `tray_info_idx`, different colour - a deliberate "right product,
|
||||
/// wrong colour" allowance.
|
||||
/// 3. same `filament_type` and same colour - "right material", a different
|
||||
/// product.
|
||||
///
|
||||
/// Tiers 2 and 3 are better than refusing a print, but they are not what the
|
||||
/// slicer asked for, so a caller may want to warn. Ties within a tier go to
|
||||
/// the lowest slot so the result never depends on tray ordering. A filament
|
||||
/// matching no tier is an error, never a fallback.
|
||||
pub fn map_to_trays(
|
||||
filaments: &[ProjectFilament],
|
||||
trays: &[AvailableTray],
|
||||
) -> Result<Vec<i32>, ProjectFileError> {
|
||||
let Some(max) = filaments.iter().map(|f| f.index).max() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut mapping = vec![-1; max + 1];
|
||||
|
||||
for filament in filaments {
|
||||
let best = trays
|
||||
.iter()
|
||||
.filter_map(|tray| Some((tier(filament, tray)?, tray.slot)))
|
||||
.min();
|
||||
let Some((_, slot)) = best else {
|
||||
let loaded = trays
|
||||
.iter()
|
||||
.map(|t| format!("slot {}: {} {} #{}", t.slot, t.filament_type, t.tray_info_idx, t.color))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
return Err(ProjectFileError::NoMatchingTray {
|
||||
index: filament.index,
|
||||
wanted: format!(
|
||||
"{} {} #{}",
|
||||
filament.filament_type, filament.tray_info_idx, filament.color
|
||||
),
|
||||
loaded,
|
||||
});
|
||||
};
|
||||
mapping[filament.index] = slot;
|
||||
}
|
||||
Ok(mapping)
|
||||
}
|
||||
|
||||
/// Match quality, lower is better; `None` means no match.
|
||||
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;
|
||||
let same_type = filament.filament_type.eq_ignore_ascii_case(&tray.filament_type);
|
||||
match (same_product, same_color, same_type) {
|
||||
(true, true, _) => Some(1),
|
||||
(true, false, _) => Some(2),
|
||||
(false, true, true) => Some(3),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
const REAL: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<header>
|
||||
<header_item key="X-BBL-Client-Type" value="slicer"/>
|
||||
</header>
|
||||
<plate>
|
||||
<metadata key="index" value="1"/>
|
||||
<metadata key="nozzle_diameters" value="0.4"/>
|
||||
<metadata key="prediction" value="2408"/>
|
||||
<object identify_id="69" name="Bambu Cube" skipped="false" />
|
||||
<filament id="1" tray_info_idx="GFG99" type="PETG" color="#2850E0" used_m="2.57" used_g="7.86" group_id="0" nozzle_diameter="0.40" volume_type="Standard" used_for_object="true" used_for_support="false"/>
|
||||
<warning msg="bed_temperature_too_high_than_filament" level="3" error_code ="1000C001" />
|
||||
<nozzle id="0" extruder_id="1" nozzle_diameter="0.4" volume_type="Standard"/>
|
||||
</plate>
|
||||
</config>
|
||||
"##;
|
||||
|
||||
const TWO_PLATES: &str = r##"<config>
|
||||
<plate>
|
||||
<metadata key="index" value="1"/>
|
||||
<filament id="1" tray_info_idx="GFA00" type="PLA" color="#FF0000" group_id="0"/>
|
||||
</plate>
|
||||
<plate>
|
||||
<metadata key="index" value="2"/>
|
||||
<filament id="2" tray_info_idx="GFB00" type="ABS" color="#00ff00" group_id="1"/>
|
||||
<filament id="1" tray_info_idx="GFG99" type="PETG" color="#0000FFFF" group_id="0"/>
|
||||
</plate>
|
||||
</config>
|
||||
"##;
|
||||
|
||||
fn zip_with(name: &str, body: &str) -> Vec<u8> {
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let opts = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored);
|
||||
w.start_file(name, opts).unwrap();
|
||||
w.write_all(body.as_bytes()).unwrap();
|
||||
w.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn filament(index: usize, idx: &str, ty: &str, color: &str) -> ProjectFilament {
|
||||
ProjectFilament {
|
||||
index,
|
||||
tray_info_idx: idx.into(),
|
||||
filament_type: ty.into(),
|
||||
color: color.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tray(slot: i32, idx: &str, ty: &str, color: &str) -> AvailableTray {
|
||||
AvailableTray {
|
||||
slot,
|
||||
tray_info_idx: idx.into(),
|
||||
filament_type: ty.into(),
|
||||
color: color.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalises_every_spelling() {
|
||||
assert_eq!(normalise_color("#2850E0"), "2850E0");
|
||||
assert_eq!(normalise_color("2850e0"), "2850E0");
|
||||
assert_eq!(normalise_color("2850E0FF"), "2850E0");
|
||||
assert_eq!(normalise_color("#2850e0ff"), "2850E0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_or_garbage_colour_does_not_panic() {
|
||||
assert_eq!(normalise_color("#abc"), "ABC");
|
||||
assert_eq!(normalise_color(""), "");
|
||||
assert_eq!(normalise_color("zzzzzzzz"), "ZZZZZZZZ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_real_single_filament_plate() {
|
||||
let got = plate_filaments(&zip_with(SLICE_INFO, REAL), 1).unwrap();
|
||||
assert_eq!(got, vec![filament(0, "GFG99", "PETG", "2850E0")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_right_plate_and_converts_ids() {
|
||||
let archive = zip_with(SLICE_INFO, TWO_PLATES);
|
||||
let got = plate_filaments(&archive, 2).unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
filament(0, "GFG99", "PETG", "0000FF"),
|
||||
filament(1, "GFB00", "ABS", "00FF00"),
|
||||
]
|
||||
);
|
||||
let first = plate_filaments(&archive, 1).unwrap();
|
||||
assert_eq!(first, vec![filament(0, "GFA00", "PLA", "FF0000")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archive_errors() {
|
||||
assert!(matches!(
|
||||
plate_filaments(b"not a zip", 1),
|
||||
Err(ProjectFileError::NotAnArchive(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
plate_filaments(&zip_with("other.txt", "x"), 1),
|
||||
Err(ProjectFileError::MissingEntry(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
plate_filaments(&zip_with(SLICE_INFO, REAL), 7),
|
||||
Err(ProjectFileError::NoSuchPlate(7))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plate_without_filaments_is_an_error() {
|
||||
let xml = r#"<config><plate><metadata key="index" value="1"/></plate></config>"#;
|
||||
assert!(matches!(
|
||||
plate_filaments(&zip_with(SLICE_INFO, xml), 1),
|
||||
Err(ProjectFileError::NoFilaments(1))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_does_not_match_longer_names() {
|
||||
let tag = r#"filament group_id="9" identify_id="8" volume_type="Std" id="1" type="PLA""#;
|
||||
assert_eq!(attr(tag, "id"), Some("1"));
|
||||
assert_eq!(attr(tag, "type"), Some("PLA"));
|
||||
assert_eq!(attr(r#"x error_code ="E1""#, "error_code"), Some("E1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_product_different_colour_picks_the_colour_match() {
|
||||
let filaments = [filament(0, "GFG99", "PETG", "2850E0")];
|
||||
let trays = [
|
||||
tray(2, "GFG99", "PETG", "161616"),
|
||||
tray(3, "GFG99", "PETG", "2850E0"),
|
||||
];
|
||||
assert_eq!(map_to_trays(&filaments, &trays).unwrap(), vec![3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiers_and_tie_break() {
|
||||
// Tier 2 (right product, wrong colour) beats nothing, lowest slot wins.
|
||||
let f = [filament(0, "GFG99", "PETG", "2850E0")];
|
||||
let t = [tray(5, "GFG99", "PETG", "111111"), tray(1, "gfg99", "PETG", "222222")];
|
||||
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![1]);
|
||||
// Tier 3 (right material and colour, different product).
|
||||
let t = [tray(4, "GFA00", "petg", "2850E0")];
|
||||
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![4]);
|
||||
// Tier 1 outranks a lower-numbered tier 2.
|
||||
let t = [tray(0, "GFG99", "PETG", "111111"), tray(6, "GFG99", "PETG", "2850E0")];
|
||||
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_filament_is_an_error() {
|
||||
let f = [filament(0, "GFG99", "PETG", "2850E0")];
|
||||
let t = [tray(1, "GFA00", "PLA", "FF0000")];
|
||||
match map_to_trays(&f, &t) {
|
||||
Err(ProjectFileError::NoMatchingTray { index, wanted, loaded }) => {
|
||||
assert_eq!(index, 0);
|
||||
assert_eq!(wanted, "PETG GFG99 #2850E0");
|
||||
assert_eq!(loaded, "slot 1: PLA GFA00 #FF0000");
|
||||
}
|
||||
other => panic!("expected NoMatchingTray, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaps_become_minus_one() {
|
||||
let f = [filament(0, "GFA00", "PLA", "FF0000"), filament(2, "GFG99", "PETG", "2850E0")];
|
||||
let t = [tray(1, "GFA00", "PLA", "FF0000"), tray(3, "GFG99", "PETG", "2850E0")];
|
||||
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![1, -1, 3]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests against a real project, not just hand-built XML.
|
||||
///
|
||||
/// `tests/fixtures/cube_slice_info.3mf` holds the `slice_info.config` of a
|
||||
/// project read off a P1P over FTPS on 2026-10-11 - the one that exposed the
|
||||
/// `use_ams: false` bug - re-zipped with **deflate**. The compression matters
|
||||
/// as much as the content: every other test here builds a `Stored` archive,
|
||||
/// so without this the decompression path (and the flate2 backend wired up
|
||||
/// behind `zip`'s `deflate-flate2` feature) is never exercised, and a real
|
||||
/// archive is always deflated.
|
||||
#[cfg(test)]
|
||||
mod real_project_tests {
|
||||
use super::*;
|
||||
|
||||
const REAL_PROJECT: &[u8] = include_bytes!("../../tests/fixtures/cube_slice_info.3mf");
|
||||
|
||||
#[test]
|
||||
fn reads_a_deflated_archive_from_a_real_printer() {
|
||||
let filaments = plate_filaments(REAL_PROJECT, 1).expect("the real project should parse");
|
||||
|
||||
assert_eq!(filaments.len(), 1, "the cube is a single-filament plate");
|
||||
assert_eq!(filaments[0].index, 0, "id=\"1\" is 1-based, so index 0");
|
||||
assert_eq!(filaments[0].tray_info_idx, "GFG99");
|
||||
assert_eq!(filaments[0].filament_type, "PETG");
|
||||
// `#2850E0` in the project; the printer calls the same colour
|
||||
// `2850E0FF`.
|
||||
assert_eq!(filaments[0].color, "2850E0");
|
||||
}
|
||||
|
||||
/// End to end on the exact case that cost a print: both AMS slots hold
|
||||
/// the same product (PETG, GFG99) and only the colour distinguishes
|
||||
/// them, so a matcher that stopped at `tray_info_idx` would pick slot 2
|
||||
/// and print the cube in black.
|
||||
#[test]
|
||||
fn the_real_project_maps_to_the_slot_it_was_sliced_for() {
|
||||
let filaments = plate_filaments(REAL_PROJECT, 1).expect("should parse");
|
||||
let trays = vec![
|
||||
AvailableTray {
|
||||
slot: 2,
|
||||
tray_info_idx: "GFG99".into(),
|
||||
filament_type: "PETG".into(),
|
||||
color: "161616".into(),
|
||||
},
|
||||
AvailableTray {
|
||||
slot: 3,
|
||||
tray_info_idx: "GFG99".into(),
|
||||
filament_type: "PETG".into(),
|
||||
color: "2850E0".into(),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(map_to_trays(&filaments, &trays).expect("should match"), vec![3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_real_project_has_no_second_plate() {
|
||||
assert!(matches!(
|
||||
plate_filaments(REAL_PROJECT, 2),
|
||||
Err(ProjectFileError::NoSuchPlate(2))
|
||||
));
|
||||
}
|
||||
}
|
||||
+139
-23
@@ -1,22 +1,66 @@
|
||||
use super::{GenericPrinter, PrinterBase, PrinterError};
|
||||
|
||||
/****************** PrusaLink ******************/
|
||||
/****************** OctoPrint API family ******************/
|
||||
|
||||
pub struct PrusaLinkPrinter {
|
||||
base: PrinterBase,
|
||||
pub api_key: String,
|
||||
/// 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 PrusaLinkPrinter {
|
||||
pub fn new(id: &str, name: &str, host: &str, api_key: &str) -> Self {
|
||||
Self {
|
||||
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
|
||||
api_key: api_key.into(),
|
||||
impl OctoPrintFlavour {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
OctoPrintFlavour::PrusaLink => "PrusaLink",
|
||||
OctoPrintFlavour::Generic => "OctoPrint",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GenericPrinter for PrusaLinkPrinter {
|
||||
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
|
||||
}
|
||||
@@ -25,7 +69,12 @@ impl GenericPrinter for PrusaLinkPrinter {
|
||||
/// 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 (PrusaLink)", self.base.name);
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -37,6 +86,22 @@ impl GenericPrinter for PrusaLinkPrinter {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,38 +109,89 @@ impl GenericPrinter for PrusaLinkPrinter {
|
||||
|
||||
pub struct PrusaSerialPrinter {
|
||||
base: PrinterBase,
|
||||
pub com_port: i16,
|
||||
pub port: String,
|
||||
pub baud_rate: u32,
|
||||
/// `None` until `connect()` succeeds - a real serial connection, not a
|
||||
/// stub, so there's something to hold onto: unlike Bambu/PrusaLink/
|
||||
/// Klipper's stubbed sessions, this one has to survive across many
|
||||
/// `send_gcode_line` calls (see `marlin_serial.rs` for why).
|
||||
connection: Option<super::marlin_serial::MarlinSerialConnection>,
|
||||
}
|
||||
|
||||
impl PrusaSerialPrinter {
|
||||
pub fn new(id: &str, name: &str, com_port: i16) -> Self {
|
||||
pub fn new(id: &str, name: &str, port: &str, baud_rate: u32) -> Self {
|
||||
Self {
|
||||
base: PrinterBase { id: id.into(), name: name.into(), host: "127.0.0.1".to_string() },
|
||||
com_port,
|
||||
port: port.into(),
|
||||
baud_rate,
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GenericPrinter for PrusaSerialPrinter {
|
||||
/// OVERRIDE: Marlin has a gcode word for each - `M25` pause, `M24`
|
||||
/// resume, `M524` abort - so this goes out over the port like any other
|
||||
/// line, the same way `set_fan_speed` does.
|
||||
async fn control_print(&mut self, action: super::PrintControl) -> Result<(), PrinterError> {
|
||||
let word = match action {
|
||||
super::PrintControl::Pause => "M25",
|
||||
super::PrintControl::Resume => "M24",
|
||||
super::PrintControl::Stop => "M524",
|
||||
};
|
||||
self.send_gcode_line(word.to_string()).await
|
||||
}
|
||||
|
||||
|
||||
fn base(&self) -> &PrinterBase {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// OVERRIDE: a fourth, again-different body - no network host at all,
|
||||
/// just a local serial port (real version: open the port, e.g. via the
|
||||
/// `serialport`/`tokio-serial` crate, and speak Prusa's G-code-over-
|
||||
/// serial protocol).
|
||||
/// OVERRIDE, and a real one (not a stub, unlike the other four vendors'
|
||||
/// `connect`): opens the port and keeps it, since serial has no
|
||||
/// separate "session" concept to stub out - the open connection *is*
|
||||
/// the whole thing.
|
||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
||||
println!("[{}] connecting over serial (COM{})", self.base.name, self.com_port);
|
||||
println!("[{}] connecting over serial ({}, {} baud)", self.base.name, self.port, self.baud_rate);
|
||||
self.connection = Some(super::marlin_serial::MarlinSerialConnection::open(&self.port, self.baud_rate).await?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
|
||||
// TODO: add publishing via serial
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// OVERRIDE: same gcode as PrusaLink (still M106, still the shared
|
||||
/// conversion), but written straight to the serial port instead of
|
||||
/// sent over HTTP - a third transport for the same underlying command.
|
||||
/// conversion), but sent through `send_gcode_line` below instead of a
|
||||
/// separate transport - serial only has the one way to talk to the
|
||||
/// printer at all.
|
||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
||||
let pwm = super::percent_to_pwm(percent);
|
||||
println!("[{}] writing to COM{}: M106 S{pwm}", self.base.name, self.com_port);
|
||||
Ok(())
|
||||
self.send_gcode_line(format!("M106 S{pwm}")).await
|
||||
}
|
||||
|
||||
/// OVERRIDE, but not just "a fourth transport" like the others above -
|
||||
/// there's no printer-side storage to upload `data` to over a raw
|
||||
/// serial connection at all, so this decodes it as gcode text and
|
||||
/// streams it through `send_gcode_line` one line at a time via the
|
||||
/// generic `upload_gcode` driver, instead of one bulk write like
|
||||
/// Bambu/PrusaLink/Klipper.
|
||||
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
||||
let gcode = String::from_utf8(data)
|
||||
.map_err(|_| PrinterError(format!("{file_name} is not valid UTF-8 gcode")))?;
|
||||
println!("[{}] streaming {file_name} over {}", self.base.name, self.port);
|
||||
super::upload_gcode(self, &gcode).await
|
||||
}
|
||||
|
||||
/// OVERRIDE, and the real implementation, not a stub: writes `gcode_line`
|
||||
/// to the already-open port and waits for Marlin's "ok" - see
|
||||
/// `marlin_serial.rs` for why that wait matters.
|
||||
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||
let connection = self
|
||||
.connection
|
||||
.as_mut()
|
||||
.ok_or_else(|| PrinterError(format!("{} is not connected", self.base.name)))?;
|
||||
connection.send_line(&gcode_line).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Fetching a job file from the presigned URL a `start_print` command carries.
|
||||
//!
|
||||
//! The URL is attacker-influenceable in the worst case (a misconfigured or
|
||||
//! compromised cloud, a bad object store) and the body is held in memory
|
||||
//! because `upload_project_file` takes a `Vec<u8>`, so everything here is
|
||||
//! bounded: size, time, redirects and scheme.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Hard ceiling on the body. A sliced `.3mf` is tens of megabytes (62 MB has
|
||||
/// been seen for a multi-plate project); 256 MiB is roughly four times that,
|
||||
/// so no honest job is refused, while a hostile URL cannot make a gateway on
|
||||
/// small hardware (a Pi-class box is the target) buffer its way into the OOM
|
||||
/// killer. Also well above anything a printer's own storage would take.
|
||||
pub const MAX_JOB_FILE_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Whole-request budget, connect through last byte. 256 MiB at the slowest
|
||||
/// link worth supporting (~2 MiB/s) is just over two minutes, so 3 minutes
|
||||
/// covers the cap itself; anything slower than that is a stalled transfer, not
|
||||
/// a slow one. Presigned URLs live for minutes, so a longer wait would only
|
||||
/// be spent on a URL about to expire. Note `dispatch` runs inside the uplink
|
||||
/// loop, so this is also the longest a download can hold up heartbeats.
|
||||
pub const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
/// Separate and short: an unreachable host should fail in seconds, not use
|
||||
/// up the whole budget above.
|
||||
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Downloads `url` fully into memory, or says why not.
|
||||
///
|
||||
/// The error is a `String` naming the failure; the caller prefixes the stage.
|
||||
pub async fn fetch_job_file(url: &str) -> Result<Vec<u8>, String> {
|
||||
fetch_job_file_capped(url, MAX_JOB_FILE_BYTES).await
|
||||
}
|
||||
|
||||
/// Same as [`fetch_job_file`] with the cap as a parameter, so tests can
|
||||
/// exercise the limit without serving 256 MiB.
|
||||
pub async fn fetch_job_file_capped(url: &str, max_bytes: u64) -> Result<Vec<u8>, String> {
|
||||
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" {
|
||||
return Err(format!("refusing non-HTTPS URL (scheme '{}')", parsed.scheme()));
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.timeout(DOWNLOAD_TIMEOUT)
|
||||
// A presigned GET answers directly. Following a redirect would carry
|
||||
// us somewhere the signature never vouched for.
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|err| format!("could not build HTTP client: {err}"))?;
|
||||
|
||||
let mut response = client.get(parsed).send().await.map_err(|err| describe(&err))?;
|
||||
|
||||
// Status before body. An expired presigned URL is a 403 with an XML
|
||||
// error document, which must never be mistaken for a project file.
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let hint = if status == reqwest::StatusCode::FORBIDDEN {
|
||||
" (the presigned URL has probably expired)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return Err(format!("server answered HTTP {status}{hint}"));
|
||||
}
|
||||
|
||||
let expected = response.content_length();
|
||||
if let Some(len) = expected.filter(|len| *len > max_bytes) {
|
||||
return Err(format!("file is {len} bytes, over the {max_bytes} byte limit"));
|
||||
}
|
||||
|
||||
// Stream into a buffer so the cap holds even when the server sends no
|
||||
// Content-Length or lies about it.
|
||||
let mut body = Vec::with_capacity(expected.unwrap_or(0).min(max_bytes) as usize);
|
||||
while let Some(chunk) = response.chunk().await.map_err(|err| describe(&err))? {
|
||||
if body.len() as u64 + chunk.len() as u64 > max_bytes {
|
||||
return Err(format!("file exceeds the {max_bytes} byte limit"));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
// A `.3mf` is a zip: a truncated one has lost its central directory and
|
||||
// is unreadable, so a short body is an error, not a smaller file.
|
||||
if let Some(len) = expected.filter(|len| *len != body.len() as u64) {
|
||||
return Err(format!("received {} bytes but Content-Length said {len}", body.len()));
|
||||
}
|
||||
if body.is_empty() {
|
||||
return Err("server returned an empty body".into());
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// reqwest's `Display` for a transport error is just "error sending request";
|
||||
/// the useful part (timeout, connect, TLS) is in the flags and source chain.
|
||||
fn describe(err: &reqwest::Error) -> String {
|
||||
let kind = if err.is_timeout() {
|
||||
"timed out"
|
||||
} else if err.is_connect() {
|
||||
"could not connect"
|
||||
} else {
|
||||
"transfer failed"
|
||||
};
|
||||
let mut msg = format!("{kind}: {err}");
|
||||
let mut source = std::error::Error::source(err);
|
||||
while let Some(cause) = source {
|
||||
msg.push_str(&format!(": {cause}"));
|
||||
source = cause.source();
|
||||
}
|
||||
// Never echo the URL back: its query string is the credential.
|
||||
match err.url() {
|
||||
Some(url) => msg.replace(url.as_str(), "<url>"),
|
||||
None => msg,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Checked before any connection is made, so no server is needed.
|
||||
#[tokio::test]
|
||||
async fn a_non_https_url_is_refused_before_any_request() {
|
||||
let err = fetch_job_file("http://example.invalid/plate.3mf?sig=abc").await.unwrap_err();
|
||||
assert!(err.contains("non-HTTPS"), "got: {err}");
|
||||
assert!(fetch_job_file("not a url").await.is_err());
|
||||
}
|
||||
}
|
||||
+488
-16
@@ -1,52 +1,219 @@
|
||||
//! The cloud connection: a WebSocket to `continuum-backend`'s `/ws/edge/v1`.
|
||||
//!
|
||||
//! This is intentionally the simplest version of "a client that reconnects
|
||||
//! when it drops": no backoff curve, no channels routing messages in from
|
||||
//! other parts of the program yet - just connect, say hello, send a
|
||||
//! heartbeat on a timer, and print whatever comes back. Once you're
|
||||
//! comfortable with this, growing it (real backoff, forwarding printer
|
||||
//! telemetry, dispatching cloud-issued commands) is additive, not a rewrite.
|
||||
//! This is where the proxy earns its name. Until now it connected, said
|
||||
//! hello, and sent heartbeats into the void while the printer fleet lived in
|
||||
//! a completely separate part of the program with nothing routing between
|
||||
//! them. Both directions are wired up now:
|
||||
//!
|
||||
//! - **Up:** every `TELEMETRY_INTERVAL`, one snapshot per printer, batched
|
||||
//! into a single `Telemetry` message.
|
||||
//! - **Down:** a `Command` from the cloud is looked up by printer id,
|
||||
//! executed, and answered with a `CommandResult`.
|
||||
//!
|
||||
//! - **Out of band:** anything that needs to reach the cloud from outside
|
||||
//! this loop goes through an mpsc channel (`outbound`), which is how a
|
||||
//! command that takes minutes reports its outcome after the loop has
|
||||
//! already answered for it.
|
||||
//!
|
||||
//! Still deliberately the simplest version of a reconnecting client: a fixed
|
||||
//! retry delay rather than a backoff curve. Telemetry produced while the
|
||||
//! uplink is down is not buffered, and that is now a considered decision
|
||||
//! rather than a gap - the cloud stores only each printer's latest state and
|
||||
//! has no table for a history, and a Bambu's state is level-triggered
|
||||
//! (`FINISH` persists until the next print starts), so the first snapshot
|
||||
//! after a reconnect carries the outcome. Replaying a backlog of five-second
|
||||
//! snapshots into a row that holds one value would churn it with stale
|
||||
//! readings for no consumer. See PLAN.md for what would actually be worth
|
||||
//! buffering.
|
||||
|
||||
mod download;
|
||||
mod webrtc;
|
||||
mod protocol;
|
||||
|
||||
pub use protocol::{ClientMessage, ServerMessage};
|
||||
pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage};
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::{Request, generate_key};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::printer::{GenericPrinter, PrinterHandle};
|
||||
|
||||
/// The websocket, once connected. Named because `connect` has to hand it to
|
||||
/// `serve` and the full type is three layers of generics.
|
||||
type WsStream = tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>;
|
||||
|
||||
/// First retry delay after a failed connection, doubling up to
|
||||
/// `RETRY_DELAY_MAX`.
|
||||
const RETRY_DELAY: Duration = Duration::from_secs(5);
|
||||
/// Ceiling for the backoff.
|
||||
///
|
||||
/// A gateway cannot tell a cloud that is briefly restarting from a token the
|
||||
/// cloud will never accept - both are a failed connection here - so a flat
|
||||
/// 5s retry meant a permanently misconfigured gateway knocking twelve times a
|
||||
/// minute, forever, which is both pointless and the shape of something worth
|
||||
/// rate-limiting at the other end. A minute is short enough that an outage
|
||||
/// resolves without anyone touching the gateway, which matters when the
|
||||
/// gateway is in someone else's workshop.
|
||||
const RETRY_DELAY_MAX: Duration = Duration::from_secs(60);
|
||||
/// Depth of the out-of-band outbound queue.
|
||||
///
|
||||
/// Bounded, because an unbounded queue facing a cloud that may be down for
|
||||
/// hours is just a slow memory leak. Only out-of-band messages go through it,
|
||||
/// one per long-running command, so 64 is far more than a farm generates
|
||||
/// between reconnects. A sender waits when it is full rather than dropping,
|
||||
/// which is the right trade for a message whose whole purpose is to explain a
|
||||
/// failure.
|
||||
const OUTBOUND_CAPACITY: usize = 64;
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
|
||||
/// Faster than the heartbeat, slower than the printers push. A Bambu on v2
|
||||
/// emits a full status roughly every second; forwarding at that rate would be
|
||||
/// 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);
|
||||
|
||||
/// The printer fleet, shared between `main` (which owns it and keeps every
|
||||
/// connection alive) and this module (which reads status and dispatches
|
||||
/// commands).
|
||||
///
|
||||
/// `Arc` is shared ownership - several places hold the same value and the
|
||||
/// last one to let go drops it, roughly how every TS object reference
|
||||
/// already behaves. `Mutex` is what makes mutation across tasks safe: Rust
|
||||
/// will not let two tasks hold `&mut` to the same data, so a lock is how you
|
||||
/// take turns. `tokio::sync::Mutex` rather than `std::sync::Mutex`
|
||||
/// specifically because the code below holds the lock across an `.await`,
|
||||
/// which the std one is not designed for.
|
||||
///
|
||||
/// One lock for the whole `Vec` is coarse: a slow command to one printer
|
||||
/// stalls telemetry for all of them. Fine at the handful-of-printers scale
|
||||
/// this runs at now, and the shape to revisit (a lock per printer, or an
|
||||
/// actor task per printer owning its own handle) before a farm gets large.
|
||||
pub type SharedFleet = Arc<Mutex<Vec<PrinterHandle>>>;
|
||||
|
||||
/// The `Host` header for a websocket URL: authority without the scheme.
|
||||
///
|
||||
/// Needed because building the upgrade request by hand (to attach the edge
|
||||
/// token) means tokio-tungstenite no longer derives the handshake headers
|
||||
/// from a URL for us. An error rather than a guess if the URL has no
|
||||
/// authority: a `Host` of the wrong thing fails the handshake with something
|
||||
/// far less obvious than this does.
|
||||
fn host_header(url: &str) -> anyhow::Result<String> {
|
||||
let authority = url
|
||||
.split_once("://")
|
||||
.map(|(_, rest)| rest)
|
||||
.unwrap_or(url)
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
|
||||
if authority.is_empty() {
|
||||
anyhow::bail!("the uplink URL has no host to connect to: {url}");
|
||||
}
|
||||
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) {
|
||||
pub async fn run(
|
||||
config: Config,
|
||||
fleet: SharedFleet,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
mut inbox: tokio::sync::mpsc::Receiver<ClientMessage>,
|
||||
) {
|
||||
let mut delay = RETRY_DELAY;
|
||||
|
||||
loop {
|
||||
info!(url = %config.uplink_url, "connecting to cloud uplink");
|
||||
|
||||
if let Err(err) = connect_and_serve(&config).await {
|
||||
warn!(?err, "uplink connection failed, retrying in {RETRY_DELAY:?}");
|
||||
match connect(&config).await {
|
||||
Ok(ws_stream) => {
|
||||
// Reset on a completed handshake, which is the only
|
||||
// unambiguous evidence that the cloud is reachable and this
|
||||
// gateway's token is accepted - and the reason `connect` is
|
||||
// split out at all. Resetting after `serve` returns instead
|
||||
// would not distinguish a connection that lived for an hour
|
||||
// from one that failed to open.
|
||||
delay = RETRY_DELAY;
|
||||
if let Err(err) = serve(ws_stream, &config, &fleet, &outbound, &mut inbox).await {
|
||||
warn!(?err, "uplink connection dropped, reconnecting in {delay:?}");
|
||||
}
|
||||
}
|
||||
Err(err) => warn!(?err, "uplink connection failed, retrying in {delay:?}"),
|
||||
}
|
||||
|
||||
tokio::time::sleep(RETRY_DELAY).await;
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(RETRY_DELAY_MAX);
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_and_serve(config: &Config) -> anyhow::Result<()> {
|
||||
let (ws_stream, _response) = tokio_tungstenite::connect_async(&config.uplink_url).await?;
|
||||
/// Opens the websocket. Separate from `serve` so `run` can tell a failed
|
||||
/// handshake from a connection that opened and later dropped; they want
|
||||
/// different retry behaviour.
|
||||
async fn connect(config: &Config) -> anyhow::Result<WsStream> {
|
||||
// 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 -
|
||||
// and the credential stays out of the URL, where it would land in every
|
||||
// access and proxy log along the way. `Authorization` is the header
|
||||
// those tools already know to redact.
|
||||
let request = Request::builder()
|
||||
.uri(&config.uplink_url)
|
||||
.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.
|
||||
.header("Host", host_header(&config.uplink_url)?)
|
||||
.header("Connection", "Upgrade")
|
||||
.header("Upgrade", "websocket")
|
||||
.header("Sec-WebSocket-Version", "13")
|
||||
.header("Sec-WebSocket-Key", generate_key())
|
||||
.body(())?;
|
||||
|
||||
let (ws_stream, _response) = tokio_tungstenite::connect_async(request).await?;
|
||||
Ok(ws_stream)
|
||||
}
|
||||
|
||||
/// Says hello, then pumps the connection until it drops.
|
||||
async fn serve(
|
||||
ws_stream: WsStream,
|
||||
config: &Config,
|
||||
fleet: &SharedFleet,
|
||||
outbound: &tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
inbox: &mut tokio::sync::mpsc::Receiver<ClientMessage>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
let hello = ClientMessage::Hello {
|
||||
gateway_id: config.gateway_id.clone(),
|
||||
farm_id: config.farm_id.clone(),
|
||||
token: config.gateway_token.clone(),
|
||||
stream_base_url: config.stream_base_url.clone(),
|
||||
};
|
||||
write.send(Message::Text(serde_json::to_string(&hello)?)).await?;
|
||||
|
||||
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
|
||||
let mut telemetry = tokio::time::interval(TELEMETRY_INTERVAL);
|
||||
let mut seq: u64 = 0;
|
||||
|
||||
loop {
|
||||
@@ -57,11 +224,95 @@ async fn connect_and_serve(config: &Config) -> anyhow::Result<()> {
|
||||
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
|
||||
}
|
||||
|
||||
_ = telemetry.tick() => {
|
||||
// Read first, then ask for the next one. A v1 Bambu answers
|
||||
// `refresh_status` over MQTT some time after this returns, so
|
||||
// collecting first means every tick reports the previous
|
||||
// tick's refresh - one interval of lag, rather than a
|
||||
// guaranteed miss from reading before the answer could
|
||||
// possibly have arrived.
|
||||
let printers = collect_telemetry(fleet).await;
|
||||
refresh_fleet(fleet).await;
|
||||
// Nothing to say is not worth a frame. Happens whenever no
|
||||
// printer has reported yet - every vendor but Bambu is still
|
||||
// a stub, so a Prusa-only fleet is silent here by design.
|
||||
if !printers.is_empty() {
|
||||
let msg = ClientMessage::Telemetry { printers };
|
||||
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever a task elsewhere wants said. Taken from the queue
|
||||
// before it is written, so a send that fails here loses the
|
||||
// message - the connection is going down anyway, and the
|
||||
// alternative (peek, write, then pop) buys little for a message
|
||||
// that is already best-effort diagnostics.
|
||||
Some(msg) = inbox.recv() => {
|
||||
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
|
||||
}
|
||||
|
||||
frame = read.next() => {
|
||||
match frame {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
if let Ok(server_msg) = serde_json::from_str::<ServerMessage>(&text) {
|
||||
info!(?server_msg, "received from cloud");
|
||||
match serde_json::from_str::<ServerMessage>(&text) {
|
||||
Ok(ServerMessage::Command { command_id, printer_id, command }) => {
|
||||
// `start_print` is the one command that cannot
|
||||
// be answered inline. See `spawn_long_command`.
|
||||
let msg = if command.runs_for_minutes() {
|
||||
spawn_long_command(
|
||||
fleet.clone(),
|
||||
printer_id.clone(),
|
||||
command,
|
||||
command_id.clone(),
|
||||
outbound.clone(),
|
||||
);
|
||||
ClientMessage::CommandResult {
|
||||
command_id, ok: true, error: None,
|
||||
}
|
||||
} else {
|
||||
match dispatch(fleet, &printer_id, command).await {
|
||||
Ok(()) => ClientMessage::CommandResult {
|
||||
command_id, ok: true, error: None,
|
||||
},
|
||||
Err(err) => {
|
||||
warn!(%printer_id, %err, "command failed");
|
||||
ClientMessage::CommandResult {
|
||||
command_id, ok: false, error: Some(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
|
||||
}
|
||||
// Retrying will not help: the token is wrong, not
|
||||
// the network. Logged at error level and returned
|
||||
// so it is obvious which of the two it is.
|
||||
Ok(ServerMessage::HelloError { message }) => {
|
||||
tracing::error!(%message, "cloud rejected this gateway's handshake - check CONTINUUM_GATEWAY_TOKEN");
|
||||
return Ok(());
|
||||
}
|
||||
// Spawned, never awaited inline: go2rtc gathers
|
||||
// ICE candidates before answering, which
|
||||
// includes a STUN round trip, so this takes
|
||||
// seconds. Awaiting it here would stall
|
||||
// telemetry and heartbeats for the whole farm
|
||||
// while one person opened one camera.
|
||||
Ok(ServerMessage::WebrtcOffer { session_id, camera_stream, sdp }) => {
|
||||
spawn_webrtc_negotiation(
|
||||
config.go2rtc_api_listen.clone(),
|
||||
session_id,
|
||||
camera_stream,
|
||||
sdp,
|
||||
outbound.clone(),
|
||||
);
|
||||
}
|
||||
Ok(other) => info!(?other, "received from cloud"),
|
||||
// Loudly. This used to be an `if let Ok(..)` with
|
||||
// no else, which is the same silent-drop bug that
|
||||
// hid a real `print.push_status` failure for
|
||||
// weeks - and here it would mean commands
|
||||
// vanishing with the cloud never being told.
|
||||
Err(err) => warn!(%err, payload = %text, "unparseable message from cloud, ignoring"),
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => return Ok(()),
|
||||
@@ -72,3 +323,224 @@ async fn connect_and_serve(config: &Config) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks every printer to report in full, for the next tick to pick up. Failure
|
||||
/// is logged and otherwise ignored: a printer that cannot be asked right now
|
||||
/// is a printer whose telemetry goes stale, not a reason to tear down the
|
||||
/// cloud connection for the whole farm.
|
||||
async fn refresh_fleet(fleet: &SharedFleet) {
|
||||
let fleet = fleet.lock().await;
|
||||
for printer in fleet.iter() {
|
||||
if let Err(err) = printer.refresh_status().await {
|
||||
warn!(printer = %printer.base().id, %err, "failed to request a status refresh");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One snapshot per printer that has something to report. Translates Bambu's
|
||||
/// `print.push_status` into the vendor-neutral shape the cloud consumes - see
|
||||
/// `PrinterTelemetry` for why this isn't just forwarded raw.
|
||||
async fn collect_telemetry(fleet: &SharedFleet) -> Vec<PrinterTelemetry> {
|
||||
let fleet = fleet.lock().await;
|
||||
let mut out = Vec::new();
|
||||
|
||||
for printer in fleet.iter() {
|
||||
let base = printer.base();
|
||||
let Some(report) = printer.print_status().await else {
|
||||
continue;
|
||||
};
|
||||
|
||||
out.push(PrinterTelemetry {
|
||||
printer_id: base.id.clone(),
|
||||
name: base.name.clone(),
|
||||
state: report.gcode_state.clone(),
|
||||
percent: report.mc_percent,
|
||||
stage: report.stg_cur,
|
||||
layer: report.layer_num,
|
||||
total_layers: report.total_layer_num,
|
||||
remaining_minutes: report.mc_remaining_time,
|
||||
// An idle printer reports `""` rather than omitting the field;
|
||||
// empty-string-as-absent is worth normalising here so the cloud
|
||||
// doesn't have to know that.
|
||||
job_name: report.subtask_name.clone().filter(|s| !s.is_empty()),
|
||||
nozzle_temp: report.nozzle_temper,
|
||||
nozzle_target: report.nozzle_target_temper,
|
||||
bed_temp: report.bed_temper,
|
||||
bed_target: report.bed_target_temper,
|
||||
print_error: report.print_error,
|
||||
active_alerts: report.hms.as_ref().map(|h| h.len()),
|
||||
// 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
|
||||
// there. Asking `camera` rather than keeping a second copy of
|
||||
// the rule means the two cannot drift apart.
|
||||
camera_stream: crate::camera::source_for(
|
||||
printer,
|
||||
report.ipcam.as_ref().and_then(|c| c.rtsp_url.as_deref()),
|
||||
)
|
||||
.map(|source| source.stream_name),
|
||||
});
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Runs a command that takes minutes, off this connection's task, and answers
|
||||
/// the cloud immediately.
|
||||
///
|
||||
/// Everything else here is an MQTT publish that returns in milliseconds, so
|
||||
/// awaiting it inline is fine. `start_print` is not: it downloads tens of
|
||||
/// megabytes and then transfers them to a printer over FTPS at a measured
|
||||
/// 0.2 MiB/s, which is about four minutes for a 50 MB project. Awaited
|
||||
/// inline, that blocks this whole `select!` loop - no heartbeats and, far
|
||||
/// worse, **no telemetry** for the duration.
|
||||
///
|
||||
/// No telemetry is the one that makes this mandatory rather than tidy. The
|
||||
/// cloud derives a job's state from the telemetry that follows a start, so a
|
||||
/// gateway that goes silent for four minutes during every job start is
|
||||
/// withholding exactly the information the job depends on, while the backend
|
||||
/// is also timing the command out after ten seconds and concluding the start
|
||||
/// failed.
|
||||
///
|
||||
/// So the `ok` this produces means "accepted, and working on it" - which is
|
||||
/// all the cloud ever trusted a start reply to mean anyway. *That* a failure
|
||||
/// after this point happened is still noticed by the cloud on its own: the
|
||||
/// job never reaches `printing` and the backend reaps it. What used to be
|
||||
/// missing was *why*, which lived only in this gateway's log - on a machine
|
||||
/// in someone else's workshop.
|
||||
///
|
||||
/// So a late failure now goes back over the outbound queue as a second
|
||||
/// `CommandResult` for the same `command_id`. Be clear about what the backend
|
||||
/// does with it: that command is long since resolved, so it is not awaiting
|
||||
/// an answer and the result does not change the job's fate. It is logged with
|
||||
/// its reason, next to the gateway and farm that produced it, which is the
|
||||
/// difference between "job failed" and "job failed: filament mapping for
|
||||
/// plate 1 names a slot the AMS does not have". Attaching it to the job row
|
||||
/// needs a column `print_jobs` does not have yet.
|
||||
///
|
||||
/// Nothing is sent on success - the telemetry that follows says so better
|
||||
/// than a message could, and a result for an already-resolved command is
|
||||
/// noise unless it carries news.
|
||||
fn spawn_long_command(
|
||||
fleet: SharedFleet,
|
||||
printer_id: String,
|
||||
command: PrinterCommand,
|
||||
command_id: String,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
match dispatch(&fleet, &printer_id, command).await {
|
||||
Ok(()) => info!(%printer_id, "long-running command finished"),
|
||||
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
|
||||
// survives a gateway with no internet.
|
||||
tracing::error!(
|
||||
%printer_id, %err,
|
||||
"long-running command failed after the cloud was already told it was accepted"
|
||||
);
|
||||
let late = ClientMessage::CommandResult {
|
||||
command_id,
|
||||
ok: false,
|
||||
error: Some(err),
|
||||
};
|
||||
// `send` waits for room rather than dropping, and only fails
|
||||
// if the receiver is gone - which means `run` has returned,
|
||||
// i.e. the process is shutting down and there is nobody left
|
||||
// to tell.
|
||||
if outbound.send(late).await.is_err() {
|
||||
warn!(%printer_id, "uplink is shutting down; the failure reason was not reported");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Answers one WebRTC offer, off this connection's task.
|
||||
///
|
||||
/// Always answers, success or failure: the cloud is holding a browser's
|
||||
/// request open waiting for this, and a silent failure there is a viewer
|
||||
/// watching a video element that never loads until something times out.
|
||||
fn spawn_webrtc_negotiation(
|
||||
api_listen: String,
|
||||
session_id: String,
|
||||
camera_stream: String,
|
||||
offer_sdp: String,
|
||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let reply = match webrtc::negotiate(&api_listen, &camera_stream, &offer_sdp).await {
|
||||
Ok(sdp) => ClientMessage::WebrtcAnswer { session_id, sdp },
|
||||
Err(err) => {
|
||||
// Logged as well as returned. The cloud gets the reason, but
|
||||
// this is the copy that survives a gateway whose uplink is
|
||||
// about to drop.
|
||||
warn!(%camera_stream, %err, "could not negotiate a camera stream");
|
||||
ClientMessage::WebrtcError { session_id, message: err.to_string() }
|
||||
}
|
||||
};
|
||||
if outbound.send(reply).await.is_err() {
|
||||
warn!(%camera_stream, "uplink is shutting down; the viewer gets no answer");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
async fn dispatch(fleet: &SharedFleet, printer_id: &str, command: PrinterCommand) -> Result<(), String> {
|
||||
// Fetched before the fleet lock is taken: a download is bounded by
|
||||
// minutes of network time, and holding the lock across it would stall
|
||||
// telemetry for every other printer. The transfer to the printer that
|
||||
// follows has to hold it (it needs `&mut` access), but that is no reason
|
||||
// to hold it through this part too. A `start_print` for a printer that
|
||||
// does not exist is therefore found out after the download rather than
|
||||
// before; the lookup below is cheap, so it is done first for that case.
|
||||
let downloaded = match &command {
|
||||
PrinterCommand::StartPrint { url, .. } => {
|
||||
if !fleet.lock().await.iter().any(|p| p.base().id == printer_id) {
|
||||
return Err(format!("no printer with id '{printer_id}' in this gateway's fleet"));
|
||||
}
|
||||
Some(
|
||||
download::fetch_job_file(url)
|
||||
.await
|
||||
.map_err(|err| format!("download failed (nothing was sent to the printer): {err}"))?,
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let mut fleet = fleet.lock().await;
|
||||
let printer = fleet
|
||||
.iter_mut()
|
||||
.find(|p| p.base().id == printer_id)
|
||||
.ok_or_else(|| format!("no printer with id '{printer_id}' in this gateway's fleet"))?;
|
||||
|
||||
info!(%printer_id, ?command, "dispatching cloud command");
|
||||
|
||||
match command {
|
||||
PrinterCommand::SetFanSpeed { percent } => {
|
||||
printer.set_fan_speed(percent).await.map_err(|err| err.to_string())
|
||||
}
|
||||
PrinterCommand::SendGcodeLine { line } => {
|
||||
printer.send_gcode_line(line).await.map_err(|err| err.to_string())
|
||||
}
|
||||
PrinterCommand::ControlPrint { control } => {
|
||||
printer.control_print(control).await.map_err(|err| err.to_string())
|
||||
}
|
||||
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");
|
||||
// One call covers deriving the filament mapping, the FTPS
|
||||
// transfer, the truncation check and the start, and reports them
|
||||
// all as one error - so the stage here is "printer", not finer.
|
||||
// The printer's own message says which part (mapping, upload of
|
||||
// the file, or start) it was.
|
||||
printer
|
||||
.upload_project_file(file_name, bytes)
|
||||
.await
|
||||
.map_err(|err| format!("printer upload/start failed (download of {byte_count} bytes succeeded): {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+298
-1
@@ -1,11 +1,196 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::printer::PrintControl;
|
||||
|
||||
/// What this gateway sends up to `continuum-backend`'s `/ws/edge/v1` route.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientMessage {
|
||||
Hello { gateway_id: String, farm_id: String, token: String },
|
||||
/// The handshake, carrying no credential.
|
||||
///
|
||||
/// The edge token goes in an `Authorization: Bearer` header on the
|
||||
/// upgrade request instead - see `connect_and_serve`. It used to be a
|
||||
/// `token` field right here, which worked but meant the backend had an
|
||||
/// open socket and a parsed message from a peer that had proved nothing.
|
||||
///
|
||||
/// `farm_id` stays, even though the token already carries the farm: the
|
||||
/// backend checks the two against each other rather than trusting this
|
||||
/// one, so sending it is how a configuration mismatch gets caught
|
||||
/// instead of silently working against the wrong farm.
|
||||
Hello {
|
||||
gateway_id: String,
|
||||
farm_id: String,
|
||||
/// Where a browser on this LAN can reach the gateway's camera
|
||||
/// streams, if the operator has configured one. See
|
||||
/// `Config::stream_base_url`.
|
||||
///
|
||||
/// Reported in the handshake rather than per-printer in telemetry
|
||||
/// because it is a property of the gateway, and repeating it in
|
||||
/// every snapshot of every printer would be the same string sent
|
||||
/// hundreds of times an hour.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream_base_url: Option<String>,
|
||||
},
|
||||
Heartbeat { seq: u64 },
|
||||
/// 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
|
||||
/// wants the lowest possible latency per machine.
|
||||
Telemetry { printers: Vec<PrinterTelemetry> },
|
||||
/// The reply to a `ServerMessage::Command`. Correlated by `command_id`,
|
||||
/// which the *cloud* generates - unlike Bambu's `sequence_id`, we're
|
||||
/// both ends of this protocol, so the side that needs the correlation
|
||||
/// gets to choose it.
|
||||
CommandResult {
|
||||
command_id: String,
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
/// go2rtc's answer SDP for a `WebrtcOffer`, passed through verbatim.
|
||||
WebrtcAnswer { session_id: String, sdp: String },
|
||||
/// No answer was obtainable: no such camera here, go2rtc not running, or
|
||||
/// it refused the offer.
|
||||
///
|
||||
/// Its own variant rather than an empty answer, because the cloud is
|
||||
/// 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 },
|
||||
/// 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
|
||||
/// actually displays - deliberately not Bambu's `print.push_status` shape.
|
||||
///
|
||||
/// Two reasons for the translation rather than forwarding the raw report:
|
||||
/// this has to describe a Prusa or a Klipper printer too, and Bambu's wire
|
||||
/// format has traps that should not reach the rest of the system (fan
|
||||
/// speeds are 0-15 gears, temperatures arrive as either integers or floats,
|
||||
/// and v2 reuses one `sequence_id` forever - see `PrintReport`).
|
||||
///
|
||||
/// Every field is optional because an unreachable or just-connected printer
|
||||
/// genuinely has nothing to report, and "no data yet" is different from
|
||||
/// "zero".
|
||||
///
|
||||
/// Absent fields are *omitted* rather than serialized as `null`, which is
|
||||
/// what `skip_serializing_if` below is for. Caught by the contract test
|
||||
/// against real captures: serde's default is to emit `"job_name": null`, and
|
||||
/// the backend's validator reads a missing key and an explicit null as
|
||||
/// different things, so an idle printer's entire telemetry batch was being
|
||||
/// rejected. The backend now accepts either, but this end sends only the one
|
||||
/// the schema describes.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PrinterTelemetry {
|
||||
/// Our own id from printers.toml, not the vendor serial - this is what
|
||||
/// the cloud's `printers` table keys on.
|
||||
pub printer_id: String,
|
||||
pub name: String,
|
||||
/// `"IDLE"`, `"RUNNING"`, `"PAUSE"`, `"FINISH"`, `"FAILED"` - passed
|
||||
/// through as the printer's own string rather than narrowed to an enum,
|
||||
/// so an unrecognised state reaches the cloud as itself instead of being
|
||||
/// dropped on the floor here.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub percent: Option<i32>,
|
||||
/// What the printer is doing when it is not laying down plastic - bed
|
||||
/// levelling, nozzle cleaning, extrusion calibration. Bambu's `stg_cur`,
|
||||
/// passed through as the printer's own number rather than decoded,
|
||||
/// because the code-to-name table is not something we have. 0 means
|
||||
/// printing.
|
||||
///
|
||||
/// Here because progress without it is ambiguous in a way that reads as
|
||||
/// a fault. Confirmed on a P1P (2026-10-11): `percent` reached 14% while
|
||||
/// `layer` stayed 0 for minutes, the printer walking `stg_cur` 54 -> 14
|
||||
/// -> 13 -> 0 before starting layer 1.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stage: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub total_layers: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub remaining_minutes: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub job_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nozzle_temp: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nozzle_target: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bed_temp: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bed_target: Option<f32>,
|
||||
/// Non-zero means the printer is reporting a fault.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub print_error: Option<i32>,
|
||||
/// How many Health-Management-System entries the printer currently has
|
||||
/// active. Just the count for now - decoding the `{attr, code}` pairs
|
||||
/// into messages needs Bambu's HMS tables, which we don't have yet.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub active_alerts: Option<usize>,
|
||||
/// The go2rtc stream name for this printer's camera, if it has one that
|
||||
/// can actually be played. Combined with the farm's `stream_base_url`
|
||||
/// this is everything a browser needs.
|
||||
///
|
||||
/// Absent is the common case, not the exception: only the X1/H2 class
|
||||
/// publishes an RTSP URL. A P1 or A1 reports that it *has* a camera
|
||||
/// without giving anything to connect to, and this stays `None` for
|
||||
/// those - a UI should offer a player only when this is present. See
|
||||
/// `crate::camera`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub camera_stream: Option<String>,
|
||||
}
|
||||
|
||||
/// What `continuum-backend` sends back over the same connection.
|
||||
@@ -13,5 +198,117 @@ pub enum ClientMessage {
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerMessage {
|
||||
HelloAck { session_id: String },
|
||||
/// The handshake was rejected - a bad, expired or wrong-farm token. The
|
||||
/// backend closes the connection right after sending this.
|
||||
///
|
||||
/// Worth a variant of its own rather than letting it fall into the
|
||||
/// unparseable-message branch: retrying a rejected token every few seconds
|
||||
/// forever looks exactly like a network problem in the logs otherwise, and
|
||||
/// it is the one uplink failure a human has to go fix by hand.
|
||||
HelloError { message: String },
|
||||
HeartbeatAck { seq: u64 },
|
||||
/// Do something to one printer. `printer_id` is our own id from
|
||||
/// printers.toml.
|
||||
Command { command_id: String, printer_id: String, command: PrinterCommand },
|
||||
/// Relay a browser's WebRTC offer to this gateway's go2rtc and answer
|
||||
/// with the SDP it returns. **Signalling only - no video on this
|
||||
/// socket.**
|
||||
///
|
||||
/// Media goes browser-to-go2rtc directly over ICE, which is the whole
|
||||
/// reason only the signalling is relayed: on a workshop LAN the video
|
||||
/// never leaves the building, and it costs the cloud no bandwidth at
|
||||
/// all. go2rtc's answer carries a server-reflexive candidate alongside
|
||||
/// its host ones, so even a remote viewer can often reach the gateway
|
||||
/// without a relay.
|
||||
///
|
||||
/// The browser cannot do this part itself. go2rtc sends no CORS headers
|
||||
/// 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 },
|
||||
}
|
||||
|
||||
/// The closed set of things the cloud is allowed to ask a printer to do.
|
||||
///
|
||||
/// Deliberately small. `StartPrint` is the newest and the only one that
|
||||
/// reaches outside the gateway (it fetches a URL), so it is the one with
|
||||
/// bounds on it - see `download.rs`.
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum PrinterCommand {
|
||||
/// 0-100. Each vendor converts to whatever its own mechanism wants (for
|
||||
/// Bambu, an `M106` PWM value over MQTT).
|
||||
SetFanSpeed { percent: u8 },
|
||||
/// A single raw gcode line. The most powerful variant by far, and the
|
||||
/// one to be most careful about ever exposing to a UI.
|
||||
SendGcodeLine { line: String },
|
||||
/// Pause, resume or stop whatever the printer is currently running.
|
||||
///
|
||||
/// One variant carrying an action rather than three variants, matching
|
||||
/// `PrintControl` and `GenericPrinter::control_print` - the alternative
|
||||
/// was three near-identical arms here and three near-identical methods
|
||||
/// across four vendor implementations.
|
||||
///
|
||||
/// `Stop` is a cancel and is not undoable. Confirmed live on a P1P: a
|
||||
/// stop during `PREPARE`/`RUNNING` moves the printer to `FAILED`, which
|
||||
/// is how Bambu reports an operator-cancelled print.
|
||||
ControlPrint { control: PrintControl },
|
||||
/// Download a job's file, put it on the printer and start it.
|
||||
///
|
||||
/// A successful result means the gateway *accepted* the job, not that
|
||||
/// anything is printing: a real run on a P1P was accepted, uploaded
|
||||
/// byte-perfect, reached RUNNING, then paused itself on a filament
|
||||
/// fault. Job state must follow the telemetry, never this reply.
|
||||
///
|
||||
/// There is no AMS or filament field on purpose. The mapping is read out
|
||||
/// of the archive's own `Metadata/slice_info.config` by
|
||||
/// `upload_project_file`, because only the archive knows what each plate
|
||||
/// was sliced for - guessing it is what heated a P1P to 250C and paused
|
||||
/// it at 1%.
|
||||
///
|
||||
/// `url` is a presigned, expiring GET, minted at dispatch time (a queued
|
||||
/// job may wait days). It is a URL and not the bytes so the file stays
|
||||
/// off the JSON control channel and object-store credentials stay out of
|
||||
/// every gateway. It is a bearer credential, which is why `Debug` below
|
||||
/// 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 },
|
||||
}
|
||||
|
||||
impl PrinterCommand {
|
||||
/// Whether this command takes long enough that it must not be awaited on
|
||||
/// the uplink's own task.
|
||||
///
|
||||
/// The threshold is not arbitrary: everything but `StartPrint` is a single
|
||||
/// MQTT publish, while `StartPrint` downloads a project and then transfers
|
||||
/// it to a printer at a measured 0.2 MiB/s - minutes, during which the
|
||||
/// uplink would send no telemetry at all. See `spawn_long_command`.
|
||||
pub fn runs_for_minutes(&self) -> bool {
|
||||
// Written as an exhaustive match rather than `matches!`, so a new
|
||||
// variant has to make this decision rather than defaulting to
|
||||
// "answer it inline" and quietly reintroducing the stall.
|
||||
match self {
|
||||
Self::StartPrint { .. } => true,
|
||||
Self::SetFanSpeed { .. } | Self::SendGcodeLine { .. } | Self::ControlPrint { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand-written so the presigned `url` never reaches a log line: `dispatch`
|
||||
/// logs every command with `{:?}`, and the query string of that URL is
|
||||
/// enough to download the job file until it expires.
|
||||
impl std::fmt::Debug for PrinterCommand {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::SetFanSpeed { percent } => f.debug_struct("SetFanSpeed").field("percent", percent).finish(),
|
||||
Self::SendGcodeLine { line } => f.debug_struct("SendGcodeLine").field("line", line).finish(),
|
||||
Self::ControlPrint { control } => f.debug_struct("ControlPrint").field("control", control).finish(),
|
||||
Self::StartPrint { job_id, file_name, .. } => f
|
||||
.debug_struct("StartPrint")
|
||||
.field("job_id", job_id)
|
||||
.field("file_name", file_name)
|
||||
.field("url", &"<redacted>")
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! WebRTC signalling, relayed from the cloud to this gateway's own go2rtc.
|
||||
//!
|
||||
//! Only the signalling. The video never touches this process or the cloud:
|
||||
//! once the browser has go2rtc's answer, the two negotiate media directly
|
||||
//! over ICE. On a workshop LAN that means the stream never leaves the
|
||||
//! building and costs the cloud nothing, which is the entire reason the
|
||||
//! relay carries SDP rather than bytes.
|
||||
//!
|
||||
//! ## Why the cloud has to be in the middle at all
|
||||
//!
|
||||
//! A browser cannot signal to go2rtc itself. Measured against go2rtc 1.9.14:
|
||||
//! it returns no `Access-Control-Allow-Origin` on any response, and it checks
|
||||
//! its API password *before* CORS, so the unauthenticated preflight a browser
|
||||
//! always sends gets a 401. A gateway is also behind a workshop NAT, so the
|
||||
//! cloud cannot dial in either - the only way through is the connection this
|
||||
//! gateway already opened outbound.
|
||||
//!
|
||||
//! ## Why no credential appears here
|
||||
//!
|
||||
//! The request goes to `127.0.0.1`, and go2rtc exempts loopback from its API
|
||||
//! password. That is a measured property, not an assumption, and it is also
|
||||
//! why a probe from the gateway itself cannot be used to check that the
|
||||
//! password is working - test from another host for that.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// go2rtc answers an offer after gathering candidates, which includes a STUN
|
||||
/// round trip to discover its public address, so this is not instant. Still
|
||||
/// bounded: a viewer waiting on a dead go2rtc should be told, and the cloud
|
||||
/// is holding a request open for this.
|
||||
const NEGOTIATION_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
/// Turns go2rtc's *bind* address into a loopback URL we can call it on.
|
||||
///
|
||||
/// `CONTINUUM_GO2RTC_API_LISTEN` is a listen address - `:1984`,
|
||||
/// `0.0.0.0:1984` - and neither is something to send a request to. Only the
|
||||
/// port is portable, and loopback is the address we want regardless: it is
|
||||
/// the one go2rtc exempts from its password.
|
||||
fn local_api_base(api_listen: &str) -> anyhow::Result<String> {
|
||||
let port = api_listen
|
||||
.rsplit_once(':')
|
||||
.map(|(_, port)| port)
|
||||
.unwrap_or(api_listen)
|
||||
.trim();
|
||||
|
||||
if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
|
||||
anyhow::bail!("cannot tell which port go2rtc's API is on from '{api_listen}'");
|
||||
}
|
||||
Ok(format!("http://127.0.0.1:{port}"))
|
||||
}
|
||||
|
||||
/// Rejects a stream name that could not be a go2rtc stream name.
|
||||
///
|
||||
/// It goes into a query string, and this is cheaper and clearer than
|
||||
/// percent-encoding: a real name is a printer id from `printers.toml`, so
|
||||
/// anything outside this set is a bug or an attempt at one, and refusing it
|
||||
/// says so instead of forwarding something odd to go2rtc.
|
||||
fn checked_stream_name(name: &str) -> anyhow::Result<&str> {
|
||||
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) {
|
||||
anyhow::bail!("'{name}' is not a usable go2rtc stream name");
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Flattens an error's `source` chain into one line.
|
||||
///
|
||||
/// Needed because reqwest's own `Display` is just "error sending request for
|
||||
/// url (...)" - the reason lives one or two sources down, and "connection
|
||||
/// refused" versus "timed out" is the whole content of the message. This
|
||||
/// error travels to the cloud and then to a viewer, so it is the only
|
||||
/// explanation anyone gets.
|
||||
fn with_causes(err: &(dyn std::error::Error + 'static)) -> String {
|
||||
let mut out = err.to_string();
|
||||
let mut source = err.source();
|
||||
while let Some(cause) = source {
|
||||
let text = cause.to_string();
|
||||
// Skip a cause that only repeats what we already said, which hyper's
|
||||
// wrappers do.
|
||||
if !out.contains(&text) {
|
||||
out.push_str(": ");
|
||||
out.push_str(&text);
|
||||
}
|
||||
source = cause.source();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Posts the browser's offer to go2rtc and returns its answer SDP.
|
||||
pub async fn negotiate(api_listen: &str, camera_stream: &str, offer_sdp: &str) -> anyhow::Result<String> {
|
||||
let base = local_api_base(api_listen)?;
|
||||
let stream = checked_stream_name(camera_stream)?;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{base}/api/webrtc?src={stream}"))
|
||||
.header("Content-Type", "application/sdp")
|
||||
.body(offer_sdp.to_string())
|
||||
.timeout(NEGOTIATION_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
// Names go2rtc explicitly. The most likely cause by far is that
|
||||
// it is not running, and an error mentioning only a port sends
|
||||
// whoever reads it looking at the network.
|
||||
anyhow::anyhow!("could not reach go2rtc at {base}: {}", with_causes(&err))
|
||||
})?;
|
||||
|
||||
// go2rtc answers 201 rather than 200 here, so this checks the class and
|
||||
// not a single code.
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
// Its body is the useful half - "streams: wrong url" for a camera it
|
||||
// does not have, say - and a bare status would send whoever is
|
||||
// reading the logs looking at the network instead.
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let detail = body.trim();
|
||||
anyhow::bail!(
|
||||
"go2rtc refused the offer for '{stream}' (HTTP {}){}",
|
||||
status.as_u16(),
|
||||
if detail.is_empty() { String::new() } else { format!(": {detail}") }
|
||||
);
|
||||
}
|
||||
|
||||
let answer = response.text().await?;
|
||||
if answer.trim().is_empty() {
|
||||
anyhow::bail!("go2rtc accepted the offer for '{stream}' but returned an empty answer");
|
||||
}
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_bind_address_becomes_a_loopback_url() {
|
||||
// The three shapes an operator actually writes, all of which have to
|
||||
// end up at loopback - that is the address go2rtc does not demand a
|
||||
// password on.
|
||||
for listen in [":1984", "0.0.0.0:1984", "127.0.0.1:1984"] {
|
||||
assert_eq!(local_api_base(listen).unwrap(), "http://127.0.0.1:1984");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bind_address_with_no_port_is_refused_rather_than_guessed() {
|
||||
// Guessing 1984 would produce a gateway that silently signals to the
|
||||
// wrong place when someone has moved the port.
|
||||
for listen in ["", "localhost", ":", "1984a", ":http"] {
|
||||
assert!(local_api_base(listen).is_err(), "{listen:?} should be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stream_name_that_could_break_out_of_the_query_is_refused() {
|
||||
for name in ["", "h2c&src=other", "h2c?x", "h2c/../y", "h2c one"] {
|
||||
assert!(checked_stream_name(name).is_err(), "{name:?} should be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_printer_id_is_accepted() {
|
||||
for name in ["h2c", "p1p", "printer-2", "bay_3", "x1.c"] {
|
||||
assert_eq!(checked_stream_name(name).unwrap(), name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Proves the blocks `discovery` prints are blocks `fleet` can load.
|
||||
//!
|
||||
//! The two are coupled by nothing but a string - `Vendor::toml_value` has to
|
||||
//! match what `fleet::build_printer` matches on, and the fields rendered have
|
||||
//! to be the fields it requires. Unit tests on either side cannot catch a
|
||||
//! drift between them, and the symptom of a drift is the worst kind: an
|
||||
//! operator pastes in exactly what they were told to and the gateway refuses
|
||||
//! to start.
|
||||
//!
|
||||
//! So this renders a candidate for every vendor, fills in the credential the
|
||||
//! way a human would, and loads the result for real.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use continuum_proxy::discovery::{Candidate, Source, Vendor};
|
||||
use continuum_proxy::fleet;
|
||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||
|
||||
/// Fills in the credential a human is told to supply, so the entry loads.
|
||||
fn with_credentials(entry: &str) -> String {
|
||||
entry
|
||||
.replace("access_code = \"\"", "access_code = \"12345678\"")
|
||||
.replace("api_key = \"\"", "api_key = \"an-api-key\"")
|
||||
}
|
||||
|
||||
fn load(toml: &str) -> anyhow::Result<Vec<PrinterHandle>> {
|
||||
let mut file = tempfile();
|
||||
file.write_all(toml.as_bytes()).expect("write the fleet file");
|
||||
fleet::load(&file.path)
|
||||
}
|
||||
|
||||
/// A self-deleting temporary file, rather than a dependency for four lines.
|
||||
struct TempToml {
|
||||
path: std::path::PathBuf,
|
||||
handle: Option<std::fs::File>,
|
||||
}
|
||||
|
||||
impl TempToml {
|
||||
fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> {
|
||||
self.handle.as_mut().expect("still open").write_all(bytes)?;
|
||||
self.handle.as_mut().expect("still open").flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempToml {
|
||||
fn drop(&mut self) {
|
||||
self.handle.take();
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn tempfile() -> TempToml {
|
||||
// The pid and a counter, so two tests in one binary cannot collide.
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("continuum-fleet-{}-{}.toml", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
let handle = std::fs::File::create(&path).expect("create a temp fleet file");
|
||||
TempToml { path, handle: Some(handle) }
|
||||
}
|
||||
|
||||
fn candidate(vendor: Vendor, host: &str) -> Candidate {
|
||||
let mut candidate = Candidate::new(host, vendor, Source::HttpApi);
|
||||
// Bambu needs one, and for every other vendor it is ignored.
|
||||
candidate.serial = Some("01S09B441900143".to_string());
|
||||
candidate
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_rendered_entry_loads_as_the_printer_it_claims_to_be() {
|
||||
let cases = [
|
||||
(Vendor::Bambu, "192.168.0.20"),
|
||||
(Vendor::Klipper, "192.168.0.50"),
|
||||
(Vendor::PrusaLink, "192.168.0.51"),
|
||||
(Vendor::Octoprint, "192.168.0.60"),
|
||||
(Vendor::PrusaSerial, "/dev/ttyACM0"),
|
||||
];
|
||||
for (vendor, host) in cases {
|
||||
let entry = candidate(vendor, host).to_toml_entry("found");
|
||||
let fleet = load(&with_credentials(&entry))
|
||||
.unwrap_or_else(|err| panic!("{vendor:?} rendered an entry fleet::load rejects: {err}\n{entry}"));
|
||||
assert_eq!(fleet.len(), 1, "{vendor:?}");
|
||||
assert_eq!(fleet[0].base().id, "found", "{vendor:?}");
|
||||
|
||||
// The vendor string has to resolve to the matching handle, not just
|
||||
// to *a* handle - `octoprint` and `prusa_link` share a client, so a
|
||||
// mapping that collapsed them would still load and still be wrong.
|
||||
let matched = matches!(
|
||||
(vendor, &fleet[0]),
|
||||
(Vendor::Bambu, PrinterHandle::Bambu(_))
|
||||
| (Vendor::Klipper, PrinterHandle::Klipper(_))
|
||||
| (Vendor::PrusaLink | Vendor::Octoprint, PrinterHandle::OctoPrint(_))
|
||||
| (Vendor::PrusaSerial, PrinterHandle::PrusaSerial(_))
|
||||
);
|
||||
assert!(matched, "{vendor:?} loaded as the wrong handle");
|
||||
}
|
||||
}
|
||||
|
||||
/// The flavour is the operator's answer to a question no probe can settle, so
|
||||
/// it has to survive the trip from the file into the printer.
|
||||
#[test]
|
||||
fn the_octoprint_flavour_an_operator_chose_reaches_the_client() {
|
||||
use continuum_proxy::printer::OctoPrintFlavour;
|
||||
|
||||
for (vendor, expected) in
|
||||
[(Vendor::Octoprint, OctoPrintFlavour::Generic), (Vendor::PrusaLink, OctoPrintFlavour::PrusaLink)]
|
||||
{
|
||||
let entry = with_credentials(&candidate(vendor, "192.168.0.51").to_toml_entry("found"));
|
||||
let fleet = load(&entry).expect("loads");
|
||||
let PrinterHandle::OctoPrint(printer) = &fleet[0] else {
|
||||
panic!("{vendor:?} should load as an OctoPrint-family client");
|
||||
};
|
||||
assert_eq!(printer.flavour, expected, "{vendor:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry straight out of discovery, credential still blank, must be
|
||||
/// refused - and refused in a way that names the printer and the field.
|
||||
#[test]
|
||||
fn an_entry_with_the_credential_left_blank_is_refused_by_name() {
|
||||
for vendor in [Vendor::Bambu, Vendor::PrusaLink, Vendor::Octoprint] {
|
||||
let entry = candidate(vendor, "192.168.0.20").to_toml_entry("unfinished");
|
||||
let Err(err) = load(&entry) else {
|
||||
panic!("{vendor:?}: a blank credential must not load:\n{entry}");
|
||||
};
|
||||
let message = err.to_string();
|
||||
// This is the whole reason discovery prints instead of writing the
|
||||
// file: the failure is loud, and it says which printer and which
|
||||
// field.
|
||||
assert!(message.contains("unfinished"), "{vendor:?}: {message}");
|
||||
assert!(
|
||||
message.contains("access_code") || message.contains("api_key"),
|
||||
"{vendor:?} must name the missing field: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
+220
@@ -0,0 +1,220 @@
|
||||
{
|
||||
"upgrade_state": {
|
||||
"sequence_id": 0,
|
||||
"progress": "",
|
||||
"status": "IDLE",
|
||||
"consistency_request": false,
|
||||
"dis_state": 0,
|
||||
"err_code": 0,
|
||||
"force_upgrade": false,
|
||||
"message": "0%, 0B/s",
|
||||
"module": "",
|
||||
"new_version_state": 0,
|
||||
"cur_state_code": 0,
|
||||
"idx2": 4275297388,
|
||||
"new_ver_list": []
|
||||
},
|
||||
"ipcam": {
|
||||
"ipcam_dev": "1",
|
||||
"ipcam_record": "enable",
|
||||
"timelapse": "disable",
|
||||
"resolution": "",
|
||||
"tutk_server": "disable",
|
||||
"mode_bits": 3
|
||||
},
|
||||
"upload": {
|
||||
"status": "idle",
|
||||
"progress": 0,
|
||||
"message": ""
|
||||
},
|
||||
"net": {
|
||||
"conf": 0,
|
||||
"info": [
|
||||
{
|
||||
"ip": 0,
|
||||
"mask": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"nozzle_temper": 22.125,
|
||||
"nozzle_target_temper": 0,
|
||||
"bed_temper": 21.34375,
|
||||
"bed_target_temper": 0,
|
||||
"chamber_temper": 5,
|
||||
"mc_print_stage": "1",
|
||||
"heatbreak_fan_speed": "15",
|
||||
"cooling_fan_speed": "0",
|
||||
"big_fan1_speed": "0",
|
||||
"big_fan2_speed": "0",
|
||||
"mc_percent": 0,
|
||||
"mc_remaining_time": 0,
|
||||
"ams_status": 0,
|
||||
"ams_rfid_status": 0,
|
||||
"hw_switch_state": 0,
|
||||
"spd_mag": 100,
|
||||
"spd_lvl": 2,
|
||||
"print_error": 0,
|
||||
"lifecycle": "product",
|
||||
"wifi_signal": "-64dBm",
|
||||
"gcode_state": "IDLE",
|
||||
"gcode_file_prepare_percent": "0",
|
||||
"queue_number": 0,
|
||||
"queue_total": 0,
|
||||
"queue_est": 0,
|
||||
"queue_sts": 0,
|
||||
"project_id": "0",
|
||||
"profile_id": "0",
|
||||
"task_id": "0",
|
||||
"subtask_id": "0",
|
||||
"subtask_name": "",
|
||||
"gcode_file": "",
|
||||
"stg": [],
|
||||
"stg_cur": 0,
|
||||
"print_type": "idle",
|
||||
"home_flag": 158549400,
|
||||
"mc_print_line_number": "0",
|
||||
"mc_print_sub_stage": 0,
|
||||
"sdcard": true,
|
||||
"force_upgrade": false,
|
||||
"mess_production_state": "active",
|
||||
"layer_num": 0,
|
||||
"total_layer_num": 0,
|
||||
"s_obj": [],
|
||||
"filam_bak": [],
|
||||
"fan_gear": 0,
|
||||
"nozzle_diameter": "0.4",
|
||||
"nozzle_type": "stainless_steel",
|
||||
"cali_version": 0,
|
||||
"k": "0.0000",
|
||||
"flag3": 655,
|
||||
"hms": [],
|
||||
"online": {
|
||||
"ahb": false,
|
||||
"rfid": false,
|
||||
"version": 1849554829
|
||||
},
|
||||
"ams": {
|
||||
"ams": [
|
||||
{
|
||||
"chip_id": "<scrubbed>",
|
||||
"ams_id": "<scrubbed>",
|
||||
"check": 1,
|
||||
"id": "0",
|
||||
"humidity": "2",
|
||||
"humidity_raw": "41",
|
||||
"temp": "23.0",
|
||||
"dry_time": 0,
|
||||
"info": "1001",
|
||||
"tray": [
|
||||
{
|
||||
"id": "0"
|
||||
},
|
||||
{
|
||||
"id": "1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"state": 3,
|
||||
"remain": -1,
|
||||
"k": 0.03999999910593033,
|
||||
"n": 1,
|
||||
"cali_idx": -1,
|
||||
"total_len": 330000,
|
||||
"tag_uid": "<scrubbed>",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "GFG99",
|
||||
"tray_type": "PETG",
|
||||
"tray_sub_brands": "",
|
||||
"tray_color": "161616FF",
|
||||
"tray_weight": "0",
|
||||
"tray_diameter": "0.00",
|
||||
"tray_temp": "0",
|
||||
"tray_time": "0",
|
||||
"bed_temp_type": "0",
|
||||
"bed_temp": "0",
|
||||
"nozzle_temp_max": "270",
|
||||
"nozzle_temp_min": "220",
|
||||
"xcam_info": "000000000000000000000000",
|
||||
"tray_uuid": "<scrubbed>",
|
||||
"ctype": 0,
|
||||
"cols": [
|
||||
"161616FF"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"state": 3,
|
||||
"remain": -1,
|
||||
"k": 0.03999999910593033,
|
||||
"n": 1,
|
||||
"cali_idx": -1,
|
||||
"total_len": 330000,
|
||||
"tag_uid": "<scrubbed>",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "GFG99",
|
||||
"tray_type": "PETG",
|
||||
"tray_sub_brands": "",
|
||||
"tray_color": "2850E0FF",
|
||||
"tray_weight": "0",
|
||||
"tray_diameter": "0.00",
|
||||
"tray_temp": "0",
|
||||
"tray_time": "0",
|
||||
"bed_temp_type": "0",
|
||||
"bed_temp": "0",
|
||||
"nozzle_temp_max": "270",
|
||||
"nozzle_temp_min": "220",
|
||||
"xcam_info": "000000000000000000000000",
|
||||
"tray_uuid": "<scrubbed>",
|
||||
"ctype": 0,
|
||||
"cols": [
|
||||
"2850E0FF"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ams_exist_bits": "1",
|
||||
"tray_exist_bits": "c",
|
||||
"tray_is_bbl_bits": "c",
|
||||
"tray_tar": "255",
|
||||
"tray_now": "255",
|
||||
"tray_pre": "255",
|
||||
"tray_read_done_bits": "c",
|
||||
"tray_reading_bits": "0",
|
||||
"version": 2,
|
||||
"insert_flag": false,
|
||||
"power_on_flag": false
|
||||
},
|
||||
"vt_tray": {
|
||||
"id": "254",
|
||||
"tag_uid": "<scrubbed>",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "P9599a25",
|
||||
"tray_type": "PETG",
|
||||
"tray_sub_brands": "",
|
||||
"tray_color": "D3C5A3FF",
|
||||
"tray_weight": "0",
|
||||
"tray_diameter": "0.00",
|
||||
"tray_temp": "0",
|
||||
"tray_time": "0",
|
||||
"bed_temp_type": "0",
|
||||
"bed_temp": "0",
|
||||
"nozzle_temp_max": "240",
|
||||
"nozzle_temp_min": "225",
|
||||
"xcam_info": "000000000000000000000000",
|
||||
"tray_uuid": "<scrubbed>",
|
||||
"remain": 0,
|
||||
"k": 0.019999999552965164,
|
||||
"n": 1,
|
||||
"cali_idx": -1
|
||||
},
|
||||
"lights_report": [
|
||||
{
|
||||
"node": "chamber_light",
|
||||
"mode": "off"
|
||||
}
|
||||
],
|
||||
"command": "push_status",
|
||||
"msg": 0,
|
||||
"sequence_id": "53"
|
||||
}
|
||||
+846
@@ -0,0 +1,846 @@
|
||||
{
|
||||
"2D": {
|
||||
"bs": {
|
||||
"bi": [
|
||||
{
|
||||
"clock_in": false,
|
||||
"est_time": 0,
|
||||
"idx": 0,
|
||||
"print_then": false,
|
||||
"proc_list": [],
|
||||
"step_type": 1,
|
||||
"tool_info": {
|
||||
"color": "",
|
||||
"diameter": 0.4000000059604645,
|
||||
"id": 8978432
|
||||
},
|
||||
"type": 137
|
||||
}
|
||||
],
|
||||
"total_time": 0
|
||||
},
|
||||
"cond": 14,
|
||||
"cur_stage": {
|
||||
"clock_in_time": 0,
|
||||
"idx": 0,
|
||||
"left_time": 0,
|
||||
"process": 0,
|
||||
"state": 0
|
||||
},
|
||||
"first_confirm": false,
|
||||
"makeable": false,
|
||||
"material": {
|
||||
"cur_id_list": [],
|
||||
"state": 0,
|
||||
"tar_id": "",
|
||||
"tar_name": ""
|
||||
},
|
||||
"pm": 0
|
||||
},
|
||||
"3D": {
|
||||
"layer_num": 2,
|
||||
"print_cali_option": 2,
|
||||
"total_layer_num": 50
|
||||
},
|
||||
"ams": {
|
||||
"ams": [
|
||||
{
|
||||
"dry_time": 0,
|
||||
"humidity": "2",
|
||||
"humidity_raw": "34",
|
||||
"id": "0",
|
||||
"info": "1003",
|
||||
"temp": "30.6",
|
||||
"tray": [
|
||||
{
|
||||
"bed_temp": "0",
|
||||
"bed_temp_type": "0",
|
||||
"cali_idx": -1,
|
||||
"cols": [
|
||||
"2850E0FF"
|
||||
],
|
||||
"ctype": 0,
|
||||
"drying_temp": "0",
|
||||
"drying_time": "0",
|
||||
"id": "0",
|
||||
"nozzle_temp_max": "280",
|
||||
"nozzle_temp_min": "240",
|
||||
"remain": 0,
|
||||
"state": 11,
|
||||
"tag_uid": "<scrubbed>",
|
||||
"total_len": 330000,
|
||||
"tray_color": "2850E0FF",
|
||||
"tray_diameter": "1.75",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "GFN99",
|
||||
"tray_sub_brands": "",
|
||||
"tray_type": "PA",
|
||||
"tray_uuid": "<scrubbed>",
|
||||
"tray_weight": "0",
|
||||
"xcam_info": "000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"bed_temp": "0",
|
||||
"bed_temp_type": "0",
|
||||
"cali_idx": -1,
|
||||
"cols": [
|
||||
"7C4B00FF"
|
||||
],
|
||||
"ctype": 0,
|
||||
"drying_temp": "0",
|
||||
"drying_time": "0",
|
||||
"id": "1",
|
||||
"nozzle_temp_max": "300",
|
||||
"nozzle_temp_min": "260",
|
||||
"remain": 0,
|
||||
"state": 11,
|
||||
"tag_uid": "<scrubbed>",
|
||||
"total_len": 330000,
|
||||
"tray_color": "7C4B00FF",
|
||||
"tray_diameter": "1.75",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "GFN08",
|
||||
"tray_sub_brands": "",
|
||||
"tray_type": "PA-GF",
|
||||
"tray_uuid": "<scrubbed>",
|
||||
"tray_weight": "0",
|
||||
"xcam_info": "000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"bed_temp": "0",
|
||||
"bed_temp_type": "0",
|
||||
"cali_idx": -1,
|
||||
"cols": [
|
||||
"898989FF"
|
||||
],
|
||||
"ctype": 0,
|
||||
"drying_temp": "0",
|
||||
"drying_time": "0",
|
||||
"id": "2",
|
||||
"nozzle_temp_max": "290",
|
||||
"nozzle_temp_min": "260",
|
||||
"remain": 0,
|
||||
"state": 11,
|
||||
"tag_uid": "<scrubbed>",
|
||||
"total_len": 330000,
|
||||
"tray_color": "898989FF",
|
||||
"tray_diameter": "1.75",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "GFC99",
|
||||
"tray_sub_brands": "",
|
||||
"tray_type": "PC",
|
||||
"tray_uuid": "<scrubbed>",
|
||||
"tray_weight": "0",
|
||||
"xcam_info": "000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"bed_temp": "0",
|
||||
"bed_temp_type": "0",
|
||||
"cali_idx": -1,
|
||||
"cols": [
|
||||
"161616FF"
|
||||
],
|
||||
"ctype": 0,
|
||||
"drying_temp": "0",
|
||||
"drying_time": "0",
|
||||
"id": "3",
|
||||
"nozzle_temp_max": "290",
|
||||
"nozzle_temp_min": "260",
|
||||
"remain": 0,
|
||||
"state": 11,
|
||||
"tag_uid": "<scrubbed>",
|
||||
"total_len": 330000,
|
||||
"tray_color": "161616FF",
|
||||
"tray_diameter": "1.75",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "GFC99",
|
||||
"tray_sub_brands": "",
|
||||
"tray_type": "PC",
|
||||
"tray_uuid": "<scrubbed>",
|
||||
"tray_weight": "0",
|
||||
"xcam_info": "000000000000000000000000"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"dry_time": 0,
|
||||
"humidity": "1",
|
||||
"humidity_raw": "47",
|
||||
"id": "1",
|
||||
"info": "1003",
|
||||
"temp": "27.1",
|
||||
"tray": [
|
||||
{
|
||||
"bed_temp": "0",
|
||||
"bed_temp_type": "0",
|
||||
"cali_idx": -1,
|
||||
"cols": [
|
||||
"FFFFFF00"
|
||||
],
|
||||
"ctype": 0,
|
||||
"drying_temp": "0",
|
||||
"drying_time": "0",
|
||||
"id": "0",
|
||||
"nozzle_temp_max": "280",
|
||||
"nozzle_temp_min": "240",
|
||||
"remain": 0,
|
||||
"state": 11,
|
||||
"tag_uid": "<scrubbed>",
|
||||
"total_len": 330000,
|
||||
"tray_color": "FFFFFF00",
|
||||
"tray_diameter": "1.75",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "GFB00",
|
||||
"tray_sub_brands": "",
|
||||
"tray_type": "ABS",
|
||||
"tray_uuid": "<scrubbed>",
|
||||
"tray_weight": "0",
|
||||
"xcam_info": "000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"bed_temp": "0",
|
||||
"bed_temp_type": "0",
|
||||
"cali_idx": -1,
|
||||
"cols": [
|
||||
"FFF144FF"
|
||||
],
|
||||
"ctype": 0,
|
||||
"drying_temp": "0",
|
||||
"drying_time": "0",
|
||||
"id": "1",
|
||||
"nozzle_temp_max": "270",
|
||||
"nozzle_temp_min": "220",
|
||||
"remain": 0,
|
||||
"state": 11,
|
||||
"tag_uid": "<scrubbed>",
|
||||
"total_len": 330000,
|
||||
"tray_color": "FFF144FF",
|
||||
"tray_diameter": "1.75",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "GFG99",
|
||||
"tray_sub_brands": "",
|
||||
"tray_type": "PETG",
|
||||
"tray_uuid": "<scrubbed>",
|
||||
"tray_weight": "0",
|
||||
"xcam_info": "000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"state": 10
|
||||
},
|
||||
{
|
||||
"bed_temp": "0",
|
||||
"bed_temp_type": "0",
|
||||
"cali_idx": -1,
|
||||
"cols": [
|
||||
"898989FF"
|
||||
],
|
||||
"ctype": 0,
|
||||
"drying_temp": "0",
|
||||
"drying_time": "0",
|
||||
"id": "3",
|
||||
"nozzle_temp_max": "240",
|
||||
"nozzle_temp_min": "190",
|
||||
"remain": 0,
|
||||
"state": 11,
|
||||
"tag_uid": "<scrubbed>",
|
||||
"total_len": 330000,
|
||||
"tray_color": "898989FF",
|
||||
"tray_diameter": "1.75",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "GFL95",
|
||||
"tray_sub_brands": "",
|
||||
"tray_type": "PLA",
|
||||
"tray_uuid": "<scrubbed>",
|
||||
"tray_weight": "0",
|
||||
"xcam_info": "000000000000000000000000"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"dry_time": 0,
|
||||
"humidity": "0",
|
||||
"humidity_raw": "16",
|
||||
"id": "128",
|
||||
"info": "2104",
|
||||
"temp": "41.5",
|
||||
"tray": [
|
||||
{
|
||||
"id": "0",
|
||||
"state": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ams_exist_bits": "13",
|
||||
"ams_exist_bits_raw": "13",
|
||||
"cali_id": 255,
|
||||
"cali_stat": 0,
|
||||
"insert_flag": false,
|
||||
"power_on_flag": false,
|
||||
"tray_exist_bits": "bf",
|
||||
"tray_is_bbl_bits": "bf",
|
||||
"tray_now": "0",
|
||||
"tray_pre": "0",
|
||||
"tray_read_done_bits": "bf",
|
||||
"tray_reading_bits": "0",
|
||||
"tray_tar": "0",
|
||||
"unbind_ams_stat": 0,
|
||||
"version": 169763
|
||||
},
|
||||
"ams_rfid_status": 0,
|
||||
"ams_status": 768,
|
||||
"ap_err": 0,
|
||||
"aux": "2001004",
|
||||
"aux_part_fan": false,
|
||||
"batch_id": 0,
|
||||
"bed_target_temper": 35.0,
|
||||
"bed_temper": 35.0,
|
||||
"big_fan1_speed": "14",
|
||||
"big_fan2_speed": "4",
|
||||
"cali_version": 0,
|
||||
"canvas_id": 0,
|
||||
"care": [
|
||||
{
|
||||
"id": "ls",
|
||||
"info": "1A00"
|
||||
},
|
||||
{
|
||||
"id": "ld",
|
||||
"info": "641864"
|
||||
},
|
||||
{
|
||||
"id": "lr",
|
||||
"info": "1A00"
|
||||
},
|
||||
{
|
||||
"id": "hr",
|
||||
"info": "5E185E"
|
||||
}
|
||||
],
|
||||
"cfg": "685DDAB8",
|
||||
"command": "push_status",
|
||||
"cooling_fan_speed": "14",
|
||||
"design_id": "",
|
||||
"device": {
|
||||
"airduct": {
|
||||
"modeCur": 0,
|
||||
"modeFunc": 0,
|
||||
"modeList": [
|
||||
{
|
||||
"ctrl": [
|
||||
16,
|
||||
32,
|
||||
48
|
||||
],
|
||||
"modeId": 0,
|
||||
"off": [
|
||||
96
|
||||
]
|
||||
},
|
||||
{
|
||||
"ctrl": [
|
||||
16
|
||||
],
|
||||
"modeId": 1,
|
||||
"off": [
|
||||
32,
|
||||
48
|
||||
]
|
||||
},
|
||||
{
|
||||
"ctrl": [
|
||||
48
|
||||
],
|
||||
"modeId": 2,
|
||||
"off": []
|
||||
}
|
||||
],
|
||||
"modeVisable": 7,
|
||||
"parts": [
|
||||
{
|
||||
"func": 0,
|
||||
"id": 16,
|
||||
"range": 6553600,
|
||||
"state": 100,
|
||||
"tar_state": 100
|
||||
},
|
||||
{
|
||||
"func": 1,
|
||||
"id": 32,
|
||||
"range": 6553600,
|
||||
"state": 100,
|
||||
"tar_state": 100
|
||||
},
|
||||
{
|
||||
"func": 2,
|
||||
"id": 48,
|
||||
"range": 6553600,
|
||||
"state": 30,
|
||||
"tar_state": 30
|
||||
},
|
||||
{
|
||||
"func": 4,
|
||||
"id": 96,
|
||||
"range": 6553600,
|
||||
"state": 0,
|
||||
"tar_state": 0
|
||||
}
|
||||
],
|
||||
"subFunc": 0,
|
||||
"subMode": 0,
|
||||
"subVisable": 7,
|
||||
"version": 1
|
||||
},
|
||||
"bed": {
|
||||
"info": {
|
||||
"temp": 2293795
|
||||
},
|
||||
"state": 2
|
||||
},
|
||||
"bed_temp": 2293795,
|
||||
"cam": {
|
||||
"laser": {
|
||||
"cond": 252,
|
||||
"state": 0
|
||||
},
|
||||
"timelapse_path": ""
|
||||
},
|
||||
"ctc": {
|
||||
"info": {
|
||||
"temp": 27
|
||||
},
|
||||
"state": 0
|
||||
},
|
||||
"ext_tool": {
|
||||
"calib": 2,
|
||||
"low_prec": true,
|
||||
"mount": 0,
|
||||
"mount_3d": 1,
|
||||
"th_temp": 0,
|
||||
"type": "F000"
|
||||
},
|
||||
"extruder": {
|
||||
"info": [
|
||||
{
|
||||
"filam_bak": [],
|
||||
"hnow": 0,
|
||||
"hpre": 0,
|
||||
"htar": 0,
|
||||
"id": 0,
|
||||
"info": 1611,
|
||||
"snow": 65280,
|
||||
"spre": 65280,
|
||||
"star": 65280,
|
||||
"stat": 197376,
|
||||
"temp": 15073510
|
||||
},
|
||||
{
|
||||
"filam_bak": [],
|
||||
"hnow": 1,
|
||||
"hpre": 1,
|
||||
"htar": 1,
|
||||
"id": 1,
|
||||
"info": 1048,
|
||||
"snow": 65279,
|
||||
"spre": 65279,
|
||||
"star": 65279,
|
||||
"stat": 0,
|
||||
"temp": 34
|
||||
}
|
||||
],
|
||||
"state": 2
|
||||
},
|
||||
"fan": 419300,
|
||||
"fire_ext": {
|
||||
"bot_err": 0,
|
||||
"cd": 0,
|
||||
"cd_init": 15,
|
||||
"connect_flag": 0,
|
||||
"drill_flag": 0,
|
||||
"history": [],
|
||||
"remain_time": 0,
|
||||
"rm_init": 33,
|
||||
"state": 0
|
||||
},
|
||||
"holder": {
|
||||
"info": 1,
|
||||
"job": 0,
|
||||
"pos": 3,
|
||||
"stat": 0,
|
||||
"step": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"laser": {
|
||||
"power": 0
|
||||
},
|
||||
"nozzle": {
|
||||
"exist": 3604483,
|
||||
"info": [
|
||||
{
|
||||
"color_m": "00000000",
|
||||
"diameter": 0.4,
|
||||
"fila_id": "GFB99",
|
||||
"id": 18,
|
||||
"sn": "20D06A5A1512964",
|
||||
"stat": 0,
|
||||
"tm": 350,
|
||||
"type": "HS01",
|
||||
"wear": 0.0
|
||||
},
|
||||
{
|
||||
"color_m": "00000000",
|
||||
"diameter": 0.4,
|
||||
"fila_id": "",
|
||||
"id": 1,
|
||||
"sn": "N/A",
|
||||
"stat": 0,
|
||||
"tm": 0,
|
||||
"type": "HS01",
|
||||
"wear": 0.0
|
||||
},
|
||||
{
|
||||
"color_m": "F95D73FF",
|
||||
"diameter": 0.4,
|
||||
"fila_id": "Pf3dbd0c",
|
||||
"id": 0,
|
||||
"sn": "20D06A5A1507129",
|
||||
"stat": 0,
|
||||
"tm": 350,
|
||||
"type": "HS01",
|
||||
"wear": 0.0
|
||||
},
|
||||
{
|
||||
"color_m": "7C4B00FF",
|
||||
"diameter": 0.4,
|
||||
"fila_id": "GFN08",
|
||||
"id": 16,
|
||||
"sn": "20D06A5A1507166",
|
||||
"stat": 0,
|
||||
"tm": 350,
|
||||
"type": "HS01",
|
||||
"wear": 0.0
|
||||
},
|
||||
{
|
||||
"color_m": "FFF144FF",
|
||||
"diameter": 0.6,
|
||||
"fila_id": "GFG99",
|
||||
"id": 21,
|
||||
"sn": "20D06A592727617",
|
||||
"stat": 0,
|
||||
"tm": 350,
|
||||
"type": "HS01",
|
||||
"wear": 0.0
|
||||
},
|
||||
{
|
||||
"color_m": "2850E0FF",
|
||||
"diameter": 0.4,
|
||||
"fila_id": "GFN99",
|
||||
"id": 17,
|
||||
"sn": "20D06A5A1215962",
|
||||
"stat": 0,
|
||||
"tm": 350,
|
||||
"type": "HS01",
|
||||
"wear": 0.0
|
||||
},
|
||||
{
|
||||
"color_m": "161616FF",
|
||||
"diameter": 0.4,
|
||||
"fila_id": "GFC99",
|
||||
"id": 20,
|
||||
"sn": "20D06A592725573",
|
||||
"stat": 0,
|
||||
"tm": 350,
|
||||
"type": "HS01",
|
||||
"wear": 0.0
|
||||
}
|
||||
],
|
||||
"src_id": 19,
|
||||
"state": 0,
|
||||
"tar_id": 19
|
||||
},
|
||||
"plate": {
|
||||
"base": 6,
|
||||
"cali2d_id": "",
|
||||
"cur_id": "P0102",
|
||||
"mat": 1,
|
||||
"tar_id": ""
|
||||
},
|
||||
"type": 1
|
||||
},
|
||||
"err": "0",
|
||||
"fail_reason": "0",
|
||||
"fan_gear": 5046271,
|
||||
"file": "/data/Metadata/plate_1.gcode",
|
||||
"force_upgrade": false,
|
||||
"fun": "10023FF18FFF9CB7",
|
||||
"fun2": "E",
|
||||
"gcode_file": "/data/Metadata/plate_1.gcode",
|
||||
"gcode_file_prepare_percent": "100",
|
||||
"gcode_state": "RUNNING",
|
||||
"heatbreak_fan_speed": "14",
|
||||
"hms": [
|
||||
{
|
||||
"attr": 83952640,
|
||||
"code": 196610
|
||||
},
|
||||
{
|
||||
"attr": 83952640,
|
||||
"code": 196612
|
||||
},
|
||||
{
|
||||
"attr": 83887616,
|
||||
"code": 131156
|
||||
}
|
||||
],
|
||||
"home_flag": -1066967745,
|
||||
"hw_switch_state": 1,
|
||||
"info": {
|
||||
"temp": 27
|
||||
},
|
||||
"ipcam": {
|
||||
"agora_service": "disable",
|
||||
"brtc_service": "enable",
|
||||
"bs_state": 0,
|
||||
"cap_pic_enable": "invalid",
|
||||
"ipcam_dev": "1",
|
||||
"ipcam_record": "enable",
|
||||
"laser_preview_res": 6,
|
||||
"mode_bits": 2,
|
||||
"resolution": "1080p",
|
||||
"rtsp_url": "rtsps://192.168.0.21:322/streaming/live/1",
|
||||
"timelapse": "enable",
|
||||
"tl_store_hpd_type": 2,
|
||||
"tl_store_path_type": 2,
|
||||
"tutk_server": "disable"
|
||||
},
|
||||
"job": {
|
||||
"cur_stage": {
|
||||
"idx": 0,
|
||||
"state": 2
|
||||
},
|
||||
"stage": [
|
||||
{
|
||||
"clock_in": false,
|
||||
"color": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"diameter": [
|
||||
0.4000000059604645,
|
||||
0.4000000059604645
|
||||
],
|
||||
"est_time": 0,
|
||||
"heigh": 0.0,
|
||||
"idx": 0,
|
||||
"platform": "",
|
||||
"print_then": false,
|
||||
"proc_list": [],
|
||||
"tool": [
|
||||
"HS01",
|
||||
"HS01"
|
||||
],
|
||||
"type": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
"job_attr": 18,
|
||||
"job_id": "0",
|
||||
"lan_task_id": "0",
|
||||
"layer_num": 2,
|
||||
"lights_report": [
|
||||
{
|
||||
"mode": "on",
|
||||
"node": "chamber_light"
|
||||
},
|
||||
{
|
||||
"mode": "flashing",
|
||||
"node": "work_light"
|
||||
},
|
||||
{
|
||||
"mode": "on",
|
||||
"node": "chamber_light2"
|
||||
}
|
||||
],
|
||||
"mapping": [
|
||||
65280
|
||||
],
|
||||
"mc_action": 0,
|
||||
"mc_err": 0,
|
||||
"mc_percent": 13,
|
||||
"mc_print_error_code": "0",
|
||||
"mc_print_stage": "2",
|
||||
"mc_print_sub_stage": 0,
|
||||
"mc_remaining_time": 102,
|
||||
"mc_stage": 2,
|
||||
"model_id": "",
|
||||
"msg": 0,
|
||||
"net": {
|
||||
"conf": 16,
|
||||
"info": [
|
||||
{
|
||||
"ip": 352364736,
|
||||
"mask": 16777215
|
||||
},
|
||||
{
|
||||
"ip": 0,
|
||||
"mask": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"nozzle_diameter": "0.4",
|
||||
"nozzle_target_temper": 230.0,
|
||||
"nozzle_temper": 230.0,
|
||||
"nozzle_type": "HS01-0.4",
|
||||
"online": {
|
||||
"ahb": true,
|
||||
"ext": true,
|
||||
"version": 16
|
||||
},
|
||||
"percent": 13,
|
||||
"plate_cnt": 1,
|
||||
"plate_id": 1,
|
||||
"plate_idx": 1,
|
||||
"prepare_per": 100,
|
||||
"print_error": 0,
|
||||
"print_gcode_action": 0,
|
||||
"print_real_action": 0,
|
||||
"print_type": "local",
|
||||
"profile_id": "0",
|
||||
"project_id": "0",
|
||||
"queue": 0,
|
||||
"queue_est": 0,
|
||||
"queue_number": 0,
|
||||
"queue_sts": 0,
|
||||
"queue_total": 0,
|
||||
"remain_time": 102,
|
||||
"s_obj": [],
|
||||
"sdcard": true,
|
||||
"sequence_id": "2021",
|
||||
"spd_lvl": 2,
|
||||
"spd_mag": 100,
|
||||
"stat": "16782180F0",
|
||||
"state": 4,
|
||||
"stg": [
|
||||
64,
|
||||
29,
|
||||
13,
|
||||
74,
|
||||
72,
|
||||
4,
|
||||
54,
|
||||
39,
|
||||
8,
|
||||
14,
|
||||
1,
|
||||
3
|
||||
],
|
||||
"stg_cur": 0,
|
||||
"subtask_id": "0",
|
||||
"subtask_name": "świeczka-24.stl",
|
||||
"task_id": "5101",
|
||||
"total_layer_num": 50,
|
||||
"upgrade_state": {
|
||||
"ahb_new_version_number": "",
|
||||
"ams_new_version_number": "",
|
||||
"consistency_request": false,
|
||||
"dis_state": 0,
|
||||
"err_code": 0,
|
||||
"ext_new_version_number": "",
|
||||
"force_upgrade": false,
|
||||
"idx": 16,
|
||||
"idx2": 1393374092,
|
||||
"lower_limit": "00.00.00.00",
|
||||
"message": "",
|
||||
"module": "",
|
||||
"new_version_state": 0,
|
||||
"ota_new_version_number": "",
|
||||
"progress": "0",
|
||||
"sequence_id": 0,
|
||||
"sn": "31B8BP5A1800585",
|
||||
"status": "IDLE",
|
||||
"upgrade_fail_list": [],
|
||||
"upgrade_type": "unknown"
|
||||
},
|
||||
"upload": {
|
||||
"file_size": 0,
|
||||
"finish_size": 0,
|
||||
"message": "Good",
|
||||
"oss_url": "",
|
||||
"progress": 0,
|
||||
"sequence_id": "0903",
|
||||
"speed": 0,
|
||||
"status": "idle",
|
||||
"task_id": "",
|
||||
"time_remaining": 0,
|
||||
"trouble_id": ""
|
||||
},
|
||||
"ver": "20000",
|
||||
"vir_slot": [
|
||||
{
|
||||
"bed_temp": "0",
|
||||
"bed_temp_type": "0",
|
||||
"cali_idx": -1,
|
||||
"cols": [
|
||||
"0ACC38FF"
|
||||
],
|
||||
"ctype": 0,
|
||||
"drying_temp": "0",
|
||||
"drying_time": "0",
|
||||
"id": "254",
|
||||
"nozzle_temp_max": "250",
|
||||
"nozzle_temp_min": "200",
|
||||
"remain": 0,
|
||||
"tag_uid": "<scrubbed>",
|
||||
"total_len": 330000,
|
||||
"tray_color": "0ACC38FF",
|
||||
"tray_diameter": "1.75",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "GFU98",
|
||||
"tray_sub_brands": "",
|
||||
"tray_type": "TPU-AMS",
|
||||
"tray_uuid": "<scrubbed>",
|
||||
"tray_weight": "0",
|
||||
"xcam_info": "000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"bed_temp": "0",
|
||||
"bed_temp_type": "0",
|
||||
"cali_idx": -1,
|
||||
"cols": [
|
||||
"F95D73FF"
|
||||
],
|
||||
"ctype": 0,
|
||||
"drying_temp": "0",
|
||||
"drying_time": "0",
|
||||
"id": "255",
|
||||
"nozzle_temp_max": "240",
|
||||
"nozzle_temp_min": "230",
|
||||
"remain": 0,
|
||||
"tag_uid": "<scrubbed>",
|
||||
"total_len": 330000,
|
||||
"tray_color": "F95D73FF",
|
||||
"tray_diameter": "1.75",
|
||||
"tray_id_name": "",
|
||||
"tray_info_idx": "Pf3dbd0c",
|
||||
"tray_sub_brands": "",
|
||||
"tray_type": "TPU",
|
||||
"tray_uuid": "<scrubbed>",
|
||||
"tray_weight": "0",
|
||||
"xcam_info": "000000000000000000000000"
|
||||
}
|
||||
],
|
||||
"wifi_signal": "-66dBm",
|
||||
"xcam": {
|
||||
"allow_skip_parts": false,
|
||||
"buildplate_marker_detector": true,
|
||||
"cfg": 1797559,
|
||||
"first_layer_inspector": true,
|
||||
"halt_print_sensitivity": "medium",
|
||||
"print_halt": true,
|
||||
"printing_monitor": true,
|
||||
"spaghetti_detector": true
|
||||
},
|
||||
"xcam_status": "0"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Does `PrintReport` actually parse what real printers really send?
|
||||
//!
|
||||
//! The fixtures in `tests/fixtures/` are verbatim `print.push_status`
|
||||
//! payloads captured off real hardware on 2026-10-03 via
|
||||
//! `cargo run --example live_reports` (hardware identifiers scrubbed):
|
||||
//!
|
||||
//! - `push_status_v1_p1p_idle.json` - a P1P on BambuMQTT v1, idle.
|
||||
//! - `push_status_v2_h2c_printing.json` - an H2C on BambuMQTT v2, 13% into
|
||||
//! a real job.
|
||||
//!
|
||||
//! These exist because the struct was originally built from a *truncated*
|
||||
//! example payload, and the gap between that and reality turned out to be
|
||||
//! the cause of two live misbehaviours (silently empty `print_status()`, and
|
||||
//! bogus "sequence gap" warnings). A fixture is the only way to keep that
|
||||
//! from regressing without a printer plugged in.
|
||||
|
||||
use continuum_proxy::printer::PrintReport;
|
||||
|
||||
fn load(name: &str) -> serde_json::Value {
|
||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/");
|
||||
let text = std::fs::read_to_string(format!("{path}{name}")).expect("fixture should exist");
|
||||
serde_json::from_str(&text).expect("fixture should be valid JSON")
|
||||
}
|
||||
|
||||
/// The failure mode that mattered: `handle_incoming_report` does
|
||||
/// `if let Ok(report) = serde_json::from_value::<PrintReport>(..)`, so a
|
||||
/// parse error doesn't just lose that one update - it's invisible. Assert on
|
||||
/// the error text, not just `is_ok()`, so a regression says *what* broke.
|
||||
fn assert_parses(name: &str) {
|
||||
let value = load(name);
|
||||
match serde_json::from_value::<PrintReport>(value) {
|
||||
Ok(_) => {}
|
||||
Err(err) => panic!("{name} failed to parse as PrintReport: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_v1_idle_push_status() {
|
||||
assert_parses("push_status_v1_p1p_idle.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_v2_printing_push_status() {
|
||||
assert_parses("push_status_v2_h2c_printing.json");
|
||||
}
|
||||
|
||||
/// The whole point of consuming these at all: a print farm needs progress
|
||||
/// and temperatures. If the struct parses but drops these, it's no more
|
||||
/// useful than not parsing.
|
||||
#[test]
|
||||
fn v2_printing_report_carries_progress_and_temperatures() {
|
||||
let value = load("push_status_v2_h2c_printing.json");
|
||||
let report: PrintReport = serde_json::from_value(value).expect("should parse");
|
||||
|
||||
assert_eq!(report.gcode_state.as_deref(), Some("RUNNING"));
|
||||
assert_eq!(report.mc_percent, Some(13));
|
||||
assert_eq!(report.layer_num, Some(2));
|
||||
assert_eq!(report.total_layer_num, Some(50));
|
||||
assert_eq!(report.mc_remaining_time, Some(102));
|
||||
assert_eq!(report.nozzle_temper, Some(230.0));
|
||||
assert_eq!(report.subtask_name.as_deref(), Some("\u{15b}wieczka-24.stl"));
|
||||
}
|
||||
|
||||
/// An idle printer still has to produce a coherent report - just with
|
||||
/// nothing printing.
|
||||
#[test]
|
||||
fn v1_idle_report_reads_as_idle() {
|
||||
let value = load("push_status_v1_p1p_idle.json");
|
||||
let report: PrintReport = serde_json::from_value(value).expect("should parse");
|
||||
|
||||
assert_eq!(report.gcode_state.as_deref(), Some("IDLE"));
|
||||
assert_eq!(report.mc_percent, Some(0));
|
||||
}
|
||||
|
||||
/// v1 and v2 disagree about tray field names (`tray_temp`/`tray_time` vs
|
||||
/// `drying_temp`/`drying_time`) - this is the specific mismatch that made
|
||||
/// every v1 push carrying AMS data fail. Both spellings have to work.
|
||||
#[test]
|
||||
fn both_protocol_generations_parse_their_ams_trays() {
|
||||
for name in ["push_status_v1_p1p_idle.json", "push_status_v2_h2c_printing.json"] {
|
||||
let value = load(name);
|
||||
let report: PrintReport = serde_json::from_value(value).unwrap_or_else(|err| {
|
||||
panic!("{name}: {err}");
|
||||
});
|
||||
let ams = report.ams.expect("fixture should carry AMS state");
|
||||
assert!(!ams.ams.is_empty(), "{name}: expected at least one AMS unit");
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(name: &str) -> PrintReport {
|
||||
serde_json::from_value(load(name)).unwrap_or_else(|err| panic!("{name}: {err}"))
|
||||
}
|
||||
|
||||
/// The P1P sends an external spool; pin the fields a farm would show.
|
||||
#[test]
|
||||
fn v1_report_carries_the_external_spool() {
|
||||
let report = parse("push_status_v1_p1p_idle.json");
|
||||
let vt = report.vt_tray.expect("v1 fixture carries vt_tray");
|
||||
|
||||
assert_eq!(vt.id, "254");
|
||||
assert_eq!(vt.tray_info_idx.as_deref(), Some("P9599a25"));
|
||||
assert_eq!(vt.tray_type.as_deref(), Some("PETG"));
|
||||
assert_eq!(vt.tray_color.as_deref(), Some("D3C5A3FF"));
|
||||
assert_eq!(vt.cali_idx, Some(-1));
|
||||
assert_eq!(vt.n, Some(1));
|
||||
}
|
||||
|
||||
/// The H2C has no `vt_tray` key at all. That is the observed v1/v2
|
||||
/// divergence, NOT a parse failure: if a change makes `vt_tray` required,
|
||||
/// `parse` panics here instead of every v2 report silently vanishing in
|
||||
/// `handle_incoming_report`.
|
||||
#[test]
|
||||
fn v2_report_has_no_external_spool_and_still_parses() {
|
||||
let report = parse("push_status_v2_h2c_printing.json");
|
||||
assert!(report.vt_tray.is_none());
|
||||
}
|
||||
|
||||
/// `stg_cur` is what tells "preparing" from "stalled" while `layer_num` is 0.
|
||||
#[test]
|
||||
fn stage_fields_parse_on_both_generations() {
|
||||
let v1 = parse("push_status_v1_p1p_idle.json");
|
||||
assert_eq!(v1.stg_cur, Some(0));
|
||||
assert_eq!(v1.stg, Some(vec![]));
|
||||
|
||||
let v2 = parse("push_status_v2_h2c_printing.json");
|
||||
assert_eq!(v2.stg_cur, Some(0));
|
||||
assert_eq!(v2.stg, Some(vec![64, 29, 13, 74, 72, 4, 54, 39, 8, 14, 1, 3]));
|
||||
}
|
||||
|
||||
/// A v1 partial push without these must not erase what a full one set.
|
||||
#[test]
|
||||
fn merge_keeps_stage_and_spool_when_a_partial_update_omits_them() {
|
||||
let mut base = parse("push_status_v1_p1p_idle.json");
|
||||
let mut partial = parse("push_status_v1_p1p_idle.json");
|
||||
partial.stg_cur = None;
|
||||
partial.stg = None;
|
||||
partial.vt_tray = None;
|
||||
base.stg_cur = Some(54);
|
||||
base.stg = Some(vec![54, 14, 13]);
|
||||
|
||||
base.merge_from(partial);
|
||||
|
||||
assert_eq!(base.stg_cur, Some(54));
|
||||
assert_eq!(base.stg, Some(vec![54, 14, 13]));
|
||||
assert!(base.vt_tray.is_some());
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Does a command that outlives the answer already given for it report back?
|
||||
//!
|
||||
//! A `start_print` is acknowledged the moment it is accepted, because the
|
||||
//! transfer takes minutes and the cloud gives up after ten seconds. So its
|
||||
//! real outcome can only ever arrive late, and before the outbound queue
|
||||
//! existed there was nowhere for it to arrive from: the uplink's `select!`
|
||||
//! loop was the only writer and a spawned task could not reach the socket.
|
||||
//! A failed start therefore reported success and the reason stayed in this
|
||||
//! gateway's log, on a machine in someone else's workshop.
|
||||
//!
|
||||
//! This stands up a real websocket server and runs the real `uplink::run`
|
||||
//! against it. **It touches no printer and makes no outbound request**: the
|
||||
//! fleet is empty, and `dispatch` checks the fleet for the printer id
|
||||
//! *before* downloading anything, so the command fails on a lookup.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
use continuum_proxy::config::Config;
|
||||
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(),
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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, outbound, inbox));
|
||||
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
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");
|
||||
|
||||
ws.send(Message::Text(
|
||||
json!({
|
||||
"type": "command",
|
||||
"command_id": "cmd-1",
|
||||
"printer_id": "not-in-this-fleet",
|
||||
"command": {
|
||||
"action": "start_print",
|
||||
"job_id": "7c9e6679-0000-4000-8000-000000000000",
|
||||
"file_name": "cube.gcode.3mf",
|
||||
// Never fetched: the fleet lookup fails first. Port 1 so a
|
||||
// regression that *did* reach the network fails fast and
|
||||
// loudly rather than hanging on a real host.
|
||||
"url": "http://127.0.0.1:1/never-fetched",
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut results = Vec::new();
|
||||
while results.len() < 2 {
|
||||
let msg = next_json(&mut ws).await;
|
||||
if msg["type"] == "command_result" {
|
||||
results.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Two results for one command, in this order, and that is the contract -
|
||||
// not a bug to be deduplicated away. The first is "accepted, working on
|
||||
// it", which is all the cloud ever trusted a start reply to mean; the
|
||||
// second is what actually happened.
|
||||
assert_eq!(results[0]["command_id"], "cmd-1");
|
||||
assert_eq!(results[0]["ok"], true, "a start is acknowledged on acceptance");
|
||||
assert!(results[0]["error"].is_null());
|
||||
|
||||
assert_eq!(results[1]["command_id"], "cmd-1", "the late result reuses the id");
|
||||
assert_eq!(results[1]["ok"], false);
|
||||
|
||||
// The reason is the entire point of the queue: without it the cloud sees
|
||||
// a job that never started printing and can say nothing about why.
|
||||
let reason = results[1]["error"].as_str().expect("a failure carries its reason");
|
||||
assert!(
|
||||
reason.contains("not-in-this-fleet"),
|
||||
"the reason should name the printer that could not be found, got: {reason}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! The other half of the `/ws/edge/v1` contract test.
|
||||
//!
|
||||
//! `continuum-backend/tests/edge_protocol.test.ts` checks that what this
|
||||
//! gateway sends is what the backend accepts. This checks the reverse: that
|
||||
//! every message the backend sends is one this gateway can actually read, and
|
||||
//! that what this gateway writes matches the schema it claims to follow.
|
||||
//!
|
||||
//! Worth having on both sides because the two ends had drifted into entirely
|
||||
//! different protocols - a handshake the gateway never sent, telemetry shaped
|
||||
//! one printer per message instead of a batch, printer ids a gateway has no
|
||||
//! way to know - and nothing failed anywhere. Both test files are hand-kept
|
||||
//! mirrors; the canonical shape is
|
||||
//! `continuum-schemas/schemas/continuum/v1/edge/`.
|
||||
|
||||
use continuum_proxy::printer::PrintControl;
|
||||
use continuum_proxy::uplink::{ClientMessage, PrinterCommand, PrinterTelemetry, ServerMessage};
|
||||
|
||||
/// Verbatim from the backend's `src/routes/ws/edge.ts`. If the backend
|
||||
/// changes one of these, this test is where it should break.
|
||||
const BACKEND_MESSAGES: &[&str] = &[
|
||||
r#"{"type":"hello_ack","session_id":"3f2b1c5e-0000-4000-8000-000000000000"}"#,
|
||||
r#"{"type":"hello_error","message":"invalid edge token"}"#,
|
||||
r#"{"type":"heartbeat_ack","seq":7}"#,
|
||||
r#"{"type":"command","command_id":"9a7d2f10-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"set_fan_speed","percent":40}}"#,
|
||||
r#"{"type":"command","command_id":"9a7d2f11-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"send_gcode_line","line":"M106 S0"}}"#,
|
||||
r#"{"type":"command","command_id":"9a7d2f12-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"pause"}}"#,
|
||||
r#"{"type":"command","command_id":"9a7d2f13-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"resume"}}"#,
|
||||
r#"{"type":"command","command_id":"9a7d2f14-0000-4000-8000-000000000000","printer_id":"p1p","command":{"action":"control_print","control":"stop"}}"#,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn every_backend_message_deserializes() {
|
||||
for raw in BACKEND_MESSAGES {
|
||||
let parsed: Result<ServerMessage, _> = serde_json::from_str(raw);
|
||||
assert!(parsed.is_ok(), "backend sends this and the gateway cannot read it:\n {raw}\n {:?}", parsed.err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_arrives_with_its_payload_intact() {
|
||||
let raw = r#"{"type":"command","command_id":"cmd-1","printer_id":"p1p","command":{"action":"set_fan_speed","percent":40}}"#;
|
||||
match serde_json::from_str::<ServerMessage>(raw).expect("should parse") {
|
||||
ServerMessage::Command { command_id, printer_id, command } => {
|
||||
assert_eq!(command_id, "cmd-1");
|
||||
// The gateway's own id from printers.toml, not a cloud UUID - the
|
||||
// backend translates on the way out.
|
||||
assert_eq!(printer_id, "p1p");
|
||||
match command {
|
||||
PrinterCommand::SetFanSpeed { percent } => assert_eq!(percent, 40),
|
||||
other => panic!("wrong command variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("wrong message variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each control verb has to arrive as the right `PrintControl`, not merely
|
||||
/// parse. A silent mis-mapping here would send a stop where a pause was
|
||||
/// asked for, which on a real machine is the difference between a print that
|
||||
/// can be resumed and one that is over.
|
||||
#[test]
|
||||
fn each_print_control_verb_maps_to_its_own_action() {
|
||||
for (verb, expected) in [
|
||||
("pause", PrintControl::Pause),
|
||||
("resume", PrintControl::Resume),
|
||||
("stop", PrintControl::Stop),
|
||||
] {
|
||||
let raw = format!(
|
||||
r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"control_print","control":"{verb}"}}}}"#
|
||||
);
|
||||
match serde_json::from_str::<ServerMessage>(&raw).expect("should parse") {
|
||||
ServerMessage::Command { command: PrinterCommand::ControlPrint { control }, .. } => {
|
||||
assert_eq!(control, expected, "'{verb}' mapped to the wrong action");
|
||||
// And the wire word the vendor layer sends has to match the
|
||||
// wire word that arrived - `as_str` is what builds the Bambu
|
||||
// command name.
|
||||
assert_eq!(control.as_str(), verb);
|
||||
}
|
||||
other => panic!("wrong variant for '{verb}': {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "cancel" is the trap: it is Moonraker's word, and the Klipper
|
||||
/// implementation behind `control_print` really does send a cancel - but the
|
||||
/// wire word is "stop". A verb only one vendor recognises must be rejected
|
||||
/// here rather than reaching a gateway that cannot map it.
|
||||
#[test]
|
||||
fn a_vendor_specific_control_verb_is_rejected() {
|
||||
for verb in ["cancel", "abort", "Pause", ""] {
|
||||
let raw = format!(
|
||||
r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"control_print","control":"{verb}"}}}}"#
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<ServerMessage>(&raw).is_err(),
|
||||
"'{verb}' parsed as a print control verb and should not have"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The schema's `StartPrint` variant, in the shape the backend dispatches.
|
||||
/// Every field has to arrive intact: `url` is what gets downloaded and
|
||||
/// `file_name` is what the printer is told to call it, so a swapped or
|
||||
/// dropped one prints the wrong thing or nothing.
|
||||
#[test]
|
||||
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 }, .. } => {
|
||||
assert_eq!(job_id, "5b0e7c1a-0000-4000-8000-000000000000");
|
||||
assert_eq!(file_name, "plate_1.3mf");
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://objects.example.com/jobs/abc.3mf?X-Amz-Signature=deadbeef&X-Amz-Expires=300"
|
||||
);
|
||||
}
|
||||
other => panic!("wrong variant: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL is a bearer credential until it expires, and `dispatch` logs
|
||||
/// every command with `{:?}`.
|
||||
#[test]
|
||||
fn a_start_print_url_does_not_appear_in_debug_output() {
|
||||
let raw = r#"{"type":"command","command_id":"c","printer_id":"p1p","command":{"action":"start_print","job_id":"j","file_name":"plate_1.3mf","url":"https://objects.example.com/x?X-Amz-Signature=deadbeef"}}"#;
|
||||
let parsed = serde_json::from_str::<ServerMessage>(raw).expect("should parse");
|
||||
let shown = format!("{parsed:?}");
|
||||
assert!(!shown.contains("deadbeef"), "signature leaked into a log line: {shown}");
|
||||
assert!(shown.contains("plate_1.3mf"), "the file name should still be logged: {shown}");
|
||||
}
|
||||
|
||||
/// Each field is load-bearing and none has a sensible default: a command
|
||||
/// missing its URL must be rejected here, not discovered at the printer.
|
||||
#[test]
|
||||
fn a_start_print_missing_any_required_field_is_rejected() {
|
||||
let full = [
|
||||
("job_id", r#""job_id":"5b0e7c1a-0000-4000-8000-000000000000""#),
|
||||
("file_name", r#""file_name":"plate_1.3mf""#),
|
||||
("url", r#""url":"https://objects.example.com/jobs/abc.3mf""#),
|
||||
];
|
||||
for missing in 0..full.len() {
|
||||
let fields: Vec<&str> =
|
||||
full.iter().enumerate().filter(|(i, _)| *i != missing).map(|(_, (_, f))| *f).collect();
|
||||
let raw = format!(
|
||||
r#"{{"type":"command","command_id":"c","printer_id":"p1p","command":{{"action":"start_print",{}}}}}"#,
|
||||
fields.join(",")
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<ServerMessage>(&raw).is_err(),
|
||||
"start_print without '{}' parsed and should not have:\n {raw}",
|
||||
full[missing].0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_messages_are_rejected_rather_than_silently_accepted() {
|
||||
// The old protocol. A gateway that accepted this would ack a handshake
|
||||
// that never happened.
|
||||
assert!(serde_json::from_str::<ServerMessage>(r#"{"type":"auth_ok","farmId":"f"}"#).is_err());
|
||||
}
|
||||
|
||||
/// The bug this test exists for: serde emits `"job_name": null` for an empty
|
||||
/// `Option` unless told not to, and the backend's validator reads a missing
|
||||
/// key and an explicit null as different things. An idle printer has no job
|
||||
/// name, so every batch containing one was rejected whole.
|
||||
#[test]
|
||||
fn empty_telemetry_fields_are_omitted_not_null() {
|
||||
let msg = ClientMessage::Telemetry {
|
||||
printers: vec![PrinterTelemetry {
|
||||
printer_id: "p1p".into(),
|
||||
name: "P1P".into(),
|
||||
state: Some("IDLE".into()),
|
||||
percent: Some(0),
|
||||
// An idle printer is in no stage, so this is the second field
|
||||
// after `job_name` that must be omitted rather than nulled.
|
||||
stage: None,
|
||||
layer: Some(0),
|
||||
total_layers: Some(0),
|
||||
remaining_minutes: Some(0),
|
||||
job_name: None,
|
||||
nozzle_temp: Some(23.375),
|
||||
nozzle_target: Some(0.0),
|
||||
bed_temp: Some(22.40625),
|
||||
bed_target: Some(0.0),
|
||||
print_error: Some(0),
|
||||
active_alerts: Some(0),
|
||||
// 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,
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&msg).expect("should serialize");
|
||||
assert!(!json.contains("null"), "telemetry should omit empty fields, got:\n {json}");
|
||||
assert!(!json.contains("job_name"), "an absent job name should not appear at all");
|
||||
// The fields that *do* have values must still be there - an assertion on
|
||||
// the absence of "null" alone would pass on an empty object.
|
||||
assert!(json.contains(r#""state":"IDLE""#));
|
||||
assert!(json.contains(r#""bed_temp":22.40625"#));
|
||||
assert!(!json.contains("stage"), "an absent stage should not appear at all");
|
||||
}
|
||||
|
||||
/// A stage of 0 means "printing" and must survive the wire, because
|
||||
/// `skip_serializing_if = "Option::is_none"` on an `Option<i32>` is one
|
||||
/// `unwrap_or_default` away from turning "printing" into "no stage". The
|
||||
/// distinction matters: a printer at 14% and layer 0 is mid-bed-levelling if
|
||||
/// the stage is 54 and genuinely stuck if it is 0.
|
||||
#[test]
|
||||
fn a_zero_stage_is_sent_rather_than_treated_as_absent() {
|
||||
let msg = ClientMessage::Telemetry {
|
||||
printers: vec![PrinterTelemetry {
|
||||
printer_id: "p1p".into(),
|
||||
name: "P1P".into(),
|
||||
state: Some("RUNNING".into()),
|
||||
percent: Some(14),
|
||||
stage: Some(0),
|
||||
layer: Some(1),
|
||||
total_layers: Some(150),
|
||||
remaining_minutes: Some(39),
|
||||
job_name: Some("cube-test".into()),
|
||||
nozzle_temp: Some(255.2),
|
||||
nozzle_target: Some(255.0),
|
||||
bed_temp: Some(79.3),
|
||||
bed_target: Some(80.0),
|
||||
print_error: Some(0),
|
||||
active_alerts: Some(0),
|
||||
camera_stream: Some("p1p".into()),
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&msg).expect("should serialize");
|
||||
assert!(json.contains(r#""stage":0"#), "a zero stage must be on the wire, got:\n {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_successful_command_result_omits_the_error() {
|
||||
let json = serde_json::to_string(&ClientMessage::CommandResult {
|
||||
command_id: "cmd-1".into(),
|
||||
ok: true,
|
||||
error: None,
|
||||
})
|
||||
.expect("should serialize");
|
||||
assert_eq!(json, r#"{"type":"command_result","command_id":"cmd-1","ok":true}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_command_result_carries_the_reason() {
|
||||
let json = serde_json::to_string(&ClientMessage::CommandResult {
|
||||
command_id: "cmd-1".into(),
|
||||
ok: false,
|
||||
error: Some("no printer with id 'nope' in this gateway's fleet".into()),
|
||||
})
|
||||
.expect("should serialize");
|
||||
assert!(json.contains(r#""ok":false"#));
|
||||
assert!(json.contains("no printer with id 'nope'"));
|
||||
}
|
||||
|
||||
/// A gateway with no configured stream base URL must omit the field, for the
|
||||
/// same reason the telemetry fields are omitted: the backend reads a missing
|
||||
/// key and an explicit null differently, and an unconfigured gateway is the
|
||||
/// default case, not an edge case.
|
||||
#[test]
|
||||
fn a_hello_without_a_stream_base_url_omits_it() {
|
||||
let json = serde_json::to_string(&ClientMessage::Hello {
|
||||
gateway_id: "gw-1".into(),
|
||||
farm_id: "farm-1".into(),
|
||||
stream_base_url: None,
|
||||
})
|
||||
.expect("should serialize");
|
||||
assert!(!json.contains("stream_base_url"), "got:\n {json}");
|
||||
// Without this the assertion above would also pass on an empty object.
|
||||
assert!(json.contains(r#""gateway_id":"gw-1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hello_carries_the_stream_base_url_when_one_is_configured() {
|
||||
let json = serde_json::to_string(&ClientMessage::Hello {
|
||||
gateway_id: "gw-1".into(),
|
||||
farm_id: "farm-1".into(),
|
||||
stream_base_url: Some("http://gateway.local:1984".into()),
|
||||
})
|
||||
.expect("should serialize");
|
||||
assert!(json.contains(r#""stream_base_url":"http://gateway.local:1984""#), "got:\n {json}");
|
||||
}
|
||||
|
||||
/// The credential must not be in the handshake message. It travels in an
|
||||
/// `Authorization` header on the upgrade instead, and a `token` field here
|
||||
/// would mean the backend parsing a message from a peer that has proved
|
||||
/// nothing - which is what this used to do.
|
||||
#[test]
|
||||
fn a_hello_carries_no_credential() {
|
||||
let json = serde_json::to_string(&ClientMessage::Hello {
|
||||
gateway_id: "gw-1".into(),
|
||||
farm_id: "farm-1".into(),
|
||||
stream_base_url: None,
|
||||
})
|
||||
.expect("should serialize");
|
||||
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"}"#);
|
||||
}
|
||||
Reference in New Issue
Block a user