e2e-edge.sh proves the camera signalling relay round-trips, and says so honestly - it posts a stub SDP with go2rtc absent and accepts a 503 that came back from the gateway. But "the offer round-trips" says nothing about whether anyone would see a picture, and it once read as more than it was: a stray go2rtc left over from an earlier run answered the stub with an HTTP 500 and the test still printed ok. Video had never been shown to flow. scripts/e2e-camera.sh runs the real backend, the real proxy and the real go2rtc against the H2C, and scripts/e2e-camera-peer.ts plays the browser's part with werift: createOffer, relay through the backend, setRemoteDescription, then ICE, DTLS and SRTP, counting RTP out the far end. Measured: 124-149 packets and ~119-147 KB of payload within a second or two. It sends no printer command; the camera is a pull and the MQTT subscription is passive. It refuses to start if a go2rtc is already running, and fails if one survives cleanup. werift is a devDependency for that one file; nothing under src/ imports it. Its 30 resolved packages were audited against LICENSES.md: 26 MIT, Apache-2.0, BSD-3-Clause, Unlicense, and one MPL-2.0 (mediabunny), which the policy allows while we do not edit its files. Smaller fixes in the same pass: - e2e-edge.sh now unsets the TURN variables. It asserts TURN is off, but sourcing dev-secrets.sh turns it on, and the failure message then printed the live Cloudflare username and credential. The new script prints only the camera id and a count, never the /stream body. - the last hardcoded `psql -h localhost` in e2e-edge.sh now uses DATABASE_URL, like the rest.
129 lines
5.3 KiB
TypeScript
129 lines
5.3 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.
|
|
*/
|
|
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 pc = new RTCPeerConnection({
|
|
iceServers: [],
|
|
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");
|
|
|
|
// 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);
|