Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions fixtures/webrtc_offer_ok.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"status": "ok",
"time_server": 1765234648,
"body": {
"type": "ack",
"time_server": 1765234642,
"correlation_id": "2775454822782367811",
"session_id": "af6da83b-1fd5-46ab-bf08-8e5db3cb9725",
"tag_id": "OZzgKVlQCW0=",
"status": "ok",
"sdpAnswer": "sdp_test_answser"
}
}
14 changes: 14 additions & 0 deletions fixtures/webrtc_offer_unreachable.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"status": "ok",
"time_server": 1765234880,
"body": {
"type": "ack",
"time_server": 1765234880,
"correlation_id": "5792380006970076896",
"status": "error",
"error": {
"code": 41,
"message": "Device is unreachable"
}
}
}
14 changes: 14 additions & 0 deletions fixtures/webrtc_terminate_no_session.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"status": "ok",
"time_server": 1765234777,
"body": {
"type": "ack",
"time_server": 1765234776,
"correlation_id": "9204136935571038154",
"status": "error",
"error": {
"code": 21,
"message": "Nil session"
}
}
}
10 changes: 10 additions & 0 deletions fixtures/webrtc_terminate_ok.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"status": "ok",
"time_server": 1765234822,
"body": {
"type": "ack",
"time_server": 1765234822,
"correlation_id": "11591074238736810245",
"status": "ok"
}
}
2 changes: 2 additions & 0 deletions src/pyatmo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from pyatmo.modules import Module
from pyatmo.modules.device_types import DeviceType
from pyatmo.room import Room
from pyatmo.webrtc import WebRTCStream

__all__: list[str] = [
"AbstractAsyncAuth",
Expand All @@ -33,6 +34,7 @@
"NoDeviceError",
"NoScheduleError",
"Room",
"WebRTCStream",
"const",
"modules",
]
6 changes: 6 additions & 0 deletions src/pyatmo/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,17 @@

GETPUBLIC_DATA_ENDPOINT = "api/getpublicdata"

WEBRTC_OFFER_ENDPOINT = "api/webrtc/offer"
WEBRTC_TERMINATE_ENDPOINT = "api/webrtc/terminate"

AUTHORIZATION_HEADER = "Authorization"

# Possible scops
ALL_SCOPES: list[str] = [
"access_camera", # Netatmo camera products
"access_doorbell", # Netatmo Smart Video Doorbell
"access_presence", # Netatmo Smart Outdoor Camera
"access_camerapro", # Netatmo Indoor Camera Advance
"read_bubendorff", # Bubbendorf shutters
"read_bfi", # BTicino IP
"read_camera", # Netatmo camera products
Expand All @@ -70,6 +74,7 @@
"read_smokedetector", # Smart Smoke Alarm information and events
"read_station", # Netatmo weather station
"read_thermostat", # Netatmo climate products
"read_camerapro", # Netatmo Indoor Camera Advance
"write_bubendorff", # Bubbendorf shutters
"write_bfi", # BTicino IP
"write_camera", # Netatmo camera products
Expand All @@ -79,6 +84,7 @@
"write_presence", # Netatmo Smart Outdoor Camera
"write_smarther", # Smarther products
"write_thermostat", # Netatmo climate products
"write_camerapro", # Netatmo Indoor Camera Advance
]

EVENTS = "events"
Expand Down
128 changes: 120 additions & 8 deletions src/pyatmo/modules/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@

from aiohttp import ClientConnectorError, ClientResponse

from pyatmo.const import GETMEASURE_ENDPOINT, RawData
from pyatmo.const import (
GETMEASURE_ENDPOINT,
WEBRTC_OFFER_ENDPOINT,
WEBRTC_TERMINATE_ENDPOINT,
RawData,
)
from pyatmo.exceptions import ApiError
from pyatmo.modules.base_class import EntityBase, NetatmoBase, Place, update_name
from pyatmo.modules.device_types import (
Expand All @@ -20,6 +25,7 @@
DeviceCategory,
DeviceType,
)
from pyatmo.webrtc import WebRTCAnswer, WebRTCStream

