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:
| Platform | SDK | Guide |
|---|---|---|
| iOS (Swift) | Nabto WebRTC Client SDK for iOS | iOS SDK Guide |
| Android (Java/Kotlin) | Nabto WebRTC Client SDK for Android | Android SDK Guide |
| JavaScript / React Native | Nabto WebRTC Client SDK for JS | JS 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:
- Create a
SignalingClientwith the device’s product ID and device ID. - Set up a
MessageTransportfor the signaling protocol. - Create an
RTCPeerConnectionand wire it up withPerfectNegotiation. - 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.