Skip to content
Draft
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
1 change: 1 addition & 0 deletions docs/cli/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ prusactl team show # Show default team details
# Cameras
prusactl camera list
prusactl camera snapshot <camera-id> --output snapshot.jpg
prusactl camera webrtc <camera-id> # Open a live WebRTC stream in your browser
prusactl camera show <camera-id>
```

Expand Down
35 changes: 35 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,41 @@ if cameras:
print("Saved to snapshot.jpg")
```

## WebRTC Camera Streaming

For live video feeds, Prusa Connect supports low-latency WebRTC streaming. To
set up a WebRTC stream, you need the camera's token and a valid JWT token from
an authenticated client.

```python
from prusa.connect.client import PrusaConnectClient
from importlib import resources

client = PrusaConnectClient()
cameras = client.cameras.list()

if cameras:
cam = cameras[0]
camera_token = cam.token

# Extract the JWT Token safely
jwt_token = ""
if hasattr(client, "_credentials") and hasattr(client._credentials, "tokens"):
jwt_token = client._credentials.tokens.access_token.raw_token
Comment on lines +68 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Accessing private members like _credentials is not a safe practice as it relies on internal implementation details that can change without notice, potentially breaking your code in future SDK versions. It would be more robust to add a public method to the PrusaConnectClient for retrieving the JWT, for example client.get_raw_jwt().


print(f"Camera Token: {camera_token}")
print(f"JWT Token: {jwt_token}")

# Load the protobuf definition needed by the signaling server
proto_path = resources.files("prusa.connect.client") / "camera_v2.proto"
proto_content = proto_path.read_text("utf-8")

# You can now use these credentials and the protobuf definition to
# initialize a WebRTC connection via the Prusa Connect signaling endpoint.
# See the CLI 'prusactl camera webrtc' command for an example of injecting
# these into a local HTML template for browser-based playback.
```

## Managing Files

List files on your team's storage.
Expand Down
35 changes: 35 additions & 0 deletions proto/prusa/connect/client/camera_v2.proto
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,38 @@ message CameraToServer {
NetworkInfo network = 4;
string camera_token = 8;
}

// --- WebRTC Signaling ---

message WebRTCServer {
repeated string urls = 1;
string username = 2;
string credential = 3;
}

message WebRTCConfig {
repeated WebRTCServer servers = 1;
}

message WebRTCData {
string sdp = 1;
string sdp_mid = 2;
}

message WebRTCSignaling {
string camera_token = 1;
string session_id = 2;
string peer_id = 3;
WebRTCData data = 4;

enum SignalingType {
UNKNOWN = 0;
CONFIG = 1;
ANSWER = 2;
OFFER = 3;
CANDIDATE = 4;
}
SignalingType type = 5;
uint32 direction = 7;
WebRTCConfig config = 8;
}
9 changes: 4 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,6 @@ name = "pypi"
url = "https://pypi.org/simple"
default = true

[tool.uv.sources]
# Temporary workaround until PR #63 upstream is merged and released
essentials-openapi = { git = "https://github.com/dcode/essentials-openapi", branch = "main" }

[build-system]
requires = ["hatchling", "hatch-protobuf", "mypy-protobuf"]
build-backend = "hatchling.build"
Expand All @@ -93,6 +89,7 @@ dev = [
]
docs = [
"cyclopts[mkdocs]>=4.5.1",
"essentials-openapi>=1.4.0",
"hatch-mkdocs>=0.1.0",
"markdown-callouts>=0.4.0",
"markdown-gfm-admonition>=0.3.0",
Expand All @@ -104,7 +101,6 @@ docs = [
"mkdocs-material[imaging,recommended]>=9.7.1",
"mkdocstrings[python]>=1.0.3",
"neoteroi-mkdocs>=1.2.0",
"essentials-openapi @ git+https://github.com/dcode/essentials-openapi@main",
"pymdown-extensions>=10.20.1",
]

Expand All @@ -114,6 +110,9 @@ path = "src/prusa/connect/client/__version__.py"
[tool.hatch.build.targets.wheel]
packages = ["src/prusa"]

[tool.hatch.build.targets.wheel.force-include]
"proto/prusa/connect/client" = "prusa/connect/client/proto"

[tool.hatch.envs.default]
installer = "uv"
env-vars = { "UV_DEFAULT_INDEX" = "https://pypi.org/simple" }
Expand Down
60 changes: 48 additions & 12 deletions src/prusa/connect/client/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,7 @@ def __init__(
self.sio.on("status", self._on_status)
self.sio.on("features", self._on_features)
self.sio.on("client_trigger", self._on_client_trigger)
self.sio.on("webrtc_offer", self._on_webrtc_offer)
self.sio.on("webrtc_answer", self._on_webrtc_answer)
self.sio.on("webrtc_ice_candidate", self._on_webrtc_ice_candidate)
self.sio.on("webrtc", self._on_webrtc)

def connect(self, wait: bool = False):
"""Connects to the signaling server."""
Expand Down Expand Up @@ -131,17 +129,55 @@ def _on_client_trigger(self, data: bytes):
# This can be used for timelapse progress etc.
logger.debug("Client trigger received")

def _on_webrtc_offer(self, data: typing.Any):
"""Callback for receiving a WebRTC offer."""
logger.info("WebRTC offer received")
def _on_webrtc(self, data: typing.Any):
"""Callback for receiving WebRTC signaling messages."""
if isinstance(data, dict) and "_placeholder" in data:
return # socket.io might send placeholder dicts before the binary payload

def _on_webrtc_answer(self, data: typing.Any):
"""Callback for receiving a WebRTC answer."""
logger.info("WebRTC answer received")
signaling = pb.WebRTCSignaling()
try:
signaling.ParseFromString(data)
except Exception:
logger.exception("Failed to parse WebRTC signaling message")
return

logger.info(
"WebRTC signaling received",
type=pb.WebRTCSignaling.SignalingType.Name(signaling.type),
direction=signaling.direction
)
# Developers map their application logic to handle SDPOffer, SDPAnswer, etc.

def webrtc_send(
self,
signaling_type: int,
sdp: str = "",
sdp_mid: str = "",
session_id: str = "",
peer_id: str = "",
):
"""Sends a WebRTC signaling message to the server.

Args:
signaling_type: WebRTCSignaling.SignalingType enum (OFFER=2, etc.)
sdp: The Session Description Protocol string or ICE candidate
sdp_mid: The media stream ID for candidates
session_id: The session ID for the signaling (often matches camera token or established session)
peer_id: The peer ID for the signaling (often matches the camera token)
"""
msg = pb.WebRTCSignaling(
camera_token=self.camera_token,
session_id=session_id or getattr(self.sio, "sid", "") or self.camera_token,
peer_id=peer_id or self.camera_token,
direction=2, # Client to Server
)
msg.type = signaling_type # type: ignore

if sdp or sdp_mid:
msg.data.sdp = sdp
msg.data.sdp_mid = sdp_mid

def _on_webrtc_ice_candidate(self, data: typing.Any):
"""Callback for receiving a WebRTC ICE candidate."""
logger.info("WebRTC ICE candidate received")
self.sio.emit("webrtc", msg.SerializeToString())

# --- Control Methods ---

Expand Down
Loading
Loading