WebRTC and calls¶
workpeg.ui.rtc is the browser's WebRTC, one to one, in Python. A peg that
knows WebRTC writes the lines a JavaScript tutorial shows: classes keep the
browser's names, methods and properties take Python's (createOffer is
create_offer, connectionState is connection_state), and event names
and the keys of data dicts stay the browser's, because that data crosses the
wire to the other peer.
Nothing in the module runs in Python. The compiler turns each use into a
call on the SDK's own rtc.js, naming the member from the table that file
ships, never from your source. A page holds opaque handles, not the
browser's objects, and every value handed in is copied and checked before
the browser sees it. So a peg stays Python only.
rtc.js is shipped only to a peg whose pages import workpeg.ui.rtc, and
loads from the peg's own origin as /rtc.js?v=<hash>. A peg without it
ships exactly what it shipped before.
A call, in short¶
from workpeg.ui import App, Audio, Button, Column, State
from workpeg.ui.rtc import RTCPeerConnection, media_devices
STUN_URL = "stun:stun.example.org:3478"
class Call(App):
pc = State(None)
local = State(None)
remote = State(None)
async def start(self):
self.local = await media_devices.get_user_media({"audio": True, "video": False})
pc = RTCPeerConnection(ice_servers=[{"urls": STUN_URL}])
pc.add_event_listener("track", self.on_track)
for track in self.local.get_tracks():
pc.add_track(track, self.local)
self.pc = pc
offer = await pc.create_offer()
await pc.set_local_description(offer)
# ... send pc.local_description to the other side (see Signaling)
def on_track(self, event):
self.remote = event["streams"][0]
def build(self):
return Column(Button("Call", on_click=self.start),
Audio(src_object=self.remote))
The names, and what each is in the browser¶
| Python | Browser |
|---|---|
RTCPeerConnection(ice_servers=, ice_transport_policy=, bundle_policy=, ice_candidate_pool_size=) |
new RTCPeerConnection({iceServers, ...}) |
await pc.create_offer([options]), await pc.create_answer() |
createOffer, createAnswer |
await pc.set_local_description([d]), await pc.set_remote_description(d) |
setLocalDescription, setRemoteDescription |
await pc.add_ice_candidate([c]) |
addIceCandidate |
pc.add_track(track, *streams), pc.remove_track(sender) |
addTrack, removeTrack |
pc.add_transceiver(kind_or_track[, init]), pc.get_transceivers(), pc.get_senders(), pc.get_receivers() |
the same, camel-cased |
pc.create_data_channel(label[, options]), await pc.get_stats([track]) |
createDataChannel, getStats |
pc.restart_ice(), pc.close() |
restartIce, close |
pc.connection_state, pc.ice_gathering_state, pc.local_description, and 8 more |
connectionState, iceGatheringState, localDescription, ... |
MediaStream([tracks]), stream.get_tracks(), get_audio_tracks(), get_video_tracks() |
new MediaStream, getTracks, ... |
track.stop(), track.enabled = False, track.kind, track.ready_state |
stop, enabled, kind, readyState |
channel.send(text_or_bytes), channel.close(), channel.ready_state |
send, close, readyState |
sender.replace_track(t), sender.get_parameters(), sender.set_parameters(p) |
replaceTrack, getParameters, setParameters |
media_devices.get_user_media(c), get_display_media(o), enumerate_devices() |
navigator.mediaDevices.* |
HTMLVideoElement(src, loop=), play(), capture_stream() |
a <video> never put on the page |
x.add_event_listener("icecandidate", self.method), remove_event_listener |
addEventListener |
signaling.me(), people(q), send(...), receive(after) |
Workpeg's calls API (below) |
A peg makes RTCPeerConnection, MediaStream and HTMLVideoElement. The
other handles come back from a member: MediaStreamTrack from a stream,
RTCRtpSender, RTCRtpReceiver and RTCRtpTransceiver from a connection,
RTCDataChannel from create_data_channel or a datachannel event.
media_devices is the page's one MediaDevices, and signaling its one
Signaling.
Audio(src_object=stream) plays a stream's sound, and
Video(src_object=stream) shows it (see Widgets).
The rules the compiler holds:
- Import each name, under its own name. No
import *, no alias. - The receiver first, then the name.
signaling.send(...)is signaling's, awaited;channel.send(...)is the data channel's, which is not. Elsewhere the method name says what a call is, andrtc.jschecks the handle when the page runs. - Arguments by position; a constructor's configuration by keyword.
- Await what the browser answers later, as
x = await pc.create_offer()or a bareawait ...statement, in anasync def, never inside a comprehension or alambda. - Events are heard by a method. The handler gets one dict of plain data
with the browser's keys:
event["candidate"](None at the end of gathering),event["streams"],event["data"]. - Only three properties can be assigned:
track.enabled,transceiver.directionandchannel.buffered_amount_low_threshold. - Make connections in a method, never in
build(), and in a page or a Shell, never in a Component. The page that made them closes them, and stops its microphone and camera, when the person leaves it.
ICE servers are yours¶
The SDK has no default STUN or TURN server. A connection made without
ice_servers reaches peers on the same network only. Name the servers in
your peg's code, and know what each one learns:
- A STUN server learns the public IP address of everyone who calls or answers. It relays nothing.
- With no TURN server, two browsers behind strict NATs, or on a network that blocks UDP, cannot meet. Say so on your page.
- A TURN credential written in your Python would be public: it is in the bundle. TURN needs short-lived credentials minted by a server, which the SDK does not provide yet.
The compiler checks a literal ICE URL at build: stun:, turn: or
turns:, nothing else. A module constant is a scalar to the compiler, so
write the list where you make the connection, with the URL in a constant.
Signaling¶
WebRTC leaves it to the application to find the other person and pass the
offer and answer between the two pages. signaling does that through the
Workpeg API, for the business in the page's address (/b/<business>/...),
with the person's own session:
me = await signaling.me() # {id, name, handle}
team = await signaling.people("") # up to 20 {id, name, handle, open}
found = await signaling.people("bo") # a search, 1 to 64 characters
sent = await signaling.send(to, "offer", data) # {id, call, recipient_open}
sent = await signaling.send(to, "answer", data, call)
sent = await signaling.send(to, "accept", {}, call, True) # a claim
signals = await signaling.receive(after) # [{id, call, kind, from, from_person, data, age}]
- The API stamps who sent a signal.
fromandfrom_personare the server's, never the sender's word: show those, never a name out ofdata. - Only the two people in a call read its signals, and only people of the same business can be called. A signal lives 120 seconds.
- A claim is first come, first served. Accept, decline and busy are
claims. A second claim fails with
failure.problem == "call-taken", and the claimer's other tabs get atakensignal, so they stop ringing. openandrecipient_openare true when that person's page read its signals in the last 90 seconds.- Polling is the floor. Call
receiveevery few seconds. Where the API rings the page as a signal lands,on_table_changehears it, so a visible tab can ring within a second; a page must work when that never comes, and a hidden tab hears it only when it is shown again. - The session is kept for you. Each member waits for a fresh access
cookie first. A 401, or a 403 that names no reason, refreshes the session
once and asks once more, as every
workpegcall does. - A refusal is never asked twice. A 403 that says who is asking or from
where (
"not-callable","not-staff","scope-not-declared","not-a-peg-page","peg-origin-mismatch","peg-disabled","peg-read-only", or a dev page's"dev-origin-refused") is the API's answer, and no sign-in changes it. So are"call-taken","no-such-call","no-such-person","bad-signal","bad-query","signal-too-large"and"signal-rate". Each fails at once, with no refresh and no second request. - Rates. 30 signals per 10 seconds and 10 new calls a minute per
person; past them,
"signal-rate".
The worked peg: Phone¶
The playground's pages/calls_page.py is the Phone peg's page, whole: a
list of teammates with an online dot, ringing, Accept or Decline, Mute and
Hang up, audio only and one to one. Its flow:
- Call opens the microphone (audio only), makes the connection with the peg's one STUN server, and sets the local offer.
- When ICE gathering is complete (or after 4 seconds), the page sends one offer that holds every candidate: no trickle, so a call is a handful of requests.
- The callee's page reads the offer and rings. It makes no connection and opens no microphone until Accept, so no address of theirs leaves before they choose to answer.
- Accept claims the call first, so the callee's other tabs stop ringing and never open a microphone; then it answers.
- The two browsers connect directly, encrypted (DTLS-SRTP). Mute is
track.enabled = False. Hang up sendshangup, closes the connection and stops the tracks.
Permissions¶
- The page asks for the microphone only from a click: Call or Accept. The browser asks the person once per site.
- A peg that calls needs the
callsscope: declared in its manifest and granted by Workpeg. Without either half,signalingfails with"scope-not-declared". Declare it inworkpeg.json, and the build copies it intodist/manifest.json(scopes). - Only callable staff (active, with a role, not a guest) can call or be
called; anyone else gets
"not-callable". - A dev page (
<peg>.dev.workpeg.com) files its calls under its own test label, apart from the released peg's, so an unreleased build never answers or declines a real call.
Troubleshooting¶
No microphone¶
failure.problem says why: "not-allowed" (the person said no, or the
site is blocked in the browser's settings), "not-found" (no microphone),
"not-readable" (another app holds it), "not-supported" (the page is not
https). Allow the microphone for the site, then try again.
It rings but never connects¶
The two networks block a direct connection, and there is no TURN server to
relay it. pc.connection_state reaches "failed". Try from another
network, such as a phone's hotspot.
Connected, but no sound¶
Check that the other side is not muted, and that the page draws
Audio(src_object=self.remote) with the stream from the track event. A
browser that blocks autoplay plays the sound once the person has clicked on
the page, which Call and Accept are.
The teammate is "away"¶
Their Phone page has not read its signals in the last 90 seconds: it is not open, or it is a background tab the browser has slowed down. A background tab rings late, and an iPhone tab in the background does not ring.
"Not enabled for this business"¶
The peg has no calls scope here yet: see Permissions.
Calls stopped arriving after a few minutes¶
The access token lives minutes. signaling refreshes it; a page built on
an SDK before the refresh went deaf once it expired. Rebuild on this
release.