/** * A WebRTC receiver that is not a browser, for scripts/e2e-camera.sh. * * What it proves that nothing else here does: that video actually arrives. * The signalling relay has always been testable with a canned SDP, and the * go2rtc-to-printer leg was confirmed by hand, but "the offer round-trips" * says nothing about whether a viewer would see a picture. This runs the * browser's half of the exchange for real - createOffer, relay through the * backend, setRemoteDescription, then ICE, DTLS and SRTP to go2rtc - and * counts the RTP packets that come out the other end. * * It also exists because the suite's earlier stub offer (`v=0 ... t=0 0`, no * media section, no ice-ufrag) could only ever be refused, and the test * counted that refusal as success. A real offer has to be accepted. * * Results are printed as `oklabel` / `FAILEDlabel`, the convention * run_probe in e2e-real-services.sh also reads. werift is a devDependency of * this repo for this file alone; nothing under src/ imports it. * * Environment: BASE, PRINTER_UUID, ACCESS_TOKEN. The token never reaches a * log line. * * ICE_MODE=relay runs it the way the real browser is configured: pinned to * `iceTransportPolicy: "relay"` with the short-lived TURN servers that * `GET /printers/:id/stream` hands out, so media can only travel through * Cloudflare. That is the product's actual path, and the one go2rtc's issue * tracker warns can strand ICE in `checking` when only one side holds a relay. * The TURN credentials are used and never printed. */ import { RTCPeerConnection, RTCRtpCodecParameters } from "werift"; const base = process.env.BASE!; const printer = process.env.PRINTER_UUID!; const token = process.env.ACCESS_TOKEN!; const MIN_PACKETS = Number(process.env.MIN_RTP_PACKETS ?? 50); const WAIT_MS = Number(process.env.MEDIA_WAIT_MS ?? 25_000); let failed = false; const say = (good: boolean, label: string, detail = "") => { if (!good) failed = true; console.log(`${good ? "ok" : "FAILED"}\t${label}${detail ? ` (${detail})` : ""}`); }; // werift offers only VP8 by default, and a Bambu camera is H.264 (High, level // 4.1), so go2rtc refused the first version of this offer with "codecs not // matched: video:H264 => video:VP8" - correctly. Every browser offers H.264, // so offering it here is what a real viewer does, not a concession to the test. // The parameters are Chrome's: packetization-mode=1, constrained baseline. const relayOnly = process.env.ICE_MODE === "relay"; let iceServers: { urls: string | string[]; username?: string; credential?: string }[] = []; if (relayOnly) { const info = await fetch(`${base}/printers/${printer}/stream`, { headers: { authorization: `Bearer ${token}` }, }); const parsed = (await info.json()) as { iceServers?: typeof iceServers }; iceServers = parsed.iceServers ?? []; say(info.ok && iceServers.length > 0, "the backend hands out ICE servers for a relay-only viewer", `${iceServers.length} entr(ies)`); if (iceServers.length === 0) process.exit(1); } const pc = new RTCPeerConnection({ iceServers, iceTransportPolicy: relayOnly ? "relay" : "all", codecs: { video: [ new RTCRtpCodecParameters({ mimeType: "video/H264", clockRate: 90000, rtcpFeedback: [{ type: "nack" }, { type: "nack", parameter: "pli" }, { type: "goog-remb" }], parameters: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f", }), ], }, }); const transceiver = pc.addTransceiver("video", { direction: "recvonly" }); let packets = 0; let bytes = 0; transceiver.onTrack.subscribe((track) => { track.onReceiveRtp.subscribe((rtp) => { packets += 1; bytes += rtp.payload.length; }); }); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const offerSdp = pc.localDescription!.sdp; say(/^m=video /m.test(offerSdp) && /^a=ice-ufrag:/m.test(offerSdp), "the offer has a video section and an ice-ufrag, unlike the stub it replaces"); if (relayOnly) { // Asserted rather than assumed: if the viewer also offered host or srflx // candidates, a pass below could mean "connected directly" and prove nothing // about TURN. const kinds = [...offerSdp.matchAll(/^a=candidate:.* typ (\w+)/gm)].map((m) => m[1]); say(kinds.length > 0 && kinds.every((k) => k === "relay"), "the viewer offers only relay candidates, as the browser does", `candidates: ${kinds.length ? [...new Set(kinds)].join(",") : "none - the TURN allocation failed"}`); } // A player that gets "stream not found" just asks again, and so does this: the // gateway advertises a camera as soon as the printer reports its URL, a moment // before go2rtc has been restarted with the stream in its config. Bounded, and // the attempt count is reported, so a window that grows shows up as a number // rather than as a flaky pass. Only that one reason is retried - a codec // mismatch or any other refusal is a real failure and must fail at once. const RETRY_MS = Number(process.env.OFFER_RETRY_MS ?? 20_000); const started = Date.now(); let attempts = 0; let res: Response; let bodyText = ""; do { attempts += 1; res = await fetch(`${base}/printers/${printer}/webrtc`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ sdp: offerSdp }), }); bodyText = await res.text(); if (res.status !== 503 || !bodyText.includes("stream not found")) break; await Bun.sleep(1000); } while (Date.now() - started < RETRY_MS); say(res.status === 200, "the relayed offer is answered 200", `HTTP ${res.status} after ${attempts} attempt(s), ${Math.round((Date.now() - started) / 1000)}s`); if (res.status !== 200) { // The gateway's reason is the whole diagnosis, and it carries no secrets. console.log(`# ${bodyText.slice(0, 300).replace(/\s+/g, " ")}`); await pc.close(); process.exit(1); } const answer = (JSON.parse(bodyText) as { sdp?: string }).sdp ?? ""; say(/^m=video /m.test(answer) && /H264\/90000/i.test(answer), "the answer negotiates H264 video, which is what the printer sends"); await pc.setRemoteDescription({ type: "answer", sdp: answer }); const connected = await new Promise((resolve) => { const timer = setTimeout(() => resolve(false), WAIT_MS); pc.connectionStateChange.subscribe((state) => { if (state === "connected") { clearTimeout(timer); resolve(true); } if (state === "failed") { clearTimeout(timer); resolve(false); } }); if (pc.connectionState === "connected") { clearTimeout(timer); resolve(true); } }); say(connected, "ICE and DTLS completed with go2rtc", `state=${pc.connectionState}`); if (connected) { const deadline = Date.now() + WAIT_MS; while (packets < MIN_PACKETS && Date.now() < deadline) await Bun.sleep(250); } say(packets >= MIN_PACKETS, `video RTP reached the viewer: at least ${MIN_PACKETS} packets`, `${packets} packets, ${bytes} payload bytes`); await pc.close(); process.exit(failed ? 1 : 0);