App Development for Cambridge

Cambridge clients are standard Nabto WebRTC client applications. They use the same SDKs and connection flow as any other Nabto WebRTC client. The difference is in what happens after the connection is established: Instead of receiving a pre-negotiated media stream, the client uses datachannels with JSON-RPC to discover services, set up RTSP streams and forward HTTP requests.

SDK Selection

Choose the Nabto WebRTC Client SDK for your target platform:

PlatformSDKGuide
iOS (Swift)Nabto WebRTC Client SDK for iOSiOS SDK Guide
Android (Java/Kotlin)Nabto WebRTC Client SDK for AndroidAndroid SDK Guide
JavaScript / React NativeNabto WebRTC Client SDK for JSJS SDK Guide

Connection Setup

Connecting to a Cambridge device follows the same steps as connecting to any Nabto WebRTC device. The Client Applications guide describes the full flow including signaling client setup, Perfect Negotiation and event handling.

In summary:

  1. Create a SignalingClient with the device’s product ID and device ID.
  2. Set up a MessageTransport for the signaling protocol.
  3. Create an RTCPeerConnection and wire it up with PerfectNegotiation.
  4. Wait for the signaling channel to be ready, then begin using datachannels.

Using Datachannels

Once the WebRTC peer connection is established, the client interacts with Cambridge entirely through datachannels carrying JSON-RPC messages. A typical workflow follows.

1. Discover Available Services

Open a datachannel with the system protocol and call system.listServices to see what is available:

let config = RTCDataChannelConfiguration()
config.protocol = "nabto.system/1"
let systemChannel = peerConnection.dataChannel(forLabel: "system", configuration: config)
systemChannel?.delegate = self

// RTCDataChannelDelegate
func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) {
    if dataChannel.readyState == .open {
        let request: [String: Any] = [
            "jsonrpc": "2.0",
            "method": "system.listServices",
            "params": [:],
            "id": "1"
        ]
        let jsonData = try! JSONSerialization.data(withJSONObject: request)
        dataChannel.sendData(RTCDataBuffer(data: jsonData, isBinary: false))
    }
}

func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) {
    let response = try! JSONSerialization.jsonObject(with: buffer.data) as! [String: Any]
    let result = response["result"] as! [String: Any]
    print("Available services:", result["services"]!)
}

DataChannel.Init dcInit = new DataChannel.Init();
dcInit.protocol = "nabto.system/1";
DataChannel systemChannel = peerConnection.createDataChannel("system", dcInit);

systemChannel.registerObserver(new DataChannel.Observer() {
    @Override
    public void onStateChange() {
        if (systemChannel.state() == DataChannel.State.OPEN) {
            JSONObject request = new JSONObject();
            request.put("jsonrpc", "2.0");
            request.put("method", "system.listServices");
            request.put("params", new JSONObject());
            request.put("id", "1");

            byte[] data = request.toString().getBytes(StandardCharsets.UTF_8);
            systemChannel.send(new DataChannel.Buffer(ByteBuffer.wrap(data), false));
        }
    }

    @Override
    public void onMessage(DataChannel.Buffer buffer) {
        byte[] data = new byte[buffer.data.remaining()];
        buffer.data.get(data);
        JSONObject response = new JSONObject(new String(data, StandardCharsets.UTF_8));
        JSONObject result = response.getJSONObject("result");
        Log.d(TAG, "Available services: " + result.getJSONArray("services"));
    }

    @Override public void onBufferedAmountChange(long previousAmount) {}
});

const systemChannel = pc.createDataChannel("system", {
    protocol: "nabto.system/1",
});

systemChannel.onopen = () => {
    systemChannel.send(JSON.stringify({
        jsonrpc: "2.0",
        method: "system.listServices",
        params: {},
        id: "1"
    }));
};

systemChannel.onmessage = (event) => {
    const response = JSON.parse(event.data);
    console.log("Available services:", response.result.services);
};

2. Stream Video via RTSP

Open an RTSP datachannel, create a session and start playback:

let config = RTCDataChannelConfiguration()
config.protocol = "nabto.rtsp/1"
let rtspChannel = peerConnection.dataChannel(forLabel: "rtsp", configuration: config)
rtspChannel?.delegate = self

