Compare commits
9
Commits
main
..
94484bb190
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94484bb190
|
||
|
|
11177db2ad
|
||
|
|
1fd6529376
|
||
|
|
133db9e4aa
|
||
|
|
ce6a4c3806
|
||
|
|
db9b575b55
|
||
|
|
5d6e2e9e03
|
||
|
|
773898b682
|
||
|
|
2c4ef21291
|
+2
-28
@@ -1,40 +1,14 @@
|
|||||||
# Identity
|
# Identity
|
||||||
CONTINUUM_GATEWAY_ID=gw-farm01-rpi01
|
CONTINUUM_GATEWAY_ID=gw-farm01-rpi01
|
||||||
CONTINUUM_FARM_ID=replace-with-farm-uuid
|
CONTINUUM_FARM_ID=replace-with-farm-uuid
|
||||||
# The farm's edge proxy token, shown once by the console's Setup page (or
|
CONTINUUM_GATEWAY_TOKEN=replace-with-provisioning-token
|
||||||
# POST /farms/:id/edge-proxy-token). The gateway trades it for a one-hour edge
|
|
||||||
# JWT (POST /auth/edge-token) on every connection, and the JWT - sent as an
|
|
||||||
# Authorization: Bearer header on the WebSocket upgrade, never in a message
|
|
||||||
# or the URL - is what the backend verifies before any socket exists.
|
|
||||||
CONTINUUM_GATEWAY_TOKEN=replace-with-the-farms-edge-proxy-token
|
|
||||||
|
|
||||||
# Cloud uplink
|
# Cloud uplink
|
||||||
CONTINUUM_UPLINK_URL=wss://api.continuum.local/ws/edge/v1
|
CONTINUUM_UPLINK_URL=wss://api.continuum.local/ws/edge/v1
|
||||||
|
|
||||||
# Camera restreaming. The config file is GENERATED by this daemon from what
|
# go2rtc process management
|
||||||
# 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_BIN=/usr/local/bin/go2rtc
|
||||||
CONTINUUM_GO2RTC_CONFIG=./go2rtc.yaml
|
CONTINUUM_GO2RTC_CONFIG=./go2rtc.yaml
|
||||||
CONTINUUM_GO2RTC_API_LISTEN=:1984
|
|
||||||
|
|
||||||
# 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
|
# Fleet config - see printers.example.toml. Bambu MQTTS certificate trust
|
||||||
# (per-printer ca_cert_path/require_valid_cert, and automatic
|
# (per-printer ca_cert_path/require_valid_cert, and automatic
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
# Builds `discover_printers` as a Windows executable, for trying SSDP
|
|
||||||
# discovery on a machine that shares the printers' network segment (multicast
|
|
||||||
# does not cross a router, so the development container cannot hear them).
|
|
||||||
#
|
|
||||||
# Runs on the `windows-latest` self-hosted runner that continuum-cad's
|
|
||||||
# pipeline also uses: a host executor running as SYSTEM, Windows PowerShell
|
|
||||||
# 5.1 (no pwsh), MSVC build tools present. The notes in
|
|
||||||
# continuum-cad/.gitea/workflows/ci.yml on that machine apply here too.
|
|
||||||
# Its artifact UPLOAD works (`upload-artifact@v3`); DOWNLOAD does not, which
|
|
||||||
# is why nothing here downloads one.
|
|
||||||
#
|
|
||||||
# A job whose label matches no registered runner is skipped silently, not
|
|
||||||
# failed - if this never runs, check that the runner is online.
|
|
||||||
#
|
|
||||||
# Cargo.lock is not committed in this repo, so this resolves dependencies
|
|
||||||
# fresh; the artifact is built from whatever versions that picks today.
|
|
||||||
name: Windows discovery tool
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- "src/**"
|
|
||||||
- "examples/discover_printers.rs"
|
|
||||||
- "Cargo.toml"
|
|
||||||
- "Cargo.lock"
|
|
||||||
- ".gitea/workflows/discover-windows.yml"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: never
|
|
||||||
# Outside the runner's deep work directory, and short, for the same MAX_PATH
|
|
||||||
# reason continuum-cad gives. A directory of its own - not C:\vcpkg or
|
|
||||||
# C:\ccad-target - so the two pipelines cannot step on each other's state
|
|
||||||
# on the one machine.
|
|
||||||
CARGO_TARGET_DIR: 'C:\continuum-proxy-target'
|
|
||||||
VCPKG_ROOT: 'C:\continuum-vcpkg'
|
|
||||||
# The same release tag continuum-cad pins, known to exist.
|
|
||||||
VCPKG_REF: "2026.07.29"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
windows:
|
|
||||||
runs-on: windows-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: powershell
|
|
||||||
# Every `run` step below works in the proxy's checkout.
|
|
||||||
working-directory: continuum-proxy
|
|
||||||
timeout-minutes: 90
|
|
||||||
steps:
|
|
||||||
# Cargo.toml has path dependencies on two sibling repositories
|
|
||||||
# (`../continuum-schemas`, `../continuum-common`), so all three are laid
|
|
||||||
# out side by side inside the workspace - the checkout action will not
|
|
||||||
# place a repo outside it, which is why the proxy is in a subdirectory
|
|
||||||
# rather than at the root.
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
path: continuum-proxy
|
|
||||||
|
|
||||||
# Plain git for the siblings, not a second `actions/checkout`: on this
|
|
||||||
# Windows runner a second use of the action in one job fails syncing the
|
|
||||||
# action's own cached clone ("...\.git\objects\pack\...idx: Access is
|
|
||||||
# denied", run 413). Same branch name as this one, because the proxy's
|
|
||||||
# code needs their unreleased commits. The job's token goes in a header
|
|
||||||
# for the clone and is never written to a remote URL or a log.
|
|
||||||
- name: Check out the sibling repositories
|
|
||||||
shell: powershell
|
|
||||||
working-directory: .
|
|
||||||
env:
|
|
||||||
CI_TOKEN: ${{ github.token }}
|
|
||||||
CI_SERVER: ${{ github.server_url }}
|
|
||||||
CI_BRANCH: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
$auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("x-access-token:$($env:CI_TOKEN)"))
|
|
||||||
foreach ($repo in @("continuum-schemas", "continuum-common")) {
|
|
||||||
if (Test-Path $repo) { Remove-Item -Recurse -Force $repo }
|
|
||||||
git -c "http.extraheader=AUTHORIZATION: basic $auth" clone --quiet --depth 1 --branch $env:CI_BRANCH "$($env:CI_SERVER)/Continuum/$repo.git" $repo
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "could not clone $repo at branch $($env:CI_BRANCH)" }
|
|
||||||
Write-Host "$repo at $(git -C $repo rev-parse --short HEAD)"
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Install the Rust MSVC toolchain
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
# Genuine no-BOM UTF-8: Windows PowerShell 5.1's `-Encoding utf8`
|
|
||||||
# writes a BOM, which corrupts the first entry of GITHUB_PATH.
|
|
||||||
$NoBom = New-Object System.Text.UTF8Encoding $false
|
|
||||||
function Add-CiPath([string] $Value) {
|
|
||||||
[System.IO.File]::AppendAllText($env:GITHUB_PATH, "$Value`r`n", $NoBom)
|
|
||||||
}
|
|
||||||
if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) {
|
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
||||||
Invoke-WebRequest -UseBasicParsing -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
|
|
||||||
.\rustup-init.exe -y --default-toolchain stable-x86_64-pc-windows-msvc
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "rustup-init failed" }
|
|
||||||
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
|
|
||||||
if (-not (Test-Path (Join-Path $cargoBin "rustup.exe"))) {
|
|
||||||
throw "rustup-init reported success but rustup.exe is not in $cargoBin"
|
|
||||||
}
|
|
||||||
# GITHUB_PATH only takes effect for later steps; the in-process
|
|
||||||
# PATH is what the calls just below need.
|
|
||||||
$env:PATH = "$cargoBin;$env:PATH"
|
|
||||||
Add-CiPath $cargoBin
|
|
||||||
}
|
|
||||||
rustup default stable-x86_64-pc-windows-msvc
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "could not select the MSVC toolchain" }
|
|
||||||
rustc -vV
|
|
||||||
|
|
||||||
# The proxy links OpenSSL directly (Ed25519 verification, SHA-256, and
|
|
||||||
# reading a printer's certificate), and Windows has none. vcpkg builds
|
|
||||||
# it, fetching its own Perl and NASM. `x64-windows-static-md` links it
|
|
||||||
# into the .exe, so the tool needs no DLL beside it; openssl-sys finds
|
|
||||||
# it through VCPKG_ROOT with no further settings.
|
|
||||||
- name: Build OpenSSL with vcpkg
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
git config --global core.longpaths true
|
|
||||||
if (-not (Test-Path "$env:VCPKG_ROOT\.git")) {
|
|
||||||
git clone --depth 1 --branch $env:VCPKG_REF https://github.com/microsoft/vcpkg $env:VCPKG_ROOT
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "could not clone vcpkg at $env:VCPKG_REF" }
|
|
||||||
}
|
|
||||||
Push-Location $env:VCPKG_ROOT
|
|
||||||
try {
|
|
||||||
if (-not (Test-Path .\vcpkg.exe)) {
|
|
||||||
.\bootstrap-vcpkg.bat -disableMetrics
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "bootstrap-vcpkg failed" }
|
|
||||||
}
|
|
||||||
.\vcpkg.exe install openssl:x64-windows-static-md
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "vcpkg failed to build OpenSSL" }
|
|
||||||
} finally {
|
|
||||||
Pop-Location
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Build discover_printers
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$env:VCPKGRS_TRIPLET = "x64-windows-static-md"
|
|
||||||
cargo build --release --example discover_printers
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "cargo build failed" }
|
|
||||||
|
|
||||||
New-Item -ItemType Directory -Force -Path dist | Out-Null
|
|
||||||
Copy-Item "$env:CARGO_TARGET_DIR\release\examples\discover_printers.exe" dist\
|
|
||||||
Copy-Item scripts\discover-windows-README.txt dist\README.txt
|
|
||||||
|
|
||||||
# It must run on a machine with nothing installed. No arguments
|
|
||||||
# prints the usage and exits non-zero, which proves the binary
|
|
||||||
# loads (no missing DLL) without touching the network.
|
|
||||||
$ErrorActionPreference = "Continue"
|
|
||||||
$out = cmd /c "dist\discover_printers.exe 2>&1" | Out-String
|
|
||||||
if ($out -notmatch "identify") { throw "discover_printers.exe did not print its usage: $out" }
|
|
||||||
Write-Host "usage text looks right"
|
|
||||||
|
|
||||||
# Imports of the .exe, so a DLL that a clean machine lacks shows up
|
|
||||||
# in the log (the VC++ runtime is the one that commonly does).
|
|
||||||
if (Get-Command dumpbin -ErrorAction SilentlyContinue) {
|
|
||||||
& dumpbin /dependents dist\discover_printers.exe | Select-String "\.dll" |
|
|
||||||
ForEach-Object { Write-Host $_.Line.Trim() }
|
|
||||||
}
|
|
||||||
|
|
||||||
$hash = (Get-FileHash dist\discover_printers.exe -Algorithm SHA256).Hash.ToLower()
|
|
||||||
Set-Content -Path dist\SHA256.txt -Value "$hash discover_printers.exe" -Encoding ASCII
|
|
||||||
Write-Host "sha256 $hash"
|
|
||||||
|
|
||||||
# The usage run above exits 1 on purpose, and PowerShell hands the
|
|
||||||
# last native command's exit code to the runner when a script ends -
|
|
||||||
# so without this a build that passed every check was reported
|
|
||||||
# failed (run 414).
|
|
||||||
exit 0
|
|
||||||
|
|
||||||
- name: Upload the executable
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: continuum-discover-printers-windows
|
|
||||||
path: continuum-proxy/dist/*
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
# After the upload on purpose: the executable is useful even if a test
|
|
||||||
# that only breaks on Windows fails, and a red job still says so.
|
|
||||||
- name: Run the discovery tests on Windows
|
|
||||||
shell: powershell
|
|
||||||
run: |
|
|
||||||
$env:VCPKGRS_TRIPLET = "x64-windows-static-md"
|
|
||||||
cargo test --lib discovery
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "discovery tests failed on Windows" }
|
|
||||||
+1
-11
@@ -1,4 +1,5 @@
|
|||||||
/target
|
/target
|
||||||
|
Cargo.lock
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
*.sqlite
|
*.sqlite
|
||||||
@@ -7,14 +8,3 @@
|
|||||||
*.log
|
*.log
|
||||||
printers.toml
|
printers.toml
|
||||||
/certs/pinned/
|
/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
|
|
||||||
|
|
||||||
# The local store holds printer access codes.
|
|
||||||
continuum.db
|
|
||||||
continuum.db-*
|
|
||||||
|
|||||||
Generated
-2638
File diff suppressed because it is too large
Load Diff
+1
-42
@@ -3,10 +3,7 @@ name = "continuum-proxy"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Continuum edge gateway daemon - runs on-site, bridges printers to the cloud control plane"
|
description = "Continuum edge gateway daemon - runs on-site, bridges printers to the cloud control plane"
|
||||||
# Proprietary: deliberately not an SPDX id, so cargo-deny treats it as
|
license = "UNLICENSED"
|
||||||
# first-party. See docs/LICENSES.md in continuum-common.
|
|
||||||
license = "LicenseRef-Proprietary"
|
|
||||||
publish = false
|
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "continuum_proxy"
|
name = "continuum_proxy"
|
||||||
@@ -32,14 +29,6 @@ path = "examples/auto_connect_bambu.rs"
|
|||||||
name = "bambu_commands_demo"
|
name = "bambu_commands_demo"
|
||||||
path = "examples/bambu_commands_demo.rs"
|
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]
|
[dependencies]
|
||||||
# Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/
|
# Real printer protocol clients (MQTT for Bambu, HTTP for PrusaLink/
|
||||||
# Moonraker, FTPS for gcode transfer) go here once you're ready to build
|
# Moonraker, FTPS for gcode transfer) go here once you're ready to build
|
||||||
@@ -68,33 +57,3 @@ thiserror = "1"
|
|||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
tokio-serial = { version = "5.5.0", default-features = false }
|
tokio-serial = { version = "5.5.0", default-features = false }
|
||||||
rumqttc = { version = "0.25.1", default-features = false, features = ["use-native-tls"] }
|
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"
|
|
||||||
# The proxy's local store (src/store.rs): printers added from the console live
|
|
||||||
# here, so the gateway keeps its fleet with the API unreachable. `bundled`
|
|
||||||
# compiles SQLite from source so the appliance image needs no system library
|
|
||||||
# (SQLite itself is public domain).
|
|
||||||
# MIT - checked with `cargo deny check licenses` (docs/LICENSES.md policy).
|
|
||||||
rusqlite = { version = "0.41", features = ["bundled"] }
|
|
||||||
|
|||||||
@@ -27,65 +27,18 @@ cloud control plane (`continuum-backend`).
|
|||||||
- **Cloud uplink** - a WebSocket client to `continuum-backend` that
|
- **Cloud uplink** - a WebSocket client to `continuum-backend` that
|
||||||
connects, sends a heartbeat on a timer, and reconnects on drop
|
connects, sends a heartbeat on a timer, and reconnects on drop
|
||||||
(`src/uplink/`).
|
(`src/uplink/`).
|
||||||
- **Camera restreaming** - generates go2rtc's configuration from the camera
|
- **go2rtc watchdog** - restarts the camera-restreaming subprocess if it
|
||||||
URLs the printers report, and restarts go2rtc when it changes
|
dies (`src/go2rtc.rs`).
|
||||||
(`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
|
## 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`
|
PrusaLink (REST) and Klipper/Moonraker (WebSocket) - `printer/prusa.rs`
|
||||||
and `printer/klipper.rs` just `println!` what they'd send. Also not built
|
and `printer/klipper.rs` just `println!` what they'd send. Also not built
|
||||||
yet: local buffering for telemetry across connectivity gaps (so telemetry
|
yet: dispatching a cloud-issued command to a connected printer (the uplink
|
||||||
produced while the uplink is down is simply lost), LAN printer discovery
|
and the printer fleet run side by side in `main.rs`, but nothing routes
|
||||||
(SSDP/mDNS), and the mechanical plate-changer interface.
|
between them yet), local buffering for telemetry across connectivity gaps,
|
||||||
|
LAN printer discovery (SSDP/mDNS), and the mechanical plate-changer
|
||||||
|
interface.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
@@ -104,14 +57,11 @@ cargo run --example bambu_commands_demo # round-trips hand-written structs ag
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
main.rs Loads+connects the printer fleet, then runs the uplink, camera config, watchdog and completion watcher side by side
|
main.rs Loads+connects the printer fleet, then runs the uplink and go2rtc watchdog side by side
|
||||||
config.rs Loads settings from environment variables
|
config.rs Loads settings from environment variables
|
||||||
fleet.rs Loads printers.toml into ready-to-use PrinterHandles
|
fleet.rs Loads printers.toml into ready-to-use PrinterHandles
|
||||||
uplink/ WebSocket client to continuum-backend: connect, heartbeat, reconnect on drop
|
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
|
||||||
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/
|
printer/
|
||||||
mod.rs GenericPrinter trait + PrinterBase + PrinterHandle enum - the trait+enum
|
mod.rs GenericPrinter trait + PrinterBase + PrinterHandle enum - the trait+enum
|
||||||
"inheritance" pattern this project uses instead of class inheritance
|
"inheritance" pattern this project uses instead of class inheritance
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
# Says what kind of installation this is and where the real config lives.
|
|
||||||
# Read from CONTINUUM_BOOTSTRAP, else /etc/continuum/bootstrap.toml, else
|
|
||||||
# absent (which means: PC mode, ./continuum.toml). See src/bootstrap.rs.
|
|
||||||
#
|
|
||||||
# A Proxy Box image ships this root-owned. It must NOT come from a pendrive:
|
|
||||||
# whether a pendrive may change settings is what it decides.
|
|
||||||
|
|
||||||
mode = "proxy_box" # or "pc"
|
|
||||||
# config_path = "/var/lib/continuum/config.toml"
|
|
||||||
# pendrive_dirs = ["/media", "/run/media"] # ignored (and dropped) in pc mode
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# The proxy's OWN settings - not the printers (that is printers.toml, which
|
|
||||||
# holds per-site secrets and is never shipped on a pendrive). See
|
|
||||||
# src/config.rs for how this file layers with CONTINUUM_* environment
|
|
||||||
# variables: this file is the base, an env var overrides the same key.
|
|
||||||
#
|
|
||||||
# PC / server: copy to continuum.toml next to the proxy and edit it. Nothing
|
|
||||||
# else is needed; removable media is never read.
|
|
||||||
# Proxy Box: this file is NOT written by hand. Continuum Cloud's
|
|
||||||
# configurator produces continuum-config.zip: config.toml,
|
|
||||||
# config.toml.sha256 and config.toml.sig (an Ed25519 signature
|
|
||||||
# naming this box and a config_version), every entry
|
|
||||||
# AES-256-encrypted under the box's own key. A Box refuses
|
|
||||||
# plaintext, unsigned, other-box and older-version bundles.
|
|
||||||
# See the module docs of src/config_import.rs for the exact
|
|
||||||
# format the cloud must produce.
|
|
||||||
#
|
|
||||||
# Unknown keys are rejected on purpose, so a typo cannot silently fall back to
|
|
||||||
# a default. On a Proxy Box the file must be complete by itself.
|
|
||||||
|
|
||||||
gateway_id = "REPLACE_ME"
|
|
||||||
farm_id = "REPLACE_ME"
|
|
||||||
# The farm's edge proxy token from the console (Setup page). Traded for a
|
|
||||||
# short-lived edge JWT on every connection - see src/uplink/auth.rs.
|
|
||||||
gateway_token = "REPLACE_ME"
|
|
||||||
|
|
||||||
# uplink_url = "wss://api.continuum.local/ws/edge/v1"
|
|
||||||
# go2rtc_bin = "go2rtc"
|
|
||||||
# go2rtc_config = "./go2rtc.yaml"
|
|
||||||
# go2rtc_api_listen = ":1984"
|
|
||||||
# stream_base_url = "https://proxy.example/"
|
|
||||||
# printers_path = "/var/lib/continuum/printers.toml" # absolute on a box
|
|
||||||
|
|
||||||
# Proxy Box only. PARSED AND VALIDATED, NOT APPLIED - this build never
|
|
||||||
# touches the OS network configuration.
|
|
||||||
# [network]
|
|
||||||
# mode = "static" # or "dhcp"
|
|
||||||
# ip = "192.168.1.60"
|
|
||||||
# prefix_len = 24
|
|
||||||
# gateway = "192.168.1.1"
|
|
||||||
# dns = ["1.1.1.1"]
|
|
||||||
# [network.wifi]
|
|
||||||
# ssid = "farm"
|
|
||||||
# psk = "REPLACE_ME"
|
|
||||||
|
|
||||||
# Printer discovery: look for printers nobody has configured and report them
|
|
||||||
# to the cloud, so adding one in the web console does not start with walking
|
|
||||||
# to a machine to read its IP address off the screen.
|
|
||||||
#
|
|
||||||
# OFF by default, and the whole block is commented out here for that reason.
|
|
||||||
# A sweep is unsolicited traffic on someone else's network - 254 TLS
|
|
||||||
# handshakes and up to three HTTP requests per host, which nothing watching
|
|
||||||
# can distinguish from a port scan. Switch it on only for a network you are
|
|
||||||
# responsible for.
|
|
||||||
#
|
|
||||||
# Discovery never supplies a credential, because nothing can: a Bambu access
|
|
||||||
# code is displayed on the printer and a PrusaLink API key is in its web UI.
|
|
||||||
# It supplies the address, the serial and the vendor - the parts you would
|
|
||||||
# otherwise have to look up.
|
|
||||||
#
|
|
||||||
# `subnets` is explicit and never derived from this host's interfaces, so a
|
|
||||||
# second NIC, a VPN or a container bridge cannot widen what gets swept.
|
|
||||||
# Anything wider than a /24 is refused.
|
|
||||||
#
|
|
||||||
# `serial` reads USB descriptors from sysfs and opens nothing. It will never
|
|
||||||
# send an `M115` handshake: opening a serial port toggles DTR, which reboots
|
|
||||||
# an Arduino-based board, and on a printer mid-job that ruins the print. Use
|
|
||||||
# `cargo run --example discover_printers -- serial-probe <port>` when you want
|
|
||||||
# the handshake and are standing in front of the machine.
|
|
||||||
#
|
|
||||||
# `ssdp` listens passively for Bambu announcements. It sends nothing, but
|
|
||||||
# multicast does not cross a router, so on a real farm network a sweep is
|
|
||||||
# what actually works.
|
|
||||||
#
|
|
||||||
# [discovery]
|
|
||||||
# enabled = true
|
|
||||||
# subnets = ["192.168.0.0/24"]
|
|
||||||
# ssdp = false
|
|
||||||
# serial = false
|
|
||||||
# interval_minutes = 30
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Continuum proxy as a systemd service - the template for a Proxy Box or a
|
|
||||||
# Linux PC install. Adjust the paths; create the `continuum` user and put the
|
|
||||||
# settings it reads (continuum.toml, or the signed bundle on a Box) under
|
|
||||||
# /var/lib/continuum.
|
|
||||||
#
|
|
||||||
# Not exercised under a real systemd yet: this container's init is not
|
|
||||||
# systemd, so only `systemd-analyze verify` has been run against it.
|
|
||||||
[Unit]
|
|
||||||
Description=Continuum proxy (printer gateway)
|
|
||||||
Wants=network-online.target
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=continuum
|
|
||||||
# The proxy's relative defaults (./go2rtc.yaml, ./printers.toml, the go2rtc
|
|
||||||
# pid file) resolve here, and StateDirectory creates it owned by the user.
|
|
||||||
StateDirectory=continuum
|
|
||||||
WorkingDirectory=/var/lib/continuum
|
|
||||||
ExecStart=/usr/local/bin/continuum-proxy
|
|
||||||
|
|
||||||
# Always, including a clean exit: installing a new config from a pendrive
|
|
||||||
# deliberately ends the process and relies on this to start it on the new
|
|
||||||
# settings (src/main.rs, config_import).
|
|
||||||
Restart=always
|
|
||||||
RestartSec=2
|
|
||||||
|
|
||||||
# Do not change this to `process` or `none`. go2rtc is the proxy's child, so
|
|
||||||
# it lives in this unit's cgroup, and this is what makes systemd kill it
|
|
||||||
# whenever the proxy goes - including SIGKILL and the OOM killer, which give
|
|
||||||
# the proxy no chance to stop it itself. It is systemd's default; it is
|
|
||||||
# written out so nobody weakens it by accident. An orphaned go2rtc keeps
|
|
||||||
# :1984/:8555 bound and a printer's access code in memory.
|
|
||||||
KillMode=control-group
|
|
||||||
TimeoutStopSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -17,8 +17,9 @@ fn main() -> anyhow::Result<()> {
|
|||||||
let cert_dir = std::path::Path::new("certs/pinned");
|
let cert_dir = std::path::Path::new("certs/pinned");
|
||||||
|
|
||||||
for printer in &mut printers {
|
for printer in &mut printers {
|
||||||
if let PrinterHandle::Bambu(bambu) = printer {
|
match printer {
|
||||||
report(&bambu.ensure_trusted(cert_dir))
|
PrinterHandle::Bambu(bambu) => report(&bambu.ensure_trusted(cert_dir)),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
//! 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(())
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
//! 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(())
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
//! Run with: cargo run --example bambu_mapping_check -- <printer-id> <file-on-card>
|
|
||||||
//!
|
|
||||||
//! **Read-only dry run of what a print would do.** Connects, reads the
|
|
||||||
//! printer's state and loaded filament, downloads one project from the SD
|
|
||||||
//! card root over FTPS, and prints the AMS mapping the production path would
|
|
||||||
//! send - without sending it. Starts nothing, uploads nothing, deletes
|
|
||||||
//! nothing. An optional third argument saves the downloaded copy locally.
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use continuum_proxy::fleet;
|
|
||||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> anyhow::Result<()> {
|
|
||||||
let mut args = std::env::args().skip(1);
|
|
||||||
let (Some(target), Some(file)) = (args.next(), args.next()) else {
|
|
||||||
anyhow::bail!("usage: cargo run --example bambu_mapping_check -- <printer-id> <file-on-card> [save-to]");
|
|
||||||
};
|
|
||||||
let save_to = args.next();
|
|
||||||
|
|
||||||
let mut printers = fleet::load(std::path::Path::new("printers.toml"))?;
|
|
||||||
let printer = printers
|
|
||||||
.iter_mut()
|
|
||||||
.find(|p| p.base().id == target)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("no printer with id '{target}' in printers.toml"))?;
|
|
||||||
let PrinterHandle::Bambu(bambu) = printer else {
|
|
||||||
anyhow::bail!("'{target}' is not a Bambu printer");
|
|
||||||
};
|
|
||||||
|
|
||||||
tokio::time::timeout(Duration::from_secs(20), bambu.connect())
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("connect timed out"))??;
|
|
||||||
bambu.pushall().await.ok();
|
|
||||||
tokio::time::sleep(Duration::from_secs(4)).await;
|
|
||||||
|
|
||||||
let report = bambu.print_status().await.ok_or_else(|| anyhow::anyhow!("no status yet"))?;
|
|
||||||
println!("gcode_state: {:?}", report.gcode_state);
|
|
||||||
println!("{:#?}", report.ams);
|
|
||||||
|
|
||||||
let bytes = bambu.download_file(&format!("/{file}")).await?;
|
|
||||||
println!("downloaded {} bytes", bytes.len());
|
|
||||||
if let Some(path) = save_to {
|
|
||||||
std::fs::write(&path, &bytes)?;
|
|
||||||
println!("saved a copy to {path}");
|
|
||||||
}
|
|
||||||
for f in continuum_proxy::printer::plate_filaments(&bytes, 1)? {
|
|
||||||
println!("project filament {}: {} {} #{}", f.index, f.filament_type, f.tray_info_idx, f.color);
|
|
||||||
}
|
|
||||||
if let Some(slot) = std::env::var("AMS_SLOT").ok().and_then(|v| v.parse::<u8>().ok()) {
|
|
||||||
println!("(with AMS_SLOT={slot} chosen explicitly)");
|
|
||||||
bambu.set_ams_slot_override(Some(slot));
|
|
||||||
}
|
|
||||||
match bambu.filament_source_for(&bytes, 1).await {
|
|
||||||
Ok(source) => println!("mapping the production path would use: {source:?}"),
|
|
||||||
Err(err) => println!("the production path would REFUSE: {err}"),
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,554 +0,0 @@
|
|||||||
//! 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}");
|
|
||||||
}
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
//! 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,178 +0,0 @@
|
|||||||
//! 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(())
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
use continuum_proxy::printer::{
|
use continuum_proxy::printer::{
|
||||||
BambuPrinter, BambuProtocolPreference, BambuTls, GenericPrinter, KlipperPrinter, PrinterHandle,
|
BambuPrinter, BambuProtocolPreference, BambuTls, GenericPrinter, KlipperPrinter, PrinterHandle,
|
||||||
OctoPrintFlavour, OctoPrintPrinter, PrusaSerialPrinter,
|
PrusaLinkPrinter, PrusaSerialPrinter,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -36,13 +36,7 @@ async fn main() {
|
|||||||
BambuTls::BundledCa,
|
BambuTls::BundledCa,
|
||||||
BambuProtocolPreference::Auto,
|
BambuProtocolPreference::Auto,
|
||||||
)),
|
)),
|
||||||
PrinterHandle::OctoPrint(OctoPrintPrinter::new(
|
PrinterHandle::PrusaLink(PrusaLinkPrinter::new("p3", "MK4", "192.168.1.51", "prusa-api-key")),
|
||||||
"p3",
|
|
||||||
"MK4",
|
|
||||||
"192.168.1.51",
|
|
||||||
"prusa-api-key",
|
|
||||||
OctoPrintFlavour::PrusaLink,
|
|
||||||
)),
|
|
||||||
PrinterHandle::PrusaSerial(PrusaSerialPrinter::new("p4", "MK3S+", "/dev/ttyACM0", 115_200)),
|
PrinterHandle::PrusaSerial(PrusaSerialPrinter::new("p4", "MK3S+", "/dev/ttyACM0", 115_200)),
|
||||||
PrinterHandle::Klipper(KlipperPrinter::new("p5", "Voron 2.4", "192.168.1.52")),
|
PrinterHandle::Klipper(KlipperPrinter::new("p5", "Voron 2.4", "192.168.1.52")),
|
||||||
// No host configured - this one will hit the Err path.
|
// No host configured - this one will hit the Err path.
|
||||||
@@ -67,8 +61,9 @@ async fn main() {
|
|||||||
// cache) - none of these connected above (fake IPs), so it's always
|
// cache) - none of these connected above (fake IPs), so it's always
|
||||||
// `None`, but the point is the downcast, not a live reading.
|
// `None`, but the point is the downcast, not a live reading.
|
||||||
for printer in &fleet {
|
for printer in &fleet {
|
||||||
if let PrinterHandle::Bambu(bambu) = printer {
|
match printer {
|
||||||
println!(" {}: cached print_status = {:?}", bambu.sn, bambu.print_status().await)
|
PrinterHandle::Bambu(bambu) => println!(" {}: cached print_status = {:?}", bambu.sn, bambu.print_status().await),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,15 @@
|
|||||||
//! this trust question.
|
//! this trust question.
|
||||||
|
|
||||||
use continuum_proxy::fleet;
|
use continuum_proxy::fleet;
|
||||||
use continuum_proxy::printer::PrinterHandle;
|
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let printers = fleet::load(std::path::Path::new("printers.toml"))?;
|
let printers = fleet::load(std::path::Path::new("printers.toml"))?;
|
||||||
|
|
||||||
for printer in &printers {
|
for printer in &printers {
|
||||||
if let PrinterHandle::Bambu(bambu) = printer {
|
match printer {
|
||||||
report(bambu.base().name.as_str(), bambu.test_tls_handshake())
|
PrinterHandle::Bambu(bambu) => report(bambu.base().name.as_str(), bambu.test_tls_handshake()),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,6 @@
|
|||||||
# Copy this file to printers.toml (gitignored) and fill in your real
|
# 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
|
# 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.
|
# 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]]
|
[[printer]]
|
||||||
id = "p1p"
|
id = "p1p"
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
discover_printers.exe - finds Continuum-compatible 3D printers on this network
|
|
||||||
=============================================================================
|
|
||||||
|
|
||||||
Run it from PowerShell, in the folder it is in. It only LISTENS and asks
|
|
||||||
printers who they are. It sends no passwords or access codes and changes
|
|
||||||
nothing on any printer.
|
|
||||||
|
|
||||||
The test we need (Bambu printers announce themselves over SSDP multicast,
|
|
||||||
which only works if this PC is on the SAME network segment as the printers -
|
|
||||||
same Wi-Fi/Ethernet, not through a VPN, not a guest network):
|
|
||||||
|
|
||||||
.\discover_printers.exe raw 120 2>&1 | Tee-Object ssdp.txt
|
|
||||||
|
|
||||||
That listens for 2 minutes and prints EVERY datagram it hears, then what it
|
|
||||||
understood. Send back ssdp.txt. "listening for SSDP ... interfaces=[...]"
|
|
||||||
near the top lists the adapters it joined; the one on the printers' network
|
|
||||||
should be there.
|
|
||||||
|
|
||||||
Other commands:
|
|
||||||
|
|
||||||
.\discover_printers.exe listen 30 SSDP only, Bambu printers
|
|
||||||
.\discover_printers.exe sweep 192.168.0.0/24 probe every address on a subnet
|
|
||||||
.\discover_printers.exe all 192.168.0.0/24 both, merged
|
|
||||||
.\discover_printers.exe identify 192.168.0.21 one known address
|
|
||||||
|
|
||||||
Windows Firewall will ask whether to allow it on first run. Allow it on
|
|
||||||
PRIVATE networks; without that, Windows drops the multicast and the result is
|
|
||||||
"No printers found" even when printers are there.
|
|
||||||
|
|
||||||
Check the file is intact: Get-FileHash .\discover_printers.exe -Algorithm SHA256
|
|
||||||
(compare with SHA256.txt)
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
//! The tiny, fixed-location answer to "what kind of installation is this, and
|
|
||||||
//! where is my real config?" - which has to be known *before* the real config
|
|
||||||
//! can be found.
|
|
||||||
//!
|
|
||||||
//! # Why a second file at all
|
|
||||||
//!
|
|
||||||
//! The proxy's settings live in a config file (see `config.rs`), but a Proxy
|
|
||||||
//! Box replaces that file from a pendrive, and a PC user keeps it wherever
|
|
||||||
//! they like. Something has to say which of those two worlds this is, and
|
|
||||||
//! where the file is. That something cannot be in the file it locates, and it
|
|
||||||
//! must not be on the pendrive: whether removable media may change settings
|
|
||||||
//! at all is the very thing being decided, so the media cannot be the one to
|
|
||||||
//! say yes.
|
|
||||||
//!
|
|
||||||
//! # Discovery order
|
|
||||||
//!
|
|
||||||
//! 1. `CONTINUUM_BOOTSTRAP` - an explicit path; if set and unreadable that is
|
|
||||||
//! an error, not a fallthrough, because someone pointed at a file on
|
|
||||||
//! purpose.
|
|
||||||
//! 2. `/etc/continuum/bootstrap.toml` - the fixed location a Proxy Box image
|
|
||||||
//! ships. Root-owned, so a settings change on a box cannot be made by the
|
|
||||||
//! service user or by a pendrive.
|
|
||||||
//! 3. Nothing found: **PC mode with defaults**. This is what every existing
|
|
||||||
//! deployment (env vars only, no files) lands in, so adding the file layer
|
|
||||||
//! changes nothing for them.
|
|
||||||
//!
|
|
||||||
//! A file in the working directory is deliberately *not* searched. It would
|
|
||||||
//! be convenient on a PC, but "whichever directory the service happened to
|
|
||||||
//! start in can flip the box into a different mode" is a bad property for the
|
|
||||||
//! one file that gates removable-media import.
|
|
||||||
//!
|
|
||||||
//! # Overrides
|
|
||||||
//!
|
|
||||||
//! The same precedence as `config.rs`: bootstrap file, then env on top.
|
|
||||||
//! `CONTINUUM_MODE` (`proxy_box` | `pc`), `CONTINUUM_CONFIG_PATH`,
|
|
||||||
//! `CONTINUUM_PENDRIVE_DIRS` (colon-separated).
|
|
||||||
//!
|
|
||||||
//! Note what that means on a box: an environment variable can still override
|
|
||||||
//! a setting. The env of a service is set by its unit file, which is part of
|
|
||||||
//! the root-owned image, so it sits on the same side of the trust line as the
|
|
||||||
//! bootstrap file. It is not a way for a pendrive or the service user to
|
|
||||||
//! change anything.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::config::ProxyFile;
|
|
||||||
|
|
||||||
/// Where a Proxy Box image puts its bootstrap file.
|
|
||||||
pub const BOOTSTRAP_PATH: &str = "/etc/continuum/bootstrap.toml";
|
|
||||||
|
|
||||||
/// Where a Proxy Box keeps the config it is running on. Under `/var/lib`, not
|
|
||||||
/// `/etc`: the service has to be able to replace this file atomically (which
|
|
||||||
/// needs a writable directory), and it should not need write access to `/etc`
|
|
||||||
/// to do so.
|
|
||||||
const BOX_CONFIG_PATH: &str = "/var/lib/continuum/config.toml";
|
|
||||||
|
|
||||||
/// Relative on purpose: next to wherever the user runs the proxy from, like
|
|
||||||
/// `printers.toml`.
|
|
||||||
const PC_CONFIG_PATH: &str = "continuum.toml";
|
|
||||||
|
|
||||||
/// Where desktop environments and udev rules mount removable media. Only
|
|
||||||
/// consulted on a Proxy Box.
|
|
||||||
const DEFAULT_PENDRIVE_DIRS: [&str; 2] = ["/media", "/run/media"];
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum Mode {
|
|
||||||
/// A dedicated appliance. Settings change only by supplying a new
|
|
||||||
/// pendrive/upload.
|
|
||||||
ProxyBox,
|
|
||||||
/// A program on someone's PC or server. The config is a local file they
|
|
||||||
/// edit; removable media is never read.
|
|
||||||
Pc,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct BootstrapFile {
|
|
||||||
mode: Option<Mode>,
|
|
||||||
config_path: Option<PathBuf>,
|
|
||||||
pendrive_dirs: Option<Vec<PathBuf>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Bootstrap {
|
|
||||||
pub mode: Mode,
|
|
||||||
/// The config the proxy runs on - on a box, the installed copy that
|
|
||||||
/// imports replace.
|
|
||||||
pub config_path: PathBuf,
|
|
||||||
/// Whether `config_path` was named by someone, rather than defaulted. An
|
|
||||||
/// explicit path that does not exist is an error; a defaulted one just
|
|
||||||
/// means "no file", which keeps env-only deployments working.
|
|
||||||
pub config_path_is_explicit: bool,
|
|
||||||
/// Directories searched for a config bundle. Always empty in PC mode,
|
|
||||||
/// whatever the bootstrap said - see `from_parts`.
|
|
||||||
pub pendrive_dirs: Vec<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Bootstrap {
|
|
||||||
pub fn discover() -> anyhow::Result<Self> {
|
|
||||||
Self::discover_at(Path::new(BOOTSTRAP_PATH), &|key| std::env::var(key).ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `discover` with the fixed path and the environment injected, so tests
|
|
||||||
/// do not depend on the machine's `/etc` or mutate process-wide env.
|
|
||||||
pub fn discover_at(fixed: &Path, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
|
|
||||||
let file = match env("CONTINUUM_BOOTSTRAP") {
|
|
||||||
Some(explicit) => Self::read(Path::new(&explicit))?,
|
|
||||||
None => match std::fs::read_to_string(fixed) {
|
|
||||||
Ok(text) => Self::parse(&text, fixed)?,
|
|
||||||
// Absent is the normal case on a PC. Anything else (a file
|
|
||||||
// we cannot read) is not "absent" and must not quietly turn
|
|
||||||
// a box into a PC.
|
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => BootstrapFile::default(),
|
|
||||||
Err(err) => anyhow::bail!("failed to read bootstrap config {fixed:?}: {err}"),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Self::from_parts(file, env)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read(path: &Path) -> anyhow::Result<BootstrapFile> {
|
|
||||||
let text = std::fs::read_to_string(path)
|
|
||||||
.map_err(|err| anyhow::anyhow!("failed to read bootstrap config {path:?} (from CONTINUUM_BOOTSTRAP): {err}"))?;
|
|
||||||
Self::parse(&text, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse(text: &str, path: &Path) -> anyhow::Result<BootstrapFile> {
|
|
||||||
toml::from_str(text).map_err(|err| anyhow::anyhow!("bootstrap config {path:?} is invalid: {err}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_parts(file: BootstrapFile, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
|
|
||||||
let mode = match env("CONTINUUM_MODE") {
|
|
||||||
Some(value) => match value.as_str() {
|
|
||||||
"proxy_box" => Mode::ProxyBox,
|
|
||||||
"pc" => Mode::Pc,
|
|
||||||
other => anyhow::bail!("CONTINUUM_MODE must be \"proxy_box\" or \"pc\", not {other:?}"),
|
|
||||||
},
|
|
||||||
None => file.mode.unwrap_or(Mode::Pc),
|
|
||||||
};
|
|
||||||
|
|
||||||
let explicit_config = env("CONTINUUM_CONFIG_PATH").map(PathBuf::from).or(file.config_path);
|
|
||||||
let config_path_is_explicit = explicit_config.is_some();
|
|
||||||
let config_path = explicit_config.unwrap_or_else(|| match mode {
|
|
||||||
Mode::ProxyBox => PathBuf::from(BOX_CONFIG_PATH),
|
|
||||||
Mode::Pc => PathBuf::from(PC_CONFIG_PATH),
|
|
||||||
});
|
|
||||||
|
|
||||||
let requested_dirs = env("CONTINUUM_PENDRIVE_DIRS")
|
|
||||||
.map(|v| v.split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect())
|
|
||||||
.or(file.pendrive_dirs);
|
|
||||||
let pendrive_dirs = match mode {
|
|
||||||
Mode::ProxyBox => requested_dirs
|
|
||||||
.unwrap_or_else(|| DEFAULT_PENDRIVE_DIRS.iter().map(PathBuf::from).collect()),
|
|
||||||
// Dropped here, at the source, and not merely ignored later: the
|
|
||||||
// import code then has nothing to scan even if a future caller
|
|
||||||
// forgets to check the mode. In PC mode pendrive import is a way
|
|
||||||
// to change settings behind the user's back, so the safe state
|
|
||||||
// is the one where the list cannot be non-empty.
|
|
||||||
Mode::Pc => {
|
|
||||||
if requested_dirs.is_some_and(|dirs| !dirs.is_empty()) {
|
|
||||||
tracing::warn!("pendrive_dirs is set but this is PC mode; removable media will NOT be read");
|
|
||||||
}
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self { mode, config_path, config_path_is_explicit, pendrive_dirs })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads the real config. `None` means "no file, use env and defaults" -
|
|
||||||
/// only ever for a path nobody named, so env-only deployments keep
|
|
||||||
/// working. A named path that is missing, or any file that does not
|
|
||||||
/// parse, is an error: starting on defaults because the operator's file
|
|
||||||
/// had a typo would run a proxy that looks configured and is not.
|
|
||||||
pub fn load_config_file(&self) -> anyhow::Result<Option<ProxyFile>> {
|
|
||||||
match std::fs::read_to_string(&self.config_path) {
|
|
||||||
Ok(text) => ProxyFile::parse(&text)
|
|
||||||
.map(Some)
|
|
||||||
.map_err(|err| anyhow::anyhow!("config file {:?} is invalid: {err}", self.config_path)),
|
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound && !self.config_path_is_explicit => Ok(None),
|
|
||||||
Err(err) => Err(anyhow::anyhow!("failed to read config file {:?}: {err}", self.config_path)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn no_env(_: &str) -> Option<String> {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_text(text: &str, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Bootstrap> {
|
|
||||||
Bootstrap::from_parts(Bootstrap::parse(text, Path::new("test"))?, env)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn with_no_bootstrap_anywhere_the_proxy_is_a_pc_and_reads_no_pendrives() {
|
|
||||||
let missing = Path::new("/definitely/not/here/bootstrap.toml");
|
|
||||||
let b = Bootstrap::discover_at(missing, &no_env).unwrap();
|
|
||||||
assert_eq!(b.mode, Mode::Pc);
|
|
||||||
assert_eq!(b.config_path, PathBuf::from("continuum.toml"));
|
|
||||||
assert!(!b.config_path_is_explicit);
|
|
||||||
assert!(b.pendrive_dirs.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_proxy_box_defaults_to_the_var_lib_config_and_the_standard_mount_points() {
|
|
||||||
let b = from_text("mode = \"proxy_box\"", &no_env).unwrap();
|
|
||||||
assert_eq!(b.config_path, PathBuf::from(BOX_CONFIG_PATH));
|
|
||||||
assert_eq!(b.pendrive_dirs.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pc_mode_drops_pendrive_dirs_even_when_the_bootstrap_lists_some() {
|
|
||||||
let b = from_text("mode = \"pc\"\npendrive_dirs = [\"/media\"]", &no_env).unwrap();
|
|
||||||
assert!(b.pendrive_dirs.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pc_mode_drops_pendrive_dirs_supplied_through_the_environment_too() {
|
|
||||||
let env = |k: &str| (k == "CONTINUUM_PENDRIVE_DIRS").then(|| "/media:/mnt".to_string());
|
|
||||||
let b = from_text("mode = \"pc\"", &env).unwrap();
|
|
||||||
assert!(b.pendrive_dirs.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_environment_overrides_the_bootstrap_file() {
|
|
||||||
let env = |k: &str| match k {
|
|
||||||
"CONTINUUM_MODE" => Some("pc".to_string()),
|
|
||||||
"CONTINUUM_CONFIG_PATH" => Some("/tmp/mine.toml".to_string()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
let b = from_text("mode = \"proxy_box\"", &env).unwrap();
|
|
||||||
assert_eq!(b.mode, Mode::Pc);
|
|
||||||
assert_eq!(b.config_path, PathBuf::from("/tmp/mine.toml"));
|
|
||||||
assert!(b.config_path_is_explicit);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_unknown_mode_in_the_environment_is_an_error_not_a_guess() {
|
|
||||||
let env = |k: &str| (k == "CONTINUUM_MODE").then(|| "appliance".to_string());
|
|
||||||
assert!(from_text("", &env).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_misspelled_bootstrap_key_is_refused() {
|
|
||||||
assert!(from_text("mod = \"proxy_box\"", &no_env).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_explicit_bootstrap_path_that_does_not_exist_is_an_error() {
|
|
||||||
let env = |k: &str| (k == "CONTINUUM_BOOTSTRAP").then(|| "/definitely/not/here.toml".to_string());
|
|
||||||
assert!(Bootstrap::discover_at(Path::new("/also/missing"), &env).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_defaulted_config_path_that_does_not_exist_means_no_file_not_an_error() {
|
|
||||||
let mut b = from_text("", &no_env).unwrap();
|
|
||||||
b.config_path = PathBuf::from("/definitely/not/here/continuum.toml");
|
|
||||||
assert!(b.load_config_file().unwrap().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_named_config_path_that_does_not_exist_is_an_error() {
|
|
||||||
let mut b = from_text("", &no_env).unwrap();
|
|
||||||
b.config_path = PathBuf::from("/definitely/not/here/continuum.toml");
|
|
||||||
b.config_path_is_explicit = true;
|
|
||||||
assert!(b.load_config_file().is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-595
@@ -1,595 +0,0 @@
|
|||||||
//! 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. That is
|
|
||||||
//! not restreamable here (go2rtc cannot make WebRTC of MJPEG without ffmpeg),
|
|
||||||
//! so those printers get no go2rtc stream; they are served single frames by
|
|
||||||
//! `printer::bambu_camera` instead and advertise `camera_snapshot`.
|
|
||||||
//!
|
|
||||||
//! That is not just an inference from the missing field - it is confirmed at
|
|
||||||
//! the network layer (2026-10-15). The H2C accepts TCP on 322; the P1P
|
|
||||||
//! *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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this printer's camera is the P1/A1 kind: it reports a camera
|
|
||||||
/// block but no RTSP URL, so the only way to see it is single JPEG frames
|
|
||||||
/// from port 6000 (`printer::bambu_camera`). The one rule for "snapshot
|
|
||||||
/// camera", used by telemetry, so what is advertised is what is served.
|
|
||||||
pub fn wants_snapshot(printer: &PrinterHandle, has_ipcam: bool, report_url: Option<&str>) -> bool {
|
|
||||||
matches!(printer, PrinterHandle::Bambu(_)) && has_ipcam && report_url.is_none_or(str::is_empty)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renders a go2rtc configuration.
|
|
||||||
///
|
|
||||||
/// Hand-written YAML rather than a serializer, for the same reason the
|
|
||||||
/// `.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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
//! 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.status().await.and_then(|s| s.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+12
-624
@@ -1,51 +1,6 @@
|
|||||||
//! The proxy's own settings: what it is called, where the cloud is, where
|
|
||||||
//! go2rtc lives, and (on a Proxy Box) how it is networked.
|
|
||||||
//!
|
|
||||||
//! # Where settings come from, in one place
|
|
||||||
//!
|
|
||||||
//! **A config file is the base; `CONTINUUM_*` environment variables override
|
|
||||||
//! it; built-in defaults fill what neither says.** That is the whole
|
|
||||||
//! precedence rule and nothing else in the crate re-states it. Env wins
|
|
||||||
//! because that is what every existing deployment, test and `.env` file
|
|
||||||
//! already relies on - `Config::from_env` predates the file, and a file that
|
|
||||||
//! silently beat the env would change the behaviour of setups that never
|
|
||||||
//! asked for a file.
|
|
||||||
//!
|
|
||||||
//! *Which* file is not decided here: that is the bootstrap's job (see
|
|
||||||
//! `bootstrap.rs`), because the proxy has to know whether it is a Proxy Box
|
|
||||||
//! or a program on someone's PC before it knows where to look. This module
|
|
||||||
//! only parses a file it is handed and layers it under the environment.
|
|
||||||
//!
|
|
||||||
//! # Not the fleet file
|
|
||||||
//!
|
|
||||||
//! `printers.toml` (see `fleet.rs`) lists the printers and holds their access
|
|
||||||
//! codes. This file configures the *proxy itself*. They are separate on
|
|
||||||
//! purpose: the proxy config is meant to be shipped to a box on a pendrive by
|
|
||||||
//! whoever provisions it, and the fleet file is full of per-site secrets that
|
|
||||||
//! should never ride along on a stick that gets passed around. The two meet
|
|
||||||
//! at exactly one field, `printers_path`, which says where the fleet file is.
|
|
||||||
//! On a Proxy Box the fleet file is therefore NOT delivered by this
|
|
||||||
//! mechanism - how it gets there is a separate, unsolved question.
|
|
||||||
//!
|
|
||||||
//! # Network settings are parsed, not applied
|
|
||||||
//!
|
|
||||||
//! `[network]` is read, validated and exposed (`ProxyFile::network`), and
|
|
||||||
//! that is where it stops. Nothing in this crate touches `/etc/network`,
|
|
||||||
//! NetworkManager or any other OS state. Applying them means choosing a
|
|
||||||
//! network stack for an image that does not exist yet, running as root, and a
|
|
||||||
//! wrong write can take the box off the network with nobody able to log in
|
|
||||||
//! to fix it - so it belongs in the appliance's provisioning layer, designed
|
|
||||||
//! together with a rollback. The boundary is `NetworkSettings` itself:
|
|
||||||
//! whatever applies it consumes that type and the proxy never will.
|
|
||||||
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fmt;
|
|
||||||
use std::net::IpAddr;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
#[derive(Debug, Clone)]
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub gateway_id: String,
|
pub gateway_id: String,
|
||||||
pub farm_id: String,
|
pub farm_id: String,
|
||||||
@@ -53,22 +8,6 @@ pub struct Config {
|
|||||||
pub uplink_url: String,
|
pub uplink_url: String,
|
||||||
pub go2rtc_bin: String,
|
pub go2rtc_bin: String,
|
||||||
pub go2rtc_config: 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
|
/// Path to the fleet config file - see `fleet.rs` and
|
||||||
/// `printers.example.toml` for the shape. Bambu certificate trust
|
/// `printers.example.toml` for the shape. Bambu certificate trust
|
||||||
/// (per-printer `ca_cert_path`/`require_valid_cert`, and automatic
|
/// (per-printer `ca_cert_path`/`require_valid_cert`, and automatic
|
||||||
@@ -76,577 +15,26 @@ pub struct Config {
|
|||||||
/// `BambuPrinter::ensure_trusted`) lives entirely in that file now,
|
/// `BambuPrinter::ensure_trusted`) lives entirely in that file now,
|
||||||
/// not as a separate fleet-wide env-configured policy here.
|
/// not as a separate fleet-wide env-configured policy here.
|
||||||
pub printers_path: String,
|
pub printers_path: String,
|
||||||
/// The gateway's own database (src/store.rs): printers added from the
|
|
||||||
/// console, credentials included, so the file is created owner-only.
|
|
||||||
pub store_path: String,
|
|
||||||
/// How, and whether, to look for printers nobody has configured.
|
|
||||||
///
|
|
||||||
/// Off unless switched on - see [`DiscoverySettings`] for why that is
|
|
||||||
/// not merely a cautious default.
|
|
||||||
pub discovery: DiscoverySettings,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The file form of the proxy's settings. Every key is optional here: a key
|
|
||||||
/// the file omits falls through to the environment and then the default, so
|
|
||||||
/// a PC user can keep secrets in the environment and a file for the rest.
|
|
||||||
///
|
|
||||||
/// `deny_unknown_fields` is deliberate and the opposite of lenient. On a
|
|
||||||
/// Proxy Box nobody can edit the file afterwards, so a typo'd key
|
|
||||||
/// (`uplink_ulr`) that was silently ignored would ship a box running on the
|
|
||||||
/// default cloud URL with nothing saying so. Refusing at import time turns
|
|
||||||
/// that into a rejected pendrive the operator sees immediately.
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct ProxyFile {
|
|
||||||
pub gateway_id: Option<String>,
|
|
||||||
pub farm_id: Option<String>,
|
|
||||||
pub gateway_token: Option<String>,
|
|
||||||
pub uplink_url: Option<String>,
|
|
||||||
pub go2rtc_bin: Option<String>,
|
|
||||||
pub go2rtc_config: Option<String>,
|
|
||||||
pub go2rtc_api_listen: Option<String>,
|
|
||||||
pub stream_base_url: Option<String>,
|
|
||||||
pub printers_path: Option<String>,
|
|
||||||
pub store_path: Option<String>,
|
|
||||||
/// Only meaningful on a Proxy Box; see the module docs for why it is
|
|
||||||
/// exposed and not applied.
|
|
||||||
pub network: Option<NetworkSettings>,
|
|
||||||
/// Absent means off, which is the same as `enabled = false`. A file that
|
|
||||||
/// says nothing about discovery gets none.
|
|
||||||
pub discovery: Option<DiscoveryFile>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProxyFile {
|
|
||||||
pub fn parse(text: &str) -> anyhow::Result<Self> {
|
|
||||||
let file: Self = toml::from_str(text)?;
|
|
||||||
if let Some(network) = &file.network {
|
|
||||||
network.validate()?;
|
|
||||||
}
|
|
||||||
if let Some(discovery) = &file.discovery {
|
|
||||||
discovery.validate()?;
|
|
||||||
}
|
|
||||||
Ok(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether and how the gateway looks for printers nobody configured, and the
|
|
||||||
/// one thing it must never do while looking.
|
|
||||||
///
|
|
||||||
/// # Off by default, and not just out of caution
|
|
||||||
///
|
|
||||||
/// Discovery sends unsolicited traffic to addresses on someone else's
|
|
||||||
/// network. A `/24` sweep is 254 TLS handshakes and up to three HTTP requests
|
|
||||||
/// per host. Anything watching cannot tell that from a port scan, and on a
|
|
||||||
/// corporate network being indistinguishable from a port scan is itself the
|
|
||||||
/// problem. A gateway that did this by default would scan networks
|
|
||||||
/// its operator never meant to expose it to, and the first they would hear of
|
|
||||||
/// it is from their own security team.
|
|
||||||
///
|
|
||||||
/// So `enabled` is false until someone says otherwise, per gateway, in that
|
|
||||||
/// gateway's own config. The cloud cannot switch it on: a setting whose whole
|
|
||||||
/// purpose is to bound what the gateway does to the local network has to be
|
|
||||||
/// decided where that network is understood, which is not in a data centre.
|
|
||||||
///
|
|
||||||
/// # Serial discovery never opens a port
|
|
||||||
///
|
|
||||||
/// `serial` enumerates USB devices from sysfs and stops there. It will not
|
|
||||||
/// send `M115`, ever, and that is a correctness requirement rather than a
|
|
||||||
/// preference: opening a serial port toggles DTR, which reboots an
|
|
||||||
/// Arduino-based board. On a printer that is mid-job that ruins the print.
|
|
||||||
/// `examples/discover_printers.rs` can do the handshake because a human is
|
|
||||||
/// standing there and has typed `--yes-reset-the-printer`; a background task
|
|
||||||
/// on a schedule has nobody to ask, so it does not get the option.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct DiscoverySettings {
|
|
||||||
pub enabled: bool,
|
|
||||||
/// Subnets to sweep, as CIDR, e.g. `192.168.0.0/24`. Empty means no
|
|
||||||
/// sweep - the gateway then only listens, if `ssdp` is on.
|
|
||||||
///
|
|
||||||
/// Explicit rather than derived from the gateway's own interfaces. A box
|
|
||||||
/// with a second NIC, a VPN or a container bridge would otherwise sweep
|
|
||||||
/// a network nobody meant to point it at, and the operator naming the
|
|
||||||
/// subnet is also the operator taking responsibility for it.
|
|
||||||
pub subnets: Vec<String>,
|
|
||||||
/// Passive SSDP listening: no traffic sent, just whatever Bambu printers
|
|
||||||
/// announce themselves. Cheap and unobjectionable, but useless across a
|
|
||||||
/// router - multicast does not cross one - so a sweep is what works on a
|
|
||||||
/// real farm network.
|
|
||||||
pub ssdp: bool,
|
|
||||||
/// Enumerate USB serial devices. Reads sysfs; opens nothing. See above.
|
|
||||||
pub serial: bool,
|
|
||||||
pub interval: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DiscoverySettings {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
enabled: false,
|
|
||||||
subnets: Vec::new(),
|
|
||||||
ssdp: false,
|
|
||||||
serial: false,
|
|
||||||
// Only ever reached when `enabled` is true, so this is "how
|
|
||||||
// often a farm's hardware changes", not "how often to scan a
|
|
||||||
// network that did not ask for it". Half an hour is far more
|
|
||||||
// often than printers get bought and still negligible traffic.
|
|
||||||
interval: Duration::from_secs(30 * 60),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The file form. Every key optional, same as the rest of [`ProxyFile`].
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct DiscoveryFile {
|
|
||||||
pub enabled: Option<bool>,
|
|
||||||
pub subnets: Option<Vec<String>>,
|
|
||||||
pub ssdp: Option<bool>,
|
|
||||||
pub serial: Option<bool>,
|
|
||||||
pub interval_minutes: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DiscoveryFile {
|
|
||||||
/// Rejects at import time what would otherwise be found at scan time -
|
|
||||||
/// which on a Proxy Box means a stick the operator has already walked
|
|
||||||
/// away from.
|
|
||||||
pub fn validate(&self) -> anyhow::Result<()> {
|
|
||||||
if let Some(minutes) = self.interval_minutes {
|
|
||||||
// A floor, because the cost of a sweep falls on the printers and
|
|
||||||
// on whatever else answers on that network, not on us. One
|
|
||||||
// minute is already more often than anything here changes.
|
|
||||||
anyhow::ensure!(minutes >= 1, "discovery.interval_minutes must be at least 1");
|
|
||||||
}
|
|
||||||
for subnet in self.subnets.iter().flatten() {
|
|
||||||
// The same parser the sweep will use, so a `/16` typo is refused
|
|
||||||
// here rather than at 03:00 when the timer first fires. It
|
|
||||||
// refuses anything wider than a /24 - see `hosts_in_subnet`.
|
|
||||||
crate::discovery::net::hosts_in_subnet(subnet)
|
|
||||||
.map_err(|err| anyhow::anyhow!("discovery.subnets: {err}"))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum NetworkMode {
|
|
||||||
Dhcp,
|
|
||||||
Static,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a Proxy Box should be configured to do on the network. Parsed and
|
|
||||||
/// validated only - nothing applies it (module docs).
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct NetworkSettings {
|
|
||||||
pub mode: NetworkMode,
|
|
||||||
pub ip: Option<IpAddr>,
|
|
||||||
pub prefix_len: Option<u8>,
|
|
||||||
pub gateway: Option<IpAddr>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub dns: Vec<IpAddr>,
|
|
||||||
pub wifi: Option<WifiSettings>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NetworkSettings {
|
|
||||||
/// Rejects a static config that could not possibly work. This is the one
|
|
||||||
/// place a bad network block is caught before it is installed: a box
|
|
||||||
/// whose stored config says "static" with no address would, once
|
|
||||||
/// something does apply it, come up unreachable - the failure we least
|
|
||||||
/// want to learn about from a customer.
|
|
||||||
pub fn validate(&self) -> anyhow::Result<()> {
|
|
||||||
if self.mode == NetworkMode::Static {
|
|
||||||
let ip = self.ip.ok_or_else(|| anyhow::anyhow!("network.mode is \"static\" but network.ip is not set"))?;
|
|
||||||
let prefix = self
|
|
||||||
.prefix_len
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("network.mode is \"static\" but network.prefix_len is not set"))?;
|
|
||||||
let max = if ip.is_ipv4() { 32 } else { 128 };
|
|
||||||
anyhow::ensure!(prefix <= max, "network.prefix_len {prefix} is out of range for {ip} (max {max})");
|
|
||||||
}
|
|
||||||
if let Some(wifi) = &self.wifi {
|
|
||||||
anyhow::ensure!(!wifi.ssid.is_empty(), "network.wifi.ssid is empty");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `psk` is a secret, so `Debug` is written by hand: the derived one would
|
|
||||||
/// put the wifi password in any log line that prints the parsed config.
|
|
||||||
#[derive(Clone, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WifiSettings {
|
|
||||||
pub ssid: String,
|
|
||||||
pub psk: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for WifiSettings {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("WifiSettings")
|
|
||||||
.field("ssid", &self.ssid)
|
|
||||||
.field("psk", &self.psk.as_ref().map(|_| "<redacted>"))
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// By hand: the derived `Debug` would print `gateway_token`, the credential
|
|
||||||
/// that opens this farm's gateway socket, into any log line that formats the
|
|
||||||
/// config.
|
|
||||||
impl fmt::Debug for Config {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("Config")
|
|
||||||
.field("gateway_id", &self.gateway_id)
|
|
||||||
.field("farm_id", &self.farm_id)
|
|
||||||
.field("gateway_token", &"<redacted>")
|
|
||||||
.field("uplink_url", &self.uplink_url)
|
|
||||||
.field("printers_path", &self.printers_path)
|
|
||||||
.field("store_path", &self.store_path)
|
|
||||||
.finish_non_exhaustive()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
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> {
|
pub fn from_env() -> anyhow::Result<Self> {
|
||||||
Self::layered(None, &|key| env::var(key).ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// File as the base, environment on top (module docs).
|
|
||||||
pub fn from_file_and_env(file: Option<&ProxyFile>) -> anyhow::Result<Self> {
|
|
||||||
Self::layered(file, &|key| env::var(key).ok())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The layering itself, with the environment passed in as a lookup so
|
|
||||||
/// tests can exercise precedence without mutating the process-wide env,
|
|
||||||
/// which the parallel test runner would turn into a race.
|
|
||||||
pub fn layered(file: Option<&ProxyFile>, env: &dyn Fn(&str) -> Option<String>) -> anyhow::Result<Self> {
|
|
||||||
let empty = ProxyFile::default();
|
|
||||||
let file = file.unwrap_or(&empty);
|
|
||||||
|
|
||||||
let require = |key: &str, field: &Option<String>, name: &str| {
|
|
||||||
env(key).or_else(|| field.clone()).ok_or_else(|| {
|
|
||||||
anyhow::anyhow!("missing required env var {key} (or `{name}` in the proxy config file)")
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let or_default = |key: &str, field: &Option<String>, default: &str| {
|
|
||||||
env(key).or_else(|| field.clone()).unwrap_or_else(|| default.to_string())
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
gateway_id: require("CONTINUUM_GATEWAY_ID", &file.gateway_id, "gateway_id")?,
|
gateway_id: require("CONTINUUM_GATEWAY_ID")?,
|
||||||
farm_id: require("CONTINUUM_FARM_ID", &file.farm_id, "farm_id")?,
|
farm_id: require("CONTINUUM_FARM_ID")?,
|
||||||
gateway_token: require("CONTINUUM_GATEWAY_TOKEN", &file.gateway_token, "gateway_token")?,
|
gateway_token: require("CONTINUUM_GATEWAY_TOKEN")?,
|
||||||
uplink_url: or_default("CONTINUUM_UPLINK_URL", &file.uplink_url, "wss://api.continuum.local/ws/edge/v1"),
|
uplink_url: env_or("CONTINUUM_UPLINK_URL", "wss://api.continuum.local/ws/edge/v1"),
|
||||||
go2rtc_bin: or_default("CONTINUUM_GO2RTC_BIN", &file.go2rtc_bin, "go2rtc"),
|
go2rtc_bin: env_or("CONTINUUM_GO2RTC_BIN", "go2rtc"),
|
||||||
go2rtc_config: or_default("CONTINUUM_GO2RTC_CONFIG", &file.go2rtc_config, "./go2rtc.yaml"),
|
go2rtc_config: env_or("CONTINUUM_GO2RTC_CONFIG", "./go2rtc.yaml"),
|
||||||
go2rtc_api_listen: or_default("CONTINUUM_GO2RTC_API_LISTEN", &file.go2rtc_api_listen, ":1984"),
|
printers_path: env_or("CONTINUUM_PRINTERS_PATH", "printers.toml"),
|
||||||
// An empty value means "unset" at every layer, so an env var
|
|
||||||
// exported as empty can switch the advertisement off even when
|
|
||||||
// the file sets one.
|
|
||||||
stream_base_url: env("CONTINUUM_STREAM_BASE_URL")
|
|
||||||
.or_else(|| file.stream_base_url.clone())
|
|
||||||
.filter(|v| !v.is_empty()),
|
|
||||||
printers_path: or_default("CONTINUUM_PRINTERS_PATH", &file.printers_path, "printers.toml"),
|
|
||||||
store_path: or_default("CONTINUUM_STORE_PATH", &file.store_path, "continuum.db"),
|
|
||||||
discovery: discovery_settings(file.discovery.as_ref(), env)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Layered like everything else, except that the default is "do nothing" and
|
fn require(key: &str) -> anyhow::Result<String> {
|
||||||
/// every layer has to say so explicitly to change that.
|
env::var(key).map_err(|_| anyhow::anyhow!("missing required env var {key}"))
|
||||||
///
|
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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
@@ -1,878 +0,0 @@
|
|||||||
//! 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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,581 +0,0 @@
|
|||||||
//! 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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
//! The task that tells the cloud what this gateway can see.
|
|
||||||
//!
|
|
||||||
//! Its reason for existing is small and specific: adding a printer in the web
|
|
||||||
//! console should not begin with someone walking to a machine to read its IP
|
|
||||||
//! address off the screen. It cannot finish the job - the credential is on
|
|
||||||
//! that screen and no probe can read it - but it removes the address, the
|
|
||||||
//! serial number and the guesswork about which vendor the thing is.
|
|
||||||
//!
|
|
||||||
//! # It does nothing unless asked
|
|
||||||
//!
|
|
||||||
//! `discovery.enabled` is false by default, and this task then parks forever
|
|
||||||
//! rather than looping over a disabled check. The reason is in
|
|
||||||
//! `DiscoverySettings`: a sweep is unsolicited traffic on someone else's
|
|
||||||
//! network, and a gateway that did it by default would be scanning networks
|
|
||||||
//! its operator never meant to point it at.
|
|
||||||
//!
|
|
||||||
//! Note which way the switch faces. It is read from the gateway's own config
|
|
||||||
//! and the cloud cannot set it, because a setting whose entire purpose is to
|
|
||||||
//! bound what this process does to the local network has to be decided where
|
|
||||||
//! that network is understood.
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use tokio::sync::mpsc::Sender;
|
|
||||||
|
|
||||||
use crate::config::DiscoverySettings;
|
|
||||||
use crate::uplink::ClientMessage;
|
|
||||||
|
|
||||||
/// Scans on a timer and reports each pass, forever.
|
|
||||||
///
|
|
||||||
/// Never returns, so it sits in `main`'s `select!` alongside the other
|
|
||||||
/// long-running tasks. With discovery off it parks on a future that never
|
|
||||||
/// completes, which costs nothing and keeps the "every arm runs forever"
|
|
||||||
/// property of that `select!` true.
|
|
||||||
pub async fn run(
|
|
||||||
settings: DiscoverySettings,
|
|
||||||
printers_path: String,
|
|
||||||
store: Option<std::sync::Arc<crate::store::Store>>,
|
|
||||||
outbound: Sender<ClientMessage>,
|
|
||||||
) {
|
|
||||||
if !settings.enabled {
|
|
||||||
tracing::info!("printer discovery is off (discovery.enabled); not scanning this network");
|
|
||||||
std::future::pending::<()>().await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
subnets = ?settings.subnets,
|
|
||||||
ssdp = settings.ssdp,
|
|
||||||
serial = settings.serial,
|
|
||||||
every = ?settings.interval,
|
|
||||||
"printer discovery is ON; this gateway will probe the network above"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut ticker = tokio::time::interval(settings.interval);
|
|
||||||
// A pass takes tens of seconds - an SSDP window alone is 32 - so a tick
|
|
||||||
// can come due while one is still running. `Delay` drops the ones that
|
|
||||||
// came due rather than firing them back to back afterwards, which is what
|
|
||||||
// `Burst` (the default) would do and would turn a slow scan into a
|
|
||||||
// continuous one.
|
|
||||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// Fires immediately the first time, so a gateway reports what it can
|
|
||||||
// see shortly after boot rather than one interval later.
|
|
||||||
ticker.tick().await;
|
|
||||||
|
|
||||||
// Re-read every pass, not once at startup. Nothing re-reads the fleet
|
|
||||||
// file on a running daemon yet, so adding a printer still means a
|
|
||||||
// restart - but someone who has *edited* the file and not restarted
|
|
||||||
// should not keep being offered the printer they just configured.
|
|
||||||
let configured = match crate::fleet::configured(std::path::Path::new(&printers_path)) {
|
|
||||||
Ok(mut configured) => {
|
|
||||||
// Printers added from the console live in the store, not the
|
|
||||||
// file, and are just as "already configured".
|
|
||||||
if let Some(store) = &store {
|
|
||||||
match store.printers() {
|
|
||||||
Ok(stored) => configured.add_entries(stored),
|
|
||||||
Err(err) => {
|
|
||||||
tracing::warn!(%err, "cannot read the local store; skipping this discovery pass");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
configured
|
|
||||||
}
|
|
||||||
// Fail the pass rather than report against an empty set. The
|
|
||||||
// alternative offers every printer the farm already runs as a
|
|
||||||
// fresh find, which is worse than no report: it is a list of
|
|
||||||
// things to add that are already added.
|
|
||||||
Err(err) => {
|
|
||||||
tracing::warn!(%err, "cannot tell which printers are already configured; skipping this discovery pass");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let scan = super::scan::scan(&settings, &configured).await;
|
|
||||||
if !scan.looked() {
|
|
||||||
// Every enabled source failed. An empty report would assert that
|
|
||||||
// this network has no unconfigured printers, which is a claim
|
|
||||||
// about the farm we have no evidence for - see `Scan::looked`.
|
|
||||||
tracing::warn!(
|
|
||||||
attempted = scan.attempted,
|
|
||||||
"every discovery source failed; not reporting, because an empty report would mean 'nothing is there'"
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let printers: Vec<_> = scan.candidates.iter().map(super::Candidate::to_wire).collect();
|
|
||||||
tracing::info!(count = printers.len(), "reporting discovered printers");
|
|
||||||
let message = ClientMessage::DiscoveredPrinters {
|
|
||||||
printers,
|
|
||||||
scanned_at: now_rfc3339(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// `send`, not `try_send`: the queue outlives any one connection, so
|
|
||||||
// waiting here is waiting for the cloud to come back, which is
|
|
||||||
// exactly the right thing to do with a scan result. If the channel is
|
|
||||||
// ever closed the uplink is gone and so is the process.
|
|
||||||
if outbound.send(message).await.is_err() {
|
|
||||||
tracing::warn!("uplink queue closed; stopping discovery reports");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// RFC 3339 in UTC, which is what the schema's `date-time` means.
|
|
||||||
///
|
|
||||||
/// Public so `examples/discover_printers.rs` can print a frame with a real
|
|
||||||
/// timestamp in it - a capture is meant to be feedable straight to the
|
|
||||||
/// backend's validator, and a placeholder there would not be.
|
|
||||||
pub fn now_rfc3339() -> String {
|
|
||||||
let secs = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or(Duration::ZERO)
|
|
||||||
.as_secs();
|
|
||||||
format_rfc3339(secs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Formats a Unix second count, with the clock passed in so it is testable.
|
|
||||||
///
|
|
||||||
/// Hand-rolled because this crate has no date library and one field does not
|
|
||||||
/// justify adding one - `scanned_at` is read by a human looking at a "found
|
|
||||||
/// 20 minutes ago" label, not by arithmetic.
|
|
||||||
fn format_rfc3339(secs: u64) -> String {
|
|
||||||
let (days, time) = (secs / 86_400, secs % 86_400);
|
|
||||||
let (hour, minute, second) = (time / 3600, (time % 3600) / 60, time % 60);
|
|
||||||
|
|
||||||
// Civil date from a day count, by the algorithm in Howard Hinnant's
|
|
||||||
// `civil_from_days`: shift the epoch to 0000-03-01 so a leap day lands at
|
|
||||||
// the end of the cycle and the month-length pattern repeats cleanly.
|
|
||||||
let z = days as i64 + 719_468;
|
|
||||||
let era = z.div_euclid(146_097);
|
|
||||||
let doe = z.rem_euclid(146_097);
|
|
||||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
||||||
let mp = (5 * doy + 2) / 153;
|
|
||||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
|
||||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
||||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
|
||||||
|
|
||||||
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn timestamps_match_what_date_u_prints() {
|
|
||||||
// Pinned against `date -u -d @N`: an off-by-one in the civil date
|
|
||||||
// arithmetic is invisible until it is a day or a year out.
|
|
||||||
assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z");
|
|
||||||
// A leap day, which is the whole reason this arithmetic needs care.
|
|
||||||
assert_eq!(format_rfc3339(1_709_164_800), "2024-02-29T00:00:00Z");
|
|
||||||
assert_eq!(format_rfc3339(1_792_413_296), "2026-10-19T12:34:56Z");
|
|
||||||
// Past 2100, where the "divisible by 100 is not a leap year" rule
|
|
||||||
// bites and a naive every-four-years version drifts.
|
|
||||||
assert_eq!(format_rfc3339(4_102_444_800), "2100-01-01T00:00:00Z");
|
|
||||||
// The last second of a year, which catches a December/January slip.
|
|
||||||
assert_eq!(format_rfc3339(1_767_225_599), "2025-12-31T23:59:59Z");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
//! One pass of discovery, as a background task runs it - which is a narrower
|
|
||||||
//! thing than what `examples/discover_printers.rs` can do, and deliberately.
|
|
||||||
//!
|
|
||||||
//! The example has a human standing in front of the printers who can be asked
|
|
||||||
//! for consent and can read a warning. This has a timer. So the two differ on
|
|
||||||
//! exactly the operations where that distinction matters:
|
|
||||||
//!
|
|
||||||
//! * No `M115`. Opening a serial port toggles DTR and reboots an
|
|
||||||
//! Arduino-based board, which on a machine mid-job ruins the print. The
|
|
||||||
//! example will do it behind `--yes-reset-the-printer`; nothing on a
|
|
||||||
//! schedule gets that option, because there is nobody to type it.
|
|
||||||
//! * No subnet the operator did not name. `DiscoverySettings::subnets` is
|
|
||||||
//! explicit rather than derived from the gateway's own interfaces, so a
|
|
||||||
//! second NIC or a VPN cannot quietly widen what gets swept.
|
|
||||||
//!
|
|
||||||
//! What it produces is candidates with no credential in them, which is not a
|
|
||||||
//! simplification - see `uplink::DiscoveredPrinter` for why no probe can
|
|
||||||
//! learn one.
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use super::{Candidate, net, serial};
|
|
||||||
use crate::config::DiscoverySettings;
|
|
||||||
use crate::fleet::Configured;
|
|
||||||
|
|
||||||
/// How long to sit on the SSDP group.
|
|
||||||
///
|
|
||||||
/// Has to exceed a Bambu printer's announce interval, which is around 30
|
|
||||||
/// seconds, or a scan can open its window just after a printer announced and
|
|
||||||
/// conclude the printer is not there. Nothing here is urgent enough for that
|
|
||||||
/// to be worth shaving.
|
|
||||||
const SSDP_WINDOW: Duration = Duration::from_secs(32);
|
|
||||||
|
|
||||||
/// Per-host budget for the TLS and HTTP probes. Generous, because it is
|
|
||||||
/// spread across a batch that runs concurrently - see `net::sweep`.
|
|
||||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
|
||||||
|
|
||||||
/// The schema's `maxItems`. A gateway that somehow found more than this is
|
|
||||||
/// pointed at something that is not a printer farm, and a truncated list
|
|
||||||
/// beats a message the backend rejects whole.
|
|
||||||
const MAX_REPORTED: usize = 256;
|
|
||||||
|
|
||||||
/// The result of one pass, which is not just the candidates.
|
|
||||||
///
|
|
||||||
/// "Found nothing" and "could not look" are different answers, and an empty
|
|
||||||
/// candidate list alone cannot tell them apart. The distinction has to
|
|
||||||
/// survive as far as the cloud, because an empty report means *this gateway
|
|
||||||
/// looked and its network has no unconfigured printers* - so publishing one
|
|
||||||
/// after every probe failed would state something untrue about the farm.
|
|
||||||
/// Hence the counts: [`Scan::looked`] is what the reporter checks before it
|
|
||||||
/// sends anything.
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct Scan {
|
|
||||||
pub candidates: Vec<Candidate>,
|
|
||||||
/// How many sources were enabled - subnets, the SSDP ports, serial.
|
|
||||||
pub attempted: usize,
|
|
||||||
/// How many of them failed outright.
|
|
||||||
pub failed: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Scan {
|
|
||||||
/// Whether this pass is evidence about the network at all.
|
|
||||||
///
|
|
||||||
/// False when every enabled source failed, which is the case an empty
|
|
||||||
/// report must not be sent for. A partial failure is still evidence: a
|
|
||||||
/// gateway that swept one of two subnets found what it found, and
|
|
||||||
/// withholding that until every source works would mean a container that
|
|
||||||
/// can never bind SSDP suppresses a perfectly good sweep forever.
|
|
||||||
pub fn looked(&self) -> bool {
|
|
||||||
self.attempted > 0 && self.failed < self.attempted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything enabled, merged, with the already-configured removed.
|
|
||||||
///
|
|
||||||
/// One failing source does not fail the pass: an unreachable subnet, or an
|
|
||||||
/// SSDP socket a container will not let us bind, should still let the rest
|
|
||||||
/// report what it found. Every failure is logged, and counted in the result
|
|
||||||
/// so the caller can tell a quiet network from a blind gateway.
|
|
||||||
pub async fn scan(settings: &DiscoverySettings, configured: &Configured) -> Scan {
|
|
||||||
let mut out = Scan::default();
|
|
||||||
if !settings.enabled {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
let mut found = Vec::new();
|
|
||||||
|
|
||||||
if settings.ssdp {
|
|
||||||
// Both ports Bambu has shipped announcements on, concurrently: a
|
|
||||||
// printer uses one or the other depending on firmware, and listening
|
|
||||||
// on them in series would double the window for no reason.
|
|
||||||
let (first, second) = tokio::join!(
|
|
||||||
super::listen_ssdp(super::SSDP_PORTS[0], SSDP_WINDOW, false),
|
|
||||||
super::listen_ssdp(super::SSDP_PORTS[1], SSDP_WINDOW, false),
|
|
||||||
);
|
|
||||||
for result in [first, second] {
|
|
||||||
out.attempted += 1;
|
|
||||||
match result {
|
|
||||||
Ok(candidates) => found.extend(candidates),
|
|
||||||
// Expected on a host that cannot join a multicast group, and
|
|
||||||
// in a container whose network is a veth pair to a router -
|
|
||||||
// which is also why a sweep, not SSDP, is what works on a
|
|
||||||
// real farm network.
|
|
||||||
Err(err) => {
|
|
||||||
out.failed += 1;
|
|
||||||
tracing::warn!(%err, "SSDP listen failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for subnet in &settings.subnets {
|
|
||||||
out.attempted += 1;
|
|
||||||
match net::sweep(subnet, PROBE_TIMEOUT).await {
|
|
||||||
Ok(candidates) => found.extend(candidates),
|
|
||||||
Err(err) => {
|
|
||||||
out.failed += 1;
|
|
||||||
tracing::warn!(%err, subnet, "subnet sweep failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.serial {
|
|
||||||
out.attempted += 1;
|
|
||||||
match serial::list_ports() {
|
|
||||||
// Every USB-attached port, not only the ones whose vendor id says
|
|
||||||
// "Prusa". The alternative misses most real Marlin printers,
|
|
||||||
// because the common boards sit behind a generic CH340 or FTDI
|
|
||||||
// bridge that is indistinguishable from any other USB serial
|
|
||||||
// device without the `M115` this task will not send. So the
|
|
||||||
// unavoidable error is made the harmless one: a candidate's
|
|
||||||
// `detail` says outright that it could be a printer or anything
|
|
||||||
// else, and an extra row in a list someone is choosing from costs
|
|
||||||
// far less than a printer that never appears.
|
|
||||||
Ok(ports) => found.extend(ports.iter().map(serial::SerialDevice::to_candidate)),
|
|
||||||
Err(err) => {
|
|
||||||
out.failed += 1;
|
|
||||||
tracing::warn!(%err, "serial port enumeration failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out.candidates = super::merge(found);
|
|
||||||
let candidates = &mut out.candidates;
|
|
||||||
// Last, not first: merging can fill in a serial that one source knew and
|
|
||||||
// another did not, and a candidate the fleet file names by serial is only
|
|
||||||
// recognisable as such once it has that serial.
|
|
||||||
candidates.retain(|candidate| {
|
|
||||||
!configured.mentions(Some(&candidate.host), candidate.serial.as_deref())
|
|
||||||
});
|
|
||||||
if candidates.len() > MAX_REPORTED {
|
|
||||||
tracing::warn!(
|
|
||||||
found = candidates.len(),
|
|
||||||
reported = MAX_REPORTED,
|
|
||||||
"truncating the discovery report"
|
|
||||||
);
|
|
||||||
candidates.truncate(MAX_REPORTED);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
@@ -1,506 +0,0 @@
|
|||||||
//! 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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-188
@@ -1,188 +0,0 @@
|
|||||||
//! `continuum-proxy enrol <cloud-url> <code> [name]`: connect this gateway to
|
|
||||||
//! a farm with the one-time code the console shows, instead of copying a
|
|
||||||
//! farm id and token between machines.
|
|
||||||
//!
|
|
||||||
//! The code is traded at `POST /auth/enrol` for the farm id and the gateway
|
|
||||||
//! token, which are written into the proxy's config file (owner-only, since
|
|
||||||
//! the token is a credential) next to whatever else it already holds. The
|
|
||||||
//! next start reads them like any other setting.
|
|
||||||
//!
|
|
||||||
//! PC mode only. A Proxy Box is configured by a signed bundle, and writing
|
|
||||||
//! its installed config from here would bypass exactly the check that exists
|
|
||||||
//! to stop that.
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use anyhow::{bail, Context};
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
pub struct Enrolment {
|
|
||||||
pub farm_id: String,
|
|
||||||
pub gateway_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const USAGE: &str = "usage: continuum-proxy enrol <cloud-url> <code> [gateway-name]\n\
|
|
||||||
\n\
|
|
||||||
Connects this gateway to a farm using the one-time code shown in the console\n\
|
|
||||||
(valid for 15 minutes, usable once). <cloud-url> is the API's address, e.g.\n\
|
|
||||||
https://api.example.com. Settings are written to the proxy's config file.";
|
|
||||||
|
|
||||||
/// The websocket address of the uplink for an API at `cloud`.
|
|
||||||
pub fn uplink_url(cloud: &str) -> anyhow::Result<String> {
|
|
||||||
let cloud = cloud.trim().trim_end_matches('/');
|
|
||||||
let rest = if let Some(rest) = cloud.strip_prefix("https://") {
|
|
||||||
format!("wss://{rest}")
|
|
||||||
} else if let Some(rest) = cloud.strip_prefix("http://") {
|
|
||||||
format!("ws://{rest}")
|
|
||||||
} else {
|
|
||||||
bail!("the cloud address must start with http:// or https:// (got '{cloud}')");
|
|
||||||
};
|
|
||||||
Ok(format!("{rest}/ws/edge/v1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_response(body: &str) -> anyhow::Result<Enrolment> {
|
|
||||||
let value: Value = serde_json::from_str(body).context("the cloud's answer was not JSON")?;
|
|
||||||
let field = |name: &str| {
|
|
||||||
value
|
|
||||||
.get(name)
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
.with_context(|| format!("the cloud's answer has no '{name}'"))
|
|
||||||
};
|
|
||||||
Ok(Enrolment { farm_id: field("farmId")?, gateway_token: field("gatewayToken")? })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The config file's new text: the four enrolment settings set, everything
|
|
||||||
/// else the file already said kept. (Comments are not preserved - the file is
|
|
||||||
/// parsed and written back.)
|
|
||||||
pub fn merge_config(existing: Option<&str>, gateway_id: &str, enrolment: &Enrolment, uplink_url: &str) -> anyhow::Result<String> {
|
|
||||||
let mut table: toml::Table = match existing {
|
|
||||||
Some(text) => toml::from_str(text).context("the existing config file is not valid TOML; not touching it")?,
|
|
||||||
None => toml::Table::new(),
|
|
||||||
};
|
|
||||||
table.insert("gateway_id".into(), gateway_id.into());
|
|
||||||
table.insert("farm_id".into(), enrolment.farm_id.clone().into());
|
|
||||||
table.insert("gateway_token".into(), enrolment.gateway_token.clone().into());
|
|
||||||
table.insert("uplink_url".into(), uplink_url.into());
|
|
||||||
Ok(toml::to_string(&table)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trades `code` for the farm's credentials.
|
|
||||||
pub async fn exchange(cloud: &str, code: &str) -> anyhow::Result<Enrolment> {
|
|
||||||
let url = format!("{}/auth/enrol", cloud.trim().trim_end_matches('/'));
|
|
||||||
let body = serde_json::json!({ "code": code }).to_string();
|
|
||||||
let response = reqwest::Client::new()
|
|
||||||
.post(&url)
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.timeout(std::time::Duration::from_secs(20))
|
|
||||||
.body(body)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("could not reach {cloud}"))?;
|
|
||||||
let status = response.status();
|
|
||||||
let text = response.text().await.unwrap_or_default();
|
|
||||||
if !status.is_success() {
|
|
||||||
let message = serde_json::from_str::<Value>(&text)
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.pointer("/error/message").and_then(Value::as_str).map(str::to_string))
|
|
||||||
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
|
||||||
bail!("the cloud refused the code: {message}");
|
|
||||||
}
|
|
||||||
parse_response(&text)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Owner-only, and replaced atomically so a crash cannot leave half a file.
|
|
||||||
fn write_private(path: &Path, text: &str) -> anyhow::Result<()> {
|
|
||||||
use std::io::Write;
|
|
||||||
let tmp = path.with_extension("tmp");
|
|
||||||
let mut options = std::fs::OpenOptions::new();
|
|
||||||
options.write(true).create(true).truncate(true);
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
|
||||||
options.mode(0o600);
|
|
||||||
}
|
|
||||||
let mut file = options.open(&tmp).with_context(|| format!("writing {tmp:?}"))?;
|
|
||||||
file.write_all(text.as_bytes())?;
|
|
||||||
file.sync_all()?;
|
|
||||||
std::fs::rename(&tmp, path).with_context(|| format!("replacing {path:?}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_gateway_name() -> String {
|
|
||||||
std::env::var("HOSTNAME")
|
|
||||||
.ok()
|
|
||||||
.or_else(|| std::fs::read_to_string("/etc/hostname").ok())
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or_else(|| "gateway".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `enrol` subcommand. `args` are what follows the word `enrol`.
|
|
||||||
pub async fn run(args: &[String], bootstrap: &crate::bootstrap::Bootstrap) -> anyhow::Result<()> {
|
|
||||||
if args.iter().any(|a| a == "--help" || a == "-h") {
|
|
||||||
println!("{USAGE}");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let [cloud, code, rest @ ..] = args else {
|
|
||||||
bail!("{USAGE}");
|
|
||||||
};
|
|
||||||
if bootstrap.mode == crate::bootstrap::Mode::ProxyBox {
|
|
||||||
bail!("this is a Proxy Box: it is configured by a signed config bundle on a pendrive, not by enrol");
|
|
||||||
}
|
|
||||||
let uplink = uplink_url(cloud)?;
|
|
||||||
let enrolment = exchange(cloud, code).await?;
|
|
||||||
|
|
||||||
let path = &bootstrap.config_path;
|
|
||||||
let existing = match std::fs::read_to_string(path) {
|
|
||||||
Ok(text) => Some(text),
|
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
|
|
||||||
Err(err) => bail!("could not read {path:?}: {err}"),
|
|
||||||
};
|
|
||||||
let name = rest.first().cloned().unwrap_or_else(default_gateway_name);
|
|
||||||
let text = merge_config(existing.as_deref(), &name, &enrolment, &uplink)?;
|
|
||||||
write_private(path, &text)?;
|
|
||||||
println!("Enrolled gateway '{name}' in farm {}. Settings written to {path:?}.", enrolment.farm_id);
|
|
||||||
println!("Start the proxy to connect it.");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_uplink_follows_the_api_scheme() {
|
|
||||||
assert_eq!(uplink_url("https://api.example.com/").unwrap(), "wss://api.example.com/ws/edge/v1");
|
|
||||||
assert_eq!(uplink_url("http://localhost:3060").unwrap(), "ws://localhost:3060/ws/edge/v1");
|
|
||||||
assert!(uplink_url("api.example.com").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_answer_needs_both_fields() {
|
|
||||||
let ok = parse_response(r#"{"farmId":"f","gatewayToken":"t"}"#).unwrap();
|
|
||||||
assert_eq!((ok.farm_id.as_str(), ok.gateway_token.as_str()), ("f", "t"));
|
|
||||||
assert!(parse_response(r#"{"farmId":"f"}"#).is_err());
|
|
||||||
assert!(parse_response(r#"{"farmId":"f","gatewayToken":""}"#).is_err());
|
|
||||||
assert!(parse_response("not json").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enrolling_keeps_the_rest_of_the_file_and_the_result_parses_as_a_config() {
|
|
||||||
let enrolment = Enrolment { farm_id: "farm-1".into(), gateway_token: "secret".into() };
|
|
||||||
let existing = "go2rtc_bin = \"/opt/go2rtc\"\ngateway_token = \"old\"\n[discovery]\nenabled = false\n";
|
|
||||||
let text = merge_config(Some(existing), "bench", &enrolment, "wss://x/ws/edge/v1").unwrap();
|
|
||||||
let parsed = crate::config::ProxyFile::parse(&text).unwrap();
|
|
||||||
assert_eq!(parsed.gateway_token.as_deref(), Some("secret"), "the new token replaces the old");
|
|
||||||
assert_eq!(parsed.farm_id.as_deref(), Some("farm-1"));
|
|
||||||
assert_eq!(parsed.gateway_id.as_deref(), Some("bench"));
|
|
||||||
assert_eq!(parsed.go2rtc_bin.as_deref(), Some("/opt/go2rtc"));
|
|
||||||
assert!(parsed.discovery.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_file_that_is_not_toml_is_left_alone() {
|
|
||||||
let enrolment = Enrolment { farm_id: "f".into(), gateway_token: "t".into() };
|
|
||||||
assert!(merge_config(Some("= nonsense ["), "g", &enrolment, "ws://x").is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+23
-173
@@ -3,14 +3,13 @@
|
|||||||
//! by default) is gitignored: it holds real hostnames and access codes for
|
//! by default) is gitignored: it holds real hostnames and access codes for
|
||||||
//! your printers, which don't belong in version control.
|
//! your printers, which don't belong in version control.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::printer::{
|
use crate::printer::{
|
||||||
BambuPrinter, BambuProtocolPreference, BambuTls, KlipperPrinter, OctoPrintFlavour, OctoPrintPrinter,
|
BambuPrinter, BambuProtocolPreference, BambuTls, KlipperPrinter, PrinterHandle, PrusaLinkPrinter,
|
||||||
PrinterHandle, PrusaSerialPrinter,
|
PrusaSerialPrinter,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -19,55 +18,31 @@ struct FleetFile {
|
|||||||
printers: Vec<PrinterEntry>,
|
printers: Vec<PrinterEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One printer's connection details, as written in `printers.toml` or stored
|
#[derive(Debug, Deserialize)]
|
||||||
/// in the local store (src/store.rs). `Debug` is written by hand below: the
|
struct PrinterEntry {
|
||||||
/// derived one would print `access_code` and `api_key` into any log line that
|
id: String,
|
||||||
/// formats an entry.
|
name: String,
|
||||||
#[derive(Clone, Deserialize, Serialize)]
|
|
||||||
pub struct PrinterEntry {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
/// One of: "bambu" (auto-detects MQTT v5-vs-v3.1.1/BambuMQTT v2-vs-v1 -
|
/// One of: "bambu" (auto-detects MQTT v5-vs-v3.1.1/BambuMQTT v2-vs-v1 -
|
||||||
/// the default, since a firmware update can move the same physical
|
/// the default, since a firmware update can move the same physical
|
||||||
/// printer between them), "bambu_v1"/"bambu_v2" (force a specific one),
|
/// printer between them), "bambu_v1"/"bambu_v2" (force a specific one),
|
||||||
/// "prusa_link", "octoprint", "prusa_serial", "klipper".
|
/// "prusa_link", "prusa_serial", "klipper".
|
||||||
///
|
vendor: String,
|
||||||
/// `prusa_link` and `octoprint` are the same REST client with a different
|
host: Option<String>,
|
||||||
/// flavour - see `OctoPrintFlavour`. Pick `octoprint` if it is OctoPrint
|
access_code: Option<String>,
|
||||||
/// on a Pi and `prusa_link` if it is an MK4/XL/MINI serving its own API;
|
|
||||||
/// discovery cannot tell, so it suggests `octoprint` and says so.
|
|
||||||
pub vendor: String,
|
|
||||||
pub host: Option<String>,
|
|
||||||
pub access_code: Option<String>,
|
|
||||||
/// The printer's serial number - Bambu's real MQTT topics are addressed
|
/// The printer's serial number - Bambu's real MQTT topics are addressed
|
||||||
/// by serial (`device/{sn}/report`), not by our own `id`.
|
/// by serial (`device/{sn}/report`), not by our own `id`.
|
||||||
pub sn: Option<String>,
|
sn: Option<String>,
|
||||||
pub api_key: Option<String>,
|
api_key: Option<String>,
|
||||||
/// Serial device path, e.g. `/dev/ttyACM0` on Linux.
|
/// Serial device path, e.g. `/dev/ttyACM0` on Linux.
|
||||||
pub port: Option<String>,
|
port: Option<String>,
|
||||||
#[serde(default = "default_baud_rate")]
|
#[serde(default = "default_baud_rate")]
|
||||||
pub baud_rate: u32,
|
baud_rate: u32,
|
||||||
/// Path to a printer-specific CA certificate. Leave unset to use the
|
/// Path to a printer-specific CA certificate. Leave unset to use the
|
||||||
/// bundled Bambu CA - only needed for a printer that doesn't chain to
|
/// bundled Bambu CA - only needed for a printer that doesn't chain to
|
||||||
/// it (P1P, at least; see certs/README.md).
|
/// it (P1P, at least; see certs/README.md).
|
||||||
pub ca_cert_path: Option<String>,
|
ca_cert_path: Option<String>,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub require_valid_cert: bool,
|
require_valid_cert: bool,
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for PrinterEntry {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
let secret = |value: &Option<String>| value.as_ref().map(|_| "<redacted>");
|
|
||||||
f.debug_struct("PrinterEntry")
|
|
||||||
.field("id", &self.id)
|
|
||||||
.field("name", &self.name)
|
|
||||||
.field("vendor", &self.vendor)
|
|
||||||
.field("host", &self.host)
|
|
||||||
.field("access_code", &secret(&self.access_code))
|
|
||||||
.field("sn", &self.sn)
|
|
||||||
.field("api_key", &secret(&self.api_key))
|
|
||||||
.finish_non_exhaustive()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
@@ -91,110 +66,16 @@ pub fn load(path: &Path) -> anyhow::Result<Vec<PrinterHandle>> {
|
|||||||
file.printers.into_iter().map(build_printer).collect()
|
file.printers.into_iter().map(build_printer).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The entries in a printers file, or none if there is no file.
|
|
||||||
///
|
|
||||||
/// A missing file is normal now: a gateway whose printers were all added from
|
|
||||||
/// the console has only the store. A file that exists but does not parse is
|
|
||||||
/// still an error - that is a typo to surface, not a fleet to ignore.
|
|
||||||
pub fn entries(path: &Path) -> anyhow::Result<Vec<PrinterEntry>> {
|
|
||||||
let text = match std::fs::read_to_string(path) {
|
|
||||||
Ok(text) => text,
|
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
|
||||||
Err(err) => return Err(anyhow::anyhow!("failed to read {path:?}: {err}")),
|
|
||||||
};
|
|
||||||
Ok(toml::from_str::<FleetFile>(&text)?.printers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the client for one entry. Public so a printer added while the
|
|
||||||
/// gateway is running goes through exactly the checks a startup one does.
|
|
||||||
pub fn build(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
|
|
||||||
build_printer(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything the fleet file already names, for telling a printer the
|
|
||||||
/// operator has dealt with from one they have never seen.
|
|
||||||
///
|
|
||||||
/// Three sets rather than one, because the three identify a printer with
|
|
||||||
/// very different confidence. A serial is the machine. A host is the address
|
|
||||||
/// it answers on today. A device path is which USB socket it is plugged
|
|
||||||
/// into. A match on any of them is enough to mean "already known", which is
|
|
||||||
/// the conservative direction: the cost of a false match is one printer
|
|
||||||
/// missing from a suggestion list, and the cost of a false miss is the
|
|
||||||
/// console offering to add a printer that is already running jobs.
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
pub struct Configured {
|
|
||||||
pub hosts: HashSet<String>,
|
|
||||||
pub serials: HashSet<String>,
|
|
||||||
pub ports: HashSet<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Configured {
|
|
||||||
/// Lowercased on both sides: hostnames are case-insensitive, and a Bambu
|
|
||||||
/// serial read from a certificate has no guaranteed case either.
|
|
||||||
pub fn mentions(&self, host: Option<&str>, serial: Option<&str>) -> bool {
|
|
||||||
let known = |set: &HashSet<String>, value: Option<&str>| {
|
|
||||||
value.is_some_and(|value| set.contains(&value.to_lowercase()))
|
|
||||||
};
|
|
||||||
known(&self.hosts, host) || known(&self.serials, serial) || known(&self.ports, host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the fleet file mentions, without building a single printer client.
|
|
||||||
///
|
|
||||||
/// Parse level on purpose, and the difference matters: [`load`] refuses an
|
|
||||||
/// entry with a blank `access_code`, but that entry is exactly the case this
|
|
||||||
/// needs to catch - a printer discovery already suggested, that the operator
|
|
||||||
/// pasted in and has not yet typed the credential for. It is not a printer
|
|
||||||
/// they have never seen, so reporting it again as a fresh find would be
|
|
||||||
/// noise at the one moment they are mid-way through dealing with it.
|
|
||||||
///
|
|
||||||
/// A missing file is an empty set rather than an error: a gateway with no
|
|
||||||
/// fleet yet is the case discovery exists for.
|
|
||||||
pub fn configured(path: &Path) -> anyhow::Result<Configured> {
|
|
||||||
let text = match std::fs::read_to_string(path) {
|
|
||||||
Ok(text) => text,
|
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Configured::default()),
|
|
||||||
Err(err) => return Err(anyhow::anyhow!("failed to read {path:?}: {err}")),
|
|
||||||
};
|
|
||||||
let file: FleetFile = toml::from_str(&text)?;
|
|
||||||
let mut out = Configured::default();
|
|
||||||
out.add_entries(file.printers);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Configured {
|
|
||||||
/// Folds more entries in - the file's, and the local store's.
|
|
||||||
pub fn add_entries(&mut self, entries: impl IntoIterator<Item = PrinterEntry>) {
|
|
||||||
let add = |set: &mut HashSet<String>, value: Option<String>| {
|
|
||||||
if let Some(value) = value {
|
|
||||||
let value = value.trim().to_lowercase();
|
|
||||||
if !value.is_empty() {
|
|
||||||
set.insert(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for entry in entries {
|
|
||||||
add(&mut self.hosts, entry.host);
|
|
||||||
add(&mut self.serials, entry.sn);
|
|
||||||
add(&mut self.ports, entry.port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
|
fn build_printer(entry: PrinterEntry) -> anyhow::Result<PrinterHandle> {
|
||||||
match entry.vendor.as_str() {
|
match entry.vendor.as_str() {
|
||||||
"bambu" => build_bambu(entry, BambuProtocolPreference::Auto),
|
"bambu" => build_bambu(entry, BambuProtocolPreference::Auto),
|
||||||
"bambu_v1" => build_bambu(entry, BambuProtocolPreference::ForceV1),
|
"bambu_v1" => build_bambu(entry, BambuProtocolPreference::ForceV1),
|
||||||
"bambu_v2" => build_bambu(entry, BambuProtocolPreference::ForceV2),
|
"bambu_v2" => build_bambu(entry, BambuProtocolPreference::ForceV2),
|
||||||
// Two names for one client, because PrusaLink implements OctoPrint's
|
"prusa_link" => {
|
||||||
// API. The distinction is kept rather than collapsed: the two differ
|
let host = require_field(&entry.id, "host", entry.host)?;
|
||||||
// in which endpoints they offer beyond `/api/version`, and - the
|
let api_key = require_field(&entry.id, "api_key", entry.api_key)?;
|
||||||
// reason it has to come from config - nothing on the network can tell
|
Ok(PrinterHandle::PrusaLink(PrusaLinkPrinter::new(&entry.id, &entry.name, &host, &api_key)))
|
||||||
// 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" => {
|
"prusa_serial" => {
|
||||||
let port = require_field(&entry.id, "port", entry.port)?;
|
let port = require_field(&entry.id, "port", entry.port)?;
|
||||||
Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, &port, entry.baud_rate)))
|
Ok(PrinterHandle::PrusaSerial(PrusaSerialPrinter::new(&entry.id, &entry.name, &port, entry.baud_rate)))
|
||||||
@@ -207,12 +88,6 @@ 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> {
|
fn build_bambu(entry: PrinterEntry, preference: BambuProtocolPreference) -> anyhow::Result<PrinterHandle> {
|
||||||
let host = require_field(&entry.id, "host", entry.host)?;
|
let host = require_field(&entry.id, "host", entry.host)?;
|
||||||
let access_code = require_field(&entry.id, "access_code", entry.access_code)?;
|
let access_code = require_field(&entry.id, "access_code", entry.access_code)?;
|
||||||
@@ -221,31 +96,6 @@ fn build_bambu(entry: PrinterEntry, preference: BambuProtocolPreference) -> anyh
|
|||||||
Ok(PrinterHandle::Bambu(BambuPrinter::new(&entry.id, &entry.name, &host, &access_code, &sn, tls, preference)))
|
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> {
|
fn require_field(printer_id: &str, field: &str, value: Option<String>) -> anyhow::Result<String> {
|
||||||
match value {
|
value.ok_or_else(|| anyhow::anyhow!("{printer_id}: missing required field '{field}'"))
|
||||||
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}'")),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-169
@@ -1,97 +1,12 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::Notify;
|
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
/// Where the running go2rtc's pid is recorded: next to its config, because
|
/// Supervises the go2rtc subprocess used for camera restreaming (RTSP/USB -> WebRTC).
|
||||||
/// that is the one path this process already owns and the one a restarted
|
/// Restarts it with a fixed backoff whenever it exits, for as long as the daemon runs.
|
||||||
/// proxy is certain to compute again.
|
pub async fn watchdog(bin: String, config_path: String) {
|
||||||
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 {
|
loop {
|
||||||
info!(%bin, %config_path, "starting go2rtc");
|
info!(%bin, %config_path, "starting go2rtc");
|
||||||
|
|
||||||
@@ -104,34 +19,10 @@ pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
|
|||||||
.spawn();
|
.spawn();
|
||||||
|
|
||||||
match spawned {
|
match spawned {
|
||||||
Ok(mut child) => {
|
Ok(mut child) => match child.wait().await {
|
||||||
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"),
|
Ok(status) => warn!(%status, "go2rtc exited, restarting after backoff"),
|
||||||
Err(err) => error!(?err, "failed to wait on go2rtc process"),
|
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) => {
|
Err(err) => {
|
||||||
error!(?err, "failed to spawn go2rtc, is it installed and on PATH?");
|
error!(?err, "failed to spawn go2rtc, is it installed and on PATH?");
|
||||||
}
|
}
|
||||||
@@ -140,59 +31,3 @@ pub async fn watchdog(bin: String, config_path: String, restart: Arc<Notify>) {
|
|||||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn cmdline(args: &[&str]) -> Vec<u8> {
|
|
||||||
args.iter().flat_map(|a| a.bytes().chain([0])).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_our_binary_on_our_config_counts() {
|
|
||||||
let ours = cmdline(&["go2rtc", "-config", "./go2rtc.yaml"]);
|
|
||||||
assert!(is_our_go2rtc(&ours, "go2rtc", "./go2rtc.yaml"));
|
|
||||||
// A wrapper script puts the interpreter first.
|
|
||||||
assert!(is_our_go2rtc(&cmdline(&["/bin/sh", "go2rtc", "-config", "./go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
|
|
||||||
// Another proxy's go2rtc, an unrelated process, and a reused pid.
|
|
||||||
assert!(!is_our_go2rtc(&cmdline(&["go2rtc", "-config", "/other.yaml"]), "go2rtc", "./go2rtc.yaml"));
|
|
||||||
assert!(!is_our_go2rtc(&cmdline(&["sleep", "-config", "./go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
|
|
||||||
assert!(!is_our_go2rtc(&cmdline(&["vim", "go2rtc.yaml"]), "go2rtc", "./go2rtc.yaml"));
|
|
||||||
assert!(!is_our_go2rtc(b"", "go2rtc", "./go2rtc.yaml"), "a zombie has an empty cmdline");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A stand-in go2rtc that runs until killed, started the way the real
|
|
||||||
/// one is, whose pid file is then found by a "restarted proxy".
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn a_leftover_go2rtc_is_stopped_and_a_stranger_is_not() {
|
|
||||||
let dir = std::env::temp_dir().join(format!("continuum-go2rtc-test-{}", std::process::id()));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
let script = dir.join("go2rtc");
|
|
||||||
// Stays alive until signalled, and (run as `sh <script> -config ..`)
|
|
||||||
// looks to /proc the way the real one does behind an interpreter.
|
|
||||||
std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 1; done\n").unwrap();
|
|
||||||
let (bin, config) = (script.to_string_lossy().into_owned(), dir.join("go2rtc.yaml").to_string_lossy().into_owned());
|
|
||||||
|
|
||||||
let mut orphan = std::process::Command::new("/bin/sh").args([&bin, "-config", &config]).spawn().unwrap();
|
|
||||||
let stranger = std::process::Command::new("sleep").arg("60").spawn().unwrap();
|
|
||||||
|
|
||||||
// The stranger's pid in the file: refused, and the file is cleared.
|
|
||||||
std::fs::write(pid_path(&config), stranger.id().to_string()).unwrap();
|
|
||||||
reclaim_orphan(&bin, &config).await;
|
|
||||||
assert!(std::fs::read(format!("/proc/{}/cmdline", stranger.id())).is_ok_and(|c| !c.is_empty()), "an unrelated process must survive");
|
|
||||||
assert!(!pid_path(&config).exists());
|
|
||||||
|
|
||||||
// The orphan's pid: stopped.
|
|
||||||
std::fs::write(pid_path(&config), orphan.id().to_string()).unwrap();
|
|
||||||
reclaim_orphan(&bin, &config).await;
|
|
||||||
let status = orphan.wait().unwrap();
|
|
||||||
assert!(!status.success(), "the leftover go2rtc was terminated by a signal");
|
|
||||||
|
|
||||||
let mut stranger = stranger;
|
|
||||||
let _ = stranger.kill();
|
|
||||||
let _ = stranger.wait();
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
-185
@@ -1,185 +0,0 @@
|
|||||||
//! Following the jobs the cloud has handed this gateway, so their outcomes
|
|
||||||
//! reach the cloud even if it was unreachable when they happened.
|
|
||||||
//!
|
|
||||||
//! The cloud owns the queue; this only reports. When a `start_print` is
|
|
||||||
//! accepted the job is recorded against its printer, and each tick the
|
|
||||||
//! printer's state is compared with it. What the cloud should hear -
|
|
||||||
//! started, finished, failed - goes into the store's outbox, and the uplink
|
|
||||||
//! re-sends the outbox on every connection until the cloud acknowledges each
|
|
||||||
//! event. An outcome reached during an outage, or before a restart, is
|
|
||||||
//! therefore still delivered, instead of the cloud guessing from whatever
|
|
||||||
//! telemetry it happens to see next.
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use tracing::{info, warn};
|
|
||||||
|
|
||||||
use crate::printer::GenericPrinter;
|
|
||||||
use crate::store::{ActiveJob, Store};
|
|
||||||
use crate::uplink::SharedFleet;
|
|
||||||
|
|
||||||
const TICK: Duration = Duration::from_secs(5);
|
|
||||||
/// A job whose printer never reported printing within this long is given up on.
|
|
||||||
const NEVER_STARTED_AFTER_MS: i64 = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
pub fn now_ms() -> i64 {
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_millis() as i64)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
|
||||||
pub enum Step {
|
|
||||||
Nothing,
|
|
||||||
Started,
|
|
||||||
Finished,
|
|
||||||
Failed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a printer's reported state means for the job it was handed.
|
|
||||||
///
|
|
||||||
/// Nothing is concluded from a state until the job has been seen running: a
|
|
||||||
/// printer that has just been told to start still reports the *previous*
|
|
||||||
/// job's `FINISH` (or `FAILED`) for a while, and reading that as this job's
|
|
||||||
/// outcome would report a print that has not begun as done.
|
|
||||||
pub fn next_step(job: &ActiveJob, state: Option<&str>, now_ms: i64) -> Step {
|
|
||||||
let state = state.map(str::to_ascii_uppercase);
|
|
||||||
let running = matches!(state.as_deref(), Some("RUNNING" | "PAUSE" | "PREPARE" | "SLICING"));
|
|
||||||
|
|
||||||
if !job.started {
|
|
||||||
if running {
|
|
||||||
return Step::Started;
|
|
||||||
}
|
|
||||||
if now_ms - job.since_ms > NEVER_STARTED_AFTER_MS {
|
|
||||||
return Step::Failed("the printer never started printing it".into());
|
|
||||||
}
|
|
||||||
return Step::Nothing;
|
|
||||||
}
|
|
||||||
|
|
||||||
match state.as_deref() {
|
|
||||||
Some("FINISH") => Step::Finished,
|
|
||||||
Some("FAILED") => Step::Failed("the printer reported a fault".into()),
|
|
||||||
// Not a finish, not a fault, and no longer printing: stopped on the
|
|
||||||
// machine itself. Klipper and OctoPrint report a cancelled print as
|
|
||||||
// idle.
|
|
||||||
Some("IDLE") => Step::Failed("the printer stopped without finishing it".into()),
|
|
||||||
_ => Step::Nothing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The gateway accepted a `start_print` for `job_id`.
|
|
||||||
pub fn accepted(store: &Store, printer_id: &str, job_id: &str) {
|
|
||||||
if let Err(err) = store.active_job_set(printer_id, job_id, now_ms()) {
|
|
||||||
warn!(%err, %printer_id, %job_id, "could not record the job against its printer");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The accepted start then failed (download, transfer, start).
|
|
||||||
pub fn start_failed(store: &Store, printer_id: &str, job_id: &str, reason: &str) {
|
|
||||||
if let Err(err) = store.outbox_push(job_id, "failed", Some(reason), now_ms()) {
|
|
||||||
warn!(%err, %job_id, "could not queue the failure for the cloud");
|
|
||||||
}
|
|
||||||
let _ = store.active_job_clear(printer_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(fleet: SharedFleet, store: std::sync::Arc<Store>) {
|
|
||||||
let mut ticker = tokio::time::interval(TICK);
|
|
||||||
loop {
|
|
||||||
ticker.tick().await;
|
|
||||||
let Ok(active) = store.active_jobs() else { continue };
|
|
||||||
if active.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let states: Vec<(ActiveJob, Option<String>)> = {
|
|
||||||
let fleet = fleet.lock().await;
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for job in active {
|
|
||||||
let state = match fleet.iter().find(|p| p.base().id == job.printer_id) {
|
|
||||||
Some(printer) => printer.status().await.and_then(|s| s.state),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
out.push((job, state));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
};
|
|
||||||
for (job, state) in states {
|
|
||||||
apply(&store, &job, next_step(&job, state.as_deref(), now_ms()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply(store: &Store, job: &ActiveJob, step: Step) {
|
|
||||||
let result = match &step {
|
|
||||||
Step::Nothing => return,
|
|
||||||
Step::Started => store
|
|
||||||
.outbox_push(&job.job_id, "started", None, now_ms())
|
|
||||||
.and_then(|_| store.active_job_started(&job.printer_id)),
|
|
||||||
Step::Finished => store
|
|
||||||
.outbox_push(&job.job_id, "finished", None, now_ms())
|
|
||||||
.and_then(|_| store.active_job_clear(&job.printer_id)),
|
|
||||||
Step::Failed(reason) => store
|
|
||||||
.outbox_push(&job.job_id, "failed", Some(reason), now_ms())
|
|
||||||
.and_then(|_| store.active_job_clear(&job.printer_id)),
|
|
||||||
};
|
|
||||||
match result {
|
|
||||||
Ok(_) => info!(printer = %job.printer_id, job = %job.job_id, ?step, "job event queued for the cloud"),
|
|
||||||
Err(err) => warn!(%err, job = %job.job_id, "could not queue a job event"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn job(started: bool, since_ms: i64) -> ActiveJob {
|
|
||||||
ActiveJob { printer_id: "p1".into(), job_id: "j1".into(), since_ms, started }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_stale_finish_from_the_previous_job_is_not_this_jobs_outcome() {
|
|
||||||
assert_eq!(next_step(&job(false, 0), Some("FINISH"), 10_000), Step::Nothing);
|
|
||||||
assert_eq!(next_step(&job(false, 0), Some("FAILED"), 10_000), Step::Nothing);
|
|
||||||
assert_eq!(next_step(&job(false, 0), Some("IDLE"), 10_000), Step::Nothing);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn running_marks_it_started_and_then_finish_finishes_it() {
|
|
||||||
assert_eq!(next_step(&job(false, 0), Some("RUNNING"), 10_000), Step::Started);
|
|
||||||
assert_eq!(next_step(&job(true, 0), Some("RUNNING"), 10_000), Step::Nothing);
|
|
||||||
assert_eq!(next_step(&job(true, 0), Some("PAUSE"), 10_000), Step::Nothing);
|
|
||||||
assert_eq!(next_step(&job(true, 0), Some("FINISH"), 10_000), Step::Finished);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_fault_or_a_stop_after_starting_is_a_failure() {
|
|
||||||
assert!(matches!(next_step(&job(true, 0), Some("FAILED"), 1), Step::Failed(_)));
|
|
||||||
assert!(matches!(next_step(&job(true, 0), Some("IDLE"), 1), Step::Failed(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_printer_that_goes_quiet_leaves_the_job_alone() {
|
|
||||||
assert_eq!(next_step(&job(true, 0), None, 1), Step::Nothing);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_job_that_never_starts_is_eventually_given_up_on() {
|
|
||||||
assert_eq!(next_step(&job(false, 0), Some("IDLE"), NEVER_STARTED_AFTER_MS), Step::Nothing);
|
|
||||||
assert!(matches!(next_step(&job(false, 0), Some("IDLE"), NEVER_STARTED_AFTER_MS + 1), Step::Failed(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn applying_steps_fills_the_outbox_and_clears_the_active_job() {
|
|
||||||
let dir = std::env::temp_dir().join(format!("continuum-jobs-test-{}", std::process::id()));
|
|
||||||
let store = Store::open(&dir.join("s.db")).unwrap();
|
|
||||||
accepted(&store, "p1", "j1");
|
|
||||||
let active = store.active_jobs().unwrap().remove(0);
|
|
||||||
apply(&store, &active, Step::Started);
|
|
||||||
let active = store.active_jobs().unwrap().remove(0);
|
|
||||||
assert!(active.started);
|
|
||||||
apply(&store, &active, Step::Finished);
|
|
||||||
assert!(store.active_jobs().unwrap().is_empty());
|
|
||||||
let events: Vec<_> = store.outbox_pending().unwrap().into_iter().map(|e| e.event).collect();
|
|
||||||
assert_eq!(events, ["started", "finished"]);
|
|
||||||
let _ = std::fs::remove_dir_all(dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-20
@@ -1,23 +1,7 @@
|
|||||||
//! Library surface for `continuum-proxy`. The daemon itself is a thin binary
|
//! Library surface for `continuum-proxy`. The daemon itself is a binary
|
||||||
//! (`src/main.rs`) that uses this crate; the examples in `examples/` and the
|
//! (`src/main.rs`); this crate root exists so standalone examples (see
|
||||||
//! integration tests in `tests/` use it too.
|
//! `examples/`) and future integration tests can `use continuum_proxy::...`
|
||||||
//!
|
//! without duplicating module declarations.
|
||||||
//! 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 enrol;
|
|
||||||
pub mod discovery;
|
|
||||||
pub mod fleet;
|
pub mod fleet;
|
||||||
pub mod jobs;
|
|
||||||
pub mod printer;
|
pub mod printer;
|
||||||
pub mod provision;
|
|
||||||
pub mod store;
|
|
||||||
pub mod uplink;
|
|
||||||
|
|||||||
+24
-194
@@ -1,19 +1,13 @@
|
|||||||
// Everything but go2rtc comes from the library crate (src/lib.rs) rather than
|
mod config;
|
||||||
// being re-declared here. Declaring `mod printer;` in both places compiles the
|
mod fleet;
|
||||||
// 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 go2rtc;
|
||||||
|
mod printer;
|
||||||
|
mod uplink;
|
||||||
|
|
||||||
use tracing::info;
|
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use continuum_proxy::bootstrap::{self, Bootstrap};
|
use crate::config::Config;
|
||||||
use continuum_proxy::config::Config;
|
use crate::printer::{GenericPrinter, PrinterHandle};
|
||||||
use continuum_proxy::config_import;
|
|
||||||
use continuum_proxy::printer::{GenericPrinter, PrinterHandle};
|
|
||||||
use continuum_proxy::{camera, completion, fleet, jobs, uplink};
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
@@ -22,200 +16,36 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.init();
|
.init();
|
||||||
|
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
let config = Config::from_env()?;
|
||||||
// `continuum-proxy enrol ...` connects this gateway to a farm and exits;
|
|
||||||
// it needs the bootstrap (for where the config file is) but no settings.
|
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
||||||
if args.first().map(String::as_str) == Some("enrol") {
|
|
||||||
return continuum_proxy::enrol::run(&args[1..], &Bootstrap::discover()?).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Order matters: import first, so a stick present at boot is already
|
|
||||||
// the installed config by the time it is read. A rejected stick is
|
|
||||||
// logged inside and does not stop startup - the box runs on what it has.
|
|
||||||
let bootstrap = Bootstrap::discover()?;
|
|
||||||
// Plaintext bundles are refused on a Proxy Box and signatures are
|
|
||||||
// mandatory once a signing key is provisioned; see the config_import
|
|
||||||
// module docs. An unprovisioned box is allowed to start - it runs on its
|
|
||||||
// installed config - but says loudly what it cannot check.
|
|
||||||
//
|
|
||||||
// Loaded only on a box: on a PC nothing imports from removable media, and
|
|
||||||
// a stray or malformed file under /etc/continuum must not stop a PC user's
|
|
||||||
// proxy starting over provisioning it never uses.
|
|
||||||
let policy = if bootstrap.mode == bootstrap::Mode::ProxyBox {
|
|
||||||
config_import::load_provisioning()?
|
|
||||||
} else {
|
|
||||||
config_import::BundlePolicy::unencrypted_only()
|
|
||||||
};
|
|
||||||
if bootstrap.mode == bootstrap::Mode::ProxyBox {
|
|
||||||
if policy.signing_keys.is_empty() {
|
|
||||||
tracing::warn!("no signing key provisioned: config bundles will NOT be authenticated");
|
|
||||||
}
|
|
||||||
if policy.box_id.is_none() {
|
|
||||||
tracing::warn!("no box id provisioned: signed config bundles cannot be accepted");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for outcome in config_import::import_from_pendrives(&bootstrap, &policy) {
|
|
||||||
config_import::log_outcome(&outcome);
|
|
||||||
}
|
|
||||||
let file = bootstrap.load_config_file()?;
|
|
||||||
let config = Config::from_file_and_env(file.as_ref())?;
|
|
||||||
info!(mode = ?bootstrap.mode, config_file = ?file.as_ref().map(|_| &bootstrap.config_path), "configuration loaded");
|
|
||||||
|
|
||||||
// BOUNDARY: network settings are parsed and validated, and stop here.
|
|
||||||
// Applying them (static IP, DNS, wifi) is the appliance image's job, not
|
|
||||||
// this process's - see the module docs of `config`. Logged so that a box
|
|
||||||
// given a network block does not look like it acted on it.
|
|
||||||
if let Some(network) = file.as_ref().and_then(|f| f.network.as_ref()) {
|
|
||||||
tracing::warn!(?network, "network settings are present but this build does NOT apply them");
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(gateway_id = %config.gateway_id, farm_id = %config.farm_id, "starting continuum-proxy");
|
tracing::info!(gateway_id = %config.gateway_id, farm_id = %config.farm_id, "starting continuum-proxy");
|
||||||
|
|
||||||
// Loaded and connected here, once, at startup - nothing yet re-reads
|
// Loaded and connected here, once, at startup - nothing yet re-reads
|
||||||
// printers.toml on a running daemon, so adding or removing a printer
|
// printers.toml on a running daemon (adding/removing a printer means
|
||||||
// still means a restart.
|
// a restart), and nothing yet lets the uplink dispatch a command to
|
||||||
// Two sources, one fleet: `printers.toml` (a hand-written file - strict,
|
// one of these once connected - see PrinterHandle's own doc comment
|
||||||
// a typo stops startup) and the local store (printers added from the
|
// for what's built and what's still a stub per vendor. Keeping
|
||||||
// console, which were checked before they were saved). The store is
|
// `fleet` alive as a local variable for the rest of `main` (which
|
||||||
// opened first and is what lets the gateway come up with no file at all.
|
// never returns, below) is what keeps every connection open; it's
|
||||||
let store = std::sync::Arc::new(continuum_proxy::store::Store::open(std::path::Path::new(&config.store_path))?);
|
// not otherwise touched yet.
|
||||||
let mut fleet = Vec::new();
|
let mut fleet = fleet::load(std::path::Path::new(&config.printers_path))?;
|
||||||
for entry in fleet::entries(std::path::Path::new(&config.printers_path))? {
|
|
||||||
fleet.push(fleet::build(entry)?);
|
|
||||||
}
|
|
||||||
for entry in store.printers()? {
|
|
||||||
if fleet.iter().any(|printer| printer.base().id == entry.id) {
|
|
||||||
tracing::warn!(printer = %entry.id, "in both printers.toml and the local store; using printers.toml");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Skipped, not fatal: one entry the build no longer accepts should
|
|
||||||
// not keep the rest of a farm offline.
|
|
||||||
match fleet::build(entry) {
|
|
||||||
Ok(printer) => fleet.push(printer),
|
|
||||||
Err(err) => tracing::error!(%err, "a printer in the local store could not be built; skipping it"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fleet.is_empty() {
|
|
||||||
tracing::warn!("no printers yet - add one from the console, or list some in printers.toml");
|
|
||||||
}
|
|
||||||
connect_fleet(&mut fleet).await;
|
connect_fleet(&mut fleet).await;
|
||||||
|
|
||||||
// The fleet is now shared with the uplink rather than just parked here
|
// Three things run at once for now: the cloud uplink, the go2rtc
|
||||||
// keeping connections alive: the uplink reads each printer's status to
|
// camera-restreaming process, and (implicitly, via the connections
|
||||||
// send telemetry up, and looks printers up by id to dispatch commands
|
// opened above) every configured printer's live session. More will
|
||||||
// down. `Arc<Mutex<..>>` is how two tasks share one mutable thing -
|
// join them later - printer discovery, the plate changer - as you
|
||||||
// see `uplink::SharedFleet` for why that type and what it costs.
|
// build past src/printer/'s stubs. `tokio::select!` runs both of the
|
||||||
let fleet: uplink::SharedFleet = std::sync::Arc::new(tokio::sync::Mutex::new(fleet));
|
// tasks below and stops as soon as either one returns; both loop
|
||||||
|
// forever, so in practice this runs until the process is killed.
|
||||||
// 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! {
|
tokio::select! {
|
||||||
// Ends the process through a normal return, so the supervisors below
|
_ = uplink::run(config.clone()) => {}
|
||||||
// are dropped and go2rtc's `kill_on_drop` actually fires. Without this
|
_ = go2rtc::watchdog(config.go2rtc_bin.clone(), config.go2rtc_config.clone()) => {}
|
||||||
// arm SIGTERM - what `kill`, `docker stop` and a service manager all
|
|
||||||
// send - ends the process on the spot with no destructor run, and
|
|
||||||
// go2rtc is orphaned: it keeps :1984 and :8555 bound, so the restarted
|
|
||||||
// proxy cannot start its own, and it keeps a printer's access code in
|
|
||||||
// memory. Found when a test run left one behind.
|
|
||||||
_ = shutdown_requested() => {
|
|
||||||
info!("stop requested; exiting so go2rtc is stopped with us");
|
|
||||||
}
|
|
||||||
_ = uplink::run(config.clone(), fleet.clone(), Some(store.clone()), outbound.clone(), inbox) => {}
|
|
||||||
_ = continuum_proxy::discovery::report::run(
|
|
||||||
config.discovery.clone(),
|
|
||||||
config.printers_path.clone(),
|
|
||||||
Some(store.clone()),
|
|
||||||
outbound.clone(),
|
|
||||||
) => {}
|
|
||||||
_ = 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()) => {}
|
|
||||||
_ = jobs::run(fleet.clone(), store.clone()) => {}
|
|
||||||
// Returns only after installing a new config from a stick inserted
|
|
||||||
// while running. Settings are read once at startup, so the process
|
|
||||||
// ends here and relies on its supervisor (systemd `Restart=always`
|
|
||||||
// on a box) to start it again on the new config. Not exercised end
|
|
||||||
// to end - there is no supervisor or real stick in the dev
|
|
||||||
// environment.
|
|
||||||
_ = config_import::watch(bootstrap.clone(), policy.clone()) => {
|
|
||||||
info!("exiting so the supervisor restarts the proxy on the newly installed config");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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 -
|
/// Connects every configured printer concurrently (not one at a time -
|
||||||
/// Bambu's auto-detect fallback alone can take several seconds per
|
/// 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;
|
/// printer if it has to time out on MQTT v5 before trying v3.1.1;
|
||||||
|
|||||||
+20
-1098
File diff suppressed because it is too large
Load Diff
@@ -1,134 +0,0 @@
|
|||||||
//! The camera on a Bambu P1 or A1, which is not RTSP.
|
|
||||||
//!
|
|
||||||
//! Those models publish no `rtsp_url` and refuse port 322 (confirmed on a
|
|
||||||
//! real P1P, 2026-10-15). What they have instead is a proprietary stream on
|
|
||||||
//! TCP 6000: TLS, a single 80-byte login packet, then a continuous run of
|
|
||||||
//! JPEG frames, each behind a 16-byte header.
|
|
||||||
//!
|
|
||||||
//! This reads **one frame** and hangs up. A browser cannot play that stream
|
|
||||||
//! (it is MJPEG over a custom framing, and go2rtc cannot turn it into WebRTC
|
|
||||||
//! without ffmpeg, which a gateway does not have), so the console shows a
|
|
||||||
//! refreshing picture instead and a one-shot read is all it needs.
|
|
||||||
//!
|
|
||||||
//! Same blocking-TLS shape as `bambu_ftps`: the caller hands in the printer's
|
|
||||||
//! own `native_tls` connector, so every trust decision stays in
|
|
||||||
//! `BambuTls::build_connector`.
|
|
||||||
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::net::ToSocketAddrs;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
pub const PORT: u16 = 6000;
|
|
||||||
|
|
||||||
/// Same username as MQTT and FTPS.
|
|
||||||
const USER: &str = "bblp";
|
|
||||||
|
|
||||||
/// A frame this large is a corrupt header, not a picture. Real frames from a
|
|
||||||
/// P1P are tens of kilobytes.
|
|
||||||
const MAX_FRAME: usize = 4 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// The 80-byte login: four little-endian u32s (0x40, 0x3000, 0, 0), then the
|
|
||||||
/// username and the access code, each zero-padded to 32 bytes.
|
|
||||||
fn auth_packet(access_code: &str) -> [u8; 80] {
|
|
||||||
let mut packet = [0u8; 80];
|
|
||||||
packet[0..4].copy_from_slice(&0x40u32.to_le_bytes());
|
|
||||||
packet[4..8].copy_from_slice(&0x3000u32.to_le_bytes());
|
|
||||||
let user = USER.as_bytes();
|
|
||||||
packet[16..16 + user.len()].copy_from_slice(user);
|
|
||||||
// An access code is 8 characters; truncating anything longer keeps the
|
|
||||||
// packet shape rather than overrunning into nothing.
|
|
||||||
let code = access_code.as_bytes();
|
|
||||||
let n = code.len().min(32);
|
|
||||||
packet[48..48 + n].copy_from_slice(&code[..n]);
|
|
||||||
packet
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads one frame: a 16-byte header whose first u32 is the payload length,
|
|
||||||
/// then that many bytes, which must be a JPEG.
|
|
||||||
fn read_frame<R: Read>(stream: &mut R) -> anyhow::Result<Vec<u8>> {
|
|
||||||
let mut header = [0u8; 16];
|
|
||||||
stream
|
|
||||||
.read_exact(&mut header)
|
|
||||||
.map_err(|err| anyhow::anyhow!("the camera sent no frame (wrong access code, or the stream is in use?): {err}"))?;
|
|
||||||
let len = u32::from_le_bytes([header[0], header[1], header[2], header[3]]) as usize;
|
|
||||||
if !(4..=MAX_FRAME).contains(&len) {
|
|
||||||
anyhow::bail!("the camera announced a {len}-byte frame, which is not a picture");
|
|
||||||
}
|
|
||||||
let mut jpeg = vec![0u8; len];
|
|
||||||
stream.read_exact(&mut jpeg)?;
|
|
||||||
if jpeg[..2] != [0xFF, 0xD8] || jpeg[len - 2..] != [0xFF, 0xD9] {
|
|
||||||
anyhow::bail!("the camera's frame is not a complete JPEG");
|
|
||||||
}
|
|
||||||
Ok(jpeg)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connects, logs in and returns the first frame. Blocking; call it from
|
|
||||||
/// `spawn_blocking`.
|
|
||||||
pub fn snapshot(
|
|
||||||
host: &str,
|
|
||||||
access_code: &str,
|
|
||||||
connector: native_tls::TlsConnector,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> anyhow::Result<Vec<u8>> {
|
|
||||||
let addr = (host, PORT)
|
|
||||||
.to_socket_addrs()?
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("{host} did not resolve"))?;
|
|
||||||
let tcp = std::net::TcpStream::connect_timeout(&addr, timeout)
|
|
||||||
.map_err(|err| anyhow::anyhow!("{host}:{PORT} did not accept a connection: {err}"))?;
|
|
||||||
tcp.set_read_timeout(Some(timeout))?;
|
|
||||||
tcp.set_write_timeout(Some(timeout))?;
|
|
||||||
let mut tls = connector
|
|
||||||
.connect(host, tcp)
|
|
||||||
.map_err(|err| anyhow::anyhow!("TLS handshake with the camera failed: {err}"))?;
|
|
||||||
tls.write_all(&auth_packet(access_code))?;
|
|
||||||
tls.flush()?;
|
|
||||||
read_frame(&mut tls)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn frame(payload: &[u8]) -> Vec<u8> {
|
|
||||||
let mut out = (payload.len() as u32).to_le_bytes().to_vec();
|
|
||||||
out.extend_from_slice(&[0; 12]);
|
|
||||||
out.extend_from_slice(payload);
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_login_packet_has_the_documented_layout() {
|
|
||||||
let p = auth_packet("12345678");
|
|
||||||
assert_eq!(&p[0..8], &[0x40, 0, 0, 0, 0, 0x30, 0, 0]);
|
|
||||||
assert_eq!(&p[8..16], &[0; 8]);
|
|
||||||
assert_eq!(&p[16..20], b"bblp");
|
|
||||||
assert!(p[20..48].iter().all(|b| *b == 0));
|
|
||||||
assert_eq!(&p[48..56], b"12345678");
|
|
||||||
assert!(p[56..].iter().all(|b| *b == 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_whole_jpeg_frame_is_returned() {
|
|
||||||
let jpeg = [0xFF, 0xD8, 1, 2, 3, 0xFF, 0xD9];
|
|
||||||
let mut input = frame(&jpeg).into_iter().chain([9, 9, 9]).collect::<Vec<_>>();
|
|
||||||
assert_eq!(read_frame(&mut input.as_slice()).unwrap(), jpeg);
|
|
||||||
input.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nonsense_is_refused_not_returned() {
|
|
||||||
// not a JPEG
|
|
||||||
assert!(read_frame(&mut frame(&[1, 2, 3, 4]).as_slice()).is_err());
|
|
||||||
// length beyond the cap
|
|
||||||
let mut huge = (u32::MAX).to_le_bytes().to_vec();
|
|
||||||
huge.extend_from_slice(&[0; 12]);
|
|
||||||
assert!(read_frame(&mut huge.as_slice()).is_err());
|
|
||||||
// truncated
|
|
||||||
let mut cut = frame(&[0xFF, 0xD8, 0, 0xFF, 0xD9]);
|
|
||||||
cut.truncate(cut.len() - 2);
|
|
||||||
assert!(read_frame(&mut cut.as_slice()).is_err());
|
|
||||||
// nothing at all (what a wrong access code looks like)
|
|
||||||
assert!(read_frame(&mut [].as_slice()).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,10 +7,9 @@
|
|||||||
//! continuum-schemas for continuum.v1, which is a schema we designed
|
//! continuum-schemas for continuum.v1, which is a schema we designed
|
||||||
//! ourselves and that both TS and Rust genuinely need.
|
//! ourselves and that both TS and Rust genuinely need.
|
||||||
//!
|
//!
|
||||||
//! One file per Bambu *category* (`get_version.rs` for "info",
|
//! One file per Bambu *category* (`get_version.rs` for "info", `pushall.rs`
|
||||||
//! `pushall.rs` + `print.rs` for "pushing"/"print" - see below for why
|
//! + `print.rs` for "pushing"/"print" - see below for why those are two
|
||||||
//! those are two files, not one). Copy `get_version.rs`'s shape for your
|
//! files, not one). Copy `get_version.rs`'s shape for your next command.
|
||||||
//! next command.
|
|
||||||
//!
|
//!
|
||||||
//! Mirrors continuum-schemas' `schemas/bambulab/` JSON Schemas - same
|
//! Mirrors continuum-schemas' `schemas/bambulab/` JSON Schemas - same
|
||||||
//! category split, same "print vs pushing" correction (see that
|
//! category split, same "print vs pushing" correction (see that
|
||||||
@@ -26,10 +25,7 @@ mod print;
|
|||||||
mod pushall;
|
mod pushall;
|
||||||
|
|
||||||
pub use get_version::{GetVersionRequest, GetVersionResponse, ModuleInfo};
|
pub use get_version::{GetVersionRequest, GetVersionResponse, ModuleInfo};
|
||||||
pub use print::{
|
pub use print::{AmsState, AmsTray, AmsUnit, EmptyTray, LoadedTray, PrintReport, XcamSettings};
|
||||||
AmsState, AmsTray, AmsUnit, EmptyTray, Ipcam, LightReport, LoadedTray, PrintReport, VtTray,
|
|
||||||
XcamSettings,
|
|
||||||
};
|
|
||||||
pub use pushall::PushallRequest;
|
pub use pushall::PushallRequest;
|
||||||
|
|
||||||
/// Fields every Bambu command/response shares. This is the "inheritance"
|
/// Fields every Bambu command/response shares. This is the "inheritance"
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ pub struct PrintReport {
|
|||||||
/// actual, reliable signal for which kind a given message is -
|
/// actual, reliable signal for which kind a given message is -
|
||||||
/// confirmed against a real printer, unlike trying to infer it from
|
/// confirmed against a real printer, unlike trying to infer it from
|
||||||
/// request/reply timing (see `BambuMqttSession`'s `PrintStatusCache`,
|
/// request/reply timing (see `BambuMqttSession`'s `PrintStatusCache`,
|
||||||
/// which used to try that and got it wrong twice). Now declared in
|
/// which used to try that and got it wrong twice). Not in
|
||||||
/// `push_status.schema.json` too.
|
/// `push_status.schema.json` yet - add it there too when you're back
|
||||||
|
/// in continuum-schemas.
|
||||||
pub msg: Option<i32>,
|
pub msg: Option<i32>,
|
||||||
pub ams: Option<AmsState>,
|
pub ams: Option<AmsState>,
|
||||||
pub ams_exist_bits: Option<String>,
|
pub ams_exist_bits: Option<String>,
|
||||||
@@ -31,108 +32,10 @@ pub struct PrintReport {
|
|||||||
pub tray_is_bbl_bits: Option<String>,
|
pub tray_is_bbl_bits: Option<String>,
|
||||||
pub xcam: Option<XcamSettings>,
|
pub xcam: Option<XcamSettings>,
|
||||||
pub xcam_status: Option<String>,
|
pub xcam_status: Option<String>,
|
||||||
|
// The real payload has a lot more fields than shown here (this
|
||||||
// --- Job progress. The reason this struct exists at all, for a print
|
// example was truncated) - add them the same way, one field per line,
|
||||||
// --- farm: a real capture has 97 top-level fields and the nine above
|
// `Option<T>` for anything that isn't always present. Remember to
|
||||||
// --- contained none of the ones an operator actually wants.
|
// also add each new field to `merge_from` below.
|
||||||
/// `"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 {
|
impl PrintReport {
|
||||||
@@ -142,105 +45,34 @@ impl PrintReport {
|
|||||||
/// existing value alone instead of clobbering it. Not used for v2
|
/// existing value alone instead of clobbering it. Not used for v2
|
||||||
/// (always a full object) or for a `pushall`-triggered reply on
|
/// (always a full object) or for a `pushall`-triggered reply on
|
||||||
/// either version (also guaranteed full) - see `BambuMqttSession`.
|
/// 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) {
|
pub fn merge_from(&mut self, newer: PrintReport) {
|
||||||
self.envelope = newer.envelope;
|
self.envelope = newer.envelope;
|
||||||
self.msg = newer.msg;
|
self.msg = newer.msg;
|
||||||
|
if newer.ams.is_some() {
|
||||||
// A partial `ams` object (no unit list) must not wipe the units already known.
|
self.ams = newer.ams;
|
||||||
self.ams = match newer.ams {
|
}
|
||||||
Some(partial) if partial.ams.is_empty() => self.ams.take(),
|
if newer.ams_exist_bits.is_some() {
|
||||||
other => other.or(self.ams.take()),
|
self.ams_exist_bits = newer.ams_exist_bits;
|
||||||
};
|
}
|
||||||
self.ams_exist_bits = newer.ams_exist_bits.or(self.ams_exist_bits.take());
|
if newer.insert_flag.is_some() {
|
||||||
self.insert_flag = newer.insert_flag.or(self.insert_flag.take());
|
self.insert_flag = newer.insert_flag;
|
||||||
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());
|
if newer.power_on_flag.is_some() {
|
||||||
self.tray_is_bbl_bits = newer.tray_is_bbl_bits.or(self.tray_is_bbl_bits.take());
|
self.power_on_flag = newer.power_on_flag;
|
||||||
self.xcam = newer.xcam.or(self.xcam.take());
|
}
|
||||||
self.xcam_status = newer.xcam_status.or(self.xcam_status.take());
|
if newer.tray_exist_bits.is_some() {
|
||||||
|
self.tray_exist_bits = newer.tray_exist_bits;
|
||||||
self.gcode_state = newer.gcode_state.or(self.gcode_state.take());
|
}
|
||||||
self.mc_percent = newer.mc_percent.or(self.mc_percent.take());
|
if newer.tray_is_bbl_bits.is_some() {
|
||||||
self.mc_remaining_time = newer.mc_remaining_time.or(self.mc_remaining_time.take());
|
self.tray_is_bbl_bits = newer.tray_is_bbl_bits;
|
||||||
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());
|
if newer.xcam.is_some() {
|
||||||
self.gcode_file = newer.gcode_file.or(self.gcode_file.take());
|
self.xcam = newer.xcam;
|
||||||
self.subtask_name = newer.subtask_name.or(self.subtask_name.take());
|
}
|
||||||
self.print_error = newer.print_error.or(self.print_error.take());
|
if newer.xcam_status.is_some() {
|
||||||
|
self.xcam_status = newer.xcam_status;
|
||||||
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
|
/// Bambu's own naming has the outer container and the inner array both
|
||||||
@@ -248,31 +80,21 @@ pub struct LightReport {
|
|||||||
/// actually sends.
|
/// actually sends.
|
||||||
#[derive(serde::Deserialize, Debug, Clone)]
|
#[derive(serde::Deserialize, Debug, Clone)]
|
||||||
pub struct AmsState {
|
pub struct AmsState {
|
||||||
/// Absent from the partial `ams` objects a P1P pushes mid-print (found on
|
|
||||||
/// the first real dispatch, 2026-10-29: every update was dropped as
|
|
||||||
/// "missing field `ams`" and the job could never complete).
|
|
||||||
#[serde(default)]
|
|
||||||
pub ams: Vec<AmsUnit>,
|
pub ams: Vec<AmsUnit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One physical AMS unit (Bambu's numbering starts at 0 via `id`). Only
|
/// One physical AMS unit (Bambu's numbering starts at 0 via `id`).
|
||||||
/// `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)]
|
#[derive(serde::Deserialize, Debug, Clone)]
|
||||||
pub struct AmsUnit {
|
pub struct AmsUnit {
|
||||||
|
pub humidity: String,
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
pub temp: String,
|
||||||
pub tray: Vec<AmsTray>,
|
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) -
|
/// 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
|
/// - two genuinely different shapes, not one shape with optional fields,
|
||||||
/// this is an untagged enum rather than a struct full of `Option<T>`.
|
/// so this is an untagged enum rather than a struct full of `Option<T>`.
|
||||||
/// `#[serde(deny_unknown_fields)]` on `Empty` is load-bearing: untagged
|
/// `#[serde(deny_unknown_fields)]` on `Empty` is load-bearing: untagged
|
||||||
/// enums try variants in order, and without it a *loaded* tray (which has
|
/// enums try variants in order, and without it a *loaded* tray (which has
|
||||||
/// every field `Empty` allows, plus more) would incorrectly match `Empty`
|
/// every field `Empty` allows, plus more) would incorrectly match `Empty`
|
||||||
@@ -285,47 +107,20 @@ pub enum AmsTray {
|
|||||||
Loaded(LoadedTray),
|
Loaded(LoadedTray),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `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)]
|
#[derive(serde::Deserialize, Debug, Clone)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct EmptyTray {
|
pub struct EmptyTray {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
/// v2 sends this on an empty slot; v1 omits it entirely.
|
|
||||||
pub state: Option<i32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)]
|
#[derive(serde::Deserialize, Debug, Clone)]
|
||||||
pub struct LoadedTray {
|
pub struct LoadedTray {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub bed_temp: String,
|
pub bed_temp: String,
|
||||||
pub bed_temp_type: String,
|
pub bed_temp_type: String,
|
||||||
pub cols: Vec<String>,
|
pub cols: Vec<String>,
|
||||||
/// v2 only.
|
pub drying_temp: String,
|
||||||
pub drying_temp: Option<String>,
|
pub drying_time: 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_max: String,
|
||||||
pub nozzle_temp_min: String,
|
pub nozzle_temp_min: String,
|
||||||
pub remain: i32,
|
pub remain: i32,
|
||||||
@@ -339,53 +134,6 @@ pub struct LoadedTray {
|
|||||||
pub tray_uuid: String,
|
pub tray_uuid: String,
|
||||||
pub tray_weight: String,
|
pub tray_weight: String,
|
||||||
pub xcam_info: 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,
|
/// Camera-based AI print monitoring settings (spaghetti detection,
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
//! 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)
|
|
||||||
}
|
|
||||||
+17
-81
@@ -76,9 +76,6 @@ use super::PrinterError;
|
|||||||
type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>;
|
type PendingTable = Arc<Mutex<HashMap<String, oneshot::Sender<Value>>>>;
|
||||||
|
|
||||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
/// How long a publish may wait for room in the MQTT request queue.
|
|
||||||
const PUBLISH_TIMEOUT: Duration = Duration::from_secs(10);
|
|
||||||
/// Both rumqttc clients default to a 10KB max packet size in *both*
|
/// Both rumqttc clients default to a 10KB max packet size in *both*
|
||||||
/// directions - fine for a request, nowhere near enough for a reply:
|
/// directions - fine for a request, nowhere near enough for a reply:
|
||||||
/// confirmed live against a real H2C, whose `get_version` reply alone
|
/// confirmed live against a real H2C, whose `get_version` reply alone
|
||||||
@@ -124,15 +121,6 @@ enum BambuMqttClient {
|
|||||||
/// timing worked out; `msg` needs none of that: it's on the message
|
/// timing worked out; `msg` needs none of that: it's on the message
|
||||||
/// itself, correct regardless of which client (or nothing at all)
|
/// itself, correct regardless of which client (or nothing at all)
|
||||||
/// triggered it, and regardless of timing.
|
/// 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
|
/// - `last_sequence_id` exists to notice when v1's organic stream skipped
|
||||||
/// an update (network hiccup, a message this client wasn't fast enough
|
/// 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
|
/// to read): if the incoming `sequence_id` isn't exactly one more than
|
||||||
@@ -159,21 +147,15 @@ impl PrintStatusCache {
|
|||||||
// full) isn't a real problem worth warning about.
|
// full) isn't a real problem worth warning about.
|
||||||
let is_full_object = matches!(version, BambuProtocolVersion::V2) || incoming.msg != Some(1);
|
let is_full_object = matches!(version, BambuProtocolVersion::V2) || incoming.msg != Some(1);
|
||||||
|
|
||||||
// Only reachable for v1 partial updates. Historically this fired
|
if let (Some(last), Some(incoming_seq), false) = (self.last_sequence_id, incoming_seq, is_full_object) {
|
||||||
// constantly and blamed the network, when the real cause was local:
|
if incoming_seq != last.wrapping_add(1) {
|
||||||
// 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!(
|
eprintln!(
|
||||||
"print.push_status sequence gap: expected {}, got {incoming_seq} - the in-memory \
|
"print.push_status sequence gap: expected {}, got {incoming_seq} - the in-memory \
|
||||||
object may be missing whatever changed in between",
|
object may be missing whatever changed in between",
|
||||||
last.wrapping_add(1)
|
last.wrapping_add(1)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if let Some(incoming_seq) = incoming_seq {
|
if let Some(incoming_seq) = incoming_seq {
|
||||||
self.last_sequence_id = Some(incoming_seq);
|
self.last_sequence_id = Some(incoming_seq);
|
||||||
}
|
}
|
||||||
@@ -308,17 +290,17 @@ impl BambuMqttSession {
|
|||||||
/// whose `sequence_id` matches the one this call sent.
|
/// whose `sequence_id` matches the one this call sent.
|
||||||
///
|
///
|
||||||
/// This crate tried adding its own correlation field
|
/// This crate tried adding its own correlation field
|
||||||
/// (`continuum_correlation_id`) instead of relying on `sequence_id`, on
|
/// (`continuum_correlation_id`) instead of relying on `sequence_id`,
|
||||||
/// the understanding that extra fields survive into most replies.
|
/// on the understanding that extra fields survive into most replies.
|
||||||
/// Confirmed live that this doesn't hold for `get_version`
|
/// Confirmed live that this doesn't hold for `get_version` specifically
|
||||||
/// specifically - a real H2C's reply had exactly `["command", "module",
|
/// - a real H2C's reply had exactly `["command", "module",
|
||||||
/// "sequence_id"]`, nothing extra at all, so the correlation field
|
/// "sequence_id"]`, nothing extra at all, so the correlation field
|
||||||
/// never came back and every `get_version` call just timed out.
|
/// never came back and every `get_version` call just timed out.
|
||||||
/// `sequence_id` is what's actually proven to round-trip for it
|
/// `sequence_id` is what's actually proven to round-trip for it
|
||||||
/// (confirmed earlier, live, on both protocol versions), so that's what
|
/// (confirmed earlier, live, on both protocol versions), so that's
|
||||||
/// this uses. Whether a custom field survives on some *other* command
|
/// what this uses. Whether a custom field survives on some *other*
|
||||||
/// (`calibration`, say) is unverified, not confirmed false - this was
|
/// command (`calibration`, say) is unverified, not confirmed false -
|
||||||
/// only ever checked for `get_version`.
|
/// this was only ever checked for `get_version`.
|
||||||
///
|
///
|
||||||
/// Not for `print.push_status` - that's not a single reply, see
|
/// Not for `print.push_status` - that's not a single reply, see
|
||||||
/// `PrintStatusCache`. Not for a command with no reply at all either,
|
/// `PrintStatusCache`. Not for a command with no reply at all either,
|
||||||
@@ -362,38 +344,18 @@ impl BambuMqttSession {
|
|||||||
/// `publish` above and `GenericPrinter::publish` (a raw pass-through,
|
/// `publish` above and `GenericPrinter::publish` (a raw pass-through,
|
||||||
/// no envelope opinion at all) are built on.
|
/// no envelope opinion at all) are built on.
|
||||||
pub async fn publish_raw(&self, payload: String) -> Result<(), PrinterError> {
|
pub async fn publish_raw(&self, payload: String) -> Result<(), PrinterError> {
|
||||||
self.publish_with_qos(payload, true).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like `publish`, but QoS 0: for requests that need no acknowledgement
|
|
||||||
/// (a status refresh). QoS 1 publishes the printer never acknowledges
|
|
||||||
/// fill rumqttc's in-flight window, after which every publish blocks
|
|
||||||
/// forever - found on the first real dispatch, where that froze the whole
|
|
||||||
/// proxy about 4.5 minutes in, because callers hold the fleet lock.
|
|
||||||
pub async fn publish_unacked(&self, category: &str, body: &Value) -> Result<(), PrinterError> {
|
|
||||||
self.publish_with_qos(serde_json::json!({ category: body }).to_string(), false).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn publish_with_qos(&self, payload: String, acked: bool) -> Result<(), PrinterError> {
|
|
||||||
let topic = format!("device/{}/request", self.sn);
|
let topic = format!("device/{}/request", self.sn);
|
||||||
|
|
||||||
// Bounded: a publish waits for room in rumqttc's request queue, and an
|
|
||||||
// unbounded wait here wedges every caller that holds the fleet lock.
|
|
||||||
let publishing = async {
|
|
||||||
match &self.client {
|
match &self.client {
|
||||||
BambuMqttClient::V1(client) => client
|
BambuMqttClient::V1(client) => client
|
||||||
.publish(topic, if acked { QoS::AtLeastOnce } else { QoS::AtMostOnce }, false, payload)
|
.publish(topic, QoS::AtLeastOnce, false, payload)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
|
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
|
||||||
BambuMqttClient::V2(client) => client
|
BambuMqttClient::V2(client) => client
|
||||||
.publish(topic, if acked { QoSV5::AtLeastOnce } else { QoSV5::AtMostOnce }, false, payload)
|
.publish(topic, QoSV5::AtLeastOnce, false, payload)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
|
.map_err(|err| PrinterError(format!("publish failed: {err}"))),
|
||||||
}
|
}
|
||||||
};
|
|
||||||
tokio::time::timeout(PUBLISH_TIMEOUT, publishing)
|
|
||||||
.await
|
|
||||||
.map_err(|_| PrinterError(format!("{} publish timed out after {PUBLISH_TIMEOUT:?} - the MQTT queue is not draining", self.sn)))?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The current in-memory `print.push_status` object, if at least one
|
/// The current in-memory `print.push_status` object, if at least one
|
||||||
@@ -426,7 +388,7 @@ async fn poll_loop_v1(
|
|||||||
loop {
|
loop {
|
||||||
match event_loop.poll().await {
|
match event_loop.poll().await {
|
||||||
Ok(Event::Incoming(Packet::Publish(publish))) => {
|
Ok(Event::Incoming(Packet::Publish(publish))) => {
|
||||||
handle_incoming_report(&sn, &pending, &print_status, version, &publish.payload).await;
|
handle_incoming_report(&pending, &print_status, version, &publish.payload).await;
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -447,7 +409,7 @@ async fn poll_loop_v2(
|
|||||||
loop {
|
loop {
|
||||||
match event_loop.poll().await {
|
match event_loop.poll().await {
|
||||||
Ok(rumqttc::v5::Event::Incoming(PacketV5::Publish(publish))) => {
|
Ok(rumqttc::v5::Event::Incoming(PacketV5::Publish(publish))) => {
|
||||||
handle_incoming_report(&sn, &pending, &print_status, version, &publish.payload).await;
|
handle_incoming_report(&pending, &print_status, version, &publish.payload).await;
|
||||||
}
|
}
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -482,21 +444,11 @@ async fn poll_loop_v2(
|
|||||||
/// (including every organic `push_status` push) aren't a reply to
|
/// (including every organic `push_status` push) aren't a reply to
|
||||||
/// anything specific.
|
/// anything specific.
|
||||||
async fn handle_incoming_report(
|
async fn handle_incoming_report(
|
||||||
sn: &str,
|
|
||||||
pending: &PendingTable,
|
pending: &PendingTable,
|
||||||
print_status: &Arc<Mutex<PrintStatusCache>>,
|
print_status: &Arc<Mutex<PrintStatusCache>>,
|
||||||
version: BambuProtocolVersion,
|
version: BambuProtocolVersion,
|
||||||
payload: &[u8],
|
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 Ok(value) = serde_json::from_slice::<Value>(payload) else { return };
|
||||||
let Some(object) = value.as_object() else { return };
|
let Some(object) = value.as_object() else { return };
|
||||||
let Some((category, body)) = object.iter().next() else { return };
|
let Some((category, body)) = object.iter().next() else { return };
|
||||||
@@ -507,24 +459,8 @@ async fn handle_incoming_report(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if category == "print" && body.get("command").and_then(Value::as_str) == Some("push_status") {
|
if category == "print" && body.get("command").and_then(Value::as_str) == Some("push_status") {
|
||||||
match serde_json::from_value::<PrintReport>(body.clone()) {
|
if let Ok(report) = serde_json::from_value::<PrintReport>(body.clone()) {
|
||||||
Ok(report) => print_status.lock().await.apply(report, version),
|
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("?")
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
//! The HTTP plumbing the REST-speaking vendors (Klipper's Moonraker,
|
|
||||||
//! OctoPrint, PrusaLink) share, so each driver is only its own endpoints.
|
|
||||||
//!
|
|
||||||
//! reqwest is built without its `json` and `multipart` features (see
|
|
||||||
//! Cargo.toml), so bodies are made by hand here: JSON through `serde_json`
|
|
||||||
//! and the one multipart upload as a few lines of bytes.
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use reqwest::Method;
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
use super::PrinterError;
|
|
||||||
|
|
||||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
|
||||||
/// A project file over a Pi's Wi-Fi can be tens of megabytes.
|
|
||||||
const UPLOAD_TIMEOUT: Duration = Duration::from_secs(600);
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct Http {
|
|
||||||
client: reqwest::Client,
|
|
||||||
base: String,
|
|
||||||
api_key: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `host` as the operator wrote it: a bare `192.168.0.9`, `host:port`, or a
|
|
||||||
/// full `http(s)://` URL. A bare host gets `default_port`.
|
|
||||||
fn base_url(host: &str, default_port: u16) -> String {
|
|
||||||
let host = host.trim().trim_end_matches('/');
|
|
||||||
if host.starts_with("http://") || host.starts_with("https://") {
|
|
||||||
host.to_string()
|
|
||||||
} else if host.contains(':') {
|
|
||||||
format!("http://{host}")
|
|
||||||
} else {
|
|
||||||
format!("http://{host}:{default_port}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Http {
|
|
||||||
pub fn new(host: &str, default_port: u16, api_key: Option<&str>) -> Self {
|
|
||||||
Self {
|
|
||||||
client: reqwest::Client::new(),
|
|
||||||
base: base_url(host, default_port),
|
|
||||||
api_key: api_key.filter(|k| !k.is_empty()).map(str::to_string),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send(
|
|
||||||
&self,
|
|
||||||
method: Method,
|
|
||||||
path: &str,
|
|
||||||
content_type: Option<&str>,
|
|
||||||
extra_headers: &[(&str, &str)],
|
|
||||||
body: Option<Vec<u8>>,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> Result<Value, PrinterError> {
|
|
||||||
let url = format!("{}{path}", self.base);
|
|
||||||
let mut request = self.client.request(method.clone(), &url).timeout(timeout);
|
|
||||||
if let Some(key) = &self.api_key {
|
|
||||||
request = request.header("X-Api-Key", key);
|
|
||||||
}
|
|
||||||
if let Some(content_type) = content_type {
|
|
||||||
request = request.header("Content-Type", content_type);
|
|
||||||
}
|
|
||||||
for (name, value) in extra_headers {
|
|
||||||
request = request.header(*name, *value);
|
|
||||||
}
|
|
||||||
if let Some(body) = body {
|
|
||||||
request = request.body(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The URL is left out of errors on purpose: it is the printer's
|
|
||||||
// address, which the caller already names.
|
|
||||||
let response = request.send().await.map_err(|err| PrinterError(format!("{method} {path}: {err}")))?;
|
|
||||||
let status = response.status();
|
|
||||||
let bytes = response.bytes().await.map_err(|err| PrinterError(format!("{method} {path}: {err}")))?;
|
|
||||||
if !status.is_success() {
|
|
||||||
let text = String::from_utf8_lossy(&bytes);
|
|
||||||
let text: String = text.trim().chars().take(200).collect();
|
|
||||||
return Err(PrinterError(format!("{method} {path}: HTTP {} {text}", status.as_u16())));
|
|
||||||
}
|
|
||||||
// An empty body (204, or a 201 with nothing) is a success, not a
|
|
||||||
// parse error; so is a non-JSON one, which only the callers that
|
|
||||||
// read the answer care about.
|
|
||||||
Ok(serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_json(&self, path: &str) -> Result<Value, PrinterError> {
|
|
||||||
self.send(Method::GET, path, None, &[], None, REQUEST_TIMEOUT).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn post_json(&self, path: &str, body: &Value) -> Result<Value, PrinterError> {
|
|
||||||
let body = body.to_string().into_bytes();
|
|
||||||
self.send(Method::POST, path, Some("application/json"), &[], Some(body), REQUEST_TIMEOUT).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn post_empty(&self, path: &str) -> Result<Value, PrinterError> {
|
|
||||||
self.send(Method::POST, path, None, &[], None, REQUEST_TIMEOUT).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn put_empty(&self, path: &str) -> Result<Value, PrinterError> {
|
|
||||||
self.send(Method::PUT, path, None, &[], None, REQUEST_TIMEOUT).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete(&self, path: &str) -> Result<Value, PrinterError> {
|
|
||||||
self.send(Method::DELETE, path, None, &[], None, REQUEST_TIMEOUT).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn put_bytes(&self, path: &str, headers: &[(&str, &str)], data: Vec<u8>) -> Result<Value, PrinterError> {
|
|
||||||
self.send(Method::PUT, path, Some("application/octet-stream"), headers, Some(data), UPLOAD_TIMEOUT).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `multipart/form-data` POST: the text `fields`, then `data` as the
|
|
||||||
/// part named `file_field`.
|
|
||||||
pub async fn post_file(
|
|
||||||
&self,
|
|
||||||
path: &str,
|
|
||||||
fields: &[(&str, &str)],
|
|
||||||
file_field: &str,
|
|
||||||
file_name: &str,
|
|
||||||
data: &[u8],
|
|
||||||
) -> Result<Value, PrinterError> {
|
|
||||||
let boundary = format!(
|
|
||||||
"continuum-{:x}{:x}",
|
|
||||||
std::process::id(),
|
|
||||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
|
|
||||||
);
|
|
||||||
let body = multipart_body(&boundary, fields, file_field, file_name, data);
|
|
||||||
let content_type = format!("multipart/form-data; boundary={boundary}");
|
|
||||||
self.send(Method::POST, path, Some(&content_type), &[], Some(body), UPLOAD_TIMEOUT).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn multipart_body(boundary: &str, fields: &[(&str, &str)], file_field: &str, file_name: &str, data: &[u8]) -> Vec<u8> {
|
|
||||||
let mut body = Vec::with_capacity(data.len() + 512);
|
|
||||||
for (name, value) in fields {
|
|
||||||
body.extend_from_slice(
|
|
||||||
format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n").as_bytes(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
body.extend_from_slice(
|
|
||||||
format!(
|
|
||||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"{file_field}\"; filename=\"{file_name}\"\r\nContent-Type: application/octet-stream\r\n\r\n"
|
|
||||||
)
|
|
||||||
.as_bytes(),
|
|
||||||
);
|
|
||||||
body.extend_from_slice(data);
|
|
||||||
body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
|
|
||||||
body
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The file name to give the printer: the last path component, and nothing
|
|
||||||
/// that could break out of a multipart header or a URL path.
|
|
||||||
pub fn safe_file_name(file_name: &str) -> Result<String, PrinterError> {
|
|
||||||
let name = file_name.rsplit(['/', '\\']).next().unwrap_or("").trim();
|
|
||||||
if name.is_empty() || name.starts_with('.') || name.chars().any(|c| c.is_control() || c == '"') {
|
|
||||||
return Err(PrinterError(format!("'{file_name}' is not a usable file name")));
|
|
||||||
}
|
|
||||||
Ok(name.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Percent-encodes `name` for use as one URL path segment.
|
|
||||||
pub fn encode_segment(name: &str) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
for byte in name.bytes() {
|
|
||||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
|
||||||
out.push(byte as char);
|
|
||||||
} else {
|
|
||||||
out.push_str(&format!("%{byte:02X}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_bare_host_gets_the_default_port() {
|
|
||||||
assert_eq!(base_url("192.168.0.9", 7125), "http://192.168.0.9:7125");
|
|
||||||
assert_eq!(base_url("printer.lan:80", 7125), "http://printer.lan:80");
|
|
||||||
assert_eq!(base_url("https://p.example/", 7125), "https://p.example");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn file_names_are_reduced_to_a_safe_last_component() {
|
|
||||||
assert_eq!(safe_file_name("jobs/../a b.gcode").unwrap(), "a b.gcode");
|
|
||||||
assert!(safe_file_name("..").is_err());
|
|
||||||
assert!(safe_file_name("x\".gcode").is_err());
|
|
||||||
assert!(safe_file_name("").is_err());
|
|
||||||
assert_eq!(encode_segment("a b+c.gcode"), "a%20b%2Bc.gcode");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_multipart_body_has_fields_then_the_file() {
|
|
||||||
let body = multipart_body("B", &[("print", "true")], "file", "x.gcode", b"G1");
|
|
||||||
let text = String::from_utf8(body).unwrap();
|
|
||||||
assert!(text.starts_with("--B\r\nContent-Disposition: form-data; name=\"print\"\r\n\r\ntrue\r\n"));
|
|
||||||
assert!(text.contains("name=\"file\"; filename=\"x.gcode\""));
|
|
||||||
assert!(text.ends_with("G1\r\n--B--\r\n"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+28
-271
@@ -1,304 +1,61 @@
|
|||||||
//! Klipper, through Moonraker's HTTP API (default port 7125).
|
use super::{GenericPrinter, PrinterBase, PrinterError};
|
||||||
//!
|
|
||||||
//! Written from Moonraker's published API and exercised against a mock
|
|
||||||
//! server in the tests below - no real Klipper machine has been on the
|
|
||||||
//! bench, so treat the field names as unconfirmed until one has.
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use super::http::{safe_file_name, Http};
|
|
||||||
use super::polled::StatusFeed;
|
|
||||||
use super::{GenericPrinter, PrintControl, PrinterBase, PrinterError, PrinterStatus};
|
|
||||||
|
|
||||||
const MOONRAKER_PORT: u16 = 7125;
|
|
||||||
const POLL_EVERY: Duration = Duration::from_secs(2);
|
|
||||||
|
|
||||||
/// What one `objects/query` asks for - everything `parse_status` reads.
|
|
||||||
const STATUS_QUERY: &str =
|
|
||||||
"/printer/objects/query?print_stats&display_status&extruder&heater_bed&virtual_sdcard";
|
|
||||||
|
|
||||||
pub struct KlipperPrinter {
|
pub struct KlipperPrinter {
|
||||||
base: PrinterBase,
|
base: PrinterBase,
|
||||||
http: Http,
|
|
||||||
feed: Option<StatusFeed>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KlipperPrinter {
|
impl KlipperPrinter {
|
||||||
pub fn new(id: &str, name: &str, host: &str) -> Self {
|
pub fn new(id: &str, name: &str, host: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
|
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
|
||||||
http: Http::new(host, MOONRAKER_PORT, None),
|
|
||||||
feed: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn status(&self) -> Option<PrinterStatus> {
|
|
||||||
self.feed.as_ref().and_then(StatusFeed::latest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Moonraker's `print_stats.state` in the vocabulary the cloud already
|
|
||||||
/// understands (the strings Bambu reports; see `mapStatus` in the backend).
|
|
||||||
fn map_state(state: &str) -> Option<&'static str> {
|
|
||||||
match state {
|
|
||||||
"standby" => Some("IDLE"),
|
|
||||||
"printing" => Some("RUNNING"),
|
|
||||||
"paused" => Some("PAUSE"),
|
|
||||||
"complete" => Some("FINISH"),
|
|
||||||
// A cancelled print leaves nothing running; the machine is free.
|
|
||||||
"cancelled" => Some("IDLE"),
|
|
||||||
"error" => Some("FAILED"),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `result.status` object of an `objects/query` answer.
|
|
||||||
fn parse_status(answer: &Value) -> Option<PrinterStatus> {
|
|
||||||
let status = answer.get("result")?.get("status")?;
|
|
||||||
let stats = status.get("print_stats")?;
|
|
||||||
let state = stats.get("state")?.as_str()?;
|
|
||||||
let number = |v: Option<&Value>| v.and_then(Value::as_f64).map(|n| n as f32);
|
|
||||||
|
|
||||||
let progress = number(status.get("virtual_sdcard").and_then(|s| s.get("progress"))).unwrap_or(0.0);
|
|
||||||
let percent = match state {
|
|
||||||
"complete" => 100,
|
|
||||||
"printing" | "paused" => (progress * 100.0).round() as i32,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
let duration = number(stats.get("print_duration")).unwrap_or(0.0);
|
|
||||||
// Klipper has no estimate of its own; this is elapsed time scaled by
|
|
||||||
// progress, which is wrong early in a print and settles as it goes.
|
|
||||||
let remaining_minutes = (matches!(state, "printing" | "paused") && progress > 0.01)
|
|
||||||
.then(|| ((duration / progress - duration) / 60.0).round() as i32);
|
|
||||||
let layer_info = stats.get("info");
|
|
||||||
let int = |v: Option<&Value>| v.and_then(Value::as_i64).map(|n| n as i32);
|
|
||||||
|
|
||||||
Some(PrinterStatus {
|
|
||||||
state: map_state(state).map(str::to_string),
|
|
||||||
percent: Some(percent),
|
|
||||||
layer: int(layer_info.and_then(|i| i.get("current_layer"))),
|
|
||||||
total_layers: int(layer_info.and_then(|i| i.get("total_layer"))),
|
|
||||||
remaining_minutes,
|
|
||||||
job_name: stats
|
|
||||||
.get("filename")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.filter(|f| !f.is_empty())
|
|
||||||
.map(str::to_string),
|
|
||||||
nozzle_temp: number(status.get("extruder").and_then(|e| e.get("temperature"))),
|
|
||||||
nozzle_target: number(status.get("extruder").and_then(|e| e.get("target"))),
|
|
||||||
bed_temp: number(status.get("heater_bed").and_then(|e| e.get("temperature"))),
|
|
||||||
bed_target: number(status.get("heater_bed").and_then(|e| e.get("target"))),
|
|
||||||
print_error: (state == "error").then_some(1),
|
|
||||||
..PrinterStatus::default()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn poll(http: &Http) -> Option<PrinterStatus> {
|
|
||||||
match http.get_json(STATUS_QUERY).await {
|
|
||||||
Ok(answer) => parse_status(&answer),
|
|
||||||
// Moonraker answers 503 to object queries while Klipper itself is
|
|
||||||
// down, but still answers `/printer/info` - which is the
|
|
||||||
// difference between "off" and "faulted".
|
|
||||||
Err(_) => {
|
|
||||||
let info = http.get_json("/printer/info").await.ok()?;
|
|
||||||
let state = info.get("result")?.get("state")?.as_str()?;
|
|
||||||
matches!(state, "error" | "shutdown").then(|| PrinterStatus {
|
|
||||||
state: Some("FAILED".into()),
|
|
||||||
print_error: Some(1),
|
|
||||||
..PrinterStatus::default()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GenericPrinter for KlipperPrinter {
|
impl GenericPrinter for KlipperPrinter {
|
||||||
/// Note Klipper calls it "cancel" where Bambu calls it "stop".
|
|
||||||
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> {
|
|
||||||
let endpoint = match action {
|
|
||||||
PrintControl::Pause => "pause",
|
|
||||||
PrintControl::Resume => "resume",
|
|
||||||
PrintControl::Stop => "cancel",
|
|
||||||
};
|
|
||||||
self.http.post_empty(&format!("/printer/print/{endpoint}")).await.map(|_| ())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base(&self) -> &PrinterBase {
|
fn base(&self) -> &PrinterBase {
|
||||||
&self.base
|
&self.base
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks Moonraker answers, then starts the status poller. Klipper
|
/// OVERRIDE: a third, again-different body - Moonraker's real feed is a
|
||||||
/// itself being down is not a connect failure: Moonraker is up and
|
/// WebSocket (see `adapters::moonraker`). This also shows the `Err`
|
||||||
/// will report it.
|
/// path: an empty `host` fails immediately, same as any other vendor
|
||||||
|
/// would if it couldn't reach its printer.
|
||||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
async fn connect(&mut self) -> Result<(), PrinterError> {
|
||||||
if self.base.host.trim().is_empty() {
|
if self.base.host.is_empty() {
|
||||||
return Err(PrinterError(format!("{} has no host configured", self.base.name)));
|
return Err(PrinterError(format!("{} has no host configured", self.base.name)));
|
||||||
}
|
}
|
||||||
self.http
|
println!("[{}] connecting over WebSocket (Moonraker)", self.base.name);
|
||||||
.get_json("/printer/info")
|
|
||||||
.await
|
|
||||||
.map_err(|err| PrinterError(format!("{} did not answer as Moonraker: {err}", self.base.name)))?;
|
|
||||||
let http = self.http.clone();
|
|
||||||
self.feed = Some(StatusFeed::start(POLL_EVERY, move || {
|
|
||||||
let http = http.clone();
|
|
||||||
async move { poll(&http).await }
|
|
||||||
}));
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nothing to publish to: Moonraker is request/response.
|
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
|
// TODO: add publishing via HTTP
|
||||||
Err(PrinterError("Klipper has no publish channel".into()))
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `M106` works on Klipper for the part-cooling fan, same as elsewhere.
|
/// OVERRIDE: Moonraker's own **hypothetical** native API, not gcode injection - no
|
||||||
|
/// percent-to-PWM conversion needed since it already takes a
|
||||||
|
/// percentage directly. Real version:
|
||||||
|
/// `POST /printer/gcode/script?script=SET_FAN_SPEED SPEED=0.NN`.
|
||||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
||||||
self.send_gcode_line(format!("M106 S{}", super::percent_to_pwm(percent))).await
|
println!("[{}] Moonraker: SET_FAN_SPEED SPEED={:.2}", self.base.name, percent as f32 / 100.0);
|
||||||
}
|
|
||||||
|
|
||||||
/// Uploads to the printer's `gcodes` root and starts it (`print=true`),
|
|
||||||
/// like Bambu's upload-then-`project_file`. Klipper runs plain G-code,
|
|
||||||
/// so a slicer project (`.3mf`) is refused here rather than uploaded
|
|
||||||
/// and left to fail on the machine.
|
|
||||||
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
|
||||||
let name = safe_file_name(&file_name)?;
|
|
||||||
let lower = name.to_lowercase();
|
|
||||||
if !(lower.ends_with(".gcode") || lower.ends_with(".gco") || lower.ends_with(".g")) {
|
|
||||||
return Err(PrinterError(format!(
|
|
||||||
"{name} is not G-code - Klipper needs a sliced .gcode file, not a project"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if let Some(status) = self.status()
|
|
||||||
&& matches!(status.state.as_deref(), Some("RUNNING" | "PAUSE"))
|
|
||||||
{
|
|
||||||
return Err(PrinterError(format!("{} is already printing", self.base.name)));
|
|
||||||
}
|
|
||||||
let answer = self
|
|
||||||
.http
|
|
||||||
.post_file("/server/files/upload", &[("root", "gcodes"), ("print", "true")], "file", &name, &data)
|
|
||||||
.await?;
|
|
||||||
if answer.get("print_started").and_then(Value::as_bool) == Some(false) {
|
|
||||||
return Err(PrinterError(format!("{name} was uploaded but Klipper did not start it")));
|
|
||||||
}
|
|
||||||
Ok(())
|
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> {
|
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||||
self.http.post_json("/printer/gcode/script", &json!({ "script": gcode_line })).await.map(|_| ())
|
println!("[{}] POST /printer/gcode/script?script={gcode_line}", self.base.name);
|
||||||
}
|
Ok(())
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::printer::mock_http::MockServer;
|
|
||||||
|
|
||||||
const PRINTING: &str = r#"{"result":{"status":{
|
|
||||||
"print_stats":{"state":"printing","filename":"cube.gcode","print_duration":600.0,"info":{"current_layer":12,"total_layer":60}},
|
|
||||||
"virtual_sdcard":{"progress":0.25},
|
|
||||||
"extruder":{"temperature":214.8,"target":215.0},
|
|
||||||
"heater_bed":{"temperature":59.9,"target":60.0}}}}"#;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_printing_machine_maps_to_the_shared_vocabulary() {
|
|
||||||
let status = parse_status(&serde_json::from_str(PRINTING).unwrap()).unwrap();
|
|
||||||
assert_eq!(status.state.as_deref(), Some("RUNNING"));
|
|
||||||
assert_eq!(status.percent, Some(25));
|
|
||||||
assert_eq!((status.layer, status.total_layers), (Some(12), Some(60)));
|
|
||||||
// 600 s for a quarter: 1800 s more.
|
|
||||||
assert_eq!(status.remaining_minutes, Some(30));
|
|
||||||
assert_eq!(status.job_name.as_deref(), Some("cube.gcode"));
|
|
||||||
assert_eq!(status.nozzle_temp, Some(214.8));
|
|
||||||
assert_eq!(status.bed_target, Some(60.0));
|
|
||||||
assert_eq!(status.print_error, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn complete_is_100_percent_and_error_carries_a_code() {
|
|
||||||
let done = r#"{"result":{"status":{"print_stats":{"state":"complete","filename":"a.gcode"},"virtual_sdcard":{"progress":1.0}}}}"#;
|
|
||||||
let status = parse_status(&serde_json::from_str(done).unwrap()).unwrap();
|
|
||||||
assert_eq!((status.state.as_deref(), status.percent), (Some("FINISH"), Some(100)));
|
|
||||||
|
|
||||||
let failed = r#"{"result":{"status":{"print_stats":{"state":"error"}}}}"#;
|
|
||||||
let status = parse_status(&serde_json::from_str(failed).unwrap()).unwrap();
|
|
||||||
assert_eq!((status.state.as_deref(), status.print_error), (Some("FAILED"), Some(1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn control_gcode_and_fan_go_to_the_right_endpoints() {
|
|
||||||
let mock = MockServer::start().await;
|
|
||||||
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
|
|
||||||
mock.answer("POST /printer/print/cancel", 200, r#"{"result":"ok"}"#);
|
|
||||||
mock.answer("POST /printer/gcode/script", 200, r#"{"result":"ok"}"#);
|
|
||||||
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
|
||||||
printer.connect().await.unwrap();
|
|
||||||
|
|
||||||
printer.control_print(PrintControl::Stop).await.unwrap();
|
|
||||||
assert_eq!(mock.sent("POST", "/printer/print/cancel").len(), 1);
|
|
||||||
|
|
||||||
printer.set_fan_speed(50).await.unwrap();
|
|
||||||
let sent = mock.sent("POST", "/printer/gcode/script");
|
|
||||||
assert_eq!(sent[0].text(), r#"{"script":"M106 S127"}"#);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn connect_fails_when_nothing_answers_as_moonraker() {
|
|
||||||
let mock = MockServer::start().await; // every route is a 404
|
|
||||||
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
|
||||||
assert!(printer.connect().await.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn the_poller_feeds_status_and_clears_it_when_the_printer_goes() {
|
|
||||||
let mock = MockServer::start().await;
|
|
||||||
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
|
|
||||||
mock.answer("GET /printer/objects/query", 200, PRINTING);
|
|
||||||
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
|
||||||
printer.connect().await.unwrap();
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
||||||
assert_eq!(printer.status().unwrap().percent, Some(25));
|
|
||||||
|
|
||||||
// Klipper shuts down: queries 503, /printer/info says why.
|
|
||||||
mock.answer("GET /printer/objects/query", 503, "{}");
|
|
||||||
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"shutdown"}}"#);
|
|
||||||
tokio::time::sleep(Duration::from_millis(2500)).await;
|
|
||||||
assert_eq!(printer.status().unwrap().state.as_deref(), Some("FAILED"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn upload_sends_the_file_with_print_true_and_refuses_a_project() {
|
|
||||||
let mock = MockServer::start().await;
|
|
||||||
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
|
|
||||||
mock.answer("POST /server/files/upload", 201, r#"{"print_started":true}"#);
|
|
||||||
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
|
||||||
printer.connect().await.unwrap();
|
|
||||||
|
|
||||||
printer.upload_project_file("jobs/cube.gcode".into(), b"G28\nG1 X1\n".to_vec()).await.unwrap();
|
|
||||||
let sent = mock.sent("POST", "/server/files/upload");
|
|
||||||
assert_eq!(sent.len(), 1);
|
|
||||||
let body = sent[0].text();
|
|
||||||
assert!(body.contains("name=\"print\"\r\n\r\ntrue"));
|
|
||||||
assert!(body.contains("filename=\"cube.gcode\""));
|
|
||||||
assert!(body.contains("G28\nG1 X1\n"));
|
|
||||||
assert!(sent[0].header("content-type").unwrap().starts_with("multipart/form-data; boundary="));
|
|
||||||
|
|
||||||
let err = printer.upload_project_file("x.3mf".into(), vec![1]).await.unwrap_err();
|
|
||||||
assert!(err.0.contains("not G-code"), "{err}");
|
|
||||||
assert_eq!(mock.sent("POST", "/server/files/upload").len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn an_upload_is_refused_while_a_print_runs() {
|
|
||||||
let mock = MockServer::start().await;
|
|
||||||
mock.answer("GET /printer/info", 200, r#"{"result":{"state":"ready"}}"#);
|
|
||||||
mock.answer("GET /printer/objects/query", 200, PRINTING);
|
|
||||||
let mut printer = KlipperPrinter::new("k1", "Voron", &mock.host());
|
|
||||||
printer.connect().await.unwrap();
|
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
||||||
|
|
||||||
let err = printer.upload_project_file("b.gcode".into(), vec![1]).await.unwrap_err();
|
|
||||||
assert!(err.0.contains("already printing"), "{err}");
|
|
||||||
assert!(mock.sent("POST", "/server/files/upload").is_empty());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
//! A tiny HTTP server for the REST drivers' tests: canned answers per
|
|
||||||
//! `METHOD /path` (query string ignored), and a record of what was sent.
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Recorded {
|
|
||||||
pub method: String,
|
|
||||||
pub path: String,
|
|
||||||
pub headers: Vec<(String, String)>,
|
|
||||||
pub body: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Recorded {
|
|
||||||
pub fn header(&self, name: &str) -> Option<&str> {
|
|
||||||
self.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
|
|
||||||
}
|
|
||||||
pub fn text(&self) -> String {
|
|
||||||
String::from_utf8_lossy(&self.body).into_owned()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct MockServer {
|
|
||||||
pub addr: SocketAddr,
|
|
||||||
routes: Arc<Mutex<HashMap<String, (u16, String)>>>,
|
|
||||||
requests: Arc<Mutex<Vec<Recorded>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MockServer {
|
|
||||||
pub async fn start() -> Self {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let server = Self {
|
|
||||||
addr: listener.local_addr().unwrap(),
|
|
||||||
routes: Arc::default(),
|
|
||||||
requests: Arc::default(),
|
|
||||||
};
|
|
||||||
let shared = server.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
let Ok((stream, _)) = listener.accept().await else { return };
|
|
||||||
let shared = shared.clone();
|
|
||||||
tokio::spawn(async move { shared.handle(stream).await });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
server
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn host(&self) -> String {
|
|
||||||
self.addr.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `route` is e.g. `"GET /printer/info"`.
|
|
||||||
pub fn answer(&self, route: &str, status: u16, body: &str) {
|
|
||||||
self.routes.lock().unwrap().insert(route.to_string(), (status, body.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn requests(&self) -> Vec<Recorded> {
|
|
||||||
self.requests.lock().unwrap().clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sent(&self, method: &str, path: &str) -> Vec<Recorded> {
|
|
||||||
self.requests().into_iter().filter(|r| r.method == method && r.path == path).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle(&self, mut stream: tokio::net::TcpStream) {
|
|
||||||
let mut buf = Vec::new();
|
|
||||||
let mut chunk = [0u8; 8192];
|
|
||||||
let head_end = loop {
|
|
||||||
if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
|
|
||||||
break i + 4;
|
|
||||||
}
|
|
||||||
match stream.read(&mut chunk).await {
|
|
||||||
Ok(0) | Err(_) => return,
|
|
||||||
Ok(n) => buf.extend_from_slice(&chunk[..n]),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let head = String::from_utf8_lossy(&buf[..head_end]).into_owned();
|
|
||||||
let mut lines = head.split("\r\n");
|
|
||||||
let mut first = lines.next().unwrap_or("").split(' ');
|
|
||||||
let method = first.next().unwrap_or("").to_string();
|
|
||||||
let target = first.next().unwrap_or("").to_string();
|
|
||||||
let headers: Vec<(String, String)> = lines
|
|
||||||
.filter_map(|l| l.split_once(':').map(|(k, v)| (k.trim().to_string(), v.trim().to_string())))
|
|
||||||
.collect();
|
|
||||||
let length: usize = headers
|
|
||||||
.iter()
|
|
||||||
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
|
|
||||||
.and_then(|(_, v)| v.parse().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
while buf.len() < head_end + length {
|
|
||||||
match stream.read(&mut chunk).await {
|
|
||||||
Ok(0) | Err(_) => break,
|
|
||||||
Ok(n) => buf.extend_from_slice(&chunk[..n]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let path = target.split('?').next().unwrap_or("").to_string();
|
|
||||||
let body = buf[head_end..].to_vec();
|
|
||||||
let (status, answer) = self
|
|
||||||
.routes
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.get(&format!("{method} {path}"))
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or((404, "{}".to_string()));
|
|
||||||
self.requests.lock().unwrap().push(Recorded { method, path, headers, body });
|
|
||||||
let response = format!(
|
|
||||||
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{answer}",
|
|
||||||
answer.len()
|
|
||||||
);
|
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
|
||||||
let _ = stream.shutdown().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+24
-221
@@ -22,91 +22,24 @@
|
|||||||
//! Run `cargo run --example printer_polymorphism` to see it in action.
|
//! Run `cargo run --example printer_polymorphism` to see it in action.
|
||||||
//!
|
//!
|
||||||
//! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's
|
//! Real MQTT is built now - `bambu.rs`/`bambu_mqtt.rs` for Bambu's
|
||||||
//! protocol, `bambu_ftps.rs` for getting files onto a Bambu printer,
|
//! protocol, `marlin_serial.rs` for a real Marlin serial connection - see
|
||||||
//! `marlin_serial.rs` for a real Marlin serial connection - see each
|
//! each module's own doc comment. PrusaLink/Klipper's HTTP clients are
|
||||||
//! module's own doc comment. Klipper (`klipper.rs`) and OctoPrint/PrusaLink
|
//! still stubs; that's the next piece to build past this module.
|
||||||
//! (`octoprint.rs`) are real HTTP clients, tested against mock servers only.
|
|
||||||
|
|
||||||
mod bambu;
|
mod bambu;
|
||||||
mod bambu_camera;
|
|
||||||
mod bambu_commands;
|
mod bambu_commands;
|
||||||
mod bambu_ftps;
|
|
||||||
mod bambu_mqtt;
|
mod bambu_mqtt;
|
||||||
mod http;
|
|
||||||
mod klipper;
|
mod klipper;
|
||||||
mod marlin_serial;
|
mod marlin_serial;
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) mod mock_http;
|
|
||||||
mod octoprint;
|
|
||||||
mod polled;
|
|
||||||
mod prusa;
|
mod prusa;
|
||||||
|
|
||||||
// Reading a `.3mf` well enough to know which filament a plate wants. Kept
|
pub use bambu::{BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls};
|
||||||
// free of printer types so it is testable without a printer.
|
|
||||||
mod project_file;
|
|
||||||
pub use project_file::{
|
|
||||||
map_to_slot, map_to_trays, normalise_color, plate_filaments, AvailableTray, ProjectFilament,
|
|
||||||
ProjectFileError,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub use bambu::{SnapshotJob,
|
|
||||||
BambuPrinter, BambuProtocolPreference, BambuProtocolVersion, BambuTls, FilamentSource,
|
|
||||||
LightState, CHAMBER_LIGHT,
|
|
||||||
};
|
|
||||||
pub use bambu_commands::{
|
pub use bambu_commands::{
|
||||||
AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest,
|
AmsState, AmsTray, AmsUnit, BambuReport, BambuRequest, CommandEnvelope, EmptyTray, GetVersionRequest,
|
||||||
GetVersionResponse, Ipcam, LightReport, LoadedTray, ModuleInfo, PrintReport, PushallRequest, VtTray,
|
GetVersionResponse, LoadedTray, PrintReport, PushallRequest, XcamSettings,
|
||||||
XcamSettings,
|
|
||||||
};
|
};
|
||||||
pub use klipper::KlipperPrinter;
|
pub use klipper::KlipperPrinter;
|
||||||
pub use octoprint::{OctoPrintFlavour, OctoPrintPrinter};
|
pub use prusa::{PrusaLinkPrinter, PrusaSerialPrinter};
|
||||||
pub use polled::StatusFeed;
|
|
||||||
pub use prusa::PrusaSerialPrinter;
|
|
||||||
|
|
||||||
/// What the cloud is told about a printer's job, whatever the vendor.
|
|
||||||
///
|
|
||||||
/// `state` uses the strings Bambu reports (`IDLE`, `RUNNING`, `PAUSE`,
|
|
||||||
/// `FINISH`, `FAILED`) because the cloud's `mapStatus` and the completion
|
|
||||||
/// watcher already speak them; the other vendors translate into them.
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq)]
|
|
||||||
pub struct PrinterStatus {
|
|
||||||
pub state: Option<String>,
|
|
||||||
pub percent: Option<i32>,
|
|
||||||
pub stage: Option<i32>,
|
|
||||||
pub layer: Option<i32>,
|
|
||||||
pub total_layers: Option<i32>,
|
|
||||||
pub remaining_minutes: Option<i32>,
|
|
||||||
pub job_name: Option<String>,
|
|
||||||
pub nozzle_temp: Option<f32>,
|
|
||||||
pub nozzle_target: Option<f32>,
|
|
||||||
pub bed_temp: Option<f32>,
|
|
||||||
pub bed_target: Option<f32>,
|
|
||||||
pub print_error: Option<i32>,
|
|
||||||
pub active_alerts: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&PrintReport> for PrinterStatus {
|
|
||||||
fn from(report: &PrintReport) -> Self {
|
|
||||||
Self {
|
|
||||||
state: report.gcode_state.clone(),
|
|
||||||
percent: report.mc_percent,
|
|
||||||
stage: report.stg_cur,
|
|
||||||
layer: report.layer_num,
|
|
||||||
total_layers: report.total_layer_num,
|
|
||||||
remaining_minutes: report.mc_remaining_time,
|
|
||||||
// An idle printer reports `""` rather than omitting the field;
|
|
||||||
// empty-string-as-absent is worth normalising here so the cloud
|
|
||||||
// doesn't have to know that.
|
|
||||||
job_name: report.subtask_name.clone().filter(|s| !s.is_empty()),
|
|
||||||
nozzle_temp: report.nozzle_temper,
|
|
||||||
nozzle_target: report.nozzle_target_temper,
|
|
||||||
bed_temp: report.bed_temper,
|
|
||||||
bed_target: report.bed_target_temper,
|
|
||||||
print_error: report.print_error,
|
|
||||||
active_alerts: report.hms.as_ref().map(|h| h.len()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A tiny error type: just a message. `String` would work too - this exists
|
/// A tiny error type: just a message. `String` would work too - this exists
|
||||||
/// mainly so `?` has something concrete to convert into everywhere.
|
/// mainly so `?` has something concrete to convert into everywhere.
|
||||||
@@ -124,19 +57,6 @@ pub struct PrinterBase {
|
|||||||
|
|
||||||
/// The shared interface - think `interface GenericPrinter` (Java/TS) or an
|
/// The shared interface - think `interface GenericPrinter` (Java/TS) or an
|
||||||
/// ABC (Python). Every vendor's struct implements this.
|
/// 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 {
|
pub trait GenericPrinter {
|
||||||
/// Read-only access to the shared fields.
|
/// Read-only access to the shared fields.
|
||||||
fn base(&self) -> &PrinterBase;
|
fn base(&self) -> &PrinterBase;
|
||||||
@@ -145,11 +65,14 @@ pub trait GenericPrinter {
|
|||||||
/// so there's nothing sensible to share here. Each `impl` in
|
/// so there's nothing sensible to share here. Each `impl` in
|
||||||
/// `bambu.rs`/`prusa.rs`/`klipper.rs` provides a totally different
|
/// `bambu.rs`/`prusa.rs`/`klipper.rs` provides a totally different
|
||||||
/// body - that's the override.
|
/// 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>;
|
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>;
|
async fn publish(&mut self, payload: String) -> Result<(), PrinterError>;
|
||||||
|
|
||||||
/// REQUIRED, no default body: `percent` is 0-100, but how it actually
|
/// REQUIRED, no default body: `percent` is 0-100, but how it actually
|
||||||
@@ -176,17 +99,6 @@ pub trait GenericPrinter {
|
|||||||
/// difference lives, same division of labor as every other method here.
|
/// difference lives, same division of labor as every other method here.
|
||||||
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError>;
|
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
|
/// OPTIONAL: a default body every printer gets for free unless it
|
||||||
/// writes its own. None of ours do below, so all three vendors share
|
/// writes its own. None of ours do below, so all three vendors share
|
||||||
/// this exact implementation - the trait-method equivalent of
|
/// this exact implementation - the trait-method equivalent of
|
||||||
@@ -197,142 +109,33 @@ pub trait GenericPrinter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// M106's `S` parameter is a raw 0-255 PWM value, not a percentage. Shared
|
/// M106's `S` parameter is a raw 0-255 PWM value, not a percentage. Shared
|
||||||
/// by anything that ultimately sends gcode (Bambu, PrusaLink,
|
/// by anything that ultimately sends gcode (Bambu, PrusaLink, PrusaSerial)
|
||||||
/// PrusaSerial) - *not* a trait default, since Klipper's hypothetical
|
/// - *not* a trait default, since Klipper's hypothetical native fan API
|
||||||
/// native fan API below takes a percent directly and has no use for this at
|
/// below takes a percent directly and has no use for this at all. Private,
|
||||||
/// all. Private, but still visible to bambu.rs/prusa.rs: Rust's module
|
/// but still visible to bambu.rs/prusa.rs: Rust's module privacy reaches
|
||||||
/// privacy reaches into child modules, not just the exact file it's defined
|
/// into child modules, not just the exact file it's defined in.
|
||||||
/// in.
|
|
||||||
fn percent_to_pwm(percent: u8) -> u8 {
|
fn percent_to_pwm(percent: u8) -> u8 {
|
||||||
((percent.min(100) as u16 * 255) / 100) as 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.
|
/// The closed set of printer kinds this fleet can talk to.
|
||||||
pub enum PrinterHandle {
|
pub enum PrinterHandle {
|
||||||
Bambu(BambuPrinter),
|
Bambu(BambuPrinter),
|
||||||
OctoPrint(OctoPrintPrinter),
|
PrusaLink(PrusaLinkPrinter),
|
||||||
PrusaSerial(PrusaSerialPrinter),
|
PrusaSerial(PrusaSerialPrinter),
|
||||||
Klipper(KlipperPrinter),
|
Klipper(KlipperPrinter),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrinterHandle {
|
|
||||||
/// A pending camera-frame read, for printers whose camera is a JPEG
|
|
||||||
/// stream rather than RTSP (Bambu P1/A1).
|
|
||||||
pub fn snapshot_job(&self) -> Result<bambu::SnapshotJob, PrinterError> {
|
|
||||||
match self {
|
|
||||||
PrinterHandle::Bambu(p) => p.snapshot_job(),
|
|
||||||
_ => Err(PrinterError("this printer has no snapshot camera".into())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The latest vendor-neutral status, if the printer has one. Bambu's
|
|
||||||
/// comes from its MQTT cache, the HTTP vendors' from their pollers; the
|
|
||||||
/// serial printer has none.
|
|
||||||
pub async fn status(&self) -> Option<PrinterStatus> {
|
|
||||||
match self {
|
|
||||||
PrinterHandle::Bambu(p) => p.print_status().await.as_ref().map(PrinterStatus::from),
|
|
||||||
PrinterHandle::OctoPrint(p) => p.status(),
|
|
||||||
PrinterHandle::Klipper(p) => p.status(),
|
|
||||||
PrinterHandle::PrusaSerial(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bambu's full report, for what is Bambu-specific (camera, AMS).
|
|
||||||
///
|
|
||||||
/// The latest status this process has for the printer, if it has one.
|
|
||||||
///
|
|
||||||
/// An inherent method rather than a `GenericPrinter` trait method,
|
|
||||||
/// 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
|
// Hand-written delegation: each trait method just matches on the variant
|
||||||
// and forwards to that variant's own implementation. This one block is the
|
// and forwards to that variant's own implementation. This one block is the
|
||||||
// only "boilerplate tax" for not having inheritance - everything else reads
|
// only "boilerplate tax" for not having inheritance - everything else reads
|
||||||
// like normal code.
|
// like normal code.
|
||||||
impl GenericPrinter for PrinterHandle {
|
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 {
|
fn base(&self) -> &PrinterBase {
|
||||||
match self {
|
match self {
|
||||||
PrinterHandle::Bambu(p) => p.base(),
|
PrinterHandle::Bambu(p) => p.base(),
|
||||||
PrinterHandle::OctoPrint(p) => p.base(),
|
PrinterHandle::PrusaLink(p) => p.base(),
|
||||||
PrinterHandle::PrusaSerial(p) => p.base(),
|
PrinterHandle::PrusaSerial(p) => p.base(),
|
||||||
PrinterHandle::Klipper(p) => p.base(),
|
PrinterHandle::Klipper(p) => p.base(),
|
||||||
}
|
}
|
||||||
@@ -341,7 +144,7 @@ impl GenericPrinter for PrinterHandle {
|
|||||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
async fn connect(&mut self) -> Result<(), PrinterError> {
|
||||||
match self {
|
match self {
|
||||||
PrinterHandle::Bambu(p) => p.connect().await,
|
PrinterHandle::Bambu(p) => p.connect().await,
|
||||||
PrinterHandle::OctoPrint(p) => p.connect().await,
|
PrinterHandle::PrusaLink(p) => p.connect().await,
|
||||||
PrinterHandle::PrusaSerial(p) => p.connect().await,
|
PrinterHandle::PrusaSerial(p) => p.connect().await,
|
||||||
PrinterHandle::Klipper(p) => p.connect().await,
|
PrinterHandle::Klipper(p) => p.connect().await,
|
||||||
}
|
}
|
||||||
@@ -350,7 +153,7 @@ impl GenericPrinter for PrinterHandle {
|
|||||||
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
|
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
match self {
|
match self {
|
||||||
PrinterHandle::Bambu(p) => p.publish(payload).await,
|
PrinterHandle::Bambu(p) => p.publish(payload).await,
|
||||||
PrinterHandle::OctoPrint(p) => p.publish(payload).await,
|
PrinterHandle::PrusaLink(p) => p.publish(payload).await,
|
||||||
PrinterHandle::PrusaSerial(p) => p.publish(payload).await,
|
PrinterHandle::PrusaSerial(p) => p.publish(payload).await,
|
||||||
PrinterHandle::Klipper(p) => p.publish(payload).await,
|
PrinterHandle::Klipper(p) => p.publish(payload).await,
|
||||||
}
|
}
|
||||||
@@ -359,7 +162,7 @@ impl GenericPrinter for PrinterHandle {
|
|||||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
||||||
match self {
|
match self {
|
||||||
PrinterHandle::Bambu(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::PrusaLink(p) => p.set_fan_speed(percent).await,
|
||||||
PrinterHandle::PrusaSerial(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,
|
PrinterHandle::Klipper(p) => p.set_fan_speed(percent).await,
|
||||||
}
|
}
|
||||||
@@ -368,7 +171,7 @@ impl GenericPrinter for PrinterHandle {
|
|||||||
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
||||||
match self {
|
match self {
|
||||||
PrinterHandle::Bambu(p) => p.upload_project_file(file_name, data).await,
|
PrinterHandle::Bambu(p) => p.upload_project_file(file_name, data).await,
|
||||||
PrinterHandle::OctoPrint(p) => p.upload_project_file(file_name, data).await,
|
PrinterHandle::PrusaLink(p) => p.upload_project_file(file_name, data).await,
|
||||||
PrinterHandle::PrusaSerial(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,
|
PrinterHandle::Klipper(p) => p.upload_project_file(file_name, data).await,
|
||||||
}
|
}
|
||||||
@@ -377,7 +180,7 @@ impl GenericPrinter for PrinterHandle {
|
|||||||
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||||
match self {
|
match self {
|
||||||
PrinterHandle::Bambu(p) => p.send_gcode_line(gcode_line).await,
|
PrinterHandle::Bambu(p) => p.send_gcode_line(gcode_line).await,
|
||||||
PrinterHandle::OctoPrint(p) => p.send_gcode_line(gcode_line).await,
|
PrinterHandle::PrusaLink(p) => p.send_gcode_line(gcode_line).await,
|
||||||
PrinterHandle::PrusaSerial(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,
|
PrinterHandle::Klipper(p) => p.send_gcode_line(gcode_line).await,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,417 +0,0 @@
|
|||||||
//! OctoPrint and PrusaLink over HTTP (`X-Api-Key`).
|
|
||||||
//!
|
|
||||||
//! Written from the two projects' published REST APIs and exercised against
|
|
||||||
//! a mock server in the tests below - no real OctoPrint or PrusaLink machine
|
|
||||||
//! has been on the bench, so treat the field names as unconfirmed until one
|
|
||||||
//! has.
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
|
|
||||||
use super::http::{encode_segment, safe_file_name, Http};
|
|
||||||
use super::polled::StatusFeed;
|
|
||||||
use super::{GenericPrinter, PrintControl, PrinterBase, PrinterError, PrinterStatus};
|
|
||||||
|
|
||||||
const POLL_EVERY: Duration = Duration::from_secs(3);
|
|
||||||
|
|
||||||
/// Which implementation of the OctoPrint REST API is on the other end.
|
|
||||||
///
|
|
||||||
/// PrusaLink deliberately implements OctoPrint's API, so one client serves
|
|
||||||
/// both - but not identically. PrusaLink adds its own `/api/v1/*` (`info`,
|
|
||||||
/// `status`, `job`) which is where the useful detail lives, while OctoPrint
|
|
||||||
/// proper has the older `/api/printer` and `/api/job` and a plugin surface
|
|
||||||
/// PrusaLink does not have.
|
|
||||||
///
|
|
||||||
/// This is recorded from config now, while the client is still a stub,
|
|
||||||
/// because **no probe can settle it**: both answer `/api/version`, and every
|
|
||||||
/// PrusaLink identity endpoint needs a credential, so from outside the two
|
|
||||||
/// are indistinguishable (see `discovery::net::classify_octoprint_api`). It
|
|
||||||
/// is the operator's answer to a question only they can answer, so it needs
|
|
||||||
/// somewhere to live the moment they are asked - otherwise the real client
|
|
||||||
/// would have to ask them a second time.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum OctoPrintFlavour {
|
|
||||||
/// Prusa's own firmware-hosted implementation, on an MK4/XL/MINI.
|
|
||||||
PrusaLink,
|
|
||||||
/// OctoPrint itself, usually on a Raspberry Pi in front of any printer.
|
|
||||||
Generic,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OctoPrintFlavour {
|
|
||||||
pub fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
OctoPrintFlavour::PrusaLink => "PrusaLink",
|
|
||||||
OctoPrintFlavour::Generic => "OctoPrint",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
pub struct OctoPrintPrinter {
|
|
||||||
base: PrinterBase,
|
|
||||||
pub flavour: OctoPrintFlavour,
|
|
||||||
http: Http,
|
|
||||||
feed: Option<StatusFeed>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OctoPrintPrinter {
|
|
||||||
pub fn new(id: &str, name: &str, host: &str, api_key: &str, flavour: OctoPrintFlavour) -> Self {
|
|
||||||
Self {
|
|
||||||
base: PrinterBase { id: id.into(), name: name.into(), host: host.into() },
|
|
||||||
flavour,
|
|
||||||
// Port 80: OctoPrint's usual front (haproxy) and PrusaLink both
|
|
||||||
// answer there. An unusual port goes in `host`.
|
|
||||||
http: Http::new(host, 80, Some(api_key)),
|
|
||||||
feed: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn status(&self) -> Option<PrinterStatus> {
|
|
||||||
self.feed.as_ref().and_then(StatusFeed::latest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn number(v: Option<&Value>) -> Option<f32> {
|
|
||||||
v.and_then(Value::as_f64).map(|n| n as f32)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OctoPrint's `GET /api/job`, plus `GET /api/printer` for temperatures
|
|
||||||
/// (which is a 409 whenever the printer is not operational, hence `Option`).
|
|
||||||
fn octoprint_status(job: &Value, printer: Option<&Value>) -> Option<PrinterStatus> {
|
|
||||||
let state = job.get("state")?.as_str()?;
|
|
||||||
let completion = number(job.get("progress").and_then(|p| p.get("completion")));
|
|
||||||
let left = number(job.get("progress").and_then(|p| p.get("printTimeLeft")));
|
|
||||||
|
|
||||||
let (mapped, percent) = if state.starts_with("Printing") || state == "Cancelling" {
|
|
||||||
("RUNNING", completion.unwrap_or(0.0))
|
|
||||||
} else if state == "Pausing" || state == "Paused" {
|
|
||||||
("PAUSE", completion.unwrap_or(0.0))
|
|
||||||
} else if state == "Operational" {
|
|
||||||
// OctoPrint has no "finished" state: the job simply stays at 100%.
|
|
||||||
match completion {
|
|
||||||
Some(c) if c >= 100.0 => ("FINISH", 100.0),
|
|
||||||
_ => ("IDLE", 0.0),
|
|
||||||
}
|
|
||||||
} else if state.contains("rror") && !state.starts_with("Offline") {
|
|
||||||
("FAILED", 0.0)
|
|
||||||
} else {
|
|
||||||
// Offline, still opening the serial port, ...: not a state worth
|
|
||||||
// reporting as the printer's.
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
let tool = printer.and_then(|p| p.get("temperature")).and_then(|t| t.get("tool0"));
|
|
||||||
let bed = printer.and_then(|p| p.get("temperature")).and_then(|t| t.get("bed"));
|
|
||||||
Some(PrinterStatus {
|
|
||||||
state: Some(mapped.into()),
|
|
||||||
percent: Some(percent.round() as i32),
|
|
||||||
remaining_minutes: (mapped == "RUNNING" || mapped == "PAUSE").then_some(left).flatten().map(|s| (s / 60.0).round() as i32),
|
|
||||||
job_name: job
|
|
||||||
.get("job")
|
|
||||||
.and_then(|j| j.get("file"))
|
|
||||||
.and_then(|f| f.get("display").or_else(|| f.get("name")))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string),
|
|
||||||
nozzle_temp: number(tool.and_then(|t| t.get("actual"))),
|
|
||||||
nozzle_target: number(tool.and_then(|t| t.get("target"))),
|
|
||||||
bed_temp: number(bed.and_then(|t| t.get("actual"))),
|
|
||||||
bed_target: number(bed.and_then(|t| t.get("target"))),
|
|
||||||
print_error: (mapped == "FAILED").then_some(1),
|
|
||||||
..PrinterStatus::default()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PrusaLink's `GET /api/v1/status`, with `GET /api/v1/job` for the file name
|
|
||||||
/// when a job exists.
|
|
||||||
fn prusalink_status(status: &Value, job: Option<&Value>) -> Option<PrinterStatus> {
|
|
||||||
let printer = status.get("printer")?;
|
|
||||||
let state = printer.get("state")?.as_str()?;
|
|
||||||
let mapped = match state {
|
|
||||||
"IDLE" | "STOPPED" => "IDLE",
|
|
||||||
"BUSY" => "PREPARE",
|
|
||||||
"PRINTING" => "RUNNING",
|
|
||||||
"PAUSED" | "ATTENTION" => "PAUSE",
|
|
||||||
"FINISHED" => "FINISH",
|
|
||||||
"ERROR" => "FAILED",
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
let running = matches!(mapped, "RUNNING" | "PAUSE");
|
|
||||||
let progress = number(status.get("job").and_then(|j| j.get("progress")));
|
|
||||||
let left = number(status.get("job").and_then(|j| j.get("time_remaining")));
|
|
||||||
Some(PrinterStatus {
|
|
||||||
state: Some(mapped.into()),
|
|
||||||
percent: Some(match mapped {
|
|
||||||
"FINISH" => 100,
|
|
||||||
_ if running => progress.unwrap_or(0.0).round() as i32,
|
|
||||||
_ => 0,
|
|
||||||
}),
|
|
||||||
remaining_minutes: running.then_some(left).flatten().map(|s| (s / 60.0).round() as i32),
|
|
||||||
job_name: job
|
|
||||||
.and_then(|j| j.get("file"))
|
|
||||||
.and_then(|f| f.get("display_name").or_else(|| f.get("name")))
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(str::to_string),
|
|
||||||
nozzle_temp: number(printer.get("temp_nozzle")),
|
|
||||||
nozzle_target: number(printer.get("target_nozzle")),
|
|
||||||
bed_temp: number(printer.get("temp_bed")),
|
|
||||||
bed_target: number(printer.get("target_bed")),
|
|
||||||
print_error: (mapped == "FAILED").then_some(1),
|
|
||||||
..PrinterStatus::default()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn poll(http: &Http, flavour: OctoPrintFlavour) -> Option<PrinterStatus> {
|
|
||||||
match flavour {
|
|
||||||
OctoPrintFlavour::Generic => {
|
|
||||||
let job = http.get_json("/api/job").await.ok()?;
|
|
||||||
let printer = http.get_json("/api/printer").await.ok();
|
|
||||||
octoprint_status(&job, printer.as_ref())
|
|
||||||
}
|
|
||||||
OctoPrintFlavour::PrusaLink => {
|
|
||||||
let status = http.get_json("/api/v1/status").await.ok()?;
|
|
||||||
let has_job = status.get("job").is_some_and(|j| !j.is_null());
|
|
||||||
let job = if has_job { http.get_json("/api/v1/job").await.ok() } else { None };
|
|
||||||
prusalink_status(&status, job.as_ref())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GenericPrinter for OctoPrintPrinter {
|
|
||||||
/// OctoPrint: `POST /api/job` with a command. PrusaLink addresses the
|
|
||||||
/// running job by id, so the current job is fetched first.
|
|
||||||
async fn control_print(&mut self, action: PrintControl) -> Result<(), PrinterError> {
|
|
||||||
match self.flavour {
|
|
||||||
OctoPrintFlavour::Generic => {
|
|
||||||
let body = match action {
|
|
||||||
PrintControl::Pause => json!({ "command": "pause", "action": "pause" }),
|
|
||||||
PrintControl::Resume => json!({ "command": "pause", "action": "resume" }),
|
|
||||||
PrintControl::Stop => json!({ "command": "cancel" }),
|
|
||||||
};
|
|
||||||
self.http.post_json("/api/job", &body).await.map(|_| ())
|
|
||||||
}
|
|
||||||
OctoPrintFlavour::PrusaLink => {
|
|
||||||
let status = self.http.get_json("/api/v1/status").await?;
|
|
||||||
let id = status
|
|
||||||
.get("job")
|
|
||||||
.and_then(|j| j.get("id"))
|
|
||||||
.and_then(Value::as_i64)
|
|
||||||
.ok_or_else(|| PrinterError(format!("{} has no job to {}", self.base.name, action.as_str())))?;
|
|
||||||
match action {
|
|
||||||
PrintControl::Pause => self.http.put_empty(&format!("/api/v1/job/{id}/pause")).await,
|
|
||||||
PrintControl::Resume => self.http.put_empty(&format!("/api/v1/job/{id}/resume")).await,
|
|
||||||
PrintControl::Stop => self.http.delete(&format!("/api/v1/job/{id}")).await,
|
|
||||||
}
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn base(&self) -> &PrinterBase {
|
|
||||||
&self.base
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Checks the printer answers with this key, then starts the poller.
|
|
||||||
async fn connect(&mut self) -> Result<(), PrinterError> {
|
|
||||||
if self.base.host.trim().is_empty() {
|
|
||||||
return Err(PrinterError(format!("{} has no host configured", self.base.name)));
|
|
||||||
}
|
|
||||||
// `/api/version` needs no permissions on either, so it would pass
|
|
||||||
// with a wrong key; these two are the first that check it.
|
|
||||||
let probe = match self.flavour {
|
|
||||||
OctoPrintFlavour::Generic => "/api/job",
|
|
||||||
OctoPrintFlavour::PrusaLink => "/api/v1/status",
|
|
||||||
};
|
|
||||||
self.http
|
|
||||||
.get_json(probe)
|
|
||||||
.await
|
|
||||||
.map_err(|err| PrinterError(format!("{} did not answer as {}: {err}", self.base.name, self.flavour.as_str())))?;
|
|
||||||
let (http, flavour) = (self.http.clone(), self.flavour);
|
|
||||||
self.feed = Some(StatusFeed::start(POLL_EVERY, move || {
|
|
||||||
let http = http.clone();
|
|
||||||
async move { poll(&http, flavour).await }
|
|
||||||
}));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
|
|
||||||
Err(PrinterError(format!("{} has no publish channel", self.flavour.as_str())))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
|
||||||
self.send_gcode_line(format!("M106 S{}", super::percent_to_pwm(percent))).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Uploads and starts the print. OctoPrint takes a multipart POST to
|
|
||||||
/// `/api/files/local`; PrusaLink a raw `PUT /api/v1/files/local/{name}`
|
|
||||||
/// with `Print-After-Upload`. Both run sliced G-code, not a project.
|
|
||||||
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
|
||||||
let name = safe_file_name(&file_name)?;
|
|
||||||
let lower = name.to_lowercase();
|
|
||||||
let printable = [".gcode", ".gco", ".g", ".bgcode"].iter().any(|ext| lower.ends_with(ext));
|
|
||||||
if !printable {
|
|
||||||
return Err(PrinterError(format!(
|
|
||||||
"{name} is not G-code - {} needs a sliced file, not a project",
|
|
||||||
self.flavour.as_str()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if let Some(status) = self.status()
|
|
||||||
&& matches!(status.state.as_deref(), Some("RUNNING" | "PAUSE" | "PREPARE"))
|
|
||||||
{
|
|
||||||
return Err(PrinterError(format!("{} is already printing", self.base.name)));
|
|
||||||
}
|
|
||||||
match self.flavour {
|
|
||||||
OctoPrintFlavour::Generic => self
|
|
||||||
.http
|
|
||||||
.post_file("/api/files/local", &[("select", "true"), ("print", "true")], "file", &name, &data)
|
|
||||||
.await
|
|
||||||
.map(|_| ()),
|
|
||||||
OctoPrintFlavour::PrusaLink => self
|
|
||||||
.http
|
|
||||||
.put_bytes(
|
|
||||||
&format!("/api/v1/files/local/{}", encode_segment(&name)),
|
|
||||||
&[("Print-After-Upload", "?1"), ("Overwrite", "?1")],
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map(|_| ()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
|
||||||
match self.flavour {
|
|
||||||
OctoPrintFlavour::Generic => self
|
|
||||||
.http
|
|
||||||
.post_json("/api/printer/command", &json!({ "commands": [gcode_line] }))
|
|
||||||
.await
|
|
||||||
.map(|_| ()),
|
|
||||||
// PrusaLink's API has no G-code endpoint.
|
|
||||||
OctoPrintFlavour::PrusaLink => {
|
|
||||||
Err(PrinterError("PrusaLink cannot be sent G-code lines".into()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::printer::mock_http::MockServer;
|
|
||||||
|
|
||||||
const JOB: &str = r#"{"job":{"file":{"name":"cube.gcode","display":"cube.gcode"}},
|
|
||||||
"progress":{"completion":40.0,"printTime":600,"printTimeLeft":900},"state":"Printing"}"#;
|
|
||||||
const TEMPS: &str =
|
|
||||||
r#"{"temperature":{"tool0":{"actual":209.5,"target":210.0},"bed":{"actual":60.0,"target":60.0}}}"#;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn octoprint_states_map_to_the_shared_vocabulary() {
|
|
||||||
let status = octoprint_status(&serde_json::from_str(JOB).unwrap(), serde_json::from_str(TEMPS).ok().as_ref()).unwrap();
|
|
||||||
assert_eq!(status.state.as_deref(), Some("RUNNING"));
|
|
||||||
assert_eq!(status.percent, Some(40));
|
|
||||||
assert_eq!(status.remaining_minutes, Some(15));
|
|
||||||
assert_eq!(status.job_name.as_deref(), Some("cube.gcode"));
|
|
||||||
assert_eq!(status.nozzle_target, Some(210.0));
|
|
||||||
|
|
||||||
// Finished: still "Operational", job stays at 100%. No temperatures
|
|
||||||
// (the printer endpoint was a 409) is fine.
|
|
||||||
let done = r#"{"state":"Operational","progress":{"completion":100.0}}"#;
|
|
||||||
let status = octoprint_status(&serde_json::from_str(done).unwrap(), None).unwrap();
|
|
||||||
assert_eq!((status.state.as_deref(), status.percent, status.nozzle_temp), (Some("FINISH"), Some(100), None));
|
|
||||||
|
|
||||||
let idle = r#"{"state":"Operational","progress":{"completion":null}}"#;
|
|
||||||
assert_eq!(octoprint_status(&serde_json::from_str(idle).unwrap(), None).unwrap().state.as_deref(), Some("IDLE"));
|
|
||||||
|
|
||||||
let offline = r#"{"state":"Offline","progress":{}}"#;
|
|
||||||
assert!(octoprint_status(&serde_json::from_str(offline).unwrap(), None).is_none());
|
|
||||||
let broken = r#"{"state":"Error","progress":{}}"#;
|
|
||||||
assert_eq!(octoprint_status(&serde_json::from_str(broken).unwrap(), None).unwrap().print_error, Some(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prusalink_states_map_to_the_shared_vocabulary() {
|
|
||||||
let status = r#"{"printer":{"state":"PRINTING","temp_nozzle":215.1,"target_nozzle":215.0,"temp_bed":60.2,"target_bed":60.0},
|
|
||||||
"job":{"id":7,"progress":33.0,"time_remaining":1200,"time_printing":300}}"#;
|
|
||||||
let job = r#"{"id":7,"file":{"name":"CUBE~1.GCO","display_name":"cube.gcode"}}"#;
|
|
||||||
let parsed = prusalink_status(&serde_json::from_str(status).unwrap(), serde_json::from_str(job).ok().as_ref()).unwrap();
|
|
||||||
assert_eq!(parsed.state.as_deref(), Some("RUNNING"));
|
|
||||||
assert_eq!((parsed.percent, parsed.remaining_minutes), (Some(33), Some(20)));
|
|
||||||
assert_eq!(parsed.job_name.as_deref(), Some("cube.gcode"));
|
|
||||||
assert_eq!(parsed.bed_temp, Some(60.2));
|
|
||||||
|
|
||||||
let idle = r#"{"printer":{"state":"IDLE","temp_nozzle":25.0,"temp_bed":24.0}}"#;
|
|
||||||
let parsed = prusalink_status(&serde_json::from_str(idle).unwrap(), None).unwrap();
|
|
||||||
assert_eq!((parsed.state.as_deref(), parsed.percent), (Some("IDLE"), Some(0)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn octoprint_controls_upload_and_send_the_key() {
|
|
||||||
let mock = MockServer::start().await;
|
|
||||||
mock.answer("GET /api/job", 200, r#"{"state":"Operational","progress":{}}"#);
|
|
||||||
mock.answer("POST /api/job", 204, "");
|
|
||||||
mock.answer("POST /api/printer/command", 204, "");
|
|
||||||
mock.answer("POST /api/files/local", 201, r#"{"done":true}"#);
|
|
||||||
let mut printer = OctoPrintPrinter::new("o1", "Ender", &mock.host(), "KEY123", OctoPrintFlavour::Generic);
|
|
||||||
printer.connect().await.unwrap();
|
|
||||||
|
|
||||||
printer.control_print(PrintControl::Resume).await.unwrap();
|
|
||||||
let sent = mock.sent("POST", "/api/job");
|
|
||||||
assert_eq!(sent[0].text(), r#"{"action":"resume","command":"pause"}"#);
|
|
||||||
assert_eq!(sent[0].header("x-api-key"), Some("KEY123"));
|
|
||||||
printer.control_print(PrintControl::Stop).await.unwrap();
|
|
||||||
assert_eq!(mock.sent("POST", "/api/job")[1].text(), r#"{"command":"cancel"}"#);
|
|
||||||
|
|
||||||
printer.set_fan_speed(100).await.unwrap();
|
|
||||||
assert_eq!(mock.sent("POST", "/api/printer/command")[0].text(), r#"{"commands":["M106 S255"]}"#);
|
|
||||||
|
|
||||||
printer.upload_project_file("a.gcode".into(), b"G28".to_vec()).await.unwrap();
|
|
||||||
let body = mock.sent("POST", "/api/files/local")[0].text();
|
|
||||||
assert!(body.contains("name=\"print\"\r\n\r\ntrue") && body.contains("filename=\"a.gcode\""));
|
|
||||||
assert!(printer.upload_project_file("a.3mf".into(), vec![1]).await.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn a_wrong_key_fails_connect() {
|
|
||||||
let mock = MockServer::start().await;
|
|
||||||
mock.answer("GET /api/job", 403, r#"{"error":"forbidden"}"#);
|
|
||||||
let mut printer = OctoPrintPrinter::new("o1", "Ender", &mock.host(), "bad", OctoPrintFlavour::Generic);
|
|
||||||
assert!(printer.connect().await.unwrap_err().0.contains("403"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn prusalink_addresses_the_running_job_by_id() {
|
|
||||||
let mock = MockServer::start().await;
|
|
||||||
mock.answer("GET /api/v1/status", 200, r#"{"printer":{"state":"PRINTING"},"job":{"id":7,"progress":10.0}}"#);
|
|
||||||
mock.answer("GET /api/v1/job", 200, r#"{"id":7,"file":{"display_name":"c.gcode"}}"#);
|
|
||||||
mock.answer("PUT /api/v1/job/7/pause", 204, "");
|
|
||||||
mock.answer("DELETE /api/v1/job/7", 204, "");
|
|
||||||
let mut printer = OctoPrintPrinter::new("p1", "MK4", &mock.host(), "K", OctoPrintFlavour::PrusaLink);
|
|
||||||
printer.connect().await.unwrap();
|
|
||||||
|
|
||||||
printer.control_print(PrintControl::Pause).await.unwrap();
|
|
||||||
printer.control_print(PrintControl::Stop).await.unwrap();
|
|
||||||
assert_eq!(mock.sent("PUT", "/api/v1/job/7/pause").len(), 1);
|
|
||||||
assert_eq!(mock.sent("DELETE", "/api/v1/job/7").len(), 1);
|
|
||||||
assert!(printer.send_gcode_line("M106".into()).await.is_err());
|
|
||||||
|
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
||||||
assert_eq!(printer.status().unwrap().job_name.as_deref(), Some("c.gcode"));
|
|
||||||
// A job is running, so an upload is refused before anything is sent.
|
|
||||||
let err = printer.upload_project_file("n.bgcode".into(), vec![1]).await.unwrap_err();
|
|
||||||
assert!(err.0.contains("already printing"), "{err}");
|
|
||||||
assert!(mock.sent("PUT", "/api/v1/files/local/n.bgcode").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn prusalink_upload_is_a_put_with_print_after_upload() {
|
|
||||||
let mock = MockServer::start().await;
|
|
||||||
mock.answer("GET /api/v1/status", 200, r#"{"printer":{"state":"IDLE"}}"#);
|
|
||||||
mock.answer("PUT /api/v1/files/local/my%20part.gcode", 201, "");
|
|
||||||
let mut printer = OctoPrintPrinter::new("p1", "MK4", &mock.host(), "K", OctoPrintFlavour::PrusaLink);
|
|
||||||
printer.connect().await.unwrap();
|
|
||||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
||||||
|
|
||||||
printer.upload_project_file("my part.gcode".into(), b"G28".to_vec()).await.unwrap();
|
|
||||||
let sent = mock.sent("PUT", "/api/v1/files/local/my%20part.gcode");
|
|
||||||
assert_eq!(sent[0].header("print-after-upload"), Some("?1"));
|
|
||||||
assert_eq!(sent[0].body, b"G28");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
//! Status for vendors that have no push feed: a background task asks the
|
|
||||||
//! printer on an interval and keeps the last answer for `status()` to read.
|
|
||||||
//! (Bambu pushes over MQTT and keeps its own cache.)
|
|
||||||
use std::future::Future;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
|
|
||||||
use super::PrinterStatus;
|
|
||||||
|
|
||||||
pub struct StatusFeed {
|
|
||||||
latest: Arc<Mutex<Option<PrinterStatus>>>,
|
|
||||||
task: JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StatusFeed {
|
|
||||||
/// `fetch` returning `None` means "no usable answer" (printer
|
|
||||||
/// unreachable, or not ready), and clears the cache: a stale status
|
|
||||||
/// shown as current is worse than none.
|
|
||||||
pub fn start<F, Fut>(interval: Duration, fetch: F) -> Self
|
|
||||||
where
|
|
||||||
F: Fn() -> Fut + Send + 'static,
|
|
||||||
Fut: Future<Output = Option<PrinterStatus>> + Send,
|
|
||||||
{
|
|
||||||
let latest = Arc::new(Mutex::new(None));
|
|
||||||
let shared = latest.clone();
|
|
||||||
let task = tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
let status = fetch().await;
|
|
||||||
*shared.lock().unwrap() = status;
|
|
||||||
tokio::time::sleep(interval).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Self { latest, task }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn latest(&self) -> Option<PrinterStatus> {
|
|
||||||
self.latest.lock().unwrap().clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for StatusFeed {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.task.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
//! 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 },
|
|
||||||
#[error("cannot use AMS slot {0}: {1}")]
|
|
||||||
BadSlot(i32, 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.
|
|
||||||
/// An explicit choice of slot instead of the colour-based pick: the operator
|
|
||||||
/// said which spool to use. Deliberately narrow - a single-filament project
|
|
||||||
/// only (which filament goes where is ambiguous otherwise), onto a slot that
|
|
||||||
/// is loaded and holds the same *material type*, because the type decides
|
|
||||||
/// the temperatures. A different colour is allowed: that is the point.
|
|
||||||
pub fn map_to_slot(
|
|
||||||
filaments: &[ProjectFilament],
|
|
||||||
trays: &[AvailableTray],
|
|
||||||
slot: i32,
|
|
||||||
) -> Result<Vec<i32>, ProjectFileError> {
|
|
||||||
let [filament] = filaments else {
|
|
||||||
return Err(ProjectFileError::BadSlot(
|
|
||||||
slot,
|
|
||||||
format!("the project uses {} filaments; a chosen slot is only for single-filament projects", filaments.len()),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
let tray = trays.iter().find(|t| t.slot == slot).ok_or_else(|| {
|
|
||||||
ProjectFileError::BadSlot(slot, "no filament is loaded in that slot".into())
|
|
||||||
})?;
|
|
||||||
if !tray.filament_type.eq_ignore_ascii_case(&filament.filament_type) {
|
|
||||||
return Err(ProjectFileError::BadSlot(
|
|
||||||
slot,
|
|
||||||
format!("it holds {} but the project was sliced for {}", tray.filament_type, filament.filament_type),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut mapping = vec![-1; filament.index + 1];
|
|
||||||
mapping[filament.index] = slot;
|
|
||||||
Ok(mapping)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tier(filament: &ProjectFilament, tray: &AvailableTray) -> Option<u8> {
|
|
||||||
let same_product = filament.tray_info_idx.eq_ignore_ascii_case(&tray.tray_info_idx);
|
|
||||||
let same_color = filament.color == tray.color;
|
|
||||||
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;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_chosen_slot_overrides_the_colour_pick_but_not_the_material() {
|
|
||||||
let f = [filament(0, "GFG99", "PETG", "161616")];
|
|
||||||
let t = [tray(2, "GFG99", "PETG", "161616"), tray(3, "GFG99", "PETG", "2850E0"), tray(1, "GFL99", "PLA", "D3C5A3")];
|
|
||||||
assert_eq!(map_to_trays(&f, &t).unwrap(), vec![2]);
|
|
||||||
assert_eq!(map_to_slot(&f, &t, 3).unwrap(), vec![3]);
|
|
||||||
assert!(map_to_slot(&f, &t, 1).is_err(), "PLA slot for a PETG project");
|
|
||||||
assert!(map_to_slot(&f, &t, 4).is_err(), "empty slot");
|
|
||||||
let two = [filament(0, "GFG99", "PETG", "161616"), filament(1, "GFG99", "PETG", "2850E0")];
|
|
||||||
assert!(map_to_slot(&two, &t, 3).is_err(), "multi-filament project");
|
|
||||||
}
|
|
||||||
|
|
||||||
const REAL: &str = r##"<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<config>
|
|
||||||
<header>
|
|
||||||
<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))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+63
-14
@@ -1,4 +1,66 @@
|
|||||||
use super::{GenericPrinter, PrinterBase, PrinterError};
|
use super::{GenericPrinter, PrinterBase, PrinterError};
|
||||||
|
|
||||||
|
/****************** PrusaLink ******************/
|
||||||
|
|
||||||
|
pub struct PrusaLinkPrinter {
|
||||||
|
base: PrinterBase,
|
||||||
|
pub api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 GenericPrinter for PrusaLinkPrinter {
|
||||||
|
fn base(&self) -> &PrinterBase {
|
||||||
|
&self.base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OVERRIDE: a completely different body from Bambu's - PrusaLink is a
|
||||||
|
/// plain REST API (real version: `GET /api/v1/status` with the API
|
||||||
|
/// key, see `adapters::prusalink`), no persistent session to hold.
|
||||||
|
async fn connect(&mut self) -> Result<(), PrinterError> {
|
||||||
|
println!("[{}] connecting over HTTP (PrusaLink)", self.base.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
|
// TODO: add publishing via HTTP
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OVERRIDE: gcode via PrusaLink's command-injection endpoint - same
|
||||||
|
/// underlying M106 as Bambu, reusing the shared percent->PWM
|
||||||
|
/// conversion, but sent over HTTP instead of MQTT.
|
||||||
|
async fn set_fan_speed(&mut self, percent: u8) -> Result<(), PrinterError> {
|
||||||
|
let pwm = super::percent_to_pwm(percent);
|
||||||
|
println!("[{}] POST /api/printer/command {{\"commands\":[\"M106 S{pwm}\"]}}", self.base.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OVERRIDE: real version is `PUT /api/v1/files/local/{file_name}` with
|
||||||
|
/// `data` as the request body - PrusaLink stores it on the printer's own
|
||||||
|
/// SD card, same "upload to storage the firmware owns" model as Bambu.
|
||||||
|
async fn upload_project_file(&mut self, file_name: String, data: Vec<u8>) -> Result<(), PrinterError> {
|
||||||
|
println!("[{}] PUT /api/v1/files/local/{file_name} ({} bytes)", self.base.name, data.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OVERRIDE: PrusaLink's command-injection endpoint, one line per call -
|
||||||
|
/// same endpoint `set_fan_speed` above already uses for M106, just with
|
||||||
|
/// an arbitrary line instead of a hardcoded one.
|
||||||
|
async fn send_gcode_line(&mut self, gcode_line: String) -> Result<(), PrinterError> {
|
||||||
|
println!("[{}] POST /api/printer/command {{\"commands\":[\"{gcode_line}\"]}}", self.base.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/****************** PrusaSerial ******************/
|
/****************** PrusaSerial ******************/
|
||||||
|
|
||||||
pub struct PrusaSerialPrinter {
|
pub struct PrusaSerialPrinter {
|
||||||
@@ -24,19 +86,6 @@ impl PrusaSerialPrinter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GenericPrinter for PrusaSerialPrinter {
|
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 {
|
fn base(&self) -> &PrinterBase {
|
||||||
&self.base
|
&self.base
|
||||||
}
|
}
|
||||||
@@ -51,7 +100,7 @@ impl GenericPrinter for PrusaSerialPrinter {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish(&mut self, _payload: String) -> Result<(), PrinterError> {
|
async fn publish(&mut self, payload: String) -> Result<(), PrinterError> {
|
||||||
// TODO: add publishing via serial
|
// TODO: add publishing via serial
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,247 +0,0 @@
|
|||||||
//! Adding and removing printers on a running gateway, on the console's say-so.
|
|
||||||
//!
|
|
||||||
//! The access code arrives in a `provision_printer` message, is checked by
|
|
||||||
//! actually connecting, and is then written to the local store (src/store.rs).
|
|
||||||
//! It is never logged and never sent anywhere. The cloud does not keep it -
|
|
||||||
//! it relayed it - so this is the only place it exists.
|
|
||||||
//!
|
|
||||||
//! Connect first, store second: a printer that cannot be reached with the
|
|
||||||
//! code the operator typed is a typo to report now, not a row to discover
|
|
||||||
//! later in a fleet that quietly never comes up.
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
use crate::fleet;
|
|
||||||
use crate::printer::GenericPrinter;
|
|
||||||
use crate::store::Store;
|
|
||||||
use crate::uplink::SharedFleet;
|
|
||||||
use crate::uplink::protocol::ProvisionPrinter;
|
|
||||||
|
|
||||||
/// Bambu's protocol auto-detection can spend several seconds on a first
|
|
||||||
/// attempt, and an operator is watching a spinner.
|
|
||||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
/// Ids end up in MQTT topics, file names (pinned certificates) and log
|
|
||||||
/// lines, so they are held to a small safe alphabet.
|
|
||||||
fn valid_id(id: &str) -> bool {
|
|
||||||
!id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn add(fleet: &SharedFleet, store: &Store, request: ProvisionPrinter) -> Result<(), String> {
|
|
||||||
if !valid_id(&request.id) {
|
|
||||||
return Err("printer id must be 1-64 letters, digits, '-' or '_'".into());
|
|
||||||
}
|
|
||||||
if fleet.lock().await.iter().any(|p| p.base().id == request.id) {
|
|
||||||
return Err(format!("this gateway already has a printer with id '{}'", request.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry = fleet::PrinterEntry {
|
|
||||||
id: request.id,
|
|
||||||
name: request.name,
|
|
||||||
vendor: request.vendor,
|
|
||||||
host: request.host,
|
|
||||||
access_code: request.access_code,
|
|
||||||
sn: request.sn,
|
|
||||||
api_key: request.api_key,
|
|
||||||
port: None,
|
|
||||||
baud_rate: 115_200,
|
|
||||||
ca_cert_path: None,
|
|
||||||
require_valid_cert: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Built (which checks the required fields are present and non-blank) and
|
|
||||||
// connected before the fleet lock is taken: connecting can take seconds,
|
|
||||||
// and the lock is what telemetry waits on.
|
|
||||||
let mut printer = fleet::build(entry.clone()).map_err(|err| err.to_string())?;
|
|
||||||
match tokio::time::timeout(CONNECT_TIMEOUT, printer.connect()).await {
|
|
||||||
Ok(Ok(())) => {}
|
|
||||||
Ok(Err(err)) => return Err(format!("could not connect to the printer: {err}")),
|
|
||||||
Err(_) => return Err("the printer did not answer in time - check the address and that it is on".into()),
|
|
||||||
}
|
|
||||||
|
|
||||||
store.save_printer(&entry).map_err(|err| format!("connected, but could not save it locally: {err}"))?;
|
|
||||||
info!(printer = %entry.id, "printer added from the console");
|
|
||||||
|
|
||||||
let mut fleet = fleet.lock().await;
|
|
||||||
// Re-checked under the lock: two requests for one id can both pass the
|
|
||||||
// check above while the first is still connecting.
|
|
||||||
if fleet.iter().any(|p| p.base().id == entry.id) {
|
|
||||||
return Err(format!("this gateway already has a printer with id '{}'", entry.id));
|
|
||||||
}
|
|
||||||
fleet.push(printer);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn remove(fleet: &SharedFleet, store: &Store, printer_id: &str) -> Result<(), String> {
|
|
||||||
let stored = store.remove_printer(printer_id).map_err(|err| err.to_string())?;
|
|
||||||
let mut fleet = fleet.lock().await;
|
|
||||||
let before = fleet.len();
|
|
||||||
fleet.retain(|p| p.base().id != printer_id);
|
|
||||||
if !stored && fleet.len() == before {
|
|
||||||
return Err(format!("no printer with id '{printer_id}' on this gateway"));
|
|
||||||
}
|
|
||||||
info!(printer = %printer_id, "printer removed from the console");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ids_are_a_small_safe_alphabet() {
|
|
||||||
assert!(valid_id("p1p"));
|
|
||||||
assert!(valid_id("Printer_2-b"));
|
|
||||||
assert!(!valid_id(""));
|
|
||||||
assert!(!valid_id("../etc/passwd"));
|
|
||||||
assert!(!valid_id("has space"));
|
|
||||||
assert!(!valid_id(&"a".repeat(65)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod behaviour {
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn request(id: &str) -> ProvisionPrinter {
|
|
||||||
ProvisionPrinter {
|
|
||||||
id: id.into(),
|
|
||||||
name: "Bench P1".into(),
|
|
||||||
vendor: "bambu".into(),
|
|
||||||
// Nothing listens here, so a connect attempt is refused at once.
|
|
||||||
host: Some("127.0.0.1".into()),
|
|
||||||
access_code: Some("12345678".into()),
|
|
||||||
sn: Some("01P00A000000001".into()),
|
|
||||||
api_key: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn store() -> (Store, std::path::PathBuf) {
|
|
||||||
let path = std::env::temp_dir().join(format!("continuum-provision-test-{}-{}.db", std::process::id(), rand_suffix()));
|
|
||||||
(Store::open(&path).unwrap(), path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rand_suffix() -> u128 {
|
|
||||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn empty_fleet() -> SharedFleet {
|
|
||||||
Arc::new(tokio::sync::Mutex::new(Vec::new()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn a_bad_id_is_refused_before_anything_happens() {
|
|
||||||
let (store, path) = store();
|
|
||||||
let err = add(&empty_fleet(), &store, request("../x")).await.unwrap_err();
|
|
||||||
assert!(err.contains("printer id"), "{err}");
|
|
||||||
assert!(store.printers().unwrap().is_empty());
|
|
||||||
let _ = std::fs::remove_file(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn a_blank_credential_is_named_and_nothing_is_saved() {
|
|
||||||
let (store, path) = store();
|
|
||||||
let mut req = request("p1");
|
|
||||||
req.access_code = Some(" ".into());
|
|
||||||
let err = add(&empty_fleet(), &store, req).await.unwrap_err();
|
|
||||||
assert!(err.contains("access_code"), "{err}");
|
|
||||||
assert!(store.printers().unwrap().is_empty());
|
|
||||||
let _ = std::fs::remove_file(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn a_printer_that_cannot_be_reached_is_not_saved() {
|
|
||||||
let (store, path) = store();
|
|
||||||
let fleet = empty_fleet();
|
|
||||||
let err = add(&fleet, &store, request("p1")).await.unwrap_err();
|
|
||||||
assert!(err.contains("could not connect") || err.contains("did not answer"), "{err}");
|
|
||||||
assert!(store.printers().unwrap().is_empty(), "a failed add must not leave a stored printer");
|
|
||||||
assert!(fleet.lock().await.is_empty());
|
|
||||||
let _ = std::fs::remove_file(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn the_error_never_contains_the_access_code() {
|
|
||||||
let (store, path) = store();
|
|
||||||
let err = add(&empty_fleet(), &store, request("p1")).await.unwrap_err();
|
|
||||||
assert!(!err.contains("12345678"), "{err}");
|
|
||||||
let _ = std::fs::remove_file(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn removing_forgets_a_printer_in_both_places() {
|
|
||||||
let (store, path) = store();
|
|
||||||
let entry = crate::fleet::PrinterEntry {
|
|
||||||
id: "p1".into(),
|
|
||||||
name: "Bench".into(),
|
|
||||||
vendor: "bambu".into(),
|
|
||||||
host: Some("127.0.0.1".into()),
|
|
||||||
access_code: Some("12345678".into()),
|
|
||||||
sn: Some("01P00A000000001".into()),
|
|
||||||
api_key: None,
|
|
||||||
port: None,
|
|
||||||
baud_rate: 115_200,
|
|
||||||
ca_cert_path: None,
|
|
||||||
require_valid_cert: true,
|
|
||||||
};
|
|
||||||
store.save_printer(&entry).unwrap();
|
|
||||||
let fleet = empty_fleet();
|
|
||||||
fleet.lock().await.push(crate::fleet::build(entry).unwrap());
|
|
||||||
|
|
||||||
remove(&fleet, &store, "p1").await.unwrap();
|
|
||||||
assert!(fleet.lock().await.is_empty());
|
|
||||||
assert!(store.printers().unwrap().is_empty());
|
|
||||||
|
|
||||||
let err = remove(&fleet, &store, "p1").await.unwrap_err();
|
|
||||||
assert!(err.contains("no printer"), "{err}");
|
|
||||||
let _ = std::fs::remove_file(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The success path, using the OctoPrint client against a mock server,
|
|
||||||
/// because no real machine is needed to connect to one.
|
|
||||||
/// What this proves is the plumbing, not a vendor: a connected printer is
|
|
||||||
/// saved, joins the running fleet, and is still there after a "restart"
|
|
||||||
/// that reopens the store and rebuilds the fleet from it.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn an_added_printer_joins_the_fleet_and_survives_a_restart() {
|
|
||||||
let (store, path) = store();
|
|
||||||
let fleet = empty_fleet();
|
|
||||||
let mock = crate::printer::mock_http::MockServer::start().await;
|
|
||||||
mock.answer("GET /api/job", 200, r#"{"state":"Operational","progress":{}}"#);
|
|
||||||
let mut req = request("octo1");
|
|
||||||
req.host = Some(mock.host());
|
|
||||||
req.vendor = "octoprint".into();
|
|
||||||
req.sn = None;
|
|
||||||
req.access_code = None;
|
|
||||||
req.api_key = Some("key-123".into());
|
|
||||||
|
|
||||||
add(&fleet, &store, req.clone()).await.unwrap();
|
|
||||||
assert!(fleet.lock().await.iter().any(|p| p.base().id == "octo1"));
|
|
||||||
|
|
||||||
// Same id again: refused, and the first is untouched.
|
|
||||||
let again = add(&fleet, &store, req).await.unwrap_err();
|
|
||||||
assert!(again.contains("already has"), "{again}");
|
|
||||||
assert_eq!(fleet.lock().await.len(), 1);
|
|
||||||
|
|
||||||
// "Restart": reopen the store, rebuild what main would.
|
|
||||||
drop(store);
|
|
||||||
let reopened = Store::open(&path).unwrap();
|
|
||||||
let rebuilt: Vec<_> = reopened.printers().unwrap().into_iter().map(|e| crate::fleet::build(e).unwrap()).collect();
|
|
||||||
assert_eq!(rebuilt.len(), 1);
|
|
||||||
assert_eq!(rebuilt[0].base().id, "octo1");
|
|
||||||
let _ = std::fs::remove_file(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_wire_message_parses_and_its_debug_hides_the_credential() {
|
|
||||||
let json = r#"{"type":"provision_printer","request_id":"r1","printer":{"id":"p1","name":"Bench","vendor":"bambu","host":"10.0.0.5","access_code":"98765432","sn":"SN1"}}"#;
|
|
||||||
let message: crate::uplink::ServerMessage = serde_json::from_str(json).unwrap();
|
|
||||||
let shown = format!("{message:?}");
|
|
||||||
assert!(shown.contains("p1") && shown.contains("<redacted>"), "{shown}");
|
|
||||||
assert!(!shown.contains("98765432"), "the access code reached a Debug string: {shown}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-338
@@ -1,338 +0,0 @@
|
|||||||
//! The gateway's own database: printers an operator added from the console.
|
|
||||||
//!
|
|
||||||
//! Why the gateway keeps these itself and not the cloud: a printer's access
|
|
||||||
//! code is a credential for a machine on someone's LAN, and the platform's
|
|
||||||
//! rule is that those never rest in the cloud. The console sends the code
|
|
||||||
//! once, over the uplink, and this is where it lands. It also means the
|
|
||||||
//! fleet survives the API being unreachable - the gateway starts, connects
|
|
||||||
//! its printers and keeps reporting locally from this alone.
|
|
||||||
//!
|
|
||||||
//! SQLite, one file. The file holds credentials, so on Unix it is created
|
|
||||||
//! owner-only (0600) before SQLite touches it; SQLite gives its journal files
|
|
||||||
//! the same mode as the main one.
|
|
||||||
//!
|
|
||||||
//! `printers.toml` still works and is read alongside this; see `main.rs`.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use rusqlite::{Connection, params};
|
|
||||||
|
|
||||||
use crate::fleet::PrinterEntry;
|
|
||||||
|
|
||||||
pub struct Store {
|
|
||||||
// rusqlite's `Connection` is not `Sync`, so it cannot be shared between
|
|
||||||
// tasks directly. Wrapping it in a `Mutex` is the plain fix: one caller
|
|
||||||
// at a time, which is plenty for a few writes a day.
|
|
||||||
conn: Mutex<Connection>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Store {
|
|
||||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
|
||||||
if let Some(dir) = path.parent().filter(|dir| !dir.as_os_str().is_empty()) {
|
|
||||||
std::fs::create_dir_all(dir).with_context(|| format!("creating {dir:?} for the local store"))?;
|
|
||||||
}
|
|
||||||
create_private(path)?;
|
|
||||||
let conn = Connection::open(path).with_context(|| format!("opening the local store at {path:?}"))?;
|
|
||||||
// Each printer is a JSON document keyed by its id. The id is the
|
|
||||||
// gateway's own printer id - the one telemetry is reported under -
|
|
||||||
// so "which printer" has a single answer everywhere.
|
|
||||||
conn.execute_batch(
|
|
||||||
"CREATE TABLE IF NOT EXISTS printers (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
json TEXT NOT NULL
|
|
||||||
);
|
|
||||||
-- What happened to jobs the cloud handed this gateway, until the
|
|
||||||
-- cloud has acknowledged it (see `jobs`). Survives a restart.
|
|
||||||
CREATE TABLE IF NOT EXISTS outbox (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
job_id TEXT NOT NULL,
|
|
||||||
event TEXT NOT NULL,
|
|
||||||
detail TEXT,
|
|
||||||
at_ms INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
-- The job each printer is working on, as far as this gateway knows.
|
|
||||||
CREATE TABLE IF NOT EXISTS active_jobs (
|
|
||||||
printer_id TEXT PRIMARY KEY,
|
|
||||||
job_id TEXT NOT NULL,
|
|
||||||
since_ms INTEGER NOT NULL,
|
|
||||||
started INTEGER NOT NULL DEFAULT 0
|
|
||||||
);",
|
|
||||||
)?;
|
|
||||||
Ok(Self { conn: Mutex::new(conn) })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds a printer, or replaces the one with the same id.
|
|
||||||
pub fn save_printer(&self, entry: &PrinterEntry) -> anyhow::Result<()> {
|
|
||||||
let json = serde_json::to_string(entry)?;
|
|
||||||
let conn = self.conn.lock().expect("store lock poisoned");
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO printers (id, json) VALUES (?1, ?2)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET json = excluded.json",
|
|
||||||
params![entry.id, json],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forgets a printer. True if there was one.
|
|
||||||
pub fn remove_printer(&self, id: &str) -> anyhow::Result<bool> {
|
|
||||||
let conn = self.conn.lock().expect("store lock poisoned");
|
|
||||||
Ok(conn.execute("DELETE FROM printers WHERE id = ?1", params![id])? > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every stored printer, oldest first.
|
|
||||||
pub fn printers(&self) -> anyhow::Result<Vec<PrinterEntry>> {
|
|
||||||
let conn = self.conn.lock().expect("store lock poisoned");
|
|
||||||
let mut statement = conn.prepare("SELECT json FROM printers ORDER BY rowid")?;
|
|
||||||
let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for json in rows {
|
|
||||||
out.push(serde_json::from_str(&json?).context("a stored printer no longer parses")?);
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A job outcome waiting for the cloud's acknowledgement.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct OutboxEvent {
|
|
||||||
pub id: i64,
|
|
||||||
pub job_id: String,
|
|
||||||
pub event: String,
|
|
||||||
pub detail: Option<String>,
|
|
||||||
pub at_ms: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A job a printer was handed and has not yet finished with.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct ActiveJob {
|
|
||||||
pub printer_id: String,
|
|
||||||
pub job_id: String,
|
|
||||||
pub since_ms: i64,
|
|
||||||
pub started: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Store {
|
|
||||||
/// Queues an outcome to tell the cloud. Returns its id.
|
|
||||||
pub fn outbox_push(&self, job_id: &str, event: &str, detail: Option<&str>, at_ms: i64) -> anyhow::Result<i64> {
|
|
||||||
let conn = self.conn.lock().expect("store lock poisoned");
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO outbox (job_id, event, detail, at_ms) VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![job_id, event, detail, at_ms],
|
|
||||||
)?;
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything the cloud has not acknowledged, oldest first.
|
|
||||||
pub fn outbox_pending(&self) -> anyhow::Result<Vec<OutboxEvent>> {
|
|
||||||
let conn = self.conn.lock().expect("store lock poisoned");
|
|
||||||
let mut statement =
|
|
||||||
conn.prepare("SELECT id, job_id, event, detail, at_ms FROM outbox ORDER BY id LIMIT 500")?;
|
|
||||||
let rows = statement.query_map([], |row| {
|
|
||||||
Ok(OutboxEvent {
|
|
||||||
id: row.get(0)?,
|
|
||||||
job_id: row.get(1)?,
|
|
||||||
event: row.get(2)?,
|
|
||||||
detail: row.get(3)?,
|
|
||||||
at_ms: row.get(4)?,
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
Ok(rows.collect::<Result<_, _>>()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn outbox_ack(&self, id: i64) -> anyhow::Result<()> {
|
|
||||||
let conn = self.conn.lock().expect("store lock poisoned");
|
|
||||||
conn.execute("DELETE FROM outbox WHERE id = ?1", params![id])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records that `printer_id` was handed `job_id`, replacing any earlier job.
|
|
||||||
pub fn active_job_set(&self, printer_id: &str, job_id: &str, now_ms: i64) -> anyhow::Result<()> {
|
|
||||||
let conn = self.conn.lock().expect("store lock poisoned");
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO active_jobs (printer_id, job_id, since_ms, started) VALUES (?1, ?2, ?3, 0)
|
|
||||||
ON CONFLICT(printer_id) DO UPDATE SET job_id = excluded.job_id, since_ms = excluded.since_ms, started = 0",
|
|
||||||
params![printer_id, job_id, now_ms],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn active_job_started(&self, printer_id: &str) -> anyhow::Result<()> {
|
|
||||||
let conn = self.conn.lock().expect("store lock poisoned");
|
|
||||||
conn.execute("UPDATE active_jobs SET started = 1 WHERE printer_id = ?1", params![printer_id])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn active_job_clear(&self, printer_id: &str) -> anyhow::Result<()> {
|
|
||||||
let conn = self.conn.lock().expect("store lock poisoned");
|
|
||||||
conn.execute("DELETE FROM active_jobs WHERE printer_id = ?1", params![printer_id])?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn active_jobs(&self) -> anyhow::Result<Vec<ActiveJob>> {
|
|
||||||
let conn = self.conn.lock().expect("store lock poisoned");
|
|
||||||
let mut statement = conn.prepare("SELECT printer_id, job_id, since_ms, started FROM active_jobs")?;
|
|
||||||
let rows = statement.query_map([], |row| {
|
|
||||||
Ok(ActiveJob {
|
|
||||||
printer_id: row.get(0)?,
|
|
||||||
job_id: row.get(1)?,
|
|
||||||
since_ms: row.get(2)?,
|
|
||||||
started: row.get::<_, i64>(3)? != 0,
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
Ok(rows.collect::<Result<_, _>>()?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates the file empty and owner-only if it does not exist yet.
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn create_private(path: &Path) -> anyhow::Result<()> {
|
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
|
||||||
std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create(true)
|
|
||||||
.truncate(false)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(path)
|
|
||||||
.with_context(|| format!("creating the local store {path:?}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Windows: the file inherits its folder's permissions, which for the
|
|
||||||
/// per-user data directory is already that user's. Nothing to set here.
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn create_private(_path: &Path) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn entry(id: &str, access_code: &str) -> PrinterEntry {
|
|
||||||
PrinterEntry {
|
|
||||||
id: id.into(),
|
|
||||||
name: format!("Printer {id}"),
|
|
||||||
vendor: "bambu".into(),
|
|
||||||
host: Some("192.168.0.50".into()),
|
|
||||||
access_code: Some(access_code.into()),
|
|
||||||
sn: Some("01P00A000000000".into()),
|
|
||||||
api_key: None,
|
|
||||||
port: None,
|
|
||||||
baud_rate: 115_200,
|
|
||||||
ca_cert_path: None,
|
|
||||||
require_valid_cert: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A throwaway directory, removed when dropped. Hand-rolled rather than
|
|
||||||
/// pulling in a crate for four tests.
|
|
||||||
struct TempDir(std::path::PathBuf);
|
|
||||||
|
|
||||||
impl Drop for TempDir {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = std::fs::remove_dir_all(&self.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn temp_db() -> (TempDir, std::path::PathBuf) {
|
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
|
||||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
|
||||||
let dir = std::env::temp_dir().join(format!(
|
|
||||||
"continuum-store-test-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
|
||||||
));
|
|
||||||
let path = dir.join("nested").join("continuum.db");
|
|
||||||
(TempDir(dir), path)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn what_was_saved_comes_back_after_reopening() {
|
|
||||||
let (_dir, path) = temp_db();
|
|
||||||
Store::open(&path).unwrap().save_printer(&entry("a", "11111111")).unwrap();
|
|
||||||
|
|
||||||
let reopened = Store::open(&path).unwrap();
|
|
||||||
let printers = reopened.printers().unwrap();
|
|
||||||
assert_eq!(printers.len(), 1);
|
|
||||||
assert_eq!(printers[0].id, "a");
|
|
||||||
assert_eq!(printers[0].access_code.as_deref(), Some("11111111"));
|
|
||||||
assert_eq!(printers[0].sn.as_deref(), Some("01P00A000000000"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn saving_the_same_id_replaces_it_and_order_is_oldest_first() {
|
|
||||||
let (_dir, path) = temp_db();
|
|
||||||
let store = Store::open(&path).unwrap();
|
|
||||||
store.save_printer(&entry("a", "old")).unwrap();
|
|
||||||
store.save_printer(&entry("b", "bbb")).unwrap();
|
|
||||||
store.save_printer(&entry("a", "new")).unwrap();
|
|
||||||
|
|
||||||
let printers = store.printers().unwrap();
|
|
||||||
let ids: Vec<_> = printers.iter().map(|p| p.id.as_str()).collect();
|
|
||||||
assert_eq!(ids, ["a", "b"]);
|
|
||||||
assert_eq!(printers[0].access_code.as_deref(), Some("new"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn removing_reports_whether_anything_was_there() {
|
|
||||||
let (_dir, path) = temp_db();
|
|
||||||
let store = Store::open(&path).unwrap();
|
|
||||||
store.save_printer(&entry("a", "x")).unwrap();
|
|
||||||
assert!(store.remove_printer("a").unwrap());
|
|
||||||
assert!(!store.remove_printer("a").unwrap());
|
|
||||||
assert!(store.printers().unwrap().is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn the_file_holding_credentials_is_owner_only() {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let (_dir, path) = temp_db();
|
|
||||||
let store = Store::open(&path).unwrap();
|
|
||||||
store.save_printer(&entry("a", "secret")).unwrap();
|
|
||||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
|
||||||
assert_eq!(mode, 0o600);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_corrupt_file_is_an_error_not_an_empty_fleet() {
|
|
||||||
let (_dir, path) = temp_db();
|
|
||||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
||||||
std::fs::write(&path, b"this is not a sqlite database at all, just text").unwrap();
|
|
||||||
assert!(Store::open(&path).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_unacknowledged_event_survives_a_restart_and_an_acknowledged_one_does_not() {
|
|
||||||
let (_dir, path) = temp_db();
|
|
||||||
let store = Store::open(&path).unwrap();
|
|
||||||
let first = store.outbox_push("job-1", "finished", None, 10).unwrap();
|
|
||||||
let second = store.outbox_push("job-2", "failed", Some("download failed"), 20).unwrap();
|
|
||||||
store.outbox_ack(first).unwrap();
|
|
||||||
drop(store);
|
|
||||||
|
|
||||||
let reopened = Store::open(&path).unwrap();
|
|
||||||
let pending = reopened.outbox_pending().unwrap();
|
|
||||||
assert_eq!(pending.len(), 1);
|
|
||||||
assert_eq!(pending[0].id, second);
|
|
||||||
assert_eq!(pending[0].detail.as_deref(), Some("download failed"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_printer_has_one_active_job_and_a_new_one_replaces_it() {
|
|
||||||
let (_dir, path) = temp_db();
|
|
||||||
let store = Store::open(&path).unwrap();
|
|
||||||
store.active_job_set("p1", "job-1", 100).unwrap();
|
|
||||||
store.active_job_started("p1").unwrap();
|
|
||||||
assert!(store.active_jobs().unwrap()[0].started);
|
|
||||||
|
|
||||||
store.active_job_set("p1", "job-2", 200).unwrap();
|
|
||||||
let jobs = store.active_jobs().unwrap();
|
|
||||||
assert_eq!(jobs, vec![ActiveJob { printer_id: "p1".into(), job_id: "job-2".into(), since_ms: 200, started: false }]);
|
|
||||||
|
|
||||||
store.active_job_clear("p1").unwrap();
|
|
||||||
assert!(store.active_jobs().unwrap().is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
//! Getting from the gateway's long-lived credential to the short-lived one
|
|
||||||
//! the socket wants.
|
|
||||||
//!
|
|
||||||
//! The cloud hands a farm's owner an *edge proxy token*, which is what goes in
|
|
||||||
//! this gateway's settings as `gateway_token`. It does not open the socket
|
|
||||||
//! directly: the upgrade wants an *edge JWT*, which the cloud signs for an
|
|
||||||
//! hour in exchange for the proxy token (`POST /auth/edge-token`). So every
|
|
||||||
//! connection attempt starts by trading, which is also why a gateway that
|
|
||||||
//! has been up for days is not locked out when the first JWT expires - each
|
|
||||||
//! reconnect gets a fresh one.
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use anyhow::Context;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::config::Config;
|
|
||||||
|
|
||||||
const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(15);
|
|
||||||
const SOCKET_PATH: &str = "/ws/edge/v1";
|
|
||||||
|
|
||||||
/// Where to trade the token, derived from the socket URL so there is one
|
|
||||||
/// address to configure, not two that can disagree.
|
|
||||||
pub fn exchange_url(uplink_url: &str) -> anyhow::Result<String> {
|
|
||||||
let (scheme, rest) = if let Some(rest) = uplink_url.strip_prefix("wss://") {
|
|
||||||
("https://", rest)
|
|
||||||
} else if let Some(rest) = uplink_url.strip_prefix("ws://") {
|
|
||||||
("http://", rest)
|
|
||||||
} else {
|
|
||||||
anyhow::bail!("the uplink URL must start with ws:// or wss://: {uplink_url}");
|
|
||||||
};
|
|
||||||
let base = rest
|
|
||||||
.strip_suffix(SOCKET_PATH)
|
|
||||||
.with_context(|| format!("the uplink URL must end with {SOCKET_PATH}: {uplink_url}"))?;
|
|
||||||
Ok(format!("{scheme}{base}/auth/edge-token"))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct Exchanged {
|
|
||||||
token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trades `gateway_token` for an edge JWT.
|
|
||||||
///
|
|
||||||
/// A 401 is reported as what it means - the cloud does not accept this
|
|
||||||
/// farm/token pair - because retrying will not fix it, and the usual cause is
|
|
||||||
/// a token that was rotated in the console while this gateway kept the old
|
|
||||||
/// one.
|
|
||||||
pub async fn fetch_edge_token(config: &Config) -> anyhow::Result<String> {
|
|
||||||
let url = exchange_url(&config.uplink_url)?;
|
|
||||||
let response = reqwest::Client::builder()
|
|
||||||
.timeout(EXCHANGE_TIMEOUT)
|
|
||||||
.build()?
|
|
||||||
.post(&url)
|
|
||||||
// Built by hand: the `json` feature of reqwest is off on purpose (see
|
|
||||||
// Cargo.toml), and this is one small body.
|
|
||||||
.header("Content-Type", "application/json")
|
|
||||||
.body(
|
|
||||||
serde_json::json!({
|
|
||||||
"farmId": config.farm_id,
|
|
||||||
"edgeProxyToken": config.gateway_token,
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("could not reach {url} to get an edge token"))?;
|
|
||||||
|
|
||||||
match response.status() {
|
|
||||||
status if status.is_success() => {
|
|
||||||
let body = response.bytes().await?;
|
|
||||||
Ok(serde_json::from_slice::<Exchanged>(&body).context("the cloud's edge token reply was not understood")?.token)
|
|
||||||
}
|
|
||||||
reqwest::StatusCode::UNAUTHORIZED => {
|
|
||||||
anyhow::bail!("the cloud does not accept this farm_id / gateway_token - was the token rotated in the console?")
|
|
||||||
}
|
|
||||||
status => anyhow::bail!("the cloud answered {status} when asked for an edge token"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_exchange_address_follows_the_socket_address() {
|
|
||||||
assert_eq!(
|
|
||||||
exchange_url("wss://api.continuum.local/ws/edge/v1").unwrap(),
|
|
||||||
"https://api.continuum.local/auth/edge-token"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
exchange_url("ws://localhost:3040/ws/edge/v1").unwrap(),
|
|
||||||
"http://localhost:3040/auth/edge-token"
|
|
||||||
);
|
|
||||||
// A prefix in front of the API survives.
|
|
||||||
assert_eq!(
|
|
||||||
exchange_url("wss://example.com/api/ws/edge/v1").unwrap(),
|
|
||||||
"https://example.com/api/auth/edge-token"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_address_it_cannot_follow_is_an_error_not_a_guess() {
|
|
||||||
assert!(exchange_url("https://api/ws/edge/v1").is_err());
|
|
||||||
assert!(exchange_url("wss://api/somewhere/else").is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
|
|
||||||
/// Dev-only escape hatch for a local object store (MinIO) that speaks plain
|
|
||||||
/// HTTP: needs `CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true` AND a loopback
|
|
||||||
/// or private-network (RFC 1918) address literal, so a presigned URL can never
|
|
||||||
/// cross a public network in the clear.
|
|
||||||
fn insecure_local_allowed(url: &reqwest::Url) -> bool {
|
|
||||||
use std::net::IpAddr;
|
|
||||||
if url.scheme() != "http"
|
|
||||||
|| !std::env::var("CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS").is_ok_and(|v| v == "true")
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let Some(ip) = url.host_str().and_then(|h| h.trim_matches(['[', ']']).parse::<IpAddr>().ok()) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
match ip {
|
|
||||||
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private(),
|
|
||||||
IpAddr::V6(v6) => v6.is_loopback(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Downloads `url` fully into memory, or says why not.
|
|
||||||
///
|
|
||||||
/// The error is a `String` naming the failure; the caller prefixes the stage.
|
|
||||||
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" && !insecure_local_allowed(&parsed) {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+17
-679
@@ -1,383 +1,67 @@
|
|||||||
//! The cloud connection: a WebSocket to `continuum-backend`'s `/ws/edge/v1`.
|
//! The cloud connection: a WebSocket to `continuum-backend`'s `/ws/edge/v1`.
|
||||||
//!
|
//!
|
||||||
//! This is where the proxy earns its name. Until now it connected, said
|
//! This is intentionally the simplest version of "a client that reconnects
|
||||||
//! hello, and sent heartbeats into the void while the printer fleet lived in
|
//! when it drops": no backoff curve, no channels routing messages in from
|
||||||
//! a completely separate part of the program with nothing routing between
|
//! other parts of the program yet - just connect, say hello, send a
|
||||||
//! them. Both directions are wired up now:
|
//! heartbeat on a timer, and print whatever comes back. Once you're
|
||||||
//!
|
//! comfortable with this, growing it (real backoff, forwarding printer
|
||||||
//! - **Up:** every `TELEMETRY_INTERVAL`, one snapshot per printer, batched
|
//! telemetry, dispatching cloud-issued commands) is additive, not a rewrite.
|
||||||
//! 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 auth;
|
mod protocol;
|
||||||
mod download;
|
|
||||||
mod webrtc;
|
|
||||||
pub mod protocol;
|
|
||||||
|
|
||||||
pub use protocol::{ClientMessage, DiscoveredPrinter, PrinterCommand, PrinterTelemetry, ServerMessage};
|
pub use protocol::{ClientMessage, ServerMessage};
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use tokio_tungstenite::tungstenite::handshake::client::{Request, generate_key};
|
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::provision;
|
|
||||||
use crate::store::Store;
|
|
||||||
use protocol::ProvisionPrinter;
|
|
||||||
use crate::printer::{GenericPrinter, PrinterHandle};
|
|
||||||
|
|
||||||
/// 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);
|
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);
|
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);
|
|
||||||
/// How often the job-event outbox is looked at for anything new to send.
|
|
||||||
const OUTBOX_INTERVAL: Duration = Duration::from_secs(3);
|
|
||||||
|
|
||||||
/// The printer fleet, shared between `main` (which owns it and keeps every
|
|
||||||
/// connection alive) and this module (which reads status and dispatches
|
|
||||||
/// 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.
|
/// Runs forever: connect, serve the connection until it drops, wait, retry.
|
||||||
pub async fn run(
|
pub async fn run(config: Config) {
|
||||||
config: Config,
|
|
||||||
fleet: SharedFleet,
|
|
||||||
store: Option<Arc<Store>>,
|
|
||||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
|
||||||
mut inbox: tokio::sync::mpsc::Receiver<ClientMessage>,
|
|
||||||
) {
|
|
||||||
let mut delay = RETRY_DELAY;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
info!(url = %config.uplink_url, "connecting to cloud uplink");
|
info!(url = %config.uplink_url, "connecting to cloud uplink");
|
||||||
|
|
||||||
match connect(&config).await {
|
if let Err(err) = connect_and_serve(&config).await {
|
||||||
Ok(ws_stream) => {
|
warn!(?err, "uplink connection failed, retrying in {RETRY_DELAY:?}");
|
||||||
// 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, store.as_ref(), &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(delay).await;
|
tokio::time::sleep(RETRY_DELAY).await;
|
||||||
delay = (delay * 2).min(RETRY_DELAY_MAX);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the websocket. Separate from `serve` so `run` can tell a failed
|
async fn connect_and_serve(config: &Config) -> anyhow::Result<()> {
|
||||||
/// handshake from a connection that opened and later dropped; they want
|
let (ws_stream, _response) = tokio_tungstenite::connect_async(&config.uplink_url).await?;
|
||||||
/// different retry behaviour.
|
|
||||||
async fn connect(config: &Config) -> anyhow::Result<WsStream> {
|
|
||||||
// A fresh edge JWT per attempt: it lives an hour, so one fetched at
|
|
||||||
// startup would lock a long-running gateway out on its first reconnect.
|
|
||||||
let edge_token = auth::fetch_edge_token(config).await?;
|
|
||||||
|
|
||||||
// The edge token goes on the upgrade request, not in the first message.
|
|
||||||
// The backend verifies it before the socket exists, so a bad token is an
|
|
||||||
// HTTP 401 here rather than a connection that opens and is then closed -
|
|
||||||
// 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 {edge_token}"))
|
|
||||||
// tokio-tungstenite fills these in for a plain URL, but not once we
|
|
||||||
// hand it a request we built ourselves - and a handshake missing any
|
|
||||||
// of them is rejected by the server rather than ignored.
|
|
||||||
.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,
|
|
||||||
store: Option<&Arc<Store>>,
|
|
||||||
outbound: &tokio::sync::mpsc::Sender<ClientMessage>,
|
|
||||||
inbox: &mut tokio::sync::mpsc::Receiver<ClientMessage>,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let (mut write, mut read) = ws_stream.split();
|
let (mut write, mut read) = ws_stream.split();
|
||||||
|
|
||||||
let hello = ClientMessage::Hello {
|
let hello = ClientMessage::Hello {
|
||||||
gateway_id: config.gateway_id.clone(),
|
gateway_id: config.gateway_id.clone(),
|
||||||
farm_id: config.farm_id.clone(),
|
farm_id: config.farm_id.clone(),
|
||||||
stream_base_url: config.stream_base_url.clone(),
|
token: config.gateway_token.clone(),
|
||||||
};
|
};
|
||||||
write.send(Message::Text(serde_json::to_string(&hello)?)).await?;
|
write.send(Message::Text(serde_json::to_string(&hello)?)).await?;
|
||||||
|
|
||||||
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
|
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
|
||||||
let mut telemetry = tokio::time::interval(TELEMETRY_INTERVAL);
|
|
||||||
let mut seq: u64 = 0;
|
let mut seq: u64 = 0;
|
||||||
// Outbox events already written to THIS socket. Per connection on
|
|
||||||
// purpose: after a reconnect everything the cloud has not acknowledged
|
|
||||||
// goes out again, which is the point of keeping them.
|
|
||||||
let mut outbox_tick = tokio::time::interval(OUTBOX_INTERVAL);
|
|
||||||
let mut sent: HashSet<i64> = HashSet::new();
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = outbox_tick.tick() => {
|
|
||||||
let Some(store) = store else { continue };
|
|
||||||
let pending = match store.outbox_pending() {
|
|
||||||
Ok(pending) => pending,
|
|
||||||
Err(err) => { warn!(%err, "could not read the job-event outbox"); continue }
|
|
||||||
};
|
|
||||||
let unsent: Vec<_> = pending.into_iter().filter(|e| !sent.contains(&e.id)).collect();
|
|
||||||
for event in unsent {
|
|
||||||
let msg = ClientMessage::JobEvent {
|
|
||||||
event_id: event.id,
|
|
||||||
job_id: event.job_id,
|
|
||||||
event: event.event,
|
|
||||||
detail: event.detail,
|
|
||||||
at_ms: event.at_ms,
|
|
||||||
};
|
|
||||||
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
|
|
||||||
sent.insert(event.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = heartbeat.tick() => {
|
_ = heartbeat.tick() => {
|
||||||
seq += 1;
|
seq += 1;
|
||||||
let msg = ClientMessage::Heartbeat { seq };
|
let msg = ClientMessage::Heartbeat { seq };
|
||||||
write.send(Message::Text(serde_json::to_string(&msg)?)).await?;
|
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() => {
|
frame = read.next() => {
|
||||||
match frame {
|
match frame {
|
||||||
Some(Ok(Message::Text(text))) => {
|
Some(Ok(Message::Text(text))) => {
|
||||||
match serde_json::from_str::<ServerMessage>(&text) {
|
if let Ok(server_msg) = serde_json::from_str::<ServerMessage>(&text) {
|
||||||
Ok(ServerMessage::Command { command_id, printer_id, command }) => {
|
info!(?server_msg, "received from cloud");
|
||||||
// `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(),
|
|
||||||
store.cloned(),
|
|
||||||
);
|
|
||||||
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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Spawned: a connect, a TLS handshake and a frame
|
|
||||||
// from the printer take seconds, and awaiting that
|
|
||||||
// here would stall heartbeats and telemetry.
|
|
||||||
Ok(ServerMessage::CameraSnapshotRequest { request_id, printer_id }) => {
|
|
||||||
spawn_snapshot(fleet.clone(), request_id, printer_id, outbound.clone());
|
|
||||||
}
|
|
||||||
// Spawned: connecting to the new printer can take
|
|
||||||
// many seconds, and awaiting it here would stall
|
|
||||||
// heartbeats and telemetry for the whole farm.
|
|
||||||
Ok(ServerMessage::ProvisionPrinter { request_id, printer }) => {
|
|
||||||
spawn_provision(fleet.clone(), store.cloned(), request_id, Some(printer), outbound.clone());
|
|
||||||
}
|
|
||||||
Ok(ServerMessage::JobEventAck { event_id }) => {
|
|
||||||
sent.remove(&event_id);
|
|
||||||
if let Some(store) = store
|
|
||||||
&& let Err(err) = store.outbox_ack(event_id)
|
|
||||||
{
|
|
||||||
warn!(%err, event_id, "could not clear an acknowledged job event");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(ServerMessage::RemovePrinter { request_id, printer_id }) => {
|
|
||||||
spawn_remove(fleet.clone(), store.cloned(), request_id, printer_id, outbound.clone());
|
|
||||||
}
|
|
||||||
Ok(other) => info!(?other, "received from cloud"),
|
|
||||||
// Loudly. This used to be an `if let Ok(..)` with
|
|
||||||
// no else, which is the same silent-drop bug that
|
|
||||||
// hid a real `print.push_status` failure for
|
|
||||||
// weeks - and here it would mean commands
|
|
||||||
// vanishing with the cloud never being told.
|
|
||||||
// The payload is NOT logged. A `provision_printer` message
|
|
||||||
// carries an access code, and one that failed to parse is
|
|
||||||
// exactly the message that would end up here.
|
|
||||||
Err(err) => warn!(%err, bytes = text.len(), "unparseable message from cloud, ignoring"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Close(_))) | None => return Ok(()),
|
Some(Ok(Message::Close(_))) | None => return Ok(()),
|
||||||
@@ -388,349 +72,3 @@ async fn serve(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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(status) = printer.status().await else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// Only Bambu has a camera report to derive these from.
|
|
||||||
let report = printer.print_status().await;
|
|
||||||
|
|
||||||
out.push(PrinterTelemetry {
|
|
||||||
printer_id: base.id.clone(),
|
|
||||||
name: base.name.clone(),
|
|
||||||
state: status.state,
|
|
||||||
percent: status.percent,
|
|
||||||
stage: status.stage,
|
|
||||||
layer: status.layer,
|
|
||||||
total_layers: status.total_layers,
|
|
||||||
remaining_minutes: status.remaining_minutes,
|
|
||||||
job_name: status.job_name,
|
|
||||||
nozzle_temp: status.nozzle_temp,
|
|
||||||
nozzle_target: status.nozzle_target,
|
|
||||||
bed_temp: status.bed_temp,
|
|
||||||
bed_target: status.bed_target,
|
|
||||||
print_error: status.print_error,
|
|
||||||
active_alerts: status.active_alerts,
|
|
||||||
// Derived from the same reported URL `camera::sources` builds
|
|
||||||
// the go2rtc config from, so a printer is advertised as
|
|
||||||
// streamable here only if it is also actually being served
|
|
||||||
// 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.as_ref().and_then(|r| r.ipcam.as_ref()).and_then(|c| c.rtsp_url.as_deref()),
|
|
||||||
)
|
|
||||||
.map(|source| source.stream_name),
|
|
||||||
camera_snapshot: crate::camera::wants_snapshot(
|
|
||||||
printer,
|
|
||||||
report.as_ref().is_some_and(|r| r.ipcam.is_some()),
|
|
||||||
report.as_ref().and_then(|r| r.ipcam.as_ref()).and_then(|c| c.rtsp_url.as_deref()),
|
|
||||||
)
|
|
||||||
.then_some(true),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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>,
|
|
||||||
store: Option<Arc<Store>>,
|
|
||||||
) {
|
|
||||||
// The job is followed from here on, so its outcome is reported even if
|
|
||||||
// the cloud is unreachable when it happens (see `jobs`).
|
|
||||||
let job_id = match (&command, &store) {
|
|
||||||
(PrinterCommand::StartPrint { job_id, .. }, Some(store)) => {
|
|
||||||
crate::jobs::accepted(store, &printer_id, job_id);
|
|
||||||
Some(job_id.clone())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
tokio::spawn(async move {
|
|
||||||
match dispatch(&fleet, &printer_id, command).await {
|
|
||||||
Ok(()) => info!(%printer_id, "long-running command finished"),
|
|
||||||
Err(err) if job_id.is_some() => {
|
|
||||||
// Still logged locally; the cloud hears of it from the
|
|
||||||
// outbox, which survives it being down right now.
|
|
||||||
tracing::error!(
|
|
||||||
%printer_id, %err,
|
|
||||||
"long-running command failed after the cloud was already told it was accepted"
|
|
||||||
);
|
|
||||||
if let (Some(store), Some(job_id)) = (&store, &job_id) {
|
|
||||||
crate::jobs::start_failed(store, &printer_id, job_id, &err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
// Still logged locally, and still at error level: the queue
|
|
||||||
// only reaches a cloud that is up, and this is the copy that
|
|
||||||
// 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds a printer off the connection's task and always reports how it went:
|
|
||||||
/// the console is holding a form open waiting for exactly this.
|
|
||||||
fn spawn_provision(
|
|
||||||
fleet: SharedFleet,
|
|
||||||
store: Option<Arc<Store>>,
|
|
||||||
request_id: String,
|
|
||||||
request: Option<ProvisionPrinter>,
|
|
||||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
|
||||||
) {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let result = match (store, request) {
|
|
||||||
(Some(store), Some(request)) => provision::add(&fleet, &store, request).await,
|
|
||||||
(None, _) => Err("this gateway has no local store, so it cannot add printers".to_string()),
|
|
||||||
(_, None) => Err("no printer in the request".to_string()),
|
|
||||||
};
|
|
||||||
report(request_id, result, &outbound).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_remove(
|
|
||||||
fleet: SharedFleet,
|
|
||||||
store: Option<Arc<Store>>,
|
|
||||||
request_id: String,
|
|
||||||
printer_id: String,
|
|
||||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
|
||||||
) {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let result = match store {
|
|
||||||
Some(store) => provision::remove(&fleet, &store, &printer_id).await,
|
|
||||||
None => Err("this gateway has no local store".to_string()),
|
|
||||||
};
|
|
||||||
report(request_id, result, &outbound).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn report(request_id: String, result: Result<(), String>, outbound: &tokio::sync::mpsc::Sender<ClientMessage>) {
|
|
||||||
let msg = match result {
|
|
||||||
Ok(()) => ClientMessage::CommandResult { command_id: request_id, ok: true, error: None },
|
|
||||||
Err(error) => {
|
|
||||||
warn!(%error, "printer request failed");
|
|
||||||
ClientMessage::CommandResult { command_id: request_id, ok: false, error: Some(error) }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if outbound.send(msg).await.is_err() {
|
|
||||||
warn!("uplink is shutting down; the result was not reported");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Answers one WebRTC offer, off this connection's task.
|
|
||||||
///
|
|
||||||
/// Always answers, success or failure: the cloud is holding a browser's
|
|
||||||
/// 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");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads one camera frame off this connection's task and reports it.
|
|
||||||
///
|
|
||||||
/// The fleet lock is held only to copy out what the read needs (the access
|
|
||||||
/// code never leaves this process in the message), never across the network.
|
|
||||||
fn spawn_snapshot(
|
|
||||||
fleet: SharedFleet,
|
|
||||||
request_id: String,
|
|
||||||
printer_id: String,
|
|
||||||
outbound: tokio::sync::mpsc::Sender<ClientMessage>,
|
|
||||||
) {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let job = {
|
|
||||||
let fleet = fleet.lock().await;
|
|
||||||
match fleet.iter().find(|p| p.base().id == printer_id) {
|
|
||||||
Some(printer) => printer.snapshot_job().map_err(|err| err.to_string()),
|
|
||||||
None => Err(format!("no printer '{printer_id}' in this gateway's fleet")),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let result = match job {
|
|
||||||
Ok(job) => job.run().await.map_err(|err| err.to_string()),
|
|
||||||
Err(err) => Err(err),
|
|
||||||
};
|
|
||||||
let reply = match result {
|
|
||||||
Ok(jpeg) => {
|
|
||||||
use base64::Engine;
|
|
||||||
ClientMessage::CameraSnapshot {
|
|
||||||
request_id,
|
|
||||||
jpeg_base64: Some(base64::engine::general_purpose::STANDARD.encode(jpeg)),
|
|
||||||
error: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(%printer_id, %err, "could not read a camera frame");
|
|
||||||
ClientMessage::CameraSnapshot { request_id, jpeg_base64: None, error: Some(err) }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if outbound.send(reply).await.is_err() {
|
|
||||||
warn!(%printer_id, "uplink is shutting down; the viewer gets no picture");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Finds the printer by our own id and runs the command. The error is a
|
|
||||||
/// `String` rather than a typed error because its only destination is a
|
|
||||||
/// `CommandResult` going back to the cloud as text.
|
|
||||||
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, ams_slot, .. } => {
|
|
||||||
let bytes = downloaded.ok_or_else(|| "internal error: start_print reached dispatch with no downloaded file".to_string())?;
|
|
||||||
let byte_count = bytes.len();
|
|
||||||
info!(%printer_id, %job_id, %file_name, byte_count, "job file downloaded, sending to printer");
|
|
||||||
// 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.
|
|
||||||
if let PrinterHandle::Bambu(bambu) = &mut *printer {
|
|
||||||
bambu.set_ams_slot_override(ams_slot);
|
|
||||||
} else if ams_slot.is_some() {
|
|
||||||
return Err("this printer cannot be told which AMS slot to use".into());
|
|
||||||
}
|
|
||||||
let result = printer
|
|
||||||
.upload_project_file(file_name, bytes)
|
|
||||||
.await
|
|
||||||
.map_err(|err| format!("printer upload/start failed (download of {byte_count} bytes succeeded): {err}"));
|
|
||||||
if let PrinterHandle::Bambu(bambu) = &mut *printer {
|
|
||||||
bambu.set_ams_slot_override(None);
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-375
@@ -1,222 +1,11 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::printer::PrintControl;
|
|
||||||
|
|
||||||
/// What this gateway sends up to `continuum-backend`'s `/ws/edge/v1` route.
|
/// What this gateway sends up to `continuum-backend`'s `/ws/edge/v1` route.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ClientMessage {
|
pub enum ClientMessage {
|
||||||
/// The handshake, carrying no credential.
|
Hello { gateway_id: String, farm_id: String, token: String },
|
||||||
///
|
|
||||||
/// 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 },
|
Heartbeat { seq: u64 },
|
||||||
/// What happened to a job the cloud handed this gateway. Kept in the
|
|
||||||
/// store and re-sent on every connection until a `JobEventAck`.
|
|
||||||
JobEvent {
|
|
||||||
event_id: i64,
|
|
||||||
job_id: String,
|
|
||||||
/// `started`, `finished` or `failed`.
|
|
||||||
event: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
detail: Option<String>,
|
|
||||||
at_ms: i64,
|
|
||||||
},
|
|
||||||
/// One snapshot per printer, on a timer. Sent as a batch rather than a
|
|
||||||
/// message per printer: a farm has one uplink and many printers, and the
|
|
||||||
/// cloud wants a coherent picture of the farm at an instant more than it
|
|
||||||
/// 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 },
|
|
||||||
/// The answer to a `CameraSnapshotRequest`: one JPEG frame, base64, or
|
|
||||||
/// the reason there is none. Always sent, success or failure - the cloud
|
|
||||||
/// is holding a viewer's request open for it.
|
|
||||||
CameraSnapshot {
|
|
||||||
request_id: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
jpeg_base64: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
error: Option<String>,
|
|
||||||
},
|
|
||||||
/// Printers found on this gateway's own network and USB ports that are
|
|
||||||
/// *not* in its fleet file, so the console can offer them instead of
|
|
||||||
/// making an operator walk to a machine and read its IP address off the
|
|
||||||
/// screen.
|
|
||||||
///
|
|
||||||
/// A whole scan result that replaces the last one, not a set of
|
|
||||||
/// additions: a printer that has gone is reported by its absence. An
|
|
||||||
/// empty list is therefore meaningful - it says the gateway looked and
|
|
||||||
/// found nothing, which is different from never having sent one of these
|
|
||||||
/// at all, and a gateway with discovery off (the default) never does.
|
|
||||||
DiscoveredPrinters {
|
|
||||||
printers: Vec<DiscoveredPrinter>,
|
|
||||||
scanned_at: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One candidate, carrying no credential - and it is worth being precise
|
|
||||||
/// about why not, because this is the field a reader looks for.
|
|
||||||
///
|
|
||||||
/// It is not omitted for tidiness. No probe can learn one: a Bambu access
|
|
||||||
/// code is displayed on the printer's own screen and a PrusaLink API key is
|
|
||||||
/// in its web UI, and every identity endpoint that would reveal either needs
|
|
||||||
/// the credential first. So discovery shortens the operator's job to typing
|
|
||||||
/// one secret, rather than a secret plus an address plus a serial number -
|
|
||||||
/// and the backend's schema refuses a candidate that carries a credential,
|
|
||||||
/// so this staying empty is enforced at the far end too.
|
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
||||||
pub struct DiscoveredPrinter {
|
|
||||||
/// The `vendor` string this would need in a fleet file.
|
|
||||||
pub vendor: &'static str,
|
|
||||||
/// An IP or hostname, or a device path like `/dev/ttyACM0` for a serial
|
|
||||||
/// candidate. Named `address` rather than `host` because half of them
|
|
||||||
/// are not hosts.
|
|
||||||
pub address: String,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub serial: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub model: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub name: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub detail: Option<String>,
|
|
||||||
/// A vendor this could equally be; the operator settles it. Today only
|
|
||||||
/// `prusa_link` against `octoprint`, which nothing observable separates.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub ambiguous_with: Option<&'static str>,
|
|
||||||
pub source: &'static str,
|
|
||||||
/// Whether this build can actually drive the vendor. False is still
|
|
||||||
/// worth reporting - it answers "what is on this network?" - but the
|
|
||||||
/// console has to say so, or an entry looks finished when adding it
|
|
||||||
/// would produce a printer that connects and does nothing.
|
|
||||||
pub driver_ready: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A flattened snapshot of one printer, in the terms an operator console
|
|
||||||
/// 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>,
|
|
||||||
/// True when the camera can only be read as single JPEG frames (a P1/A1)
|
|
||||||
/// and this gateway will answer a `CameraSnapshotRequest` for it. See
|
|
||||||
/// `crate::camera::wants_snapshot`.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub camera_snapshot: Option<bool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `continuum-backend` sends back over the same connection.
|
/// What `continuum-backend` sends back over the same connection.
|
||||||
@@ -224,168 +13,5 @@ pub struct PrinterTelemetry {
|
|||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ServerMessage {
|
pub enum ServerMessage {
|
||||||
HelloAck { session_id: String },
|
HelloAck { session_id: String },
|
||||||
/// The cloud has dealt with a `JobEvent`; the gateway can forget it.
|
|
||||||
JobEventAck { event_id: i64 },
|
|
||||||
/// The handshake was rejected - a bad, expired or wrong-farm token. The
|
|
||||||
/// 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 },
|
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 },
|
|
||||||
/// Read one JPEG frame from a P1/A1-style camera and answer with a
|
|
||||||
/// `CameraSnapshot`. `printer_id` is our own id.
|
|
||||||
CameraSnapshotRequest { request_id: String, printer_id: String },
|
|
||||||
/// Add a printer to this gateway. Carries the printer's credential, sent
|
|
||||||
/// once by the console and kept only here - see `provision`. Answered
|
|
||||||
/// with a `CommandResult` carrying the same id.
|
|
||||||
ProvisionPrinter { request_id: String, printer: ProvisionPrinter },
|
|
||||||
/// Forget a printer. Answered like `ProvisionPrinter`.
|
|
||||||
RemovePrinter { request_id: String, printer_id: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the console may tell a gateway about a printer to add.
|
|
||||||
///
|
|
||||||
/// Deliberately narrower than `fleet::PrinterEntry`: no `ca_cert_path` and no
|
|
||||||
/// serial `port`, because both name something on the gateway's own disk or
|
|
||||||
/// USB bus, and the cloud has no business choosing those.
|
|
||||||
///
|
|
||||||
/// `Debug` is by hand so the credential cannot reach a log line - the
|
|
||||||
/// `Ok(other) => info!(?other, ...)` arm in the uplink formats whole messages.
|
|
||||||
#[derive(Clone, Deserialize)]
|
|
||||||
pub struct ProvisionPrinter {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub vendor: String,
|
|
||||||
pub host: Option<String>,
|
|
||||||
pub access_code: Option<String>,
|
|
||||||
pub sn: Option<String>,
|
|
||||||
pub api_key: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for ProvisionPrinter {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("ProvisionPrinter")
|
|
||||||
.field("id", &self.id)
|
|
||||||
.field("name", &self.name)
|
|
||||||
.field("vendor", &self.vendor)
|
|
||||||
.field("host", &self.host)
|
|
||||||
.field("access_code", &self.access_code.as_ref().map(|_| "<redacted>"))
|
|
||||||
.field("sn", &self.sn)
|
|
||||||
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The closed set of things the cloud is allowed to ask a printer to do.
|
|
||||||
///
|
|
||||||
/// 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,
|
|
||||||
/// Use this AMS slot (0-based, all units) instead of the project's own pick.
|
|
||||||
#[serde(default)]
|
|
||||||
ams_slot: Option<u8>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,257 +0,0 @@
|
|||||||
//! 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
@@ -1,220 +0,0 @@
|
|||||||
{
|
|
||||||
"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
@@ -1,846 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
//! 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A P1P mid-print pushes `ams` objects without the inner unit list. That
|
|
||||||
/// used to fail the whole report ("missing field `ams`"), silencing telemetry
|
|
||||||
/// for the rest of the print - found on the first real dispatch.
|
|
||||||
#[test]
|
|
||||||
fn a_partial_ams_object_parses_and_keeps_known_units() {
|
|
||||||
let partial = serde_json::json!({"command":"push_status","sequence_id":"1","msg":1,"gcode_state":"RUNNING","ams":{"tray_now":"2"}});
|
|
||||||
let report: PrintReport = serde_json::from_value(partial).expect("a partial ams object should parse");
|
|
||||||
assert!(report.ams.as_ref().is_some_and(|a| a.ams.is_empty()));
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
//! 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::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio_tungstenite::WebSocketStream;
|
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
|
||||||
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
|
|
||||||
|
|
||||||
use continuum_proxy::config::Config;
|
|
||||||
use continuum_proxy::uplink;
|
|
||||||
|
|
||||||
/// Long enough for a loopback round trip, short enough that a regression
|
|
||||||
/// fails the test instead of hanging the suite.
|
|
||||||
const WAIT: Duration = Duration::from_secs(10);
|
|
||||||
|
|
||||||
fn config(port: u16) -> Config {
|
|
||||||
Config {
|
|
||||||
gateway_id: "test-gw".into(),
|
|
||||||
farm_id: "test-farm".into(),
|
|
||||||
gateway_token: "test-token".into(),
|
|
||||||
uplink_url: format!("ws://127.0.0.1:{port}/ws/edge/v1"),
|
|
||||||
// Nothing here starts go2rtc or reads the fleet file - `uplink::run`
|
|
||||||
// is handed its fleet rather than loading one - but Config has no
|
|
||||||
// Default, so these have to say something.
|
|
||||||
go2rtc_bin: "/nonexistent-go2rtc".into(),
|
|
||||||
go2rtc_config: "/nonexistent/go2rtc.yaml".into(),
|
|
||||||
go2rtc_api_listen: "127.0.0.1:1984".into(),
|
|
||||||
stream_base_url: None,
|
|
||||||
printers_path: "/nonexistent/printers.toml".into(),
|
|
||||||
store_path: "/nonexistent/continuum.db".into(),
|
|
||||||
// Off, which is the default and also the only safe thing in a test:
|
|
||||||
// an enabled one would sweep whatever network the test host is on.
|
|
||||||
discovery: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The next frame as JSON. Heartbeats and telemetry share this socket, so
|
|
||||||
/// callers filter; `interval` fires on its first tick, which means a
|
|
||||||
/// heartbeat arrives immediately after the hello.
|
|
||||||
async fn next_json<S>(ws: &mut WebSocketStream<S>) -> Value
|
|
||||||
where
|
|
||||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
|
||||||
{
|
|
||||||
loop {
|
|
||||||
let frame = tokio::time::timeout(WAIT, ws.next())
|
|
||||||
.await
|
|
||||||
.expect("timed out waiting for a frame from the gateway")
|
|
||||||
.expect("the gateway closed the connection")
|
|
||||||
.expect("websocket error");
|
|
||||||
if let Message::Text(text) = frame {
|
|
||||||
return serde_json::from_str(&text).expect("the gateway sent invalid JSON");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Plays the cloud's `/auth/edge-token`: reads one HTTP request, checks that
|
|
||||||
/// the gateway sent its farm and long-lived token, and answers with a JWT.
|
|
||||||
/// Returns the request text so the caller can assert on it.
|
|
||||||
async fn answer_token_exchange(listener: &TcpListener) -> String {
|
|
||||||
let (mut stream, _) = listener.accept().await.unwrap();
|
|
||||||
let mut request = Vec::new();
|
|
||||||
let mut buf = [0u8; 4096];
|
|
||||||
loop {
|
|
||||||
let n = tokio::time::timeout(WAIT, stream.read(&mut buf)).await.unwrap().unwrap();
|
|
||||||
assert!(n > 0, "the gateway hung up before finishing its token request");
|
|
||||||
request.extend_from_slice(&buf[..n]);
|
|
||||||
let text = String::from_utf8_lossy(&request).into_owned();
|
|
||||||
if let Some((head, body)) = text.split_once("\r\n\r\n") {
|
|
||||||
let length = head
|
|
||||||
.lines()
|
|
||||||
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap()))
|
|
||||||
.unwrap_or(0);
|
|
||||||
if body.len() >= length {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let body = r#"{"token":"minted-edge-jwt","expiresInSeconds":3600}"#;
|
|
||||||
stream
|
|
||||||
.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
String::from_utf8_lossy(&request).into_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn a_long_command_that_fails_reports_its_reason_after_the_fact() {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let port = listener.local_addr().unwrap().port();
|
|
||||||
|
|
||||||
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let (outbound, inbox) = uplink::channel();
|
|
||||||
tokio::spawn(uplink::run(config(port), fleet, None, outbound, inbox));
|
|
||||||
|
|
||||||
// The gateway trades its long-lived token for an edge JWT first, and the
|
|
||||||
// JWT - not the token it was configured with - is what opens the socket.
|
|
||||||
let exchange = answer_token_exchange(&listener).await;
|
|
||||||
assert!(exchange.starts_with("POST /auth/edge-token "), "{exchange}");
|
|
||||||
assert!(exchange.contains(r#""farmId":"test-farm""#) && exchange.contains(r#""edgeProxyToken":"test-token""#), "{exchange}");
|
|
||||||
|
|
||||||
let (stream, _) = listener.accept().await.unwrap();
|
|
||||||
let mut upgrade_auth = String::new();
|
|
||||||
let mut ws = tokio_tungstenite::accept_hdr_async(stream, |request: &Request, response: Response| {
|
|
||||||
upgrade_auth = request.headers().get("authorization").unwrap().to_str().unwrap().to_string();
|
|
||||||
Ok(response)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(upgrade_auth, "Bearer minted-edge-jwt", "the socket must be opened with the minted JWT");
|
|
||||||
|
|
||||||
let 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}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
//! Job outcomes survive the cloud being away: an event in the gateway's store
|
|
||||||
//! is sent on connect, sent again after a reconnect if nobody acknowledged it,
|
|
||||||
//! and gone once acknowledged. A start that fails after being accepted is
|
|
||||||
//! queued the same way instead of being sent once to whoever is listening.
|
|
||||||
//!
|
|
||||||
//! Real `uplink::run` against a real websocket server; no printer is touched
|
|
||||||
//! (the fleet is empty, so the start fails on its lookup before any download).
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use serde_json::{json, Value};
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio_tungstenite::WebSocketStream;
|
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
|
||||||
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
|
|
||||||
|
|
||||||
use continuum_proxy::config::Config;
|
|
||||||
use continuum_proxy::store::Store;
|
|
||||||
use continuum_proxy::uplink;
|
|
||||||
|
|
||||||
/// Long enough for a loopback round trip, short enough that a regression
|
|
||||||
/// fails the test instead of hanging the suite.
|
|
||||||
const WAIT: Duration = Duration::from_secs(10);
|
|
||||||
|
|
||||||
fn config(port: u16) -> Config {
|
|
||||||
Config {
|
|
||||||
gateway_id: "test-gw".into(),
|
|
||||||
farm_id: "test-farm".into(),
|
|
||||||
gateway_token: "test-token".into(),
|
|
||||||
uplink_url: format!("ws://127.0.0.1:{port}/ws/edge/v1"),
|
|
||||||
// Nothing here starts go2rtc or reads the fleet file - `uplink::run`
|
|
||||||
// is handed its fleet rather than loading one - but Config has no
|
|
||||||
// Default, so these have to say something.
|
|
||||||
go2rtc_bin: "/nonexistent-go2rtc".into(),
|
|
||||||
go2rtc_config: "/nonexistent/go2rtc.yaml".into(),
|
|
||||||
go2rtc_api_listen: "127.0.0.1:1984".into(),
|
|
||||||
stream_base_url: None,
|
|
||||||
printers_path: "/nonexistent/printers.toml".into(),
|
|
||||||
store_path: "/nonexistent/continuum.db".into(),
|
|
||||||
// Off, which is the default and also the only safe thing in a test:
|
|
||||||
// an enabled one would sweep whatever network the test host is on.
|
|
||||||
discovery: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The next frame as JSON. Heartbeats and telemetry share this socket, so
|
|
||||||
/// callers filter; `interval` fires on its first tick, which means a
|
|
||||||
/// heartbeat arrives immediately after the hello.
|
|
||||||
async fn next_json<S>(ws: &mut WebSocketStream<S>) -> Value
|
|
||||||
where
|
|
||||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
|
||||||
{
|
|
||||||
loop {
|
|
||||||
let frame = tokio::time::timeout(WAIT, ws.next())
|
|
||||||
.await
|
|
||||||
.expect("timed out waiting for a frame from the gateway")
|
|
||||||
.expect("the gateway closed the connection")
|
|
||||||
.expect("websocket error");
|
|
||||||
if let Message::Text(text) = frame {
|
|
||||||
return serde_json::from_str(&text).expect("the gateway sent invalid JSON");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Plays the cloud's `/auth/edge-token`: reads one HTTP request, checks that
|
|
||||||
/// the gateway sent its farm and long-lived token, and answers with a JWT.
|
|
||||||
/// Returns the request text so the caller can assert on it.
|
|
||||||
async fn answer_token_exchange(listener: &TcpListener) -> String {
|
|
||||||
let (mut stream, _) = listener.accept().await.unwrap();
|
|
||||||
let mut request = Vec::new();
|
|
||||||
let mut buf = [0u8; 4096];
|
|
||||||
loop {
|
|
||||||
let n = tokio::time::timeout(WAIT, stream.read(&mut buf)).await.unwrap().unwrap();
|
|
||||||
assert!(n > 0, "the gateway hung up before finishing its token request");
|
|
||||||
request.extend_from_slice(&buf[..n]);
|
|
||||||
let text = String::from_utf8_lossy(&request).into_owned();
|
|
||||||
if let Some((head, body)) = text.split_once("\r\n\r\n") {
|
|
||||||
let length = head
|
|
||||||
.lines()
|
|
||||||
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap()))
|
|
||||||
.unwrap_or(0);
|
|
||||||
if body.len() >= length {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let body = r#"{"token":"minted-edge-jwt","expiresInSeconds":3600}"#;
|
|
||||||
stream
|
|
||||||
.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
String::from_utf8_lossy(&request).into_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Answers the token exchange and accepts the websocket, returning it after
|
|
||||||
/// the gateway's hello.
|
|
||||||
async fn accept(listener: &TcpListener) -> WebSocketStream<tokio::net::TcpStream> {
|
|
||||||
answer_token_exchange(listener).await;
|
|
||||||
let (stream, _) = listener.accept().await.unwrap();
|
|
||||||
let mut ws = tokio_tungstenite::accept_hdr_async(stream, |_: &Request, response: Response| Ok(response)).await.unwrap();
|
|
||||||
let hello = next_json(&mut ws).await;
|
|
||||||
assert_eq!(hello["type"], "hello");
|
|
||||||
ws
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads frames until `want` job events have arrived, returning them by job id.
|
|
||||||
async fn job_events(ws: &mut WebSocketStream<tokio::net::TcpStream>, want: usize) -> Vec<Value> {
|
|
||||||
let mut events = Vec::new();
|
|
||||||
while events.len() < want {
|
|
||||||
let msg = next_json(ws).await;
|
|
||||||
if msg["type"] == "job_event" {
|
|
||||||
events.push(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
events.sort_by_key(|e| e["event_id"].as_i64());
|
|
||||||
events
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn unacknowledged_job_events_are_resent_after_a_reconnect_and_stop_once_acknowledged() {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let port = listener.local_addr().unwrap().port();
|
|
||||||
|
|
||||||
let dir = std::env::temp_dir().join(format!("continuum-outbox-test-{}", std::process::id()));
|
|
||||||
let store = Arc::new(Store::open(&dir.join("s.db")).unwrap());
|
|
||||||
// An outcome reached while the cloud was away.
|
|
||||||
store.outbox_push("11111111-1111-4111-8111-111111111111", "finished", None, 1_000).unwrap();
|
|
||||||
|
|
||||||
let fleet: uplink::SharedFleet = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let (outbound, inbox) = uplink::channel();
|
|
||||||
tokio::spawn(uplink::run(config(port), fleet, Some(store.clone()), outbound, inbox));
|
|
||||||
|
|
||||||
let mut ws = accept(&listener).await;
|
|
||||||
let first = job_events(&mut ws, 1).await;
|
|
||||||
assert_eq!(first[0]["job_id"], "11111111-1111-4111-8111-111111111111");
|
|
||||||
assert_eq!(first[0]["event"], "finished");
|
|
||||||
assert_eq!(first[0]["at_ms"], 1_000);
|
|
||||||
|
|
||||||
// A start that fails after being accepted goes through the same outbox.
|
|
||||||
ws.send(Message::Text(
|
|
||||||
json!({
|
|
||||||
"type": "command", "command_id": "cmd-1", "printer_id": "not-in-this-fleet",
|
|
||||||
"command": { "action": "start_print", "job_id": "22222222-2222-4222-8222-222222222222",
|
|
||||||
"file_name": "cube.gcode", "url": "http://127.0.0.1:1/never-fetched" }
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
// Only the new one: the first was already sent on this connection.
|
|
||||||
let mut both = first.clone();
|
|
||||||
both.extend(job_events(&mut ws, 1).await);
|
|
||||||
assert_eq!(both[1]["job_id"], "22222222-2222-4222-8222-222222222222");
|
|
||||||
assert_eq!(both[1]["event"], "failed");
|
|
||||||
assert!(both[1]["detail"].as_str().unwrap().contains("not-in-this-fleet"), "{}", both[1]);
|
|
||||||
|
|
||||||
// The cloud never answers; the connection drops.
|
|
||||||
drop(ws);
|
|
||||||
|
|
||||||
let mut ws = accept(&listener).await;
|
|
||||||
let again = job_events(&mut ws, 2).await;
|
|
||||||
assert_eq!(
|
|
||||||
again.iter().map(|e| e["event_id"].clone()).collect::<Vec<_>>(),
|
|
||||||
both.iter().map(|e| e["event_id"].clone()).collect::<Vec<_>>(),
|
|
||||||
"after a reconnect everything unacknowledged is sent again"
|
|
||||||
);
|
|
||||||
|
|
||||||
ws.send(Message::Text(json!({ "type": "job_event_ack", "event_id": again[0]["event_id"] }).to_string()))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let deadline = tokio::time::Instant::now() + WAIT;
|
|
||||||
while store.outbox_pending().unwrap().len() != 1 {
|
|
||||||
assert!(tokio::time::Instant::now() < deadline, "the acknowledged event was not cleared");
|
|
||||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
||||||
}
|
|
||||||
assert_eq!(store.outbox_pending().unwrap()[0].job_id, "22222222-2222-4222-8222-222222222222");
|
|
||||||
let _ = std::fs::remove_dir_all(dir);
|
|
||||||
}
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
//! 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,
|
|
||||||
camera_snapshot: 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()),
|
|
||||||
camera_snapshot: None,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
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"}"#);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_snapshot_request_is_readable_and_its_answer_matches_the_schema() {
|
|
||||||
let request = r#"{"type":"camera_snapshot_request","request_id":"r1","printer_id":"p1p"}"#;
|
|
||||||
assert!(matches!(
|
|
||||||
serde_json::from_str::<ServerMessage>(request),
|
|
||||||
Ok(ServerMessage::CameraSnapshotRequest { .. })
|
|
||||||
));
|
|
||||||
|
|
||||||
let ok = serde_json::to_string(&ClientMessage::CameraSnapshot {
|
|
||||||
request_id: "r1".into(),
|
|
||||||
jpeg_base64: Some("/9j/".into()),
|
|
||||||
error: None,
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(ok, r#"{"type":"camera_snapshot","request_id":"r1","jpeg_base64":"/9j/"}"#);
|
|
||||||
|
|
||||||
let failed = serde_json::to_string(&ClientMessage::CameraSnapshot {
|
|
||||||
request_id: "r1".into(),
|
|
||||||
jpeg_base64: None,
|
|
||||||
error: Some("no frame".into()),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(failed, r#"{"type":"camera_snapshot","request_id":"r1","error":"no frame"}"#);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user