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.
- debug_control_plane/__init__.py +7 -0
- debug_control_plane/device_discovery/__init__.py +82 -0
- debug_control_plane/device_discovery/device_candidates.py +326 -0
- debug_control_plane/device_discovery/device_pool.py +369 -0
- debug_control_plane/device_discovery/discovery/__init__.py +14 -0
- debug_control_plane/device_discovery/discovery/cross_identify.py +396 -0
- debug_control_plane/device_discovery/discovery/lan_scan.py +183 -0
- debug_control_plane/device_discovery/discovery/manual_registry.py +245 -0
- debug_control_plane/device_discovery/discovery/usb_identity.py +228 -0
- debug_control_plane/device_discovery/discovery/vpn_immune.py +103 -0
- debug_control_plane/device_discovery/endpoint.py +317 -0
- debug_control_plane/device_discovery/protocol.py +230 -0
- debug_control_plane/mcp_plane/__init__.py +47 -0
- debug_control_plane/mcp_plane/bridge_client.py +525 -0
- debug_control_plane/mcp_plane/capability_mirror.py +642 -0
- debug_control_plane/mcp_plane/semantic_provider.py +57 -0
- debug_control_plane/mcp_plane/server.py +874 -0
- debug_control_plane-0.1.0.dist-info/METADATA +98 -0
- debug_control_plane-0.1.0.dist-info/RECORD +23 -0
- debug_control_plane-0.1.0.dist-info/WHEEL +5 -0
- debug_control_plane-0.1.0.dist-info/entry_points.txt +2 -0
- debug_control_plane-0.1.0.dist-info/licenses/LICENSE +21 -0
- debug_control_plane-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|