Skip to content

Commit eb4bb67

Browse files
committed
chore(oob): Handle auto connection through CDP
1 parent 1e333cd commit eb4bb67

4 files changed

Lines changed: 399 additions & 36 deletions

File tree

src/core/cloudxr/python/__main__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ def _parse_args() -> argparse.Namespace:
5151
action="store_true",
5252
default=False,
5353
help=(
54-
"Enable OOB teleop control hub and open the teleop page on the headset via USB adb. "
54+
"Enable OOB teleop control hub, open the teleop page on the headset via USB adb, "
55+
"and auto-click CONNECT via CDP (Chrome DevTools Protocol). "
5556
"The headset must be connected via USB cable (for adb) and on WiFi (for streaming). "
5657
'See docs: "Out-of-band teleop control".'
5758
),

src/core/cloudxr/python/oob_teleop_adb.py

Lines changed: 346 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,20 @@
99

1010
from __future__ import annotations
1111

12+
import asyncio
13+
import json
1214
import logging
1315
import os
16+
import re
1417
import shlex
1518
import shutil
1619
import subprocess
20+
import time
21+
import urllib.request
1722

1823
from .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

Comments
 (0)