WebRTC Signaling Protocol¶
This document describes the WebRTC signaling protocol used by BTicino Classe 100X/300X video intercoms via the Netatmo cloud infrastructure. All details were discovered through reverse engineering of the official BTicino/Netatmo Android app (com.netatmo.camera).
WebSocket Endpoints¶
The system uses two separate WebSocket connections for different purposes:
| Purpose | URL | app_type |
Description |
|---|---|---|---|
| Push WS | wss://app-ws.netatmo.net/ws/ |
app_camera |
Persistent connection for real-time event notifications (doorbell rings, call offers, connection status) |
| Signaling WS | wss://app-ws.netatmo.net/appws/ |
app_security |
On-demand connection for WebRTC signaling (SDP exchange, ICE candidates, session management) |
URL distinction
Note the different paths: /ws/ for push notifications and /appws/ for signaling. The signaling endpoint is at app-ws.netatmo.net, NOT app.netatmo.net (which redirects and does not work).
Subscribe Messages¶
Both WebSocket connections require a subscribe message after connecting.
Push WS Subscribe¶
{
"action": "Subscribe",
"access_token": "<oauth_token>",
"app_type": "app_camera",
"platform": "Android",
"version": "4.1.1.3"
}
No filter field
Do not include a filter field in the push WS subscribe message. The official app does not send one. Including filter=silent or similar will cause real-time call notifications to be suppressed.
Signaling WS Subscribe¶
{
"action": "subscribe",
"access_token": "<oauth_token>",
"app_type": "app_security",
"version": "1.0",
"platform": "android"
}
Both endpoints respond with:
Session State¶
Every signaling session tracks four identifiers:
| Field | Type | Description |
|---|---|---|
session_id |
UUID string | Unique session identifier, assigned by the server in the offer ack |
tag_id |
Base64 string | Session tag, assigned by the server in the offer ack |
correlation_id |
Number/string | Client-generated identifier to correlate requests with acks |
device_id |
MAC address | The bridge device MAC (e.g., 00:03:50:xx:xx:xx) |
These values must be included in every subsequent signaling message (answer, candidate, terminate) after the session is established.
Two Call Modes¶
Offer Mode (On-Demand Viewing)¶
The client initiates a call to view the intercom camera on demand. The client sends an SDP offer, and the device responds with an SDP answer.
Client (pybticino) Netatmo Server BTicino Device
| | |
|-- subscribe -------------->| |
|<-- {status: "ok"} --------| |
| | |
|-- offer (SDP) ----------->|-- forward offer ------>|
|<-- ack (session_id, -------| |
| tag_id) | |
| |<-- answer (SDP) ------|
|<-- answer (SDP) ----------| |
| | |
|-- candidate (ICE) ------->|-- forward candidate -->|
|<-- candidate (ICE) -------|<-- candidate (ICE) ----|
| | |
|========= WebRTC media flows directly ===============|
| | |
|-- terminate -------------->|-- forward terminate -->|
Answer Mode (Incoming Call)¶
The device initiates a call (doorbell ring). The call offer arrives via the push WS, but the answer is sent via the signaling WS.
Push WS Client (pybticino) Signaling WS Device
| | | |
|<-- BNC1-rtc (offer SDP) ---|<-- doorbell ring --------|<-- ring ---------|
| | | |
| |-- connect + subscribe ->| |
| |<-- {status: "ok"} ------| |
| | | |
| |-- set_session_from_push | |
| | (session_id, tag_id, | |
| | correlation_id, | |
| | device_id) | |
| | | |
| |-- answer (SDP) -------->|-- forward ------>|
| | | |
| |-- candidate (ICE) ----->|-- forward ------>|
| | | |
| |========= WebRTC media =================== |
The key difference is that in answer mode:
- The session identifiers (
session_id,tag_id,correlation_id,device_id) come from the push WS event'sextra_params, not from an offer ack - The client calls
set_session_from_push()to inject these values into theSignalingClientbefore sending the answer - The client sends
send_answer()instead ofsend_offer() - The DTLS setup role in the browser's SDP must be changed from
actpasstoactive(since the client is now the answerer, not the offerer)
Signaling Message Types¶
Offer¶
Sent by the client (offer mode) or received from the device (answer mode via push WS).
Client sends offer (offer mode):
{
"action": "rtc",
"data": {
"type": "offer",
"session_description": {
"type": "call",
"sdp": "v=0\r\no=- 123456 2 IN IP4 127.0.0.1\r\n...",
"module_id": "optional_external_unit_id"
}
},
"device_id": "00:03:50:xx:xx:xx",
"correlation_id": "12345"
}
The module_id field is optional and specifies which external unit to call when there are multiple units.
Device sends offer (answer mode, via push WS):
{
"type": "Websocket",
"push_type": "BNC1-rtc",
"category": "rtc",
"voip_call": true,
"expiry": 30,
"extra_params": {
"session_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"tag_id": "base64_encoded_tag",
"correlation_id": 12345,
"device_id": "00:03:50:xx:xx:xx",
"home_id": "home_id_string",
"data": {
"type": "offer",
"session_description": {
"type": "call",
"sdp": "v=0\r\no=- 123456 2 IN IP4 127.0.0.1\r\n...",
"module_id": "calling_unit_id",
"modules": ["module_id_1", "module_id_2"]
}
}
}
}
Offer Ack¶
The server responds to an offer with an acknowledgement containing the session identifiers.
{
"type": "ack",
"session_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"tag_id": "base64_encoded_tag"
}
Ack handling
Only the offer ack contains session_id and tag_id. Subsequent acks (for ICE candidates, terminate, etc.) return null for both fields. The client must not overwrite the session state with null values, or the terminate message will fail with data/tag_id must be string.
Answer¶
Received from the device (offer mode) or sent by the client (answer mode).
Device answer (received in offer mode):
{
"session_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"data": {
"type": "answer",
"session_description": {
"type": "call",
"sdp": "v=0\r\no=- 789012 2 IN IP4 127.0.0.1\r\n..."
}
}
}
Client answer (sent in answer mode):
{
"action": "rtc",
"data": {
"type": "answer",
"session_description": {
"type": "call",
"sdp": "v=0\r\n..."
}
},
"session_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"tag_id": "base64_encoded_tag",
"device_id": "00:03:50:xx:xx:xx",
"correlation_id": "12345"
}
ICE Candidate¶
Exchanged bidirectionally for trickle ICE.
Send candidate:
{
"action": "rtc",
"data": {
"type": "candidate",
"ice_candidate": {
"sdp_m_line_index": 0,
"candidate": "candidate:1 1 udp 2122260223 192.168.1.100 54321 typ host ..."
}
},
"session_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"tag_id": "base64_encoded_tag",
"device_id": "00:03:50:xx:xx:xx",
"correlation_id": "12345"
}
Receive candidate:
{
"session_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"data": {
"type": "candidate",
"ice_candidate": {
"sdp_m_line_index": 0,
"candidate": "candidate:1 1 udp 2122260223 10.0.0.50 12345 typ host ..."
}
}
}
Terminate¶
Ends the session. Can be sent by either side.
Send terminate:
{
"action": "rtc",
"data": {
"type": "terminate"
},
"session_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"tag_id": "base64_encoded_tag",
"device_id": "00:03:50:xx:xx:xx",
"correlation_id": "12345"
}
Receive terminate (normal):
Receive terminate (with error):
{
"session_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"data": {
"type": "terminate",
"error": {
"code": 1,
"message": "Max number of peers reached"
}
}
}
Rescind¶
Received when the call is answered by another device (e.g., the physical handset or another app instance).
Session Lifecycle¶
Offer Mode Lifecycle¶
1. DISCONNECTED
└─ connect() → signaling WS
2. CONNECTED
└─ subscribe (app_security)
3. SUBSCRIBED
└─ send_offer(device_id, sdp)
4. OFFER_SENT
└─ receive ack (session_id, tag_id stored)
5. ACK_RECEIVED
└─ receive answer (SDP)
6. ANSWER_RECEIVED
├─ exchange ICE candidates
└─ WebRTC media flowing
7. CONNECTED (media)
└─ send_terminate() or receive terminate
8. TERMINATED
└─ session_id cleared
Answer Mode Lifecycle¶
1. Push WS receives BNC1-rtc offer
└─ extract session_id, tag_id, correlation_id, device_id, SDP
2. connect() → signaling WS (if not already connected)
└─ subscribe (app_security)
3. set_session_from_push(session_id, tag_id, correlation_id, device_id)
4. send_answer(sdp)
├─ exchange ICE candidates
└─ WebRTC media flowing
5. CONNECTED (media)
└─ send_terminate() or receive terminate
6. TERMINATED
Device Timeout¶
In offer mode, the device has an approximately 30-second timeout. If the WebRTC connection is not fully established within this window, the device sends a terminate message. The official app uses a 20-second per-transition timeout in its internal state machine.
Keepalive¶
There are no application-level keepalive messages during active WebRTC sessions. The official app does not send ping/pong or any heartbeat during calls. TCP keepalive handles the WebSocket connection.
For the push WS connection (which is long-lived), tokens expire approximately every hour. Use resubscribe() to send a fresh token without disconnecting.
TURN/STUN Servers¶
ICE server credentials for NAT traversal are obtained from the /turn endpoint:
POST https://app.netatmo.net/turn
Authorization: Bearer <access_token>
Content-Type: application/x-www-form-urlencoded
client_type=user
The response contains ICE server configurations:
{
"iceServers": [
{
"urls": ["turn:turn.netatmo.net:443?transport=tcp"],
"username": "temporary_username",
"credential": "temporary_password",
"credentialType": "password"
},
{
"urls": ["stun:stun.netatmo.net:3478"]
}
]
}
pybticino API Reference¶
SignalingClient¶
Constructor¶
from pybticino import AuthHandler, SignalingClient
signaling = SignalingClient(
auth_handler=auth,
on_answer=async_callback, # (session_id: str, sdp: str) -> None
on_candidate=async_callback, # (session_id: str, ice_candidate: dict) -> None
on_event=async_callback, # (session_id: str, event_type: str, data: dict) -> None
)
All callbacks are optional and async (async def). on_event receives signaling events like terminate, rescind, and error conditions.
Methods¶
| Method | Description |
|---|---|
connect() |
Connect and subscribe to the signaling WS |
disconnect() |
Disconnect and clean up session state |
send_offer(device_id, sdp, module_id=None) |
Send an SDP offer (offer mode). Returns session_id |
send_answer(sdp) |
Send an SDP answer (answer mode). Requires prior set_session_from_push() |
send_candidate(candidate, sdp_m_line_index) |
Send an ICE candidate |
send_terminate() |
Terminate the current session |
set_session_from_push(session_id, tag_id, correlation_id, device_id) |
Set session state from a push WS event for answer mode |
resubscribe() |
Re-subscribe on the existing connection with a refreshed OAuth token, without disconnecting |
ensure_connected() |
Ensure the signaling WS is connected, reconnecting if needed |
Properties¶
| Property | Type | Description |
|---|---|---|
is_connected |
bool |
True if the signaling WS is connected and subscribed |
session_id |
str \| None |
The current signaling session ID, or None if no session is active |
AsyncAccount (TURN servers)¶
turn_servers = await account.async_get_turn_servers()
# Returns a list of ICE server dicts:
# [{"urls": ["turn:turn.netatmo.net:443?transport=tcp"],
# "username": "...", "credential": "...", "credentialType": "password"},
# {"urls": ["stun:stun.netatmo.net:3478"]}]
Exceptions¶
All signaling methods can raise:
| Exception | When |
|---|---|
PyBticinoException |
Connection failed, message send failed, invalid session state |
AuthError |
OAuth token refresh failed |