Add Klipper, OctoPrint and PrusaLink printers from the API
POST /farms/:id/printers now takes any of the four vendors the gateway can drive. Credentials depend on the vendor: Bambu needs an access code and serial, OctoPrint and PrusaLink an API key, Klipper nothing. A missing one is a 422 naming what is needed. The API key is relayed to the gateway and kept out of logs and errors like the access code. The printer_vendor enum gains octoprint (migration 0006); PrusaLink is stored as prusa, the name the enum already had. scripts/e2e-vendors.sh adds each kind through the API against a fake REST printer (scripts/fake-http-printer.ts) and runs a job through the real scheduler to completion. The fake shares its author's reading of the docs with the driver, so this proves the plumbing, not the protocols.
This commit is contained in:
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env bash
|
||||
# The non-Bambu vendors end to end, against FAKE printers: add each one through
|
||||
# the API (so the provision frame, the real proxy's driver and its store are
|
||||
# all exercised), then run a job through the real scheduler and watch it go
|
||||
# queued -> uploading -> printing -> completed from the fake's own telemetry.
|
||||
#
|
||||
# What this proves is the plumbing around the drivers. It does NOT prove the
|
||||
# drivers speak the real Klipper / OctoPrint / PrusaLink protocols: the fake
|
||||
# (scripts/fake-http-printer.ts) is written from the same reading of the docs
|
||||
# as the driver, so a shared misreading passes here and fails on hardware.
|
||||
#
|
||||
# Needs the dev stack (scripts/dev-env.sh up && schema) and the object store
|
||||
# credentials in ../dev-secrets.sh (the job file goes through the real
|
||||
# presigned URLs). No real printer is touched.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
[ -f "$REPO_ROOT/../dev-secrets.sh" ] && . "$REPO_ROOT/../dev-secrets.sh"
|
||||
|
||||
KINDS="${KINDS:-klipper octoprint prusa_link}"
|
||||
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
|
||||
PORT="${PORT:-3062}"; AUTH_PORT="${AUTH_PORT:-3063}"; FAKE_PORT="${FAKE_PORT:-7141}"
|
||||
BASE="http://localhost:$PORT"
|
||||
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
|
||||
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
|
||||
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
|
||||
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
|
||||
WORK="$(mktemp -d)"
|
||||
BACKEND_PID=""; PROXY_PID=""; AUTH_PID=""; FAKE_PID=""; ORGS=""
|
||||
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||
ok() { echo " ok: $*"; }
|
||||
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
|
||||
new_uuid() { cat /proc/sys/kernel/random/uuid; }
|
||||
json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)"; }
|
||||
|
||||
stop() { for pid in "$@"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done; sleep 0.5; for pid in "$@"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done; }
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
stop "$PROXY_PID" "$FAKE_PID" "$BACKEND_PID" "$AUTH_PID"
|
||||
for org in $ORGS; do psql_q "DELETE FROM organisations WHERE id = '$org'" >/dev/null 2>&1 || true; done
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "--- backend log (tail) ---"; tail -25 "$WORK/backend.log" 2>/dev/null || true
|
||||
echo "--- proxy log (tail) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true
|
||||
echo "--- fake printer log (tail) ---"; tail -15 "$WORK/fake.log" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$WORK"; exit $rc
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[ -n "${R2_ENDPOINT:-}" ] || fail "no object store credentials - ../dev-secrets.sh"
|
||||
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
|
||||
|
||||
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
|
||||
AUTH_PID=$!
|
||||
AUTH_URL="http://localhost:$AUTH_PORT"
|
||||
for _ in $(seq 1 40); do curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break; sleep 0.25; done
|
||||
export SUPABASE_URL="$AUTH_URL"
|
||||
USER_ID="$(new_uuid)"
|
||||
TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" | json 'd["access_token"]')"
|
||||
auth=(-H "authorization: Bearer $TOKEN")
|
||||
|
||||
DISPATCH_ENABLED=true bun run src/index.ts > "$WORK/backend.log" 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
for _ in $(seq 1 60); do curl -sf "$BASE/health" >/dev/null 2>&1 && break; sleep 0.5; done
|
||||
curl -sf "$BASE/health" >/dev/null || fail "backend did not become healthy"
|
||||
|
||||
for KIND in $KINDS; do
|
||||
echo "==> $KIND"
|
||||
KEY="key-$(new_uuid | cut -c1-8)"
|
||||
bun scripts/fake-http-printer.ts "$KIND" "$FAKE_PORT" "$KEY" > "$WORK/fake.log" 2>&1 &
|
||||
FAKE_PID=$!
|
||||
for _ in $(seq 1 40); do grep -q listening "$WORK/fake.log" && break; sleep 0.25; done
|
||||
|
||||
ORG_ID="$(curl -sf "${auth[@]}" -H 'content-type: application/json' -d "{\"name\":\"e2e vendors $KIND\"}" "$BASE/orgs" | json 'd["id"]')"
|
||||
ORGS="$ORGS $ORG_ID"
|
||||
FARM_ID="$(curl -sf "${auth[@]}" -H 'content-type: application/json' -d "{\"name\":\"farm $KIND\",\"orgId\":\"$ORG_ID\"}" "$BASE/farms" | json 'd["id"]')"
|
||||
GW_TOKEN="$(curl -sf "${auth[@]}" -X POST "$BASE/farms/$FARM_ID/edge-proxy-token" | json 'd["edgeProxyToken"]')"
|
||||
|
||||
env -C "$PROXY_DIR" \
|
||||
CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true CONTINUUM_GATEWAY_ID=e2e-vendors-gw CONTINUUM_FARM_ID="$FARM_ID" CONTINUUM_GATEWAY_TOKEN="$GW_TOKEN" \
|
||||
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" CONTINUUM_GO2RTC_BIN=/nonexistent-go2rtc CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
|
||||
CONTINUUM_PRINTERS_PATH="$WORK/no-such-printers.toml" CONTINUUM_STORE_PATH="$WORK/store-$KIND/continuum.db" RUST_LOG=info \
|
||||
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
|
||||
PROXY_PID=$!
|
||||
# This farm's own connect line: the log is shared across the loop.
|
||||
for _ in $(seq 1 60); do grep -q "connected for farm $FARM_ID" "$WORK/backend.log" && break; sleep 0.5; done
|
||||
grep -q "connected for farm $FARM_ID" "$WORK/backend.log" || fail "the proxy never connected"
|
||||
|
||||
# Klipper needs no credential; the others take an API key.
|
||||
CRED=""; [ "$KIND" != klipper ] && CRED=",\"apiKey\":\"$KEY\""
|
||||
CODE="$(curl -s -o "$WORK/add.json" -w '%{http_code}' "${auth[@]}" -H 'content-type: application/json' \
|
||||
-d "{\"name\":\"Fake $KIND\",\"vendor\":\"$KIND\",\"host\":\"127.0.0.1:$FAKE_PORT\"$CRED}" "$BASE/farms/$FARM_ID/printers")"
|
||||
[ "$CODE" = 201 ] || fail "adding the $KIND printer returned $CODE: $(head -c 300 "$WORK/add.json")"
|
||||
PRN_UUID="$(json 'd["id"]' < "$WORK/add.json")"
|
||||
ok "added through the API and connected by the proxy"
|
||||
grep -q "$KEY" "$WORK/proxy.log" "$WORK/backend.log" && fail "the API key is in a log"
|
||||
ok "the API key is in neither log"
|
||||
|
||||
if [ "$KIND" != klipper ]; then
|
||||
CODE="$(curl -s -o /dev/null -w '%{http_code}' "${auth[@]}" -H 'content-type: application/json' \
|
||||
-d "{\"name\":\"No key\",\"vendor\":\"$KIND\",\"host\":\"127.0.0.1:$FAKE_PORT\"}" "$BASE/farms/$FARM_ID/printers")"
|
||||
[ "$CODE" = 422 ] && ok "a missing API key is refused (422)" || fail "a keyless add returned $CODE"
|
||||
fi
|
||||
|
||||
for _ in $(seq 1 30); do [ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = idle ] && break; sleep 1; done
|
||||
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = idle ] || fail "the cloud never saw the printer idle"
|
||||
ok "the cloud sees it idle (telemetry from the driver's poller)"
|
||||
|
||||
J="$(curl -sf "${auth[@]}" -H 'content-type: application/json' \
|
||||
-d "{\"farmId\":\"$FARM_ID\",\"fileName\":\"e2e-part.gcode\",\"contentType\":\"application/octet-stream\"}" "$BASE/jobs")"
|
||||
JOB_ID="$(printf '%s' "$J" | json 'd["job"]["id"]')"
|
||||
OBJECT_KEY="$(printf '%s' "$J" | json 'd["job"]["r2ObjectKey"]')"
|
||||
UPLOAD_URL="$(printf '%s' "$J" | json 'd["uploadUrl"]')"
|
||||
printf 'G28\nG1 X10 Y10 F3000\nM104 S0\n' > "$WORK/part.gcode"
|
||||
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/octet-stream' --data-binary @"$WORK/part.gcode" "$UPLOAD_URL")"
|
||||
[ "$CODE" = 200 ] || fail "job file upload returned $CODE"
|
||||
|
||||
SEEN=""; START=$SECONDS; STATUS=""
|
||||
while [ $((SECONDS - START)) -lt 90 ]; do
|
||||
STATUS="$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_ID'")"
|
||||
case " $SEEN " in *" $STATUS "*) ;; *) SEEN="$SEEN $STATUS" ;; esac
|
||||
[ "$STATUS" = completed ] || [ "$STATUS" = failed ] && break
|
||||
sleep 1
|
||||
done
|
||||
echo " job status history:$SEEN"
|
||||
[ "$STATUS" = completed ] || fail "the $KIND job ended as '$STATUS'"
|
||||
ok "$KIND: queued, scheduled, uploaded, printed and completed (fake printer)"
|
||||
|
||||
bun -e "
|
||||
import { s3 } from './src/s3';
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET ?? 'continuum', Key: process.argv[1] }));
|
||||
" "$OBJECT_KEY" >/dev/null 2>&1 || true
|
||||
stop "$PROXY_PID" "$FAKE_PID"; PROXY_PID=""; FAKE_PID=""
|
||||
done
|
||||
echo "PASS - $KINDS: added from the API, scheduled and completed against fake printers."
|
||||
@@ -0,0 +1,103 @@
|
||||
// A stand-in for the REST printers (Klipper's Moonraker, OctoPrint, PrusaLink)
|
||||
// so the real proxy can be driven end to end without owning one. It is only as
|
||||
// good as my reading of those APIs - it proves the plumbing, not the hardware.
|
||||
//
|
||||
// bun scripts/fake-http-printer.ts <klipper|octoprint|prusa_link> <port> [apiKey]
|
||||
//
|
||||
// A file uploaded with "print" set starts a fake print that runs for RUN_S
|
||||
// seconds (default 8) and then finishes. Every request is logged to stdout.
|
||||
const [kind, portArg, apiKey = "e2e-key"] = process.argv.slice(2);
|
||||
if (!["klipper", "octoprint", "prusa_link"].includes(kind ?? "")) {
|
||||
console.error("usage: fake-http-printer.ts <klipper|octoprint|prusa_link> <port> [apiKey]");
|
||||
process.exit(2);
|
||||
}
|
||||
const RUN_S = Number(process.env.RUN_S ?? 8);
|
||||
|
||||
let file: string | undefined;
|
||||
let startedAt: number | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const progress = () => (startedAt === undefined ? 0 : cancelled ? 0 : Math.min(1, (Date.now() - startedAt) / (RUN_S * 1000)));
|
||||
const phase = () => (startedAt === undefined ? "idle" : cancelled ? "cancelled" : progress() >= 1 ? "done" : "printing");
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
|
||||
function startFrom(body: string, name?: string) {
|
||||
file = name ?? /filename="([^"]+)"/.exec(body)?.[1] ?? "unknown";
|
||||
startedAt = Date.now();
|
||||
cancelled = false;
|
||||
}
|
||||
|
||||
Bun.serve({
|
||||
port: Number(portArg),
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const body = req.method === "GET" ? "" : new TextDecoder().decode(await req.arrayBuffer());
|
||||
console.log(`${req.method} ${url.pathname} ${body.length}b`);
|
||||
const route = `${req.method} ${url.pathname}`;
|
||||
|
||||
if (kind === "klipper") {
|
||||
if (route === "GET /printer/info") return json({ result: { state: "ready" } });
|
||||
if (route === "GET /printer/objects/query") {
|
||||
const p = progress();
|
||||
const state = { idle: "standby", printing: "printing", done: "complete", cancelled: "cancelled" }[phase()];
|
||||
return json({
|
||||
result: {
|
||||
status: {
|
||||
print_stats: { state, filename: file ?? "", print_duration: p * RUN_S, info: { current_layer: Math.ceil(p * 10), total_layer: 10 } },
|
||||
virtual_sdcard: { progress: p },
|
||||
extruder: { temperature: phase() === "printing" ? 210 : 24, target: phase() === "printing" ? 210 : 0 },
|
||||
heater_bed: { temperature: phase() === "printing" ? 60 : 23, target: phase() === "printing" ? 60 : 0 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (route === "POST /server/files/upload") {
|
||||
if (!/name="print"\r\n\r\ntrue/.test(body)) return json({ error: "print flag missing" }, 400);
|
||||
startFrom(body);
|
||||
return json({ print_started: true }, 201);
|
||||
}
|
||||
if (route === "POST /printer/print/cancel") { cancelled = true; return json({ result: "ok" }); }
|
||||
if (route === "POST /printer/gcode/script") return json({ result: "ok" });
|
||||
} else {
|
||||
if (req.headers.get("x-api-key") !== apiKey) return json({ error: "bad key" }, 403);
|
||||
const ph = phase();
|
||||
if (kind === "octoprint") {
|
||||
if (route === "GET /api/job") {
|
||||
return json({
|
||||
state: ph === "printing" ? "Printing" : "Operational",
|
||||
job: { file: { name: file ?? null, display: file ?? null } },
|
||||
progress: { completion: ph === "idle" || ph === "cancelled" ? null : progress() * 100, printTimeLeft: Math.round((1 - progress()) * RUN_S) },
|
||||
});
|
||||
}
|
||||
if (route === "GET /api/printer") {
|
||||
return json({ temperature: { tool0: { actual: ph === "printing" ? 210 : 24, target: ph === "printing" ? 210 : 0 }, bed: { actual: 60, target: ph === "printing" ? 60 : 0 } } });
|
||||
}
|
||||
if (route === "POST /api/files/local") {
|
||||
if (!/name="print"\r\n\r\ntrue/.test(body)) return json({ error: "print flag missing" }, 400);
|
||||
startFrom(body);
|
||||
return json({ done: true }, 201);
|
||||
}
|
||||
if (route === "POST /api/job") { if (body.includes("cancel")) cancelled = true; return new Response(null, { status: 204 }); }
|
||||
if (route === "POST /api/printer/command") return new Response(null, { status: 204 });
|
||||
} else {
|
||||
const state = { idle: "IDLE", printing: "PRINTING", done: "FINISHED", cancelled: "STOPPED" }[ph];
|
||||
if (route === "GET /api/v1/status") {
|
||||
return json({
|
||||
printer: { state, temp_nozzle: ph === "printing" ? 215 : 25, target_nozzle: ph === "printing" ? 215 : 0, temp_bed: 60, target_bed: 0 },
|
||||
...(ph === "printing" ? { job: { id: 1, progress: progress() * 100, time_remaining: Math.round((1 - progress()) * RUN_S) } } : {}),
|
||||
});
|
||||
}
|
||||
if (route === "GET /api/v1/job") return json({ id: 1, file: { display_name: file } });
|
||||
if (req.method === "PUT" && url.pathname.startsWith("/api/v1/files/local/")) {
|
||||
if (req.headers.get("print-after-upload") !== "?1") return json({ error: "print flag missing" }, 400);
|
||||
startFrom(body, decodeURIComponent(url.pathname.split("/").pop() ?? ""));
|
||||
return new Response(null, { status: 201 });
|
||||
}
|
||||
if (route === "DELETE /api/v1/job/1") { cancelled = true; return new Response(null, { status: 204 }); }
|
||||
}
|
||||
}
|
||||
return json({ error: `no route ${route}` }, 404);
|
||||
},
|
||||
});
|
||||
console.log(`fake ${kind} listening on :${portArg}`);
|
||||
Reference in New Issue
Block a user