WechatOnCloud/bridge/woc_bridge/ui/actions.py

311 lines
11 KiB
Python
Raw Normal View History

"""L3 Actions 原子动作库find / click / type / wait / screenshot。
Actions L1 Backend L2 Locator 的组合层给定 kind 与窗口几何
Locator 解析为 ElementHandle图像优先几何兜底Backend 据此执行点击/输入
L4 Flow 通过 Actions 组合出完整业务流程 SendTextFlow
策略
- 图像路径P2 启用sel.by_image 非空且 locator.is_circuited False opencv None 时优先
- 命中record_image_success返回 strategy=image
- 未命中record_image_failurerequire_image=True ElementNotFoundError否则回退 _geom_find
- 几何兜底P1 默认 GeomSpec 计算绝对坐标
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Optional
from woc_bridge.ui.backends.base import BackendProtocol, WindowGeometry
from woc_bridge.ui.locators.base import ElementHandle, Selector
from woc_bridge.ui.locators.registry import LocatorRegistry
logger = logging.getLogger("woc-bridge")
class ElementNotFoundError(Exception):
"""元素未找到(图像匹配失败且 require_image=True或几何规格缺失
TODO: P3 时统一到 errors.py FlowError(code="ELEMENT_NOT_FOUND")
"""
pass
class Actions:
"""L3 原子动作库:组合 BackendL1与 LocatorL2
所有方法均为 async L4 Flow 编排调用
"""
def __init__(
self, backend: BackendProtocol, locator: LocatorRegistry
) -> None:
"""初始化 Actions。
Args:
backend: L1 Backend 实例 XdotoolBackend
locator: L2 LocatorRegistry 实例
"""
self.backend = backend
self.locator = locator
# 截图缓存:同一次 flow 内连续查找可避免重复截图CPU/IO 优化)
self._shot_cache: Optional[tuple[float, bytes]] = None
self._shot_cache_ttl_ms = 150
def _get_cached_screenshot(self) -> Optional[bytes]:
"""返回未过期的缓存截图,若无则 None。"""
if self._shot_cache is None:
return None
ts, shot = self._shot_cache
if (time.monotonic() - ts) * 1000 > self._shot_cache_ttl_ms:
self._shot_cache = None
return None
return shot
def _set_cached_screenshot(self, shot: bytes) -> None:
"""写入截图缓存。"""
self._shot_cache = (time.monotonic(), shot)
# ------------------------------------------------------------------
# 元素查找
# ------------------------------------------------------------------
async def find_element(
self, kind: str, win_geom: WindowGeometry
) -> ElementHandle:
"""查找元素,优先图像匹配,失败回退几何兜底。
Args:
kind: 元素类别键 search_box / send_button
win_geom: 当前窗口几何绝对坐标 + 宽高
Returns:
ElementHandle命中元素含绝对坐标与策略
Raises:
ElementNotFoundError: require_image=True 且图像匹配失败
by_geom 为空且无图像命中
"""
sel = self.locator.get(kind)
# 图像路径P2 启用P1 阶段 opencv=None 直接跳过)
if (
sel.by_image
and not self.locator.is_circuited(kind)
and self.locator.opencv is not None
):
shot = self._get_cached_screenshot()
if shot is None:
shot = await self.backend.screenshot()
self._set_cached_screenshot(shot)
roi = (win_geom.x, win_geom.y, win_geom.width, win_geom.height)
try:
result = await self.locator.opencv.find_template(
shot,
sel.by_image,
threshold=sel.threshold,
roi=roi,
)
except Exception as exc:
logger.warning(
"[action] opencv.find_template exception kind=%s: %s",
kind,
exc,
)
result = None
if result is not None:
# result = (x, y, w, h, confidence)(绝对坐标,含 ROI 偏移)
x, y, w, h, conf = result
self.locator.record_image_success(kind)
logger.info(
"[action] image match success kind=%s at (%d,%d) conf=%.3f",
kind, x + w // 2, y + h // 2, conf,
)
return ElementHandle(
selector=sel,
x=x + w // 2,
y=y + h // 2,
w=w,
h=h,
confidence=conf,
strategy="image",
)
# 图像未命中
self.locator.record_image_failure(kind)
logger.warning(
"[action] image match failed kind=%s, fall back to geom", kind
)
if sel.require_image:
raise ElementNotFoundError(
f"image_match_failed kind={kind}"
)
logger.debug("[action] geom fallback kind=%s", kind)
return self._geom_find(sel, win_geom)
async def click_element(
self, kind: str, win_geom: WindowGeometry
) -> ElementHandle:
"""查找并点击元素,返回 ElementHandle。
Args:
kind: 元素类别键
win_geom: 当前窗口几何
Returns:
命中的 ElementHandle
"""
elem = await self.find_element(kind, win_geom)
await self.backend.click(elem.x, elem.y)
logger.info(
"[action] click %s at (%d,%d) strategy=%s conf=%.3f",
kind,
elem.x,
elem.y,
elem.strategy,
elem.confidence,
)
return elem
async def type_into(
self,
kind: str,
text: str,
win_geom: WindowGeometry,
clear_first: bool = True,
) -> None:
"""点击元素聚焦后输入文本,可选先清空。
Args:
kind: 元素类别键通常为 input_box
text: 要输入的文本
win_geom: 当前窗口几何
clear_first: 是否先 BackSpace×30 清空旧内容
"""
t0 = time.perf_counter()
logger.info(
"[action] type_into start kind=%s text_len=%d clear_first=%s",
kind, len(text), clear_first,
)
await self.click_element(kind, win_geom)
if clear_first:
await self.backend.key_press("BackSpace", repeat=30)
await self.backend.type_text(text)
logger.info(
"[action] type_into done kind=%s text_len=%d (%.0fms)",
kind, len(text), (time.perf_counter() - t0) * 1000,
)
async def wait_for(
self,
kind: str,
win_geom: WindowGeometry,
timeout: float = 5.0,
interval: float = 0.3,
) -> Optional[ElementHandle]:
"""轮询等待元素出现,超时返回 None。
Args:
kind: 元素类别键
win_geom: 当前窗口几何
timeout: 最大等待秒数
interval: 轮询间隔秒数
Returns:
ElementHandle None超时
"""
t0 = time.perf_counter()
deadline = time.monotonic() + timeout
attempts = 0
last_err: Optional[Exception] = None
while time.monotonic() < deadline:
attempts += 1
try:
elem = await self.find_element(kind, win_geom)
if elem:
logger.info(
"[action] wait_for OK kind=%s strategy=%s attempts=%d (%.0fms)",
kind, elem.strategy, attempts,
(time.perf_counter() - t0) * 1000,
)
return elem
except ElementNotFoundError as e:
last_err = e
await asyncio.sleep(interval)
logger.warning(
"[action] wait_for TIMEOUT kind=%s attempts=%d timeout=%.1fs last_err=%s",
kind, attempts, timeout, last_err,
)
return None
async def screenshot(self) -> bytes:
"""截图代理方法,带 150ms 缓存。"""
logger.debug("[action] screenshot start")
data = self._get_cached_screenshot()
if data is None:
data = await self.backend.screenshot()
self._set_cached_screenshot(data)
logger.debug(
"[action] screenshot done bytes=%d", len(data) if data else 0
)
return data
# ------------------------------------------------------------------
# 几何兜底
# ------------------------------------------------------------------
def _geom_find(
self, selector: Selector, win_geom: WindowGeometry
) -> ElementHandle:
"""按 GeomSpec 计算绝对坐标,返回 ElementHandle。
支持两种 relative_to 语义
- window: 以窗口左上角为原点
base_x = win_geom.x + win_geom.width * g.x_ratio + g.x_offset
base_y = win_geom.y + win_geom.height * g.y_ratio + g.y_offset
- window_bottom_right: 以窗口右下角为原点offset 含正负号
base_x = win_geom.x + win_geom.width + g.x_offset
base_y = win_geom.y + win_geom.height + g.y_offset
Args:
selector: 元素选择器必须含 by_geom
win_geom: 当前窗口几何
Returns:
ElementHandlestrategy=geom, confidence=1.0
Raises:
ElementNotFoundError: selector.by_geom 为空
"""
if selector.by_geom is None:
raise ElementNotFoundError(
f"no geom spec kind={selector.kind}"
)
g = selector.by_geom
if g.relative_to == "window_bottom_right":
base_x = win_geom.x + win_geom.width + g.x_offset
base_y = win_geom.y + win_geom.height + g.y_offset
else:
base_x = win_geom.x + win_geom.width * g.x_ratio + g.x_offset
base_y = win_geom.y + win_geom.height * g.y_ratio + g.y_offset
logger.debug(
"[action] geom_find kind=%s relative_to=%s win=(%d,%d,%dx%d) "
"ratio=(%.3f,%.3f) offset=(%d,%d) → abs=(%d,%d)",
selector.kind, g.relative_to,
win_geom.x, win_geom.y, win_geom.width, win_geom.height,
g.x_ratio, g.y_ratio, g.x_offset, g.y_offset,
int(base_x), int(base_y),
)
return ElementHandle(
selector=selector,
x=int(base_x),
y=int(base_y),
w=0,
h=0,
confidence=1.0,
strategy="geom",
)