sos-bot/core/adb.py
bachko 3309a47e10 Initial commit: SoS bot framework
- ADB client (connect, tap, swipe, screencap, app control)
- Vision module (in-memory screenshot + OpenCV template matching)
- Action loader (auto-discovers .py files in actions/)
- Engine (screenshot → detect → act loop with YAML playbook)
- 12 built-in actions: tap, click, wait, swipe, find,
  find_and_click, wait_until, click_seq, keyevent, app, log
- Example custom action template
- Debug screenshot tool for template creation
- Detailed plan.md with 20 actions across 9 phases
- Sample playbook with 10 steps
2026-07-09 13:52:31 +00:00

120 lines
4.7 KiB
Python

"""
ADB client — communicates with BlueStacks emulator via Android Debug Bridge.
All screenshot data stays in memory (never written to disk).
"""
import subprocess
import time
import logging
logger = logging.getLogger("sos.adb")
class AdbError(Exception):
"""Raised when an ADB command fails."""
pass
class AdbClient:
"""Low-level ADB wrapper over a single device."""
def __init__(self, host="127.0.0.1", port=5555):
self.host = host
self.port = port
self.serial = None # set by connect()
# ── connection ────────────────────────────────────────────
def connect(self) -> str:
"""Connect to the emulator. Returns device serial."""
out = self._raw(f"connect {self.host}:{self.port}")
self.serial = f"{self.host}:{self.port}"
lines = [l.strip() for l in out.splitlines() if l.strip()]
logger.info("ADB connect: %s", lines[-1] if lines else out)
return self.serial
def disconnect(self):
"""Disconnect from the emulator."""
if self.serial:
self._raw(f"disconnect {self.serial}")
def is_connected(self) -> bool:
"""Check if device shows as 'device' in adb devices."""
try:
out = self._raw("devices")
return self.serial in out and "device" in out.split(self.serial)[1][:20]
except Exception:
return False
# ── screenshots (pure memory — no files) ──────────────────
def screencap(self) -> bytes:
"""
Capture screen as raw PNG bytes, fully in memory.
Returns PNG bytes ready for OpenCV / PIL.
"""
raw = self._exec_out(f"shell screencap -p")
if not raw:
raise AdbError("screencap returned empty bytes — device disconnected?")
return raw
# ── input actions ─────────────────────────────────────────
def tap(self, x: int, y: int):
"""Tap at screen coordinates (x, y)."""
self._shell(f"input tap {x} {y}")
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration_ms: int = 300):
"""Swipe from (x1,y1) to (x2,y2) over duration_ms."""
self._shell(f"input swipe {x1} {y1} {x2} {y2} {duration_ms}")
def keyevent(self, keycode: int):
"""Send a key event (e.g. 4 = BACK, 3 = HOME)."""
self._shell(f"input keyevent {keycode}")
def text(self, text: str):
"""Type text (only ASCII safe; use with care)."""
# escape special chars for shell
safe = text.replace(" ", "%s").replace("'", "\\'")
self._shell(f"input text '{safe}'")
# ── app management ────────────────────────────────────────
def force_stop(self, package: str):
"""Force-stop an app by package name."""
self._shell(f"am force-stop {package}")
def launch_app(self, package: str, activity: str = None):
"""Launch an app. If activity is omitted, monkey-launch it."""
if activity:
self._shell(f"am start -n {package}/{activity}")
else:
self._shell(f"monkey -p {package} -c android.intent.category.LAUNCHER 1")
# ── helpers ───────────────────────────────────────────────
def _shell(self, cmd: str) -> str:
"""Run 'adb -s <serial> shell <cmd>'. Returns stdout decoded."""
if not self.serial:
raise AdbError("Not connected — call connect() first")
return self._raw(f"-s {self.serial} shell {cmd}")
def _exec_out(self, cmd: str) -> bytes:
"""Run 'adb -s <serial> exec-out <cmd>'. Returns raw bytes (for images)."""
if not self.serial:
raise AdbError("Not connected — call connect() first")
full = ["adb", "-s", self.serial, "exec-out"] + cmd.split()
logger.debug("ADB exec-out: %s", " ".join(full))
r = subprocess.run(full, capture_output=True, timeout=30)
if r.returncode != 0:
raise AdbError(f"ADB exec-out failed: {r.stderr.decode(errors='replace')}")
return r.stdout
def _raw(self, cmd: str) -> str:
"""Run 'adb <cmd>'. Returns decoded stdout."""
full = ["adb"] + cmd.split()
logger.debug("ADB: %s", " ".join(full))
r = subprocess.run(full, capture_output=True, text=True, timeout=30)
if r.returncode != 0:
raise AdbError(f"ADB error: {r.stderr.strip()}")
return r.stdout