debug-control-plane 0.1.0__tar.gz

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.
Files changed (41) hide show
  1. debug_control_plane-0.1.0/LICENSE +21 -0
  2. debug_control_plane-0.1.0/PKG-INFO +98 -0
  3. debug_control_plane-0.1.0/README.md +81 -0
  4. debug_control_plane-0.1.0/debug_control_plane/__init__.py +7 -0
  5. debug_control_plane-0.1.0/debug_control_plane/device_discovery/__init__.py +82 -0
  6. debug_control_plane-0.1.0/debug_control_plane/device_discovery/device_candidates.py +326 -0
  7. debug_control_plane-0.1.0/debug_control_plane/device_discovery/device_pool.py +369 -0
  8. debug_control_plane-0.1.0/debug_control_plane/device_discovery/discovery/__init__.py +14 -0
  9. debug_control_plane-0.1.0/debug_control_plane/device_discovery/discovery/cross_identify.py +396 -0
  10. debug_control_plane-0.1.0/debug_control_plane/device_discovery/discovery/lan_scan.py +183 -0
  11. debug_control_plane-0.1.0/debug_control_plane/device_discovery/discovery/manual_registry.py +245 -0
  12. debug_control_plane-0.1.0/debug_control_plane/device_discovery/discovery/usb_identity.py +228 -0
  13. debug_control_plane-0.1.0/debug_control_plane/device_discovery/discovery/vpn_immune.py +103 -0
  14. debug_control_plane-0.1.0/debug_control_plane/device_discovery/endpoint.py +317 -0
  15. debug_control_plane-0.1.0/debug_control_plane/device_discovery/protocol.py +230 -0
  16. debug_control_plane-0.1.0/debug_control_plane/mcp_plane/__init__.py +47 -0
  17. debug_control_plane-0.1.0/debug_control_plane/mcp_plane/bridge_client.py +525 -0
  18. debug_control_plane-0.1.0/debug_control_plane/mcp_plane/capability_mirror.py +642 -0
  19. debug_control_plane-0.1.0/debug_control_plane/mcp_plane/semantic_provider.py +57 -0
  20. debug_control_plane-0.1.0/debug_control_plane/mcp_plane/server.py +874 -0
  21. debug_control_plane-0.1.0/debug_control_plane.egg-info/PKG-INFO +98 -0
  22. debug_control_plane-0.1.0/debug_control_plane.egg-info/SOURCES.txt +39 -0
  23. debug_control_plane-0.1.0/debug_control_plane.egg-info/dependency_links.txt +1 -0
  24. debug_control_plane-0.1.0/debug_control_plane.egg-info/entry_points.txt +2 -0
  25. debug_control_plane-0.1.0/debug_control_plane.egg-info/requires.txt +7 -0
  26. debug_control_plane-0.1.0/debug_control_plane.egg-info/top_level.txt +1 -0
  27. debug_control_plane-0.1.0/pyproject.toml +43 -0
  28. debug_control_plane-0.1.0/setup.cfg +4 -0
  29. debug_control_plane-0.1.0/tests/test_bridge_client.py +811 -0
  30. debug_control_plane-0.1.0/tests/test_capability_mirror.py +641 -0
  31. debug_control_plane-0.1.0/tests/test_device_candidates.py +304 -0
  32. debug_control_plane-0.1.0/tests/test_device_pool.py +389 -0
  33. debug_control_plane-0.1.0/tests/test_discovery_cross_identify.py +576 -0
  34. debug_control_plane-0.1.0/tests/test_discovery_lan_scan.py +527 -0
  35. debug_control_plane-0.1.0/tests/test_discovery_manual_registry.py +513 -0
  36. debug_control_plane-0.1.0/tests/test_discovery_usb_identity.py +510 -0
  37. debug_control_plane-0.1.0/tests/test_discovery_vpn_immune.py +330 -0
  38. debug_control_plane-0.1.0/tests/test_e2e_mock.py +1118 -0
  39. debug_control_plane-0.1.0/tests/test_endpoint.py +209 -0
  40. debug_control_plane-0.1.0/tests/test_import_sanity.py +100 -0
  41. debug_control_plane-0.1.0/tests/test_server.py +1134 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present tangxiaolu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: debug-control-plane
3
+ Version: 0.1.0
4
+ Summary: Multi-product reusable debug control plane (device discovery + MCP adapter).
5
+ Author: tangxiaolu
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: mcp<2,>=1.28
11
+ Requires-Dist: httpx>=0.28
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest>=7; extra == "test"
14
+ Requires-Dist: pytest-cov>=4; extra == "test"
15
+ Requires-Dist: ruff>=0.4; extra == "test"
16
+ Dynamic: license-file
17
+
18
+ # debug-control-plane (Python)
19
+
20
+ Multi-product reusable debug control plane — Python slice.
21
+ 多产品复用的调试控制平面——Python 切片。
22
+
23
+ Provides / 提供:
24
+ - **device_discovery**: USB/WiFi device discovery + cross-identity pairing (USB identity source / LAN scan / VPN-immune subnet / device pool with TTL).
25
+ USB/WiFi 设备发现 + 跨身份配对(USB 身份源 / LAN 扫描 / VPN 免疫网段 / 带 TTL 的设备池)。
26
+ - **mcp_plane**: MCP adapter over the R019 8-endpoint debug HTTP protocol — capability mirror (dynamic tools), bridge client, bare server, and a `SemanticProvider` protocol hook for product semantic sugar.
27
+ 建立在 R019 8 端点调试 HTTP 协议之上的 MCP 适配器——能力镜像(动态 tools)、桥客户端、裸服务端,以及供产品语义糖接入的 `SemanticProvider` 协议钩子。
28
+
29
+ ## Dependency direction / 依赖方向
30
+
31
+ ```
32
+ business app (pantas_launcher / future products) 业务应用(pantas_launcher / 未来产品)
33
+ │ depends on (downward) 依赖(向下)
34
+
35
+ debug_control_plane (this package) 本包
36
+ │ depends on (downward) 依赖(向下)
37
+
38
+ mcp (SDK) · httpx (HTTP client)
39
+ ```
40
+
41
+ This package is **pure infrastructure**: it MUST NOT import any business package (`pantas_launcher`, `host4_flutter_gmacro`, gamepad/feature code). Business apps depend on this package; never the reverse.
42
+ 本包是**纯基础设施**:绝不 import 任何业务包(`pantas_launcher`、`host4_flutter_gmacro`、手柄/特性代码)。业务应用依赖本包,反之禁止。
43
+
44
+ ## Install / 安装
45
+
46
+ ```bash
47
+ # editable install — picks up source changes without reinstall
48
+ # 可编辑安装——源码改动即时生效,无需重装
49
+ python -m pip install -e .
50
+
51
+ # import sanity check / 导入自检
52
+ python -c "import debug_control_plane; print(debug_control_plane.__version__)"
53
+ ```
54
+
55
+ ## Layout / 结构
56
+
57
+ ```
58
+ python/
59
+ ├── pyproject.toml # single-package, two sub-packages / 单包双子包
60
+ ├── README.md # this file
61
+ ├── LICENSE # MIT
62
+ └── debug_control_plane/
63
+ ├── __init__.py # __version__ = "0.1.0"
64
+ ├── device_discovery/ # USB/LAN device discovery + device pool
65
+ │ ├── device_candidates.py
66
+ │ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
67
+ │ ├── endpoint.py
68
+ │ ├── protocol.py
69
+ │ └── discovery/
70
+ │ ├── usb_identity.py # USB identity source (adb / flutter devices) / USB 身份源
71
+ │ ├── lan_scan.py # LAN /hello probe / LAN /hello 探测
72
+ │ ├── vpn_immune.py # VPN-immune subnet calc / VPN 免疫网段计算
73
+ │ ├── cross_identify.py # USB ⊕ LAN cross pairing / USB ⊕ LAN 交叉配对
74
+ │ └── manual_registry.py # manually-told devices / 人工告知设备
75
+ └── mcp_plane/ # MCP adapter
76
+ ├── capability_mirror.py # dynamic tools from /hello / 由 /hello 动态镜像 tools
77
+ ├── bridge_client.py # HTTP bridge client / HTTP 桥客户端
78
+ ├── server.py # bare stdio MCP server / 裸 stdio MCP 服务端
79
+ └── semantic_provider.py # SemanticProvider Protocol (product hook) / 语义糖协议钩子
80
+ ```
81
+
82
+ ## Console script / 控制台脚本
83
+
84
+ ```
85
+ debug-control-plane-server
86
+ ```
87
+
88
+ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no business capability assembly) for debugging / plane self-testing.
89
+ 指向 `debug_control_plane.mcp_plane.server:main`——裸服务端(无业务能力装配),用于调试 / 平面自测。
90
+
91
+ ## Version / 版本
92
+
93
+ `0.1.0` — initial release, API unstable. 首发,API 不稳定。
94
+
95
+ ## License / 许可证
96
+
97
+ Licensed under the MIT License — see [LICENSE](LICENSE).
98
+ 采用 MIT 许可证授权——见 [LICENSE](LICENSE)。
@@ -0,0 +1,81 @@
1
+ # debug-control-plane (Python)
2
+
3
+ Multi-product reusable debug control plane — Python slice.
4
+ 多产品复用的调试控制平面——Python 切片。
5
+
6
+ Provides / 提供:
7
+ - **device_discovery**: USB/WiFi device discovery + cross-identity pairing (USB identity source / LAN scan / VPN-immune subnet / device pool with TTL).
8
+ USB/WiFi 设备发现 + 跨身份配对(USB 身份源 / LAN 扫描 / VPN 免疫网段 / 带 TTL 的设备池)。
9
+ - **mcp_plane**: MCP adapter over the R019 8-endpoint debug HTTP protocol — capability mirror (dynamic tools), bridge client, bare server, and a `SemanticProvider` protocol hook for product semantic sugar.
10
+ 建立在 R019 8 端点调试 HTTP 协议之上的 MCP 适配器——能力镜像(动态 tools)、桥客户端、裸服务端,以及供产品语义糖接入的 `SemanticProvider` 协议钩子。
11
+
12
+ ## Dependency direction / 依赖方向
13
+
14
+ ```
15
+ business app (pantas_launcher / future products) 业务应用(pantas_launcher / 未来产品)
16
+ │ depends on (downward) 依赖(向下)
17
+
18
+ debug_control_plane (this package) 本包
19
+ │ depends on (downward) 依赖(向下)
20
+
21
+ mcp (SDK) · httpx (HTTP client)
22
+ ```
23
+
24
+ This package is **pure infrastructure**: it MUST NOT import any business package (`pantas_launcher`, `host4_flutter_gmacro`, gamepad/feature code). Business apps depend on this package; never the reverse.
25
+ 本包是**纯基础设施**:绝不 import 任何业务包(`pantas_launcher`、`host4_flutter_gmacro`、手柄/特性代码)。业务应用依赖本包,反之禁止。
26
+
27
+ ## Install / 安装
28
+
29
+ ```bash
30
+ # editable install — picks up source changes without reinstall
31
+ # 可编辑安装——源码改动即时生效,无需重装
32
+ python -m pip install -e .
33
+
34
+ # import sanity check / 导入自检
35
+ python -c "import debug_control_plane; print(debug_control_plane.__version__)"
36
+ ```
37
+
38
+ ## Layout / 结构
39
+
40
+ ```
41
+ python/
42
+ ├── pyproject.toml # single-package, two sub-packages / 单包双子包
43
+ ├── README.md # this file
44
+ ├── LICENSE # MIT
45
+ └── debug_control_plane/
46
+ ├── __init__.py # __version__ = "0.1.0"
47
+ ├── device_discovery/ # USB/LAN device discovery + device pool
48
+ │ ├── device_candidates.py
49
+ │ ├── device_pool.py # identity-keyed pool, TTL expiry / 身份键池,TTL 过期
50
+ │ ├── endpoint.py
51
+ │ ├── protocol.py
52
+ │ └── discovery/
53
+ │ ├── usb_identity.py # USB identity source (adb / flutter devices) / USB 身份源
54
+ │ ├── lan_scan.py # LAN /hello probe / LAN /hello 探测
55
+ │ ├── vpn_immune.py # VPN-immune subnet calc / VPN 免疫网段计算
56
+ │ ├── cross_identify.py # USB ⊕ LAN cross pairing / USB ⊕ LAN 交叉配对
57
+ │ └── manual_registry.py # manually-told devices / 人工告知设备
58
+ └── mcp_plane/ # MCP adapter
59
+ ├── capability_mirror.py # dynamic tools from /hello / 由 /hello 动态镜像 tools
60
+ ├── bridge_client.py # HTTP bridge client / HTTP 桥客户端
61
+ ├── server.py # bare stdio MCP server / 裸 stdio MCP 服务端
62
+ └── semantic_provider.py # SemanticProvider Protocol (product hook) / 语义糖协议钩子
63
+ ```
64
+
65
+ ## Console script / 控制台脚本
66
+
67
+ ```
68
+ debug-control-plane-server
69
+ ```
70
+
71
+ Points at `debug_control_plane.mcp_plane.server:main` — a bare server (no business capability assembly) for debugging / plane self-testing.
72
+ 指向 `debug_control_plane.mcp_plane.server:main`——裸服务端(无业务能力装配),用于调试 / 平面自测。
73
+
74
+ ## Version / 版本
75
+
76
+ `0.1.0` — initial release, API unstable. 首发,API 不稳定。
77
+
78
+ ## License / 许可证
79
+
80
+ Licensed under the MIT License — see [LICENSE](LICENSE).
81
+ 采用 MIT 许可证授权——见 [LICENSE](LICENSE)。
@@ -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