99
1010from __future__ import annotations
1111
12+ import asyncio
13+ import json
1214import logging
1315import os
16+ import re
1417import shlex
1518import shutil
1619import subprocess
20+ import time
21+ import urllib .request
1722
1823from .oob_teleop_env import (
1924 DEFAULT_WEB_CLIENT_ORIGIN ,
25+ _parse_env_port ,
2026 build_headset_bookmark_url ,
2127 client_ui_fields_from_env ,
2228 resolve_lan_host_for_oob ,
@@ -103,6 +109,11 @@ def assert_exactly_one_adb_device() -> None:
103109 "Cannot use --setup-oob: `adb` was not found on PATH.\n \n "
104110 "Install Android Platform Tools and ensure `adb` is available, or omit --setup-oob."
105111 ) from e
112+ except subprocess .TimeoutExpired as e :
113+ raise OobAdbError (
114+ "adb command timed out; ensure Android Platform Tools are installed and adb is callable.\n \n "
115+ "Try `adb kill-server` and reconnect the USB cable, or omit --setup-oob."
116+ ) from e
106117 if proc .returncode != 0 :
107118 diag = _adb_output_text (proc )
108119 raise OobAdbError (
@@ -143,7 +154,9 @@ def run_adb_headset_bookmark(*, resolved_port: int) -> tuple[int, str]:
143154 is set explicitly. Returns ``(exit_code, diagnostic)``.
144155 """
145156 env_port = os .environ .get ("TELEOP_STREAM_PORT" , "" ).strip ()
146- signaling_port = int (env_port ) if env_port else resolved_port
157+ signaling_port = (
158+ _parse_env_port ("TELEOP_STREAM_PORT" , env_port ) if env_port else resolved_port
159+ )
147160 proxy_host = resolve_lan_host_for_oob ()
148161 stream_cfg : dict = {
149162 "serverIP" : proxy_host ,
@@ -162,10 +175,340 @@ def run_adb_headset_bookmark(*, resolved_port: int) -> tuple[int, str]:
162175
163176 shell_cmd = "am start -a android.intent.action.VIEW -d " + shlex .quote (url )
164177 full = ["adb" , "shell" , shell_cmd ]
165- log .info ("ADB automation: %s" , " " .join (shlex .quote (c ) for c in full ))
166- proc = subprocess .run (full , capture_output = True , text = True )
178+ redacted = " " .join (shlex .quote (c ) for c in full )
179+ redacted = re .sub (r"(controlToken=)[^&\s'\"]+" , r"\1<REDACTED>" , redacted )
180+ log .info ("ADB automation: %s" , redacted )
181+ try :
182+ proc = subprocess .run (full , capture_output = True , text = True , timeout = 30 )
183+ except subprocess .TimeoutExpired as e :
184+ partial = (
185+ (e .stderr or e .stdout or b"" )
186+ if isinstance (e .stderr or e .stdout , bytes )
187+ else (e .stderr or e .stdout or "" )
188+ )
189+ if isinstance (partial , bytes ):
190+ partial = partial .decode (errors = "replace" )
191+ diag = f"adb shell timed out after 30s. { partial } " .strip ()
192+ return 1 , diag
167193 if proc .returncode != 0 :
168194 diag = _adb_output_text (proc )
169195 return proc .returncode , diag
170196 log .info ("ADB automation: am start completed" )
171197 return 0 , ""
198+
199+
200+ # ---------------------------------------------------------------------------
201+ # CDP automation — click the CONNECT button via Chrome DevTools Protocol
202+ # ---------------------------------------------------------------------------
203+
204+ _CDP_LOCAL_PORT = 9223 # avoid clashing with any pre-existing 9222 forward
205+
206+
207+ def _discover_devtools_socket () -> str | None :
208+ """Return the bare name of the browser's DevTools abstract socket, or None.
209+
210+ Pico Browser (WebLayer/Chromium) exposes a socket like
211+ ``@weblayer_devtools_remote_<pid>`` in ``/proc/net/unix``.
212+ The PID suffix changes every time the browser starts.
213+ """
214+ try :
215+ proc = subprocess .run (
216+ ["adb" , "shell" , "cat" , "/proc/net/unix" ],
217+ capture_output = True ,
218+ text = True ,
219+ timeout = 10 ,
220+ check = False ,
221+ )
222+ except FileNotFoundError :
223+ return None
224+ for line in proc .stdout .splitlines ():
225+ if "weblayer_devtools_remote" in line :
226+ for token in line .split ():
227+ if token .startswith ("@weblayer_devtools_remote" ):
228+ return token [1 :] # strip leading @
229+ return None
230+
231+
232+ def _adb_forward_cdp (socket_name : str , local_port : int ) -> None :
233+ subprocess .run (
234+ ["adb" , "forward" , f"tcp:{ local_port } " , f"localabstract:{ socket_name } " ],
235+ capture_output = True ,
236+ text = True ,
237+ timeout = 10 ,
238+ check = True ,
239+ )
240+ log .info ("CDP: forwarded tcp:%d -> @%s" , local_port , socket_name )
241+
242+
243+ def _adb_forward_remove (local_port : int ) -> None :
244+ subprocess .run (
245+ ["adb" , "forward" , "--remove" , f"tcp:{ local_port } " ],
246+ capture_output = True ,
247+ text = True ,
248+ timeout = 10 ,
249+ check = False ,
250+ )
251+
252+
253+ def _cdp_list_tabs (local_port : int ) -> list [dict ]:
254+ try :
255+ with urllib .request .urlopen (
256+ f"http://localhost:{ local_port } /json" , timeout = 3
257+ ) as resp :
258+ return json .loads (resp .read ())
259+ except Exception as exc :
260+ log .debug ("CDP: failed to list tabs on port %d: %s" , local_port , exc )
261+ return []
262+
263+
264+ async def _cdp_session_click_connect (ws_url : str ) -> None :
265+ """Open a single CDP session and click the CONNECT button.
266+
267+ Handles the self-signed cert interstitial before looking for the button:
268+
269+ * Primary path — ``Security.setIgnoreCertificateErrors`` + ``Page.navigate``
270+ (re-loads the page with cert checking disabled).
271+ * Fallback — DOM click-through: ``details-button`` → ``proceed-link``
272+ (standard Chromium cert-warning IDs).
273+ """
274+ from websockets .asyncio .client import connect as ws_connect # already a dep
275+
276+ _seq = 0
277+
278+ async def send (ws , method , params = None ):
279+ nonlocal _seq
280+ _seq += 1
281+ req_id = _seq
282+ await ws .send (
283+ json .dumps ({"id" : req_id , "method" : method , "params" : params or {}})
284+ )
285+ while True :
286+ msg = json .loads (await asyncio .wait_for (ws .recv (), timeout = 10.0 ))
287+ if msg .get ("id" ) == req_id :
288+ return msg .get ("result" , {})
289+
290+ async with ws_connect (ws_url ) as ws :
291+ # ---- cert warning handling ----------------------------------------
292+ cert_suppressed = False
293+ try :
294+ await send (ws , "Security.setIgnoreCertificateErrors" , {"ignore" : True })
295+ cert_suppressed = True
296+ log .info ("CDP: cert errors suppressed" )
297+ except Exception as exc :
298+ log .debug (
299+ "CDP: Security domain unavailable (%s), will try DOM fallback" , exc
300+ )
301+
302+ # Detect interstitial: Chromium cert warning pages have #details-button
303+ r = await send (
304+ ws ,
305+ "Runtime.evaluate" ,
306+ {
307+ "expression" : "!!document.getElementById('details-button')" ,
308+ "returnByValue" : True ,
309+ },
310+ )
311+ on_interstitial = r .get ("result" , {}).get ("value" , False )
312+
313+ if on_interstitial :
314+ log .info ("CDP: cert interstitial detected" )
315+ navigated = False
316+ if cert_suppressed :
317+ r2 = await send (
318+ ws ,
319+ "Runtime.evaluate" ,
320+ {
321+ "expression" : "window.location.href" ,
322+ "returnByValue" : True ,
323+ },
324+ )
325+ current_url = r2 .get ("result" , {}).get ("value" , "" )
326+ if current_url and not current_url .startswith ("chrome-error" ):
327+ log .info ("CDP: re-navigating to %s" , current_url )
328+ await send (ws , "Page.navigate" , {"url" : current_url })
329+ await asyncio .sleep (3.0 )
330+ navigated = True
331+ else :
332+ log .warning (
333+ "CDP: interstitial URL is %r, falling back to DOM click-through" ,
334+ current_url ,
335+ )
336+
337+ if not navigated :
338+ await send (
339+ ws ,
340+ "Runtime.evaluate" ,
341+ {
342+ "expression" : "document.getElementById('details-button')?.click()" ,
343+ },
344+ )
345+ await asyncio .sleep (1.5 )
346+ await send (
347+ ws ,
348+ "Runtime.evaluate" ,
349+ {
350+ "expression" : "document.getElementById('proceed-link')?.click()" ,
351+ },
352+ )
353+ await asyncio .sleep (3.0 )
354+
355+ # ---- find CONNECT button with retries --------------------------------
356+ val : dict = {}
357+ for attempt in range (1 , 4 ):
358+ r = await send (
359+ ws ,
360+ "Runtime.evaluate" ,
361+ {
362+ "expression" : """(function() {
363+ const btn = Array.from(document.querySelectorAll('button,[role=button]'))
364+ .find(e => e.textContent.trim().toUpperCase() === 'CONNECT');
365+ if (!btn) return {found: false};
366+ const rc = btn.getBoundingClientRect();
367+ return {found: true, x: rc.left + rc.width / 2, y: rc.top + rc.height / 2};
368+ })()""" ,
369+ "returnByValue" : True ,
370+ },
371+ )
372+ val = (r .get ("result" ) or {}).get ("value" ) or {}
373+ if val .get ("found" ):
374+ break
375+ log .info ("CDP: CONNECT button not found yet (attempt %d/3)" , attempt )
376+ if attempt < 3 :
377+ await asyncio .sleep (2.0 )
378+
379+ if not val .get ("found" ):
380+ raise OobAdbError (
381+ "CDP: CONNECT button not found on the teleop page.\n "
382+ "The page may not have finished loading — check the headset."
383+ )
384+
385+ x , y = val ["x" ], val ["y" ]
386+ log .info ("CDP: clicking CONNECT at (%.0f, %.0f)" , x , y )
387+ for event_type in ("mousePressed" , "mouseReleased" ):
388+ await send (
389+ ws ,
390+ "Input.dispatchMouseEvent" ,
391+ {
392+ "type" : event_type ,
393+ "x" : x ,
394+ "y" : y ,
395+ "button" : "left" ,
396+ "clickCount" : 1 ,
397+ },
398+ )
399+ log .info ("CDP: CONNECT click dispatched" )
400+
401+ # ---- monitor connection outcome -------------------------------------
402+ # DOM facts learned from page inspection:
403+ # - Start/stop button: id="startButton", text "CONNECT" when idle
404+ # - Error box: first [role=alert] is empty validation-message-box;
405+ # error text lives in the *second* [role=alert] (error-message-box)
406+ _CONNECT_TIMEOUT = 30.0
407+ loop = asyncio .get_running_loop ()
408+ deadline = loop .time () + _CONNECT_TIMEOUT
409+ while loop .time () < deadline :
410+ await asyncio .sleep (2.0 )
411+ r = await send (
412+ ws ,
413+ "Runtime.evaluate" ,
414+ {
415+ "expression" : """(function() {
416+ const alertText = Array.from(document.querySelectorAll('[role=alert]'))
417+ .map(e => e.innerText?.trim()).find(t => !!t) || null;
418+ const btn = document.getElementById('startButton');
419+ const btnText = btn?.textContent?.trim()?.toUpperCase() || null;
420+ return {alertText, btnText};
421+ })()""" ,
422+ "returnByValue" : True ,
423+ },
424+ )
425+ state = (r .get ("result" ) or {}).get ("value" ) or {}
426+ btn_text = state .get ("btnText" )
427+ if btn_text is not None and btn_text != "CONNECT" :
428+ log .info ("CDP: start button changed to %r — session active" , btn_text )
429+ return
430+ if state .get ("alertText" ):
431+ raise OobAdbError (f"Teleop connection failed: { state ['alertText' ]} " )
432+ log .warning (
433+ "CDP: connection state unknown after %.0fs — check headset" ,
434+ _CONNECT_TIMEOUT ,
435+ )
436+
437+
438+ async def run_oob_connect (* , resolved_port : int , timeout : float = 60.0 ) -> None :
439+ """Open the teleop page on the headset and click CONNECT via CDP.
440+
441+ Combines the ``am start`` bookmark step with CDP automation so that the
442+ new tab can be identified unambiguously by diffing tab IDs before and after
443+ ``am start`` — regardless of how many other tabs are already open.
444+
445+ Flow:
446+ 1. Discover the Pico Browser DevTools abstract socket and forward it.
447+ 2. Snapshot existing tab IDs.
448+ 3. Run ``am start`` to open the teleop bookmark URL.
449+ 4. Wait for a new tab (ID not in snapshot) to appear.
450+ 5. Handle self-signed cert interstitial if present.
451+ 6. Find the CONNECT button and click it via ``Input.dispatchMouseEvent``.
452+ 7. Clean up the ``adb forward``.
453+
454+ Raises :exc:`OobAdbError` on any unrecoverable failure; callers should
455+ treat this as non-fatal and ask the user to tap CONNECT manually.
456+ """
457+ deadline = time .monotonic () + timeout
458+
459+ # --- DevTools socket discovery -------------------------------------------
460+ socket_name = _discover_devtools_socket ()
461+ if not socket_name :
462+ raise OobAdbError (
463+ "CDP: Pico Browser DevTools socket not found.\n "
464+ "Ensure the browser is open on the headset, then retry or tap CONNECT manually."
465+ )
466+ log .info ("CDP: found socket @%s" , socket_name )
467+
468+ try :
469+ _adb_forward_cdp (socket_name , _CDP_LOCAL_PORT )
470+ except subprocess .CalledProcessError as exc :
471+ raise OobAdbError (f"CDP: adb forward failed: { exc } " ) from exc
472+
473+ try :
474+ # --- snapshot existing tabs before am start --------------------------
475+ tabs_before = {t ["id" ] for t in _cdp_list_tabs (_CDP_LOCAL_PORT ) if "id" in t }
476+ log .info ("CDP: %d tab(s) open before am start" , len (tabs_before ))
477+
478+ # --- open URL on headset ---------------------------------------------
479+ rc , diag = await asyncio .to_thread (
480+ run_adb_headset_bookmark , resolved_port = resolved_port
481+ )
482+ if rc != 0 :
483+ hint = adb_automation_failure_hint (diag )
484+ raise OobAdbError (oob_adb_automation_message (rc , diag , hint ))
485+ log .info ("ADB: am start completed; waiting for new tab" )
486+
487+ # --- wait for the newly opened tab (not in pre-snapshot) -------------
488+ ws_url : str | None = None
489+ while time .monotonic () < deadline :
490+ for tab in _cdp_list_tabs (_CDP_LOCAL_PORT ):
491+ if "id" not in tab :
492+ continue
493+ if tab ["id" ] not in tabs_before and tab .get ("webSocketDebuggerUrl" ):
494+ ws_url = tab ["webSocketDebuggerUrl" ]
495+ log .info ("CDP: new tab %r url=%s" , tab .get ("title" ), tab .get ("url" ))
496+ break
497+ if ws_url :
498+ break
499+ await asyncio .sleep (1.0 )
500+
501+ if ws_url is None :
502+ raise OobAdbError (
503+ "CDP: new browser tab not found within timeout.\n "
504+ "The browser may not have opened the teleop page — tap CONNECT manually."
505+ )
506+
507+ # Give the page JS time to finish initializing before we interact with it
508+ log .info ("CDP: waiting for page to initialize..." )
509+ await asyncio .sleep (4.0 )
510+
511+ # --- cert interstitial + CONNECT click --------------------------------
512+ await _cdp_session_click_connect (ws_url )
513+ finally :
514+ _adb_forward_remove (_CDP_LOCAL_PORT )
0 commit comments