if TYPE_CHECKING:
from pyatmo.event import Event
Expand Down Expand Up @@ -474,8 +480,8 @@ async def async_move_to_preferred_position(self) -> bool:
return await self.async_set_target_position(self.__preferred_position)


class CameraMixin(EntityBase):
"""Mixin for camera data."""
class CameraMixinBase(EntityBase):
"""Base class for camera mixins."""

def __init__(self, home: Home, module: ModuleT) -> None:
"""Initialize camera mixin."""
Expand All @@ -488,13 +494,28 @@ def __init__(self, home: Home, module: ModuleT) -> None:
self.alim_status: int | None = None
self.device_type: DeviceType

self._force_vpn_url = False

@property
def camera_url(self) -> str | None:
"""Return the camera URL, if available.

Depending on the camera streaming protocol, this URL can be used to retrieve:
- The live snapshot (HLS and WebRTC).
- The live stream (HLS only).
"""
return self.local_url or self.vpn_url
Comment on lines +497 to +507
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Consider making camera_url respect _force_vpn_url directly rather than via side effects on local_url.

Currently _force_vpn_url only takes effect via async_update_camera_urls setting local_url to None, so camera_url assumes local_url is already sanitized and is sensitive to call order. It would be safer for camera_url to apply the flag directly, e.g. return self.vpn_url if self._force_vpn_url else (self.local_url or self.vpn_url), so callers aren’t relying on async_update_camera_urls having been run first.

Suggested change
self._force_vpn_url = False
@property
def camera_url(self) -> str | None:
"""Return the camera URL, if available.
Depending on the camera streaming protocol, this URL can be used to retrieve:
- The live snapshot (HLS and WebRTC).
- The live stream (HLS only).
"""
return self.local_url or self.vpn_url
self._force_vpn_url = False
@property
def camera_url(self) -> str | None:
"""Return the camera URL, if available.
Depending on the camera streaming protocol, this URL can be used to retrieve:
- The live snapshot (HLS and WebRTC).
- The live stream (HLS only).
"""
if self._force_vpn_url:
return self.vpn_url
return self.local_url or self.vpn_url


async def async_get_live_snapshot(self) -> bytes | None:
"""Fetch live camera image."""

if not self.local_url and not self.vpn_url:
url = self.camera_url

if not url:
return None

resp = await self.home.auth.async_get_image(
base_url=f"{self.local_url or self.vpn_url}",
base_url=f"{url}",
endpoint="/live/snapshot_720.jpg",
)

Expand All @@ -503,8 +524,9 @@ async def async_get_live_snapshot(self) -> bytes | None:
async def async_update_camera_urls(self) -> None:
"""Update and validate the camera urls."""

if self.device_type == "NDB":
self.is_local = None
if self._force_vpn_url:
self.local_url = None
return

if self.vpn_url and self.is_local:
temp_local_url = await self._async_check_url(self.vpn_url)
Expand Down Expand Up @@ -539,6 +561,96 @@ async def _async_check_url(self, url: str) -> str | None:
return resp_data.get("local_url") if resp_data else None


class HLSCameraMixin(CameraMixinBase):
"""Mixin for cameras using the HLS protocol."""


class WebRTCCameraMixin(CameraMixinBase):
"""Mixin for cameras using the WebRTC protocol."""

def __init__(self, home: Home, module: ModuleT) -> None:
"""Initialize WebRTC camera mixin."""

super().__init__(home, module)

# WebRTC cameras don't support accessing the live stream nor snapshot via the local URL
self._force_vpn_url = True

async def async_start_stream(self, session_id: str, sdp_offer: str) -> WebRTCAnswer:
"""Start WebRTC streaming session.

Netatmo cameras do not support ICE trickle when accessed through third-party applications,
so the SDP offer must include all ICE candidates.
"""

