@@ -34,7 +34,7 @@ def __init__(self, config: Config):
3434 self .config = config
3535 self .token_store = TokenStore (config )
3636 self .telemetry = TelemetryClient (config , self .token_store )
37- self .pc : RTCPeerConnection | None = None
37+ self .peer_connections : dict [ str , RTCPeerConnection ] = {}
3838 self .ws = None
3939 self .running = True
4040 self .turn_credentials : dict | None = None
@@ -129,16 +129,8 @@ def refresh_flow(self):
129129 # WebRTC + Signaling
130130 # =========================
131131
132- async def setup_peer_connection (self ):
133- # Null out self.pc before closing the old one so that the stale
134- # "closed" connectionstatechange event doesn't see itself as current
135- # and close the active signaling WebSocket.
136- old_pc = self .pc
137- self .pc = None
138- if old_pc :
139- await old_pc .close ()
140-
141- ice_servers = [
132+ def _build_ice_servers (self ) -> list [RTCIceServer ]:
133+ servers = [
142134 RTCIceServer (urls = "stun:stun.l.google.com:19302" ),
143135 RTCIceServer (urls = "stun:stun1.l.google.com:19302" ),
144136 ]
@@ -147,49 +139,62 @@ async def setup_peer_connection(self):
147139 turn_username = creds .get ("username" ) or self .config .turn_username
148140 turn_credential = creds .get ("credential" ) or self .config .turn_credential
149141 if turn_url and turn_username and turn_credential :
150- ice_servers .append (
142+ servers .append (
151143 RTCIceServer (
152144 urls = turn_url ,
153145 username = turn_username ,
154146 credential = turn_credential ,
155147 )
156148 )
157- pc = RTCPeerConnection (configuration = RTCConfiguration (iceServers = ice_servers ))
158- self .pc = pc
149+ return servers
150+
151+ async def setup_peer_connection (self , viewer_channel : str ) -> RTCPeerConnection :
152+ # Close existing PC for this viewer if there is one
153+ old_pc = self .peer_connections .pop (viewer_channel , None )
154+ if old_pc :
155+ await old_pc .close ()
156+
157+ pc = RTCPeerConnection (
158+ configuration = RTCConfiguration (iceServers = self ._build_ice_servers ())
159+ )
160+ self .peer_connections [viewer_channel ] = pc
159161
160162 @pc .on ("connectionstatechange" )
161163 async def on_connectionstatechange ():
162- if pc is not self . pc :
163- return # stale event from a replaced PC
164+ if self . peer_connections . get ( viewer_channel ) is not pc :
165+ return
164166 state = pc .connectionState
165- LOG .info ("WebRTC state: %s" , state )
167+ LOG .info ("WebRTC state for %s : %s" , viewer_channel , state )
166168 if state == "connected" :
167169 await asyncio .to_thread (self .telemetry .send , "streaming" )
168170 elif state == "failed" :
169171 await asyncio .to_thread (
170172 self .telemetry .send , "error" , "WebRTC connection failed" , "WARNING"
171173 )
172- # Reset the PC so we're ready for the next offer.
173- # The signaling WebSocket stays alive — don't close it here.
174- await self . setup_peer_connection ()
175- self . _register_ice_handler ()
174+ await self . setup_peer_connection ( viewer_channel )
175+ self . _register_ice_handler ( viewer_channel )
176+
177+ return pc
176178
177- def _register_ice_handler (self ):
178- pc = self .pc
179+ def _register_ice_handler (self , viewer_channel : str ):
180+ pc = self .peer_connections [ viewer_channel ]
179181
180182 @pc .on ("icecandidate" )
181183 async def on_icecandidate (candidate ):
182- if pc is not self . pc :
184+ if self . peer_connections . get ( viewer_channel ) is not pc :
183185 return
184186 if candidate is not None :
185- LOG .info ("ICE candidate generated: %s" , candidate )
187+ LOG .info (
188+ "ICE candidate generated for %s: %s" , viewer_channel , candidate
189+ )
186190 await self .ws .send (
187191 json .dumps (
188192 {
189193 "type" : "candidate" ,
190194 "candidate" : candidate .candidate ,
191195 "sdpMid" : candidate .sdpMid ,
192196 "sdpMLineIndex" : candidate .sdpMLineIndex ,
197+ "viewer_channel" : viewer_channel ,
193198 }
194199 )
195200 )
@@ -199,43 +204,44 @@ async def signaling_loop(self):
199204 access_token = token_store ["access" ]["token_value" ]
200205 device_code = token_store ["device_code" ]
201206
207+ self .turn_credentials = await asyncio .to_thread (
208+ fetch_turn_credentials , self .config .http_api_base_url , access_token
209+ )
210+
202211 headers = {"Authorization" : f"Bearer { access_token } " }
203212
204213 base = self .config .websocket_api_base_url .rstrip ("/" )
205214 if "/ws/live_stream" not in base :
206215 base = f"{ base } /ws/live_stream"
207216 websocket_url = f"{ base } /{ device_code } /"
208217
209- self .turn_credentials = await asyncio .to_thread (
210- fetch_turn_credentials , self .config .http_api_base_url , access_token
211- )
212-
213218 async with websockets .connect (
214219 websocket_url ,
215220 additional_headers = headers ,
216221 ) as ws :
217222 self .ws = ws
218223 LOG .info ("Connected to signaling server" )
219-
220- await self .setup_peer_connection ()
221- self ._register_ice_handler ()
222224 await asyncio .to_thread (self .telemetry .send , "connected" )
223225
224226 async for message in ws :
225227 data = json .loads (message )
226228 LOG .info ("Received message: %s" , data )
227229
228230 if data ["type" ] == "offer" :
229- await self .pc .setRemoteDescription (
231+ viewer_channel = data .get ("viewer_channel" , "" )
232+ pc = await self .setup_peer_connection (viewer_channel )
233+ self ._register_ice_handler (viewer_channel )
234+
235+ await pc .setRemoteDescription (
230236 RTCSessionDescription (
231237 sdp = data ["sdp" ],
232- type = data [ "type" ] ,
238+ type = "offer" ,
233239 )
234240 )
235241
236242 try :
237243 camera_track = CameraVideoStreamTrack (self .config )
238- self . pc .addTrack (camera_track )
244+ pc .addTrack (camera_track )
239245 LOG .info ("Camera track added successfully" )
240246 except Exception as e :
241247 LOG .warning (
@@ -245,42 +251,50 @@ async def signaling_loop(self):
245251 self .telemetry .send , "error" , str (e ), "WARNING"
246252 )
247253
248- answer = await self .pc .createAnswer ()
249-
250- await self .pc .setLocalDescription (answer )
254+ answer = await pc .createAnswer ()
255+ await pc .setLocalDescription (answer )
251256 await self .ws .send (
252257 json .dumps (
253258 {
254- "type" : self .pc .localDescription .type ,
255- "sdp" : self .pc .localDescription .sdp ,
259+ "type" : pc .localDescription .type ,
260+ "sdp" : pc .localDescription .sdp ,
261+ "viewer_channel" : viewer_channel ,
256262 }
257263 )
258264 )
259- LOG .info ("Answer sent successfully" )
265+ LOG .info ("Answer sent to viewer %s" , viewer_channel )
260266
261267 elif data ["type" ] in ("ice" , "candidate" ) and data .get ("candidate" ):
268+ viewer_channel = data .get ("viewer_channel" , "" )
269+ pc = self .peer_connections .get (viewer_channel )
270+ if not pc :
271+ LOG .warning (
272+ "Received candidate for unknown viewer %s" , viewer_channel
273+ )
274+ continue
262275 sdp_str = data ["candidate" ]
263276 if sdp_str .startswith ("candidate:" ):
264277 sdp_str = sdp_str [len ("candidate:" ) :]
265278 ice_candidate = candidate_from_sdp (sdp_str )
266279 ice_candidate .sdpMid = data .get ("sdpMid" )
267280 ice_candidate .sdpMLineIndex = data .get ("sdpMLineIndex" )
268- await self . pc .addIceCandidate (ice_candidate )
281+ await pc .addIceCandidate (ice_candidate )
269282
270283 elif (
271284 data ["type" ] == "status"
272285 and data .get ("event" ) == "peer_disconnected"
273286 and data .get ("peer_type" ) != "device"
274287 ):
275- LOG .info (
276- "Viewer disconnected; resetting peer connection for new offer"
277- )
288+ viewer_channel = data .get ("viewer_channel" , "" )
289+ pc = self .peer_connections .pop (viewer_channel , None )
290+ if pc :
291+ await pc .close ()
292+ LOG .info ("Viewer %s disconnected" , viewer_channel )
278293 await asyncio .to_thread (
279294 self .telemetry .send , "disconnected" , "Viewer disconnected"
280295 )
281- await self .setup_peer_connection ()
282- self ._register_ice_handler ()
283- await asyncio .to_thread (self .telemetry .send , "connected" )
296+ if not self .peer_connections :
297+ await asyncio .to_thread (self .telemetry .send , "connected" )
284298
285299 # =========================
286300 # Lifecycle
@@ -309,8 +323,9 @@ async def shutdown(self):
309323 self .telemetry .send , "disconnected" , "Client shutting down"
310324 )
311325
312- if self .pc :
313- await self .pc .close ()
326+ for pc in list (self .peer_connections .values ()):
327+ await pc .close ()
328+ self .peer_connections .clear ()
314329
315330 if self .ws :
316331 await self .ws .close ()
0 commit comments