// RTCDataChannelDelegate
func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) {
    if dataChannel.readyState == .open {
        // Create a session for the desired camera and stream path
        let request: [String: Any] = [
            "jsonrpc": "2.0",
            "method": "rtsp.createSession",
            "params": ["service": "camera1", "target": "/stream"],
            "id": "2"
        ]
        let jsonData = try! JSONSerialization.data(withJSONObject: request)
        dataChannel.sendData(RTCDataBuffer(data: jsonData, isBinary: false))
    }
}

func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) {
    let response = try! JSONSerialization.jsonObject(with: buffer.data) as! [String: Any]
    if let result = response["result"] as? [String: Any],
       let session = result["session"] as? String {
        // Session created, a WebRTC renegotiation will follow.
        // After renegotiation completes, start playback:
        let playRequest: [String: Any] = [
            "jsonrpc": "2.0",
            "method": "rtsp.play",
            "params": ["session": session],
            "id": "3"
        ]
        let jsonData = try! JSONSerialization.data(withJSONObject: playRequest)
        dataChannel.sendData(RTCDataBuffer(data: jsonData, isBinary: false))
    }
}

// Handle incoming media tracks from the renegotiation (RTCPeerConnectionDelegate)
func peerConnection(_ peerConnection: RTCPeerConnection, didAdd rtpReceiver: RTCRtpReceiver,
                    streams mediaStreams: [RTCMediaStream]) {
    if let track = rtpReceiver.track as? RTCVideoTrack {
        DispatchQueue.main.async {
            track.add(self.remoteVideoView)
        }
    }
}

DataChannel.Init dcInit = new DataChannel.Init();
dcInit.protocol = "nabto.rtsp/1";
DataChannel rtspChannel = peerConnection.createDataChannel("rtsp", dcInit);

rtspChannel.registerObserver(new DataChannel.Observer() {
    @Override
    public void onStateChange() {
        if (rtspChannel.state() == DataChannel.State.OPEN) {
            // Create a session for the desired camera and stream path
            JSONObject request = new JSONObject();
            request.put("jsonrpc", "2.0");
            request.put("method", "rtsp.createSession");
            request.put("params", new JSONObject()
                .put("service", "camera1")
                .put("target", "/stream"));
            request.put("id", "2");

            byte[] data = request.toString().getBytes(StandardCharsets.UTF_8);
            rtspChannel.send(new DataChannel.Buffer(ByteBuffer.wrap(data), false));
        }
    }

    @Override
    public void onMessage(DataChannel.Buffer buffer) {
        byte[] data = new byte[buffer.data.remaining()];
        buffer.data.get(data);
        JSONObject response = new JSONObject(new String(data, StandardCharsets.UTF_8));
        JSONObject result = response.optJSONObject("result");
        if (result != null && result.has("session")) {
            // Session created, a WebRTC renegotiation will follow.
            // After renegotiation completes, start playback:
            String session = result.getString("session");
            JSONObject playRequest = new JSONObject();
            playRequest.put("jsonrpc", "2.0");
            playRequest.put("method", "rtsp.play");
            playRequest.put("params", new JSONObject().put("session", session));
            playRequest.put("id", "3");

            byte[] playData = playRequest.toString().getBytes(StandardCharsets.UTF_8);
            rtspChannel.send(new DataChannel.Buffer(ByteBuffer.wrap(playData), false));
        }
    }

    @Override public void onBufferedAmountChange(long previousAmount) {}
});

// Handle incoming media tracks from the renegotiation (PeerConnection.Observer)
@Override
public void onTrack(RtpTransceiver transceiver) {
    MediaStreamTrack track = transceiver.getReceiver().track();
    if (track instanceof VideoTrack) {
        VideoTrack videoTrack = (VideoTrack) track;
        runOnUiThread(() -> videoTrack.addSink(remoteVideoView));
    }
}

const rtspChannel = pc.createDataChannel("rtsp", {
    protocol: "nabto.rtsp/1",
});

rtspChannel.onopen = () => {
    // Create a session for the desired camera and stream path
    rtspChannel.send(JSON.stringify({
        jsonrpc: "2.0",
        method: "rtsp.createSession",
        params: {
            service: "camera1",
            target: "/stream"
        },
        id: "2"
    }));
};

rtspChannel.onmessage = (event) => {
    const response = JSON.parse(event.data);
    if (response.result && response.result.session) {
        // Session created, a WebRTC renegotiation will follow.
        // After renegotiation completes, start playback:
        rtspChannel.send(JSON.stringify({
            jsonrpc: "2.0",
            method: "rtsp.play",
            params: { session: response.result.session },
            id: "3"
        }));
    }
};

// Handle incoming media tracks from the renegotiation
pc.ontrack = (event) => {
    const videoElement = document.getElementById("video");
    videoElement.srcObject = event.streams[0];
};

3. Make HTTP Requests

Open an HTTP datachannel and forward requests to the camera’s management API:

let config = RTCDataChannelConfiguration()
config.protocol = "nabto.http/1"
let httpChannel = peerConnection.dataChannel(forLabel: "http", configuration: config)
httpChannel?.delegate = self

// RTCDataChannelDelegate
func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) {
    if dataChannel.readyState == .open {
        let request: [String: Any] = [
            "jsonrpc": "2.0",
            "method": "http.request",
            "params": [
                "service": "my_camera_api",
                "method": "GET",
                "target": "/api/status"
            ],
            "id": "4"
        ]
        let jsonData = try! JSONSerialization.data(withJSONObject: request)
        dataChannel.sendData(RTCDataBuffer(data: jsonData, isBinary: false))
    }
}

func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) {
    let response = try! JSONSerialization.jsonObject(with: buffer.data) as! [String: Any]
    if let result = response["result"] as? [String: Any] {
        print("HTTP status:", result["status"]!)
        if let body = result["body"] as? String,
           let decodedData = Data(base64Encoded: body),
           let decodedString = String(data: decodedData, encoding: .utf8) {
            print("Body:", decodedString)
        }
    }
}

DataChannel.Init dcInit = new DataChannel.Init();
dcInit.protocol = "nabto.http/1";
DataChannel httpChannel = peerConnection.createDataChannel("http", dcInit);

httpChannel.registerObserver(new DataChannel.Observer() {
    @Override
    public void onStateChange() {
        if (httpChannel.state() == DataChannel.State.OPEN) {
            JSONObject request = new JSONObject();
            request.put("jsonrpc", "2.0");
            request.put("method", "http.request");
            request.put("params", new JSONObject()
                .put("service", "my_camera_api")
                .put("method", "GET")
                .put("target", "/api/status"));
            request.put("id", "4");

            byte[] data = request.toString().getBytes(StandardCharsets.UTF_8);
            httpChannel.send(new DataChannel.Buffer(ByteBuffer.wrap(data), false));
        }
    }

    @Override
    public void onMessage(DataChannel.Buffer buffer) {
        byte[] data = new byte[buffer.data.remaining()];
        buffer.data.get(data);
        JSONObject response = new JSONObject(new String(data, StandardCharsets.UTF_8));
        JSONObject result = response.optJSONObject("result");
        if (result != null) {
            Log.d(TAG, "HTTP status: " + result.getInt("status"));
            if (result.has("body")) {
                byte[] decoded = Base64.decode(result.getString("body"), Base64.DEFAULT);
                Log.d(TAG, "Body: " + new String(decoded, StandardCharsets.UTF_8));
            }
        }
    }

    @Override public void onBufferedAmountChange(long previousAmount) {}
});

const httpChannel = pc.createDataChannel("http", {
    protocol: "nabto.http/1",
});

httpChannel.onopen = () => {
    httpChannel.send(JSON.stringify({
        jsonrpc: "2.0",
        method: "http.request",
        params: {
            service: "my_camera_api",
            method: "GET",
            target: "/api/status"
        },
        id: "4"
    }));
};

httpChannel.onmessage = (event) => {
    const response = JSON.parse(event.data);
    if (response.result) {
        console.log("HTTP status:", response.result.status);
        if (response.result.body) {
            console.log("Body:", atob(response.result.body));
        }
    }
};

Demo Applications

Nabto Cambridge is currently available to select partners. If you are interested in evaluating it, please contact Nabto.