debug-control-plane 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """debug_control_plane — multi-product debug control plane.
2
+
3
+ Reusable across products: device discovery (USB/WiFi/identity) + MCP adapter
4
+ (debug HTTP protocol → MCP tool surface). Extracted from pantas_launcher R019/
5
+ R020 (S2 Python slice, R021-BF004).
6
+ """
7
+ __version__ = "0.1.0"
@@ -0,0 +1,82 @@
1
+ """device_discovery — 设备发现平面 (网络 DTO + 设备池 + 发现逻辑).
2
+
3
+ BF006 迁入 (R021):
4
+ - network 3 模块 (protocol / endpoint / device_candidates, 字节级零修改,
5
+ 自旧 network 子包)
6
+ - device_pool (认身份不认地址)
7
+ - discovery/ 5 模块 (USB / LAN / 手动 / 交叉识别)
8
+ - AD-B9: DeviceUnreachable 下沉 (直继承 Exception 脱离协议层基类反向依赖;
9
+ BF007 删旧定义 + 改正向 import)
10
+
11
+ 零业务依赖: 不 import 旧 network 子包 / host4 gmacro / launcher /
12
+ 协议层 client / mcp plane. 纯 stdlib + 同包相对 import, 可独立 pip install.
13
+ """
14
+ from .device_candidates import (
15
+ CommandRunner,
16
+ ConnectedDeviceEndpoint,
17
+ ConnectedDeviceInventory,
18
+ IosDeviceCandidate,
19
+ discover_connected_device_endpoints,
20
+ discover_connected_device_inventory,
21
+ discover_connected_devices,
22
+ discover_ios_flutter_candidates,
23
+ )
24
+ from .device_pool import DevicePool, DeviceRecord
25
+ from .discovery.cross_identify import CrossIdentify
26
+ from .discovery.lan_scan import LanCandidate, LanScan
27
+ from .discovery.manual_registry import ManualRegistry
28
+ from .discovery.usb_identity import UsbCandidate, UsbIdentity
29
+ from .discovery.vpn_immune import VpnImmune
30
+ from .endpoint import (
31
+ Endpoint,
32
+ UrlOpen,
33
+ default_urlopen,
34
+ discover_default_endpoints,
35
+ discover_targets,
36
+ local_ipv4_addresses,
37
+ probe_hello,
38
+ )
39
+ from .protocol import (
40
+ ControllerProfile,
41
+ DebugEvent,
42
+ DeviceUnreachable,
43
+ NetworkState,
44
+ NetworkTarget,
45
+ )
46
+
47
+ __all__ = [
48
+ # protocol (BF006 network DTO + AD-B9 DeviceUnreachable)
49
+ "NetworkTarget",
50
+ "NetworkState",
51
+ "DebugEvent",
52
+ "ControllerProfile",
53
+ "DeviceUnreachable",
54
+ # endpoint (BF006 network probe)
55
+ "Endpoint",
56
+ "UrlOpen",
57
+ "default_urlopen",
58
+ "probe_hello",
59
+ "discover_targets",
60
+ "discover_default_endpoints",
61
+ "local_ipv4_addresses",
62
+ # device_candidates (BF006 connected-device inventory)
63
+ "CommandRunner",
64
+ "ConnectedDeviceEndpoint",
65
+ "ConnectedDeviceInventory",
66
+ "IosDeviceCandidate",
67
+ "discover_connected_device_endpoints",
68
+ "discover_connected_device_inventory",
69
+ "discover_connected_devices",
70
+ "discover_ios_flutter_candidates",
71
+ # device_pool (BF006 认身份不认地址)
72
+ "DevicePool",
73
+ "DeviceRecord",
74
+ # discovery (BF006 USB/LAN/手动/交叉)
75
+ "LanScan",
76
+ "LanCandidate",
77
+ "UsbIdentity",
78
+ "UsbCandidate",
79
+ "ManualRegistry",
80
+ "VpnImmune",
81
+ "CrossIdentify",
82
+ ]
@@ -0,0 +1,326 @@
1
+ """Connected device endpoint discovery for Network mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from .endpoint import Endpoint
13
+
14
+ CommandRunner = Callable[[list[str], float], str]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ConnectedDeviceEndpoint:
19
+ endpoint: Endpoint
20
+ label: str
21
+ platform: str
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ConnectedDeviceInventory:
26
+ device_id: str
27
+ label: str
28
+ platform: str
29
+ state: str
30
+ endpoint: Endpoint | None = None
31
+
32
+
33
+ def discover_connected_device_endpoints(
34
+ *,
35
+ default_port: int = 18080,
36
+ run_command: CommandRunner | None = None,
37
+ ) -> list[Endpoint]:
38
+ devices = discover_connected_devices(
39
+ default_port=default_port,
40
+ run_command=run_command,
41
+ )
42
+ return [device.endpoint for device in devices]
43
+
44
+
45
+ def discover_connected_device_inventory(
46
+ *,
47
+ default_port: int = 18080,
48
+ run_command: CommandRunner | None = None,
49
+ ) -> list[ConnectedDeviceInventory]:
50
+ runner = run_command or _run_command
51
+ devices: list[ConnectedDeviceInventory] = []
52
+
53
+ for device in _android_connected_devices(runner, default_port):
54
+ devices.append(
55
+ ConnectedDeviceInventory(
56
+ device_id=f"{device.endpoint.host}:{device.endpoint.port}",
57
+ label=device.label,
58
+ platform="android",
59
+ state="connected",
60
+ endpoint=device.endpoint,
61
+ )
62
+ )
63
+
64
+ for device in _ios_inventory_devices(runner):
65
+ if not any(
66
+ existing.platform == "ios" and existing.label == device.label
67
+ for existing in devices
68
+ ):
69
+ devices.append(device)
70
+
71
+ return devices
72
+
73
+
74
+ def discover_connected_devices(
75
+ *,
76
+ default_port: int = 18080,
77
+ run_command: CommandRunner | None = None,
78
+ ) -> list[ConnectedDeviceEndpoint]:
79
+ runner = run_command or _run_command
80
+ devices: list[ConnectedDeviceEndpoint] = []
81
+ seen: set[tuple[str, int]] = set()
82
+
83
+ for device in _android_connected_devices(runner, default_port):
84
+ key = (device.endpoint.host, device.endpoint.port)
85
+ if key not in seen:
86
+ seen.add(key)
87
+ devices.append(device)
88
+
89
+ return devices
90
+
91
+
92
+ def _android_connected_devices(
93
+ run_command: CommandRunner,
94
+ default_port: int,
95
+ ) -> list[ConnectedDeviceEndpoint]:
96
+ try:
97
+ output = run_command(["adb", "devices", "-l"], 5.0)
98
+ except OSError:
99
+ return []
100
+ devices: list[ConnectedDeviceEndpoint] = []
101
+ for line in output.splitlines()[1:]:
102
+ parts = line.split()
103
+ if len(parts) < 2 or parts[1] != "device":
104
+ continue
105
+ serial = parts[0]
106
+ ip = _android_device_ip(serial, run_command)
107
+ if ip is None:
108
+ continue
109
+ model = _metadata_value(parts, "model") or serial
110
+ devices.append(
111
+ ConnectedDeviceEndpoint(
112
+ endpoint=Endpoint(ip, default_port),
113
+ label=model,
114
+ platform="android",
115
+ )
116
+ )
117
+ return devices
118
+
119
+
120
+ def _android_device_ip(serial: str, run_command: CommandRunner) -> str | None:
121
+ try:
122
+ output = run_command(["adb", "-s", serial, "shell", "ip", "route", "get", "1.1.1.1"], 5.0)
123
+ except OSError:
124
+ return None
125
+ match = re.search(r"\bsrc\s+(\d+\.\d+\.\d+\.\d+)\b", output)
126
+ return match.group(1) if match else None
127
+
128
+
129
+ def _ios_inventory_devices(run_command: CommandRunner) -> list[ConnectedDeviceInventory]:
130
+ try:
131
+ output = run_command(["xcrun", "devicectl", "list", "devices"], 8.0)
132
+ except OSError:
133
+ return []
134
+ devices: list[ConnectedDeviceInventory] = []
135
+ for line in output.splitlines():
136
+ if ".coredevice.local" not in line:
137
+ continue
138
+ parsed = _parse_ios_device_line(line)
139
+ if parsed is None:
140
+ continue
141
+ label, identifier, state = parsed
142
+ if state != "connected":
143
+ continue
144
+ devices.append(
145
+ ConnectedDeviceInventory(
146
+ device_id=identifier,
147
+ label=label,
148
+ platform="ios",
149
+ state=state,
150
+ endpoint=None,
151
+ )
152
+ )
153
+ return devices
154
+
155
+
156
+ def _parse_ios_device_line(line: str) -> tuple[str, str, str] | None:
157
+ hostname = _extract_coredevice_hostname(line)
158
+ if hostname is None:
159
+ return None
160
+ prefix, suffix = line.split(hostname, 1)
161
+ parts = suffix.split()
162
+ if len(parts) < 2:
163
+ return None
164
+ label = prefix.strip() or hostname
165
+ identifier = parts[0]
166
+ state = parts[1]
167
+ return label, identifier, state
168
+
169
+
170
+ def _is_available_ios_device(line: str) -> bool:
171
+ return " connected " in f" {line} "
172
+
173
+
174
+ def _extract_coredevice_hostname(line: str) -> str | None:
175
+ match = re.search(r"([\w.-]+\.coredevice\.local)", line)
176
+ return match.group(1) if match else None
177
+
178
+
179
+ def _metadata_value(parts: list[str], key: str) -> str | None:
180
+ prefix = f"{key}:"
181
+ for part in parts:
182
+ if part.startswith(prefix):
183
+ return part[len(prefix) :]
184
+ return None
185
+
186
+
187
+ def _run_command(command: list[str], timeout: float) -> str:
188
+ try:
189
+ result = subprocess.run(
190
+ command,
191
+ check=False,
192
+ capture_output=True,
193
+ text=True,
194
+ timeout=timeout,
195
+ )
196
+ except subprocess.TimeoutExpired as error:
197
+ output = error.stdout or ""
198
+ if isinstance(output, bytes):
199
+ return output.decode("utf-8", errors="replace")
200
+ return output
201
+ if result.returncode != 0:
202
+ raise OSError(result.stderr.strip() or f"command failed: {' '.join(command)}")
203
+ return result.stdout
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # R020-BF002: iOS device discovery via `flutter devices` (USB identity source).
208
+ #
209
+ # 新增独立函数,不改旧 `_ios_inventory_devices`(xcrun devicectl 路径,保留供 GUI
210
+ # 兼容)。原因:iPhone X 真机 iOS 16,`xcrun devicectl` 永远 unavailable
211
+ # (memory ios16-device-devicectl-pitfall),`flutter devices` 是可用路径。
212
+ #
213
+ # 设计见 .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-backend.md
214
+ # §2「iOS 发现不可用」+ analysis iOS/Android §USB 通道
215
+ # ---------------------------------------------------------------------------
216
+
217
+
218
+ # macOS 上 flutter 不在默认 PATH,显式回退路径(防止 mcp_debug_bridge 进程环境没装 fvm)
219
+ _FLUTTER_CANDIDATES: tuple[str, ...] = (
220
+ "flutter",
221
+ "/usr/local/bin/flutter",
222
+ "/opt/homebrew/bin/flutter",
223
+ "fvm",
224
+ )
225
+
226
+
227
+ @dataclass(frozen=True)
228
+ class IosDeviceCandidate:
229
+ """iOS 设备身份候选(来自 `flutter devices` 解析)。
230
+
231
+ 供 R020 mcp_debug_bridge/discovery USB 身份源消费(BF004 UsbCandidate 用此)。
232
+ `device_id` = usbmuxd id(如 3992f440...,稳定身份源);
233
+ `model` = 机型显示名(iPhone X / iPhone 14 Pro 等,弱唯一供交叉识别用)。
234
+ """
235
+
236
+ device_id: str # usbmuxd id (`flutter devices --machine` id 字段)
237
+ model: str # 机型 (`flutter devices --machine` name 字段)
238
+ platform: str = "ios"
239
+
240
+
241
+ def discover_ios_flutter_candidates(
242
+ *,
243
+ run_command: CommandRunner | None = None,
244
+ ) -> list[IosDeviceCandidate]:
245
+ """iOS 设备身份候选(经 `flutter devices`,不调 devicectl)。
246
+
247
+ R020-BF002 落地:替代 iOS 16 不可用的 `_ios_inventory_devices`(devicectl 路径)。
248
+ 解析 `flutter devices --machine` 的 JSON 输出,只取真机(emulator/simulator 排除)。
249
+
250
+ Args:
251
+ run_command: 可注入命令执行器(默认 _run_command),便于 mock 测试
252
+
253
+ Returns:
254
+ IosDeviceCandidate 列表(空列表表示无真机或 flutter 不可用)
255
+ """
256
+ runner = run_command or _run_command
257
+ output = _try_flutter_devices_machine(runner)
258
+ if output is None:
259
+ return []
260
+ return _parse_flutter_devices_machine(output)
261
+
262
+
263
+ def _try_flutter_devices_machine(run_command: CommandRunner) -> str | None:
264
+ """尝试多个 flutter 候选命令,返回首个成功的 `flutter devices --machine` 输出。
265
+
266
+ 所有候选都失败(或 _FLUTTER_CANDIDATES 为空)→ None。
267
+ """
268
+ for flutter in _FLUTTER_CANDIDATES:
269
+ try:
270
+ return run_command([flutter, "devices", "--machine"], 8.0)
271
+ except OSError:
272
+ continue
273
+ return None
274
+
275
+
276
+ def _parse_flutter_devices_machine(output: str) -> list[IosDeviceCandidate]:
277
+ """解析 `flutter devices --machine` JSON。
278
+
279
+ `flutter devices --machine` 输出 JSON 数组,元素结构(节选):
280
+ {
281
+ "id": "<usbmuxd id 或 emulator-id>",
282
+ "name": "iPhone X",
283
+ "targetPlatform": "ios",
284
+ "emulator": false,
285
+ "category": "mobile",
286
+ "platform": "ios" / "android-ios",
287
+ ...
288
+ }
289
+
290
+ 真机筛选:`emulator == false` 且 `targetPlatform`/`platform` 含 "ios"。
291
+ """
292
+ try:
293
+ data = json.loads(output)
294
+ except (json.JSONDecodeError, ValueError):
295
+ return []
296
+ if not isinstance(data, list):
297
+ return []
298
+ candidates: list[IosDeviceCandidate] = []
299
+ for entry in data:
300
+ if not isinstance(entry, dict):
301
+ continue
302
+ if not _is_ios_physical_device(entry):
303
+ continue
304
+ device_id = entry.get("id")
305
+ model = entry.get("name") or entry.get("model")
306
+ if not isinstance(device_id, str) or not device_id:
307
+ continue
308
+ if not isinstance(model, str):
309
+ model = device_id
310
+ candidates.append(IosDeviceCandidate(device_id=device_id, model=model))
311
+ return candidates
312
+
313
+
314
+ def _is_ios_physical_device(entry: dict[str, Any]) -> bool:
315
+ """判断 flutter devices 条目是否 iOS 物理真机(排除 simulator/emulator)。"""
316
+ is_emulator = entry.get("emulator")
317
+ if is_emulator is True:
318
+ return False
319
+ platform = str(entry.get("targetPlatform") or entry.get("platform") or "")
320
+ if "ios" not in platform:
321
+ return False
322
+ # simulator 设备类别/id 形如 "iOS Simulator" / "ios-simulator"
323
+ category = str(entry.get("category") or "")
324
+ if "simulator" in category.lower():
325
+ return False
326
+ return True