The first e2e-camera.sh connected over direct host candidates. The real browser is pinned to iceTransportPolicy "relay", and PLAN.md recorded go2rtc's known trap for that configuration: a relay candidate on one side only can leave ICE stuck in `checking`. The script now runs the peer a second time pinned the same way, with the iceServers the backend hands out. The peer asserts its offer contains only `typ relay` candidates, so a pass cannot mean it connected directly, and never prints the credentials. Measured against the H2C: 937 packets (~943 KB) arrive through Cloudflare, and the trap did not occur. The phase is skipped with an explicit message when TURN credentials are absent, so a run without them cannot be mistaken for one that verified the relay. Both limits are written into the script header: viewer and gateway share a host, so this is not two distinct NATs, and no real browser has rendered a frame.
160 lines
6.9 KiB
TypeScript
160 lines
6.9 KiB
TypeScript
/**
|
|
* 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 `ok<TAB>label` / `FAILED<TAB>label`, 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<boolean>((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);
|