|
| 1 | +""" |
| 2 | +设备池管理器(v9.0 Phase3) |
| 3 | +
|
| 4 | +管理可用测试设备/浏览器实例,支持:设备注册/注销、自动分配、健康检查、并发控制。 |
| 5 | +""" |
| 6 | + |
| 7 | +import time |
| 8 | +from dataclasses import dataclass, field |
| 9 | +from enum import Enum |
| 10 | +from typing import Any, Optional |
| 11 | + |
| 12 | +from loguru import logger |
| 13 | + |
| 14 | + |
| 15 | +class DeviceType(str, Enum): |
| 16 | + """设备类型。""" |
| 17 | + BROWSER = "browser" |
| 18 | + ANDROID = "android" |
| 19 | + IOS = "ios" |
| 20 | + DESKTOP = "desktop" |
| 21 | + MINIPROGRAM = "miniprogram" |
| 22 | + |
| 23 | + |
| 24 | +class DeviceState(str, Enum): |
| 25 | + """设备状态。""" |
| 26 | + AVAILABLE = "available" |
| 27 | + IN_USE = "in_use" |
| 28 | + OFFLINE = "offline" |
| 29 | + MAINTENANCE = "maintenance" |
| 30 | + |
| 31 | + |
| 32 | +@dataclass |
| 33 | +class DeviceInfo: |
| 34 | + """设备信息。""" |
| 35 | + device_id: str |
| 36 | + device_type: DeviceType |
| 37 | + name: str = "" |
| 38 | + state: DeviceState = DeviceState.AVAILABLE |
| 39 | + capabilities: dict = field(default_factory=dict) |
| 40 | + assigned_to: str = "" # 分配给哪个玩家 |
| 41 | + last_heartbeat: float = 0.0 |
| 42 | + registered_at: float = 0.0 |
| 43 | + tags: list[str] = field(default_factory=list) |
| 44 | + |
| 45 | + |
| 46 | +class DevicePoolManager: |
| 47 | + """设备池管理器。""" |
| 48 | + |
| 49 | + def __init__(self, max_devices: int = 20) -> None: |
| 50 | + self.max_devices = max_devices |
| 51 | + self._devices: dict[str, DeviceInfo] = {} |
| 52 | + |
| 53 | + @property |
| 54 | + def device_count(self) -> int: |
| 55 | + return len(self._devices) |
| 56 | + |
| 57 | + @property |
| 58 | + def available_count(self) -> int: |
| 59 | + return sum(1 for d in self._devices.values() if d.state == DeviceState.AVAILABLE) |
| 60 | + |
| 61 | + def register(self, device_id: str, device_type: DeviceType, |
| 62 | + name: str = "", capabilities: dict = None, tags: list[str] = None) -> DeviceInfo: |
| 63 | + """注册设备到池中。""" |
| 64 | + if len(self._devices) >= self.max_devices: |
| 65 | + raise ValueError(f"设备池已满 ({self.max_devices})") |
| 66 | + if device_id in self._devices: |
| 67 | + raise ValueError(f"设备 {device_id} 已存在") |
| 68 | + |
| 69 | + device = DeviceInfo( |
| 70 | + device_id=device_id, |
| 71 | + device_type=device_type, |
| 72 | + name=name or device_id, |
| 73 | + capabilities=capabilities or {}, |
| 74 | + tags=tags or [], |
| 75 | + registered_at=time.time(), |
| 76 | + last_heartbeat=time.time(), |
| 77 | + ) |
| 78 | + self._devices[device_id] = device |
| 79 | + logger.info("设备注册 | {} | 类型: {} | 池中: {}", device_id, device_type.value, len(self._devices)) |
| 80 | + return device |
| 81 | + |
| 82 | + def unregister(self, device_id: str) -> None: |
| 83 | + """从池中注销设备。""" |
| 84 | + if device_id not in self._devices: |
| 85 | + raise KeyError(f"设备 {device_id} 不存在") |
| 86 | + del self._devices[device_id] |
| 87 | + logger.info("设备注销 | {} | 剩余: {}", device_id, len(self._devices)) |
| 88 | + |
| 89 | + def get(self, device_id: str) -> Optional[DeviceInfo]: |
| 90 | + return self._devices.get(device_id) |
| 91 | + |
| 92 | + def acquire(self, player_id: str, device_type: DeviceType = None, |
| 93 | + tags: list[str] = None) -> Optional[DeviceInfo]: |
| 94 | + """为玩家分配一个可用设备。 |
| 95 | +
|
| 96 | + Args: |
| 97 | + player_id: 请求设备的玩家 |
| 98 | + device_type: 需要的设备类型(None=任意) |
| 99 | + tags: 需要匹配的标签(全部匹配) |
| 100 | + """ |
| 101 | + for device in self._devices.values(): |
| 102 | + if device.state != DeviceState.AVAILABLE: |
| 103 | + continue |
| 104 | + if device_type and device.device_type != device_type: |
| 105 | + continue |
| 106 | + if tags and not all(t in device.tags for t in tags): |
| 107 | + continue |
| 108 | + |
| 109 | + device.state = DeviceState.IN_USE |
| 110 | + device.assigned_to = player_id |
| 111 | + logger.info("设备分配 | {} -> {} | 类型: {}", device.device_id, player_id, device.device_type.value) |
| 112 | + return device |
| 113 | + |
| 114 | + logger.warning("无可用设备 | 玩家: {} | 类型: {} | 标签: {}", player_id, device_type, tags) |
| 115 | + return None |
| 116 | + |
| 117 | + def release(self, device_id: str) -> None: |
| 118 | + """释放设备,使其可重新分配。""" |
| 119 | + device = self._devices.get(device_id) |
| 120 | + if not device: |
| 121 | + raise KeyError(f"设备 {device_id} 不存在") |
| 122 | + device.state = DeviceState.AVAILABLE |
| 123 | + device.assigned_to = "" |
| 124 | + logger.info("设备释放 | {}", device_id) |
| 125 | + |
| 126 | + def heartbeat(self, device_id: str) -> None: |
| 127 | + """更新设备心跳。""" |
| 128 | + device = self._devices.get(device_id) |
| 129 | + if device: |
| 130 | + device.last_heartbeat = time.time() |
| 131 | + |
| 132 | + def set_state(self, device_id: str, state: DeviceState) -> None: |
| 133 | + """设置设备状态。""" |
| 134 | + device = self._devices.get(device_id) |
| 135 | + if not device: |
| 136 | + raise KeyError(f"设备 {device_id} 不存在") |
| 137 | + device.state = state |
| 138 | + |
| 139 | + def check_health(self, timeout_seconds: float = 60) -> list[str]: |
| 140 | + """检查设备健康:超过 timeout 无心跳的标记为 offline。返回离线设备ID列表。""" |
| 141 | + now = time.time() |
| 142 | + offline = [] |
| 143 | + for device in self._devices.values(): |
| 144 | + if device.state == DeviceState.OFFLINE: |
| 145 | + continue |
| 146 | + if now - device.last_heartbeat > timeout_seconds: |
| 147 | + device.state = DeviceState.OFFLINE |
| 148 | + offline.append(device.device_id) |
| 149 | + logger.warning("设备离线 | {} | 最后心跳: {:.0f}s前", device.device_id, now - device.last_heartbeat) |
| 150 | + return offline |
| 151 | + |
| 152 | + def list_devices(self, device_type: DeviceType = None, |
| 153 | + state: DeviceState = None) -> list[DeviceInfo]: |
| 154 | + """列出设备(可按类型/状态过滤)。""" |
| 155 | + devices = list(self._devices.values()) |
| 156 | + if device_type: |
| 157 | + devices = [d for d in devices if d.device_type == device_type] |
| 158 | + if state: |
| 159 | + devices = [d for d in devices if d.state == state] |
| 160 | + return devices |
| 161 | + |
| 162 | + def auto_assign(self, player_configs: list[dict]) -> dict[str, Optional[str]]: |
| 163 | + """批量自动分配设备。 |
| 164 | +
|
| 165 | + Args: |
| 166 | + player_configs: [{"player_id": "p1", "device_type": "browser", "tags": [...]}] |
| 167 | + Returns: |
| 168 | + {player_id: device_id or None} |
| 169 | + """ |
| 170 | + result = {} |
| 171 | + for cfg in player_configs: |
| 172 | + pid = cfg.get("player_id", "") |
| 173 | + dtype = DeviceType(cfg["device_type"]) if "device_type" in cfg else None |
| 174 | + tags = cfg.get("tags", []) |
| 175 | + device = self.acquire(pid, dtype, tags) |
| 176 | + result[pid] = device.device_id if device else None |
| 177 | + return result |
| 178 | + |
| 179 | + def release_all(self) -> int: |
| 180 | + """释放所有在用设备。""" |
| 181 | + count = 0 |
| 182 | + for device in self._devices.values(): |
| 183 | + if device.state == DeviceState.IN_USE: |
| 184 | + device.state = DeviceState.AVAILABLE |
| 185 | + device.assigned_to = "" |
| 186 | + count += 1 |
| 187 | + return count |
| 188 | + |
| 189 | + def clear(self) -> None: |
| 190 | + """清空设备池。""" |
| 191 | + self._devices.clear() |
| 192 | + |
| 193 | + def get_summary(self) -> dict: |
| 194 | + """获取设备池摘要。""" |
| 195 | + by_type = {} |
| 196 | + by_state = {} |
| 197 | + for d in self._devices.values(): |
| 198 | + by_type[d.device_type.value] = by_type.get(d.device_type.value, 0) + 1 |
| 199 | + by_state[d.state.value] = by_state.get(d.state.value, 0) + 1 |
| 200 | + return { |
| 201 | + "total": len(self._devices), |
| 202 | + "max": self.max_devices, |
| 203 | + "available": self.available_count, |
| 204 | + "by_type": by_type, |
| 205 | + "by_state": by_state, |
| 206 | + "devices": [ |
| 207 | + { |
| 208 | + "id": d.device_id, |
| 209 | + "type": d.device_type.value, |
| 210 | + "name": d.name, |
| 211 | + "state": d.state.value, |
| 212 | + "assigned_to": d.assigned_to, |
| 213 | + } |
| 214 | + for d in self._devices.values() |
| 215 | + ], |
| 216 | + } |
0 commit comments