params = {
"home_id": self.home.entity_id,
"device_id": self.entity_id,
"sdp": sdp_offer,
"session_id": session_id,
}

resp = await self.home.auth.async_post_api_request(
endpoint=WEBRTC_OFFER_ENDPOINT, params=params
)

json_resp = await resp.json()
resp_body = self._parse_webrtc_response_body(json_resp)

try:
if session_id != resp_body["session_id"]:
msg = "Invalid WebRTC answer: session ID mismatch"
raise ApiError(msg)

tag_id = resp_body["tag_id"]
sdp_answer = resp_body["sdpAnswer"]
except KeyError as exc:
msg = f"Invalid WebRTC answer: missing {exc} field"
raise ApiError(msg) from exc

return WebRTCAnswer(WebRTCStream(session_id, tag_id), sdp_answer)

async def async_stop_stream(self, stream: WebRTCStream) -> None:
"""Stop an active WebRTC streaming session."""

params = {
"home_id": self.home.entity_id,
"device_id": self.entity_id,
"session_id": stream.session_id,
"tag_id": stream.tag_id,
}

resp = await self.home.auth.async_post_api_request(
endpoint=WEBRTC_TERMINATE_ENDPOINT, params=params
)

json_resp = await resp.json()
self._parse_webrtc_response_body(json_resp)

def _parse_webrtc_response_body(self, json_resp: dict[str, Any]) -> dict[str, Any]:
"""Extract the body from a WebRTC API response and check for errors."""

if not json_resp:
msg = "Empty WebRTC response"
raise ApiError(msg)

try:
resp_body = json_resp["body"]

if resp_body["status"] != "ok":
error_dict = resp_body["error"]
error_code = error_dict["code"]
error_msg = error_dict["message"]

msg = f"WebRTC API error: {error_msg} ({error_code})"
raise ApiError(msg)
except KeyError as exc:
msg = f"Invalid WebRTC response: missing {exc} field"
raise ApiError(msg) from exc

return resp_body


class FloodlightMixin(EntityBase):
"""Mixin for floodlight data."""

Expand Down Expand Up @@ -1145,7 +1257,7 @@ class Camera(
FirmwareMixin,
MonitoringMixin,
EventMixin,
CameraMixin,
CameraMixinBase,
WifiMixin,
Module,
):
Expand Down
10 changes: 6 additions & 4 deletions src/pyatmo/modules/netatmo.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
FirmwareMixin,
FloodlightMixin,
HealthIndexMixin,
HLSCameraMixin,
HumidityMixin,
Module,
MonitoringMixin,
Expand All @@ -37,6 +38,7 @@
RfMixin,
StatusMixin,
TemperatureMixin,
WebRTCCameraMixin,
WifiMixin,
WindMixin,
)
Expand Down Expand Up @@ -68,19 +70,19 @@ class OTM(FirmwareMixin, RfMixin, BatteryMixin, BoilerMixin, Module):
"""Class to represent a Netatmo OTM."""


class NACamera(Camera):
class NACamera(HLSCameraMixin, Camera):
"""Class to represent a Netatmo NACamera."""


class NPC(Camera):
class NPC(WebRTCCameraMixin, Camera):
"""Class to represent a Netatmo NPC."""


class NOC(FloodlightMixin, Camera):
class NOC(FloodlightMixin, HLSCameraMixin, Camera):
"""Class to represent a Netatmo NOC."""


class NDB(Camera):
class NDB(WebRTCCameraMixin, Camera):
"""Class to represent a Netatmo NDB."""


Expand Down
19 changes: 19 additions & 0 deletions src/pyatmo/webrtc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""WebRTC-related definitions."""

from dataclasses import dataclass


@dataclass
class WebRTCStream:
"""A WebRTC stream."""

session_id: str
tag_id: str


@dataclass
class WebRTCAnswer:
"""A WebRTC answer from the Netatmo API."""

stream: WebRTCStream
sdp: str
Loading