chore: 初始化仓库(排除 adb 二进制文件以解决推送超时)
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
"""OpenSTF REST API 客户端封装。
|
||||
|
||||
负责:设备列表查询、占用、远程 ADB 隧道建立、释放。
|
||||
不负责实际 UI 操作(那是 DeviceWorker 的事)。
|
||||
|
||||
健壮性设计:
|
||||
- 所有请求带 timeout,避免 STF 网关 504 时长时间卡死
|
||||
- remoteConnect 带重试,STF provider 偶尔慢启动
|
||||
- 友好错误分类:设备离线 / 网络不通 / STF 内部错误,便于上层决策
|
||||
- 占用冲突自动重试(STF 偶发 400 "device already in use")
|
||||
"""
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from config import STF_URL, STF_TOKEN
|
||||
from core.logger import get_logger
|
||||
|
||||
_log = get_logger("core.stf")
|
||||
|
||||
# 请求超时(秒)。connect 超时 + read 超时
|
||||
_TIMEOUT = (5, 15)
|
||||
# remoteConnect 重试次数(STF provider 慢启动时需要重试)
|
||||
_RC_RETRIES = 3
|
||||
# remoteConnect 重试间隔
|
||||
_RC_DELAY = 2
|
||||
|
||||
|
||||
class STFError(Exception):
|
||||
"""STF 相关错误基类。"""
|
||||
|
||||
def __init__(self, message, code=""):
|
||||
super().__init__(message)
|
||||
self.code = code # 错误码:offline / network / conflict / server / unknown
|
||||
|
||||
|
||||
class DeviceOfflineError(STFError):
|
||||
"""设备离线/不可用。"""
|
||||
|
||||
|
||||
class STFNetworkError(STFError):
|
||||
"""STF 服务不可达。"""
|
||||
|
||||
|
||||
class DeviceConflictError(STFError):
|
||||
"""设备已被占用。"""
|
||||
|
||||
|
||||
def _headers():
|
||||
return {"Authorization": f"Bearer {STF_TOKEN}"}
|
||||
|
||||
|
||||
class STFClient:
|
||||
"""OpenSTF REST API 封装。"""
|
||||
|
||||
def __init__(self, base_url=STF_URL, token=STF_TOKEN):
|
||||
self.base_url = base_url
|
||||
self.headers = _headers()
|
||||
|
||||
# ================== 设备查询 ==================
|
||||
def list_all_devices(self):
|
||||
"""返回 STF 上所有设备(含状态)。"""
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/v1/devices",
|
||||
headers=self.headers, timeout=_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("devices", [])
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise STFNetworkError(f"STF 服务不可达: {e}", "network") from e
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise STFNetworkError(f"STF 请求超时: {e}", "network") from e
|
||||
except requests.exceptions.RequestException as e:
|
||||
_log.error(f"获取设备列表失败: {e}")
|
||||
raise STFError(f"获取设备列表失败: {e}", "server") from e
|
||||
|
||||
def list_free_devices(self):
|
||||
"""返回可占用的空闲设备。"""
|
||||
return [d for d in self.list_all_devices()
|
||||
if d.get("present") and d.get("ready")
|
||||
and not d.get("using") and d.get("owner") is None]
|
||||
|
||||
def list_my_devices(self):
|
||||
"""返回当前账户已占用的设备。"""
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/v1/user/devices",
|
||||
headers=self.headers, timeout=_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("devices", [])
|
||||
except requests.exceptions.RequestException as e:
|
||||
_log.error(f"获取已占用设备失败: {e}")
|
||||
return []
|
||||
|
||||
# ================== 占用/释放 ==================
|
||||
def occupy(self, serial, retries=2):
|
||||
"""占用设备。返回 (ok, msg)。
|
||||
|
||||
冲突(已被占用)时自动重试,偶发 400 "already in use" 可能是脏状态。
|
||||
"""
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
r = requests.post(f"{self.base_url}/api/v1/user/devices/{serial}",
|
||||
headers=self.headers, timeout=_TIMEOUT)
|
||||
if r.status_code in (200, 201):
|
||||
return True, r.text[:200]
|
||||
if r.status_code == 400 and "already" in r.text.lower():
|
||||
# 已被占用(可能是自己之前占用没释放干净),尝试先释放再占
|
||||
_log.warning(f"{serial} 占用冲突,尝试清理后重试 ({attempt}/{retries})")
|
||||
try:
|
||||
requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}",
|
||||
headers=self.headers, timeout=_TIMEOUT)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
continue
|
||||
return False, f"HTTP {r.status_code}: {r.text[:200]}"
|
||||
except requests.exceptions.RequestException as e:
|
||||
_log.warning(f"{serial} 占用请求异常 ({attempt}/{retries}): {e}")
|
||||
if attempt < retries:
|
||||
time.sleep(1)
|
||||
return False, str(e)
|
||||
return False, "占用冲突重试耗尽"
|
||||
|
||||
def release(self, serial):
|
||||
"""释放设备占用(含远程断开)。"""
|
||||
self.remote_disconnect(serial)
|
||||
try:
|
||||
requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}",
|
||||
headers=self.headers, timeout=_TIMEOUT)
|
||||
except requests.exceptions.RequestException as e:
|
||||
_log.warning(f"释放 {serial} 请求失败: {e}")
|
||||
|
||||
def release_all_mine(self):
|
||||
"""释放当前账户占用的所有设备(清理用)。"""
|
||||
released = []
|
||||
for d in self.list_my_devices():
|
||||
serial = d["serial"]
|
||||
try:
|
||||
self.release(serial)
|
||||
released.append(serial)
|
||||
_log.info(f"已释放 {serial}")
|
||||
except Exception as e:
|
||||
_log.error(f"释放 {serial} 失败: {e}")
|
||||
return released
|
||||
|
||||
# ================== 远程 ADB 隧道 ==================
|
||||
def remote_connect(self, serial):
|
||||
"""建立远程 ADB 隧道,返回 remoteConnectUrl。
|
||||
|
||||
STF provider 慢启动或设备掉线时会 504,这里带重试。
|
||||
设备真离线时快速失败,不长时间卡住 worker。
|
||||
"""
|
||||
last_err = None
|
||||
for attempt in range(1, _RC_RETRIES + 1):
|
||||
try:
|
||||
conn = requests.post(
|
||||
f"{self.base_url}/api/v1/user/devices/{serial}/remoteConnect",
|
||||
headers=self.headers, timeout=_TIMEOUT,
|
||||
)
|
||||
# 504 = STF 网关等 provider 响应超时,通常是设备掉线或 provider 卡死
|
||||
if conn.status_code == 504:
|
||||
last_err = f"STF 网关超时(504),设备可能掉线"
|
||||
_log.warning(f"{serial} remoteConnect 504 ({attempt}/{_RC_RETRIES})")
|
||||
if attempt < _RC_RETRIES:
|
||||
time.sleep(_RC_DELAY)
|
||||
continue
|
||||
raise DeviceOfflineError(
|
||||
f"{serial} remoteConnect 超时,设备可能掉线或 provider 卡死",
|
||||
"offline",
|
||||
)
|
||||
conn.raise_for_status()
|
||||
data = conn.json()
|
||||
if not data.get("success"):
|
||||
raise STFError(f"远程连接失败: {data.get('description')}", "server")
|
||||
url = (data.get("remoteConnectUrl") or data.get("remoteAdbUrl")
|
||||
or data.get("remote_adb_url") or data.get("adbUrl")
|
||||
or data.get("url"))
|
||||
if not url:
|
||||
raise STFError("STF 返回成功但无 remoteConnectUrl", "server")
|
||||
return url
|
||||
except DeviceOfflineError:
|
||||
raise
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
last_err = str(e)
|
||||
_log.warning(f"{serial} remoteConnect 网络异常 ({attempt}/{_RC_RETRIES}): {e}")
|
||||
if attempt < _RC_RETRIES:
|
||||
time.sleep(_RC_DELAY)
|
||||
except requests.exceptions.Timeout as e:
|
||||
last_err = str(e)
|
||||
_log.warning(f"{serial} remoteConnect 超时 ({attempt}/{_RC_RETRIES}): {e}")
|
||||
if attempt < _RC_RETRIES:
|
||||
time.sleep(_RC_DELAY)
|
||||
except requests.exceptions.RequestException as e:
|
||||
last_err = str(e)
|
||||
_log.error(f"{serial} remoteConnect 请求异常: {e}")
|
||||
raise STFError(f"远程连接请求失败: {e}", "server") from e
|
||||
# 重试耗尽
|
||||
raise DeviceOfflineError(
|
||||
f"{serial} remoteConnect 重试 {_RC_RETRIES} 次失败: {last_err}",
|
||||
"offline",
|
||||
)
|
||||
|
||||
def remote_disconnect(self, serial):
|
||||
"""断开远程 ADB 隧道。"""
|
||||
try:
|
||||
requests.post(
|
||||
f"{self.base_url}/api/v1/user/devices/{serial}/remoteDisconnect",
|
||||
headers=self.headers, timeout=_TIMEOUT,
|
||||
)
|
||||
except requests.exceptions.RequestException:
|
||||
pass # 断开失败不影响主流程
|
||||
Reference in New Issue
Block a user