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,317 @@
|
|
|
1
|
+
"""Endpoint parsing and /hello probing for network targets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import socket
|
|
8
|
+
import subprocess
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from collections.abc import Callable, Iterable
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from ipaddress import IPv4Address, IPv4Network
|
|
15
|
+
|
|
16
|
+
from .protocol import NetworkTarget
|
|
17
|
+
|
|
18
|
+
UrlOpen = Callable[[urllib.request.Request, float], object]
|
|
19
|
+
LocalIpv4Provider = Callable[[], list[str]]
|
|
20
|
+
CommandRunner = Callable[[list[str], float], str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Endpoint:
|
|
25
|
+
host: str
|
|
26
|
+
port: int = 18080
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def base_url(self) -> str:
|
|
30
|
+
return f"http://{self.host}:{self.port}"
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def parse(cls, value: str, default_port: int = 18080) -> Endpoint:
|
|
34
|
+
raw = value.strip()
|
|
35
|
+
if raw.startswith("http://"):
|
|
36
|
+
raw = raw[len("http://") :]
|
|
37
|
+
if raw.startswith("https://"):
|
|
38
|
+
raw = raw[len("https://") :]
|
|
39
|
+
raw = raw.split("/", 1)[0]
|
|
40
|
+
if not raw:
|
|
41
|
+
raise ValueError("endpoint is empty")
|
|
42
|
+
if ":" in raw:
|
|
43
|
+
host, port_text = raw.rsplit(":", 1)
|
|
44
|
+
if not host:
|
|
45
|
+
raise ValueError("endpoint host is empty")
|
|
46
|
+
return cls(host=host, port=int(port_text))
|
|
47
|
+
return cls(host=raw, port=default_port)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def default_urlopen(request: urllib.request.Request, timeout: float) -> object:
|
|
51
|
+
return urllib.request.urlopen(request, timeout=timeout)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def probe_hello(
|
|
55
|
+
endpoint: Endpoint,
|
|
56
|
+
*,
|
|
57
|
+
timeout: float = 1.0,
|
|
58
|
+
urlopen: UrlOpen = default_urlopen,
|
|
59
|
+
) -> NetworkTarget | None:
|
|
60
|
+
request = urllib.request.Request(
|
|
61
|
+
f"{endpoint.base_url}/hello",
|
|
62
|
+
method="GET",
|
|
63
|
+
headers={"Accept": "application/json"},
|
|
64
|
+
)
|
|
65
|
+
try:
|
|
66
|
+
with urlopen(request, timeout) as response:
|
|
67
|
+
body = response.read().decode("utf-8")
|
|
68
|
+
except (OSError, urllib.error.URLError, TimeoutError, ValueError):
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
data = json.loads(body)
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
return None
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
return None
|
|
76
|
+
return NetworkTarget.from_hello(data, host=endpoint.host, port=endpoint.port)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def local_ipv4_addresses() -> list[str]:
|
|
80
|
+
return sorted(_default_route_ipv4_addresses())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def discover_default_endpoints(
|
|
84
|
+
*,
|
|
85
|
+
local_ipv4_addresses: LocalIpv4Provider = local_ipv4_addresses,
|
|
86
|
+
default_port: int = 18080,
|
|
87
|
+
) -> list[Endpoint]:
|
|
88
|
+
endpoints: list[Endpoint] = []
|
|
89
|
+
seen: set[str] = set()
|
|
90
|
+
for address in local_ipv4_addresses():
|
|
91
|
+
if not _is_lan_ipv4(address):
|
|
92
|
+
continue
|
|
93
|
+
network = IPv4Network(f"{address}/24", strict=False)
|
|
94
|
+
for host in network.hosts():
|
|
95
|
+
host_text = str(host)
|
|
96
|
+
if host_text == address or host_text in seen:
|
|
97
|
+
continue
|
|
98
|
+
seen.add(host_text)
|
|
99
|
+
endpoints.append(Endpoint(host_text, default_port))
|
|
100
|
+
return endpoints
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def discover_targets(
|
|
104
|
+
endpoints: Iterable[Endpoint],
|
|
105
|
+
*,
|
|
106
|
+
timeout: float = 1.0,
|
|
107
|
+
urlopen: UrlOpen = default_urlopen,
|
|
108
|
+
max_workers: int = 64,
|
|
109
|
+
) -> list[NetworkTarget]:
|
|
110
|
+
endpoint_list = list(endpoints)
|
|
111
|
+
if not endpoint_list:
|
|
112
|
+
return []
|
|
113
|
+
if len(endpoint_list) == 1:
|
|
114
|
+
target = probe_hello(endpoint_list[0], timeout=timeout, urlopen=urlopen)
|
|
115
|
+
return [target] if target is not None else []
|
|
116
|
+
|
|
117
|
+
targets_by_endpoint: dict[Endpoint, NetworkTarget] = {}
|
|
118
|
+
with ThreadPoolExecutor(
|
|
119
|
+
max_workers=min(max_workers, len(endpoint_list))
|
|
120
|
+
) as executor:
|
|
121
|
+
futures = {
|
|
122
|
+
executor.submit(
|
|
123
|
+
probe_hello,
|
|
124
|
+
endpoint,
|
|
125
|
+
timeout=timeout,
|
|
126
|
+
urlopen=urlopen,
|
|
127
|
+
): endpoint
|
|
128
|
+
for endpoint in endpoint_list
|
|
129
|
+
}
|
|
130
|
+
for future in as_completed(futures):
|
|
131
|
+
endpoint = futures[future]
|
|
132
|
+
try:
|
|
133
|
+
target = future.result()
|
|
134
|
+
except Exception:
|
|
135
|
+
target = None
|
|
136
|
+
if target is not None:
|
|
137
|
+
targets_by_endpoint[endpoint] = target
|
|
138
|
+
targets: list[NetworkTarget] = []
|
|
139
|
+
for endpoint in endpoint_list:
|
|
140
|
+
target = targets_by_endpoint.get(endpoint)
|
|
141
|
+
if target is not None:
|
|
142
|
+
targets.append(target)
|
|
143
|
+
return targets
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _is_lan_ipv4(address: str) -> bool:
|
|
147
|
+
try:
|
|
148
|
+
ip = IPv4Address(address)
|
|
149
|
+
except ValueError:
|
|
150
|
+
return False
|
|
151
|
+
return ip.is_private and not ip.is_loopback and not ip.is_link_local
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _default_route_ipv4_addresses() -> set[str]:
|
|
155
|
+
addresses: set[str] = set()
|
|
156
|
+
for probe_host in ("1.1.1.1", "8.8.8.8"):
|
|
157
|
+
try:
|
|
158
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
159
|
+
sock.connect((probe_host, 80))
|
|
160
|
+
address = sock.getsockname()[0]
|
|
161
|
+
except OSError:
|
|
162
|
+
continue
|
|
163
|
+
if _is_lan_ipv4(address):
|
|
164
|
+
addresses.add(address)
|
|
165
|
+
return addresses
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# R020-BF002: VPN-immune LAN CIDR discovery (route-table based).
|
|
170
|
+
#
|
|
171
|
+
# 新增独立函数,不改任何旧签名(旧 _default_route_ipv4_addresses/
|
|
172
|
+
# local_ipv4_addresses/discover_default_endpoints 保持原状供 GUI 复用)。
|
|
173
|
+
# 供 R020 mcp_debug_bridge/discovery 经 sys.path 复用。
|
|
174
|
+
# 设计见 .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-backend.md §5.4
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _run_command_default(command: list[str], timeout: float) -> str:
|
|
179
|
+
"""Run a system command and return stdout (raises OSError on failure)."""
|
|
180
|
+
try:
|
|
181
|
+
result = subprocess.run(
|
|
182
|
+
command,
|
|
183
|
+
check=False,
|
|
184
|
+
capture_output=True,
|
|
185
|
+
text=True,
|
|
186
|
+
timeout=timeout,
|
|
187
|
+
)
|
|
188
|
+
except subprocess.TimeoutExpired as error:
|
|
189
|
+
output = error.stdout or ""
|
|
190
|
+
if isinstance(output, bytes):
|
|
191
|
+
return output.decode("utf-8", errors="replace")
|
|
192
|
+
return output
|
|
193
|
+
if result.returncode != 0:
|
|
194
|
+
raise OSError(result.stderr.strip() or f"command failed: {' '.join(command)}")
|
|
195
|
+
return result.stdout
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _default_route_interface(
|
|
199
|
+
run_command: CommandRunner = _run_command_default,
|
|
200
|
+
) -> str | None:
|
|
201
|
+
"""`route -n get default` → interface name (e.g. en0).
|
|
202
|
+
|
|
203
|
+
macOS-only; returns None if route command unavailable or no default route.
|
|
204
|
+
网段来源改读路由表(替代旧 socket 出口法),避开全局 VPN TUN 污染:
|
|
205
|
+
TUN 接口(utun1024 等)绑定具体 198.18.x.x IP,默认出口接口仍是物理 en0。
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
output = run_command(["route", "-n", "get", "default"], 3.0)
|
|
209
|
+
except OSError:
|
|
210
|
+
return None
|
|
211
|
+
match = re.search(r"^\s*interface:\s*(\S+)\s*$", output, re.MULTILINE)
|
|
212
|
+
return match.group(1) if match else None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _interface_ipv4(
|
|
216
|
+
interface: str,
|
|
217
|
+
run_command: CommandRunner = _run_command_default,
|
|
218
|
+
) -> str | None:
|
|
219
|
+
"""`ipconfig getifaddr <interface>` → IPv4 address."""
|
|
220
|
+
try:
|
|
221
|
+
output = run_command(["ipconfig", "getifaddr", interface], 3.0)
|
|
222
|
+
except OSError:
|
|
223
|
+
return None
|
|
224
|
+
address = output.strip()
|
|
225
|
+
return address if _is_lan_ipv4(address) else None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def vpn_immune_lan_cidrs(
|
|
229
|
+
*,
|
|
230
|
+
run_command: CommandRunner = _run_command_default,
|
|
231
|
+
cidr_prefix_len: int = 24,
|
|
232
|
+
) -> list[str]:
|
|
233
|
+
"""VPN-immune LAN CIDRs via route table (R020-BF002).
|
|
234
|
+
|
|
235
|
+
流程:
|
|
236
|
+
1. `route -n get default` → 默认出口接口(物理 en0,非 utun)
|
|
237
|
+
2. `ipconfig getifaddr <if>` → 接口 IPv4(真实 LAN,非 198.18.x VPN TUN)
|
|
238
|
+
3. /<prefix> CIDR
|
|
239
|
+
|
|
240
|
+
任一步失败 → fallback 旧 `_default_route_ipv4_addresses()`(socket 出口法)。
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
run_command: 可注入命令执行器(默认 subprocess.run),便于 mock 测试
|
|
244
|
+
cidr_prefix_len: CIDR 前缀长度(默认 /24,与 discover_default_endpoints 一致)
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
CIDR 字符串列表(如 ["192.168.1.0/24"]);空列表表示无 LAN 可达
|
|
248
|
+
"""
|
|
249
|
+
cidrs, _source = _resolve_lan_cidrs(
|
|
250
|
+
run_command=run_command, cidr_prefix_len=cidr_prefix_len
|
|
251
|
+
)
|
|
252
|
+
return cidrs
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def vpn_immune_lan_cidrs_traced(
|
|
256
|
+
*,
|
|
257
|
+
run_command: CommandRunner = _run_command_default,
|
|
258
|
+
cidr_prefix_len: int = 24,
|
|
259
|
+
) -> tuple[list[str], str]:
|
|
260
|
+
"""VPN-immune LAN CIDRs + source 标记 (R020-BF003 可观测性变体)。
|
|
261
|
+
|
|
262
|
+
与 :func:`vpn_immune_lan_cidrs` 同源 (都委托 :func:`_resolve_lan_cidrs`),
|
|
263
|
+
额外返回 source 字符串供上层服务 (VpnImmune) 记录 fallback 事件。
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
run_command: 可注入命令执行器(默认 subprocess.run)
|
|
267
|
+
cidr_prefix_len: CIDR 前缀长度(默认 /24)
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
(cidrs, source) 元组:
|
|
271
|
+
- cidrs: CIDR 字符串列表(与 vpn_immune_lan_cidrs 完全一致)
|
|
272
|
+
- source: ``"route_table"`` (路由表成功) 或
|
|
273
|
+
``"socket_fallback"`` (路由表失败, 回退 socket 出口法,
|
|
274
|
+
可能受 VPN TUN 污染)
|
|
275
|
+
"""
|
|
276
|
+
return _resolve_lan_cidrs(
|
|
277
|
+
run_command=run_command, cidr_prefix_len=cidr_prefix_len
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _resolve_lan_cidrs(
|
|
282
|
+
*,
|
|
283
|
+
run_command: CommandRunner,
|
|
284
|
+
cidr_prefix_len: int,
|
|
285
|
+
) -> tuple[list[str], str]:
|
|
286
|
+
"""VPN-immune LAN CIDR 解析核心 (R020-BF002/BF003 共享实现).
|
|
287
|
+
|
|
288
|
+
返回 (cidrs, source), source 标记走了哪条路径:
|
|
289
|
+
|
|
290
|
+
* ``route_table`` — 路由表 + ipconfig 成功(真实 LAN, VPN TUN 不污染)
|
|
291
|
+
* ``socket_fallback`` — 任一步失败回退旧 socket 出口法(可能受 VPN TUN 污染)
|
|
292
|
+
|
|
293
|
+
旧 :func:`vpn_immune_lan_cidrs` 和新 :func:`vpn_immune_lan_cidrs_traced`
|
|
294
|
+
都委托本函数 — 保证两条调用路径输出完全一致(零破坏 BF002 测试).
|
|
295
|
+
"""
|
|
296
|
+
interface = _default_route_interface(run_command)
|
|
297
|
+
if interface is None:
|
|
298
|
+
return _fallback_lan_cidrs(cidr_prefix_len), "socket_fallback"
|
|
299
|
+
|
|
300
|
+
address = _interface_ipv4(interface, run_command)
|
|
301
|
+
if address is None:
|
|
302
|
+
return _fallback_lan_cidrs(cidr_prefix_len), "socket_fallback"
|
|
303
|
+
|
|
304
|
+
network = IPv4Network(f"{address}/{cidr_prefix_len}", strict=False)
|
|
305
|
+
return [str(network)], "route_table"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _fallback_lan_cidrs(cidr_prefix_len: int) -> list[str]:
|
|
309
|
+
"""VPN-immune 失败时回退旧 socket 出口法 (design §5.4 fallback)。"""
|
|
310
|
+
addresses = _default_route_ipv4_addresses()
|
|
311
|
+
cidrs: list[str] = []
|
|
312
|
+
for address in addresses:
|
|
313
|
+
network = IPv4Network(f"{address}/{cidr_prefix_len}", strict=False)
|
|
314
|
+
cidr = str(network)
|
|
315
|
+
if cidr not in cidrs:
|
|
316
|
+
cidrs.append(cidr)
|
|
317
|
+
return cidrs
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""DTO parsing for the App debug controller HTTP/SSE protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
JsonMap = dict[str, Any]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _string(data: JsonMap, key: str, default: str = "") -> str:
|
|
12
|
+
value = data.get(key, default)
|
|
13
|
+
return value if isinstance(value, str) else default
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _bool(data: JsonMap, key: str, default: bool = False) -> bool:
|
|
17
|
+
value = data.get(key, default)
|
|
18
|
+
return value if isinstance(value, bool) else default
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _int(data: JsonMap, key: str, default: int = 0) -> int:
|
|
22
|
+
value = data.get(key, default)
|
|
23
|
+
return value if isinstance(value, int) else default
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _strings(data: JsonMap, key: str) -> tuple[str, ...]:
|
|
27
|
+
value = data.get(key, [])
|
|
28
|
+
if not isinstance(value, list):
|
|
29
|
+
return ()
|
|
30
|
+
return tuple(item for item in value if isinstance(item, str) and item)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _opt_string(data: JsonMap, key: str) -> str | None:
|
|
34
|
+
"""Optional string: returns None when key missing or value not a str.
|
|
35
|
+
|
|
36
|
+
Used for R020 FF001 bridge fields (hardwareName/machineId) that older
|
|
37
|
+
/hello responses may omit — backward compatible.
|
|
38
|
+
"""
|
|
39
|
+
value = data.get(key)
|
|
40
|
+
return value if isinstance(value, str) and value else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _opt_json_list(data: JsonMap, key: str) -> tuple[JsonMap, ...] | None:
|
|
44
|
+
"""Optional list-of-objects: returns None when key missing/invalid.
|
|
45
|
+
|
|
46
|
+
Used for R020 FF002 ``registeredCapabilities`` — older /hello without
|
|
47
|
+
FF001/FF002 returns None; valid array of objects returns a frozen tuple
|
|
48
|
+
of dict copies (immutable snapshot). Non-list or non-object items are
|
|
49
|
+
filtered; an explicitly empty array yields ``()`` (present-but-empty).
|
|
50
|
+
"""
|
|
51
|
+
if key not in data:
|
|
52
|
+
return None
|
|
53
|
+
value = data.get(key)
|
|
54
|
+
if not isinstance(value, list):
|
|
55
|
+
return None
|
|
56
|
+
items: list[JsonMap] = []
|
|
57
|
+
for item in value:
|
|
58
|
+
if isinstance(item, dict):
|
|
59
|
+
items.append(dict(item))
|
|
60
|
+
return tuple(items)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class NetworkTarget:
|
|
65
|
+
device_id: str
|
|
66
|
+
device_name: str
|
|
67
|
+
platform: str
|
|
68
|
+
host: str
|
|
69
|
+
port: int
|
|
70
|
+
protocol_version: int
|
|
71
|
+
capabilities: frozenset[str]
|
|
72
|
+
active_source: str
|
|
73
|
+
virtual_connected: bool
|
|
74
|
+
profile_id: str | None
|
|
75
|
+
real_controller_active: bool
|
|
76
|
+
last_error: str | None
|
|
77
|
+
profile_revision: int
|
|
78
|
+
app: str = ""
|
|
79
|
+
server_host: str = ""
|
|
80
|
+
server_port: int = 0
|
|
81
|
+
local_ips: tuple[str, ...] = ()
|
|
82
|
+
events_endpoint: str = "/events"
|
|
83
|
+
# --- R020 FF001/FF002 bridge fields (optional, backward compatible) ---
|
|
84
|
+
# Older /hello responses omit these; they default to None so existing GUI
|
|
85
|
+
# / R019 contract behavior is unchanged. Consumed by mcp_debug_bridge
|
|
86
|
+
# BF005 LanCandidate / BF006 cross_identify for device disambiguation.
|
|
87
|
+
hardware_name: str | None = None
|
|
88
|
+
machine_id: str | None = None
|
|
89
|
+
registered_capabilities: tuple[JsonMap, ...] | None = None
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_hello(cls, data: JsonMap, *, host: str, port: int) -> NetworkTarget:
|
|
93
|
+
capabilities = data.get("capabilities", [])
|
|
94
|
+
if not isinstance(capabilities, list):
|
|
95
|
+
capabilities = []
|
|
96
|
+
profile_id = data.get("profileId")
|
|
97
|
+
last_error = data.get("lastError")
|
|
98
|
+
return cls(
|
|
99
|
+
device_id=_string(data, "deviceId", f"{host}:{port}"),
|
|
100
|
+
device_name=_string(data, "deviceName", f"{host}:{port}"),
|
|
101
|
+
platform=_string(data, "platform", "unknown"),
|
|
102
|
+
host=host,
|
|
103
|
+
port=port,
|
|
104
|
+
protocol_version=_int(data, "protocolVersion", 0),
|
|
105
|
+
capabilities=frozenset(str(item) for item in capabilities),
|
|
106
|
+
active_source=_string(data, "activeSource", "none"),
|
|
107
|
+
virtual_connected=_bool(data, "virtualConnected"),
|
|
108
|
+
profile_id=profile_id if isinstance(profile_id, str) else None,
|
|
109
|
+
real_controller_active=_bool(data, "realControllerActive"),
|
|
110
|
+
last_error=last_error if isinstance(last_error, str) else None,
|
|
111
|
+
profile_revision=_int(data, "profileRevision", 0),
|
|
112
|
+
app=_string(data, "app"),
|
|
113
|
+
server_host=_string(data, "serverHost", host),
|
|
114
|
+
server_port=_int(data, "serverPort", port),
|
|
115
|
+
local_ips=_strings(data, "localIps"),
|
|
116
|
+
events_endpoint=_string(data, "eventsEndpoint", "/events"),
|
|
117
|
+
hardware_name=_opt_string(data, "hardwareName"),
|
|
118
|
+
machine_id=_opt_string(data, "machineId"),
|
|
119
|
+
registered_capabilities=_opt_json_list(data, "registeredCapabilities"),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class NetworkState:
|
|
125
|
+
active_source: str
|
|
126
|
+
virtual_connected: bool
|
|
127
|
+
profile_id: str | None
|
|
128
|
+
real_controller_active: bool
|
|
129
|
+
last_error: str | None
|
|
130
|
+
profile_revision: int
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_json(cls, data: JsonMap) -> NetworkState:
|
|
134
|
+
profile_id = data.get("profileId")
|
|
135
|
+
last_error = data.get("lastError")
|
|
136
|
+
return cls(
|
|
137
|
+
active_source=_string(data, "activeSource", "none"),
|
|
138
|
+
virtual_connected=_bool(data, "virtualConnected"),
|
|
139
|
+
profile_id=profile_id if isinstance(profile_id, str) else None,
|
|
140
|
+
real_controller_active=_bool(data, "realControllerActive"),
|
|
141
|
+
last_error=last_error if isinstance(last_error, str) else None,
|
|
142
|
+
profile_revision=_int(data, "profileRevision", 0),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class DebugEvent:
|
|
148
|
+
event_type: str
|
|
149
|
+
sequence: int
|
|
150
|
+
active_source: str | None = None
|
|
151
|
+
real_controller_active: bool | None = None
|
|
152
|
+
virtual_connected: bool | None = None
|
|
153
|
+
profile_id: str | None = None
|
|
154
|
+
profile_revision: int | None = None
|
|
155
|
+
error_code: str | None = None
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def from_json(cls, data: JsonMap) -> DebugEvent:
|
|
159
|
+
return cls(
|
|
160
|
+
event_type=_string(data, "type", "unknown"),
|
|
161
|
+
sequence=_int(data, "sequence", 0),
|
|
162
|
+
active_source=data.get("activeSource") if isinstance(data.get("activeSource"), str) else None,
|
|
163
|
+
real_controller_active=data.get("realControllerActive") if isinstance(data.get("realControllerActive"), bool) else None,
|
|
164
|
+
virtual_connected=data.get("virtualConnected") if isinstance(data.get("virtualConnected"), bool) else None,
|
|
165
|
+
profile_id=data.get("profileId") if isinstance(data.get("profileId"), str) else None,
|
|
166
|
+
profile_revision=data.get("profileRevision") if isinstance(data.get("profileRevision"), int) else None,
|
|
167
|
+
error_code=data.get("errorCode") if isinstance(data.get("errorCode"), str) else None,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class ControllerActionPayload:
|
|
173
|
+
action_id: str
|
|
174
|
+
display_label: str
|
|
175
|
+
capabilities: frozenset[str]
|
|
176
|
+
frame: JsonMap
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_json(cls, action_id: str, data: JsonMap) -> ControllerActionPayload:
|
|
180
|
+
capabilities = data.get("capabilities", [])
|
|
181
|
+
if not isinstance(capabilities, list):
|
|
182
|
+
capabilities = []
|
|
183
|
+
frame = data.get("frame", {})
|
|
184
|
+
if not isinstance(frame, dict):
|
|
185
|
+
frame = {}
|
|
186
|
+
return cls(
|
|
187
|
+
action_id=_string(data, "actionId", action_id),
|
|
188
|
+
display_label=_string(data, "displayLabel", action_id),
|
|
189
|
+
capabilities=frozenset(str(item) for item in capabilities),
|
|
190
|
+
frame=dict(frame),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass(frozen=True)
|
|
195
|
+
class ControllerProfile:
|
|
196
|
+
profile_id: str
|
|
197
|
+
name: str
|
|
198
|
+
platform: str
|
|
199
|
+
connection_mode: str
|
|
200
|
+
created_at: str
|
|
201
|
+
actions: dict[str, ControllerActionPayload]
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def from_json(cls, data: JsonMap) -> ControllerProfile:
|
|
205
|
+
actions_raw = data.get("actions", {})
|
|
206
|
+
actions: dict[str, ControllerActionPayload] = {}
|
|
207
|
+
if isinstance(actions_raw, dict):
|
|
208
|
+
for action_id, payload in actions_raw.items():
|
|
209
|
+
if isinstance(action_id, str) and isinstance(payload, dict):
|
|
210
|
+
actions[action_id] = ControllerActionPayload.from_json(action_id, payload)
|
|
211
|
+
return cls(
|
|
212
|
+
profile_id=_string(data, "id"),
|
|
213
|
+
name=_string(data, "name"),
|
|
214
|
+
platform=_string(data, "platform"),
|
|
215
|
+
connection_mode=_string(data, "connectionMode"),
|
|
216
|
+
created_at=_string(data, "createdAt"),
|
|
217
|
+
actions=actions,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# AD-B9: DeviceUnreachable 下沉 (device_discovery 网络平面异常)
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
#: BF006 自 ``mcp_debug_bridge/bridge_client.py:99`` 下沉至此。原继承 ``BridgeError``
|
|
225
|
+
#: (MCP 协议层基类),下沉后直继承 ``Exception`` 脱离 mcp_plane 反向依赖 —— 本属
|
|
226
|
+
#: 网络平面异常 (设备不可达/设备池未识别 device_id)。BF007 删 ``bridge_client.py:99``
|
|
227
|
+
#: 旧定义 + 改 ``from debug_control_plane.device_discovery.protocol import DeviceUnreachable``
|
|
228
|
+
#: 正向 import;BF006 后双定义中间态 (不同类/不同基类/不同模块路径) 语义不冲突。
|
|
229
|
+
class DeviceUnreachable(Exception):
|
|
230
|
+
"""The device_id is unknown to the pool (连手机失败, 网络平面异常)."""
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""mcp_plane — MCP 适配平面(server/bridge_client/capability_mirror).
|
|
2
|
+
|
|
3
|
+
BF007 迁入(自旧 mcp_debug_bridge):
|
|
4
|
+
- server.py(MCP stdio server, R019 8 端点 handler 字节级不变;★ 边界 1
|
|
5
|
+
gamepad import 降级,保 AC-B6, BF008-010 收尾)
|
|
6
|
+
- bridge_client.py(HTTP + SSE 客户端;AD-B9 DeviceUnreachable 改 forward
|
|
7
|
+
import from device_discovery.protocol, 直继承 Exception 脱 BridgeError)
|
|
8
|
+
- capability_mirror.py(Capability 镜像;AD-B1 剥 gamepad 常量 +
|
|
9
|
+
_parse_schemas E 方案 sentinel 通用化, 平面零 gamepad 业务知识)
|
|
10
|
+
- semantic_provider.py(BF002 SemanticProvider Protocol, 字符串前向引用)
|
|
11
|
+
|
|
12
|
+
零业务依赖: 不 import gamepad / host4 / launcher / semantic_tools(留业务).
|
|
13
|
+
mcp_plane → device_discovery 单向正向依赖(AD-B9 下沉 DeviceUnreachable 后).
|
|
14
|
+
"""
|
|
15
|
+
from .bridge_client import (
|
|
16
|
+
BridgeClient,
|
|
17
|
+
BridgeError,
|
|
18
|
+
DeviceHttpError,
|
|
19
|
+
DeviceStale,
|
|
20
|
+
)
|
|
21
|
+
from .capability_mirror import (
|
|
22
|
+
CapabilityMirror,
|
|
23
|
+
CapabilitySchema,
|
|
24
|
+
CommandDecl,
|
|
25
|
+
ResourceDecl,
|
|
26
|
+
ToolSpec,
|
|
27
|
+
)
|
|
28
|
+
from .semantic_provider import SemanticProvider
|
|
29
|
+
from .server import McpServer
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
# bridge_client
|
|
33
|
+
"BridgeClient",
|
|
34
|
+
"BridgeError",
|
|
35
|
+
"DeviceHttpError",
|
|
36
|
+
"DeviceStale",
|
|
37
|
+
# capability_mirror
|
|
38
|
+
"CapabilityMirror",
|
|
39
|
+
"CapabilitySchema",
|
|
40
|
+
"CommandDecl",
|
|
41
|
+
"ResourceDecl",
|
|
42
|
+
"ToolSpec",
|
|
43
|
+
# semantic_provider (BF002)
|
|
44
|
+
"SemanticProvider",
|
|
45
|
+
# server
|
|
46
|
+
"McpServer",
|
|
47
|
+
]
|