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,396 @@
|
|
|
1
|
+
"""USB ⊕ LAN 交叉识别服务 (R020-BF006).
|
|
2
|
+
|
|
3
|
+
职责 (design §3.4 + D7): 把 BF004 USB 身份候选 (UsbCandidate) 与 BF005 LAN
|
|
4
|
+
响应候选 (LanCandidate) 做**交叉识别**, 产出 ``DeviceRecord`` 列表供调用方
|
|
5
|
+
(BF001/BF007) ``DevicePool.upsert``.
|
|
6
|
+
|
|
7
|
+
设计要点 (D7 分层兜底链 + D9 device_id 来源 + analysis iOS/Android 差异):
|
|
8
|
+
|
|
9
|
+
* **纯函数无 I/O** (decision 5): ``identify`` 只做匹配逻辑, 不触网不调命令.
|
|
10
|
+
所有 I/O 在 BF004 (USB) / BF005 (LAN) 已完成. 输入是 frozen dataclass,
|
|
11
|
+
本服务不改输入 (返回新 list).
|
|
12
|
+
* **device_id 取 USB 身份** (D9): ``DeviceRecord.device_id`` = UsbCandidate
|
|
13
|
+
的 adb serial (Android) / usbmuxd id (iOS). **绝不**用 ``LanCandidate
|
|
14
|
+
.network_target.device_id`` (R019 /hello.deviceId 是固定字符串
|
|
15
|
+
``gmacro-virtual-iOS``, 多设备撞).
|
|
16
|
+
* **分层兜底** (D7, 逐层降级):
|
|
17
|
+
层 1 — 单设备 1对1 (usb 1 + lan 1 直接合并)
|
|
18
|
+
层 2 — Android serial 强匹配 (android_lan_ip ↔ host 精确 IP)
|
|
19
|
+
层 3 — 多设备桥梁字段匹配 (hardware_name + machine_id 各异)
|
|
20
|
+
层 4 — iOS 同型号同名靠 USB 物理在不在 (插着的优先)
|
|
21
|
+
层 5 — 仍歧义 → ambiguous 标记 (label 加 [ambiguous] 前缀 + note)
|
|
22
|
+
* **ambiguous 不强行猜** (D7 核心): 无法消歧时不随机绑定, 标 ``[ambiguous]``
|
|
23
|
+
交上层 (AI/开发者/BF007 人指认) 决策, 避免错误绑定.
|
|
24
|
+
|
|
25
|
+
Android vs iOS 身份强度差异 (analysis §设备身份桥梁字段):
|
|
26
|
+
* Android: ``android_lan_ip`` (USB 经 adb ip route 精确拿) ↔ LAN host 强唯一,
|
|
27
|
+
直接闭环. 多设备靠 IP 强匹配, 不依赖 FF001 桥梁字段.
|
|
28
|
+
* iOS: 无 ``android_lan_ip`` (恒 None), 靠 ``hardware_name`` + ``machine_id``
|
|
29
|
+
(FF001) 匹配, 弱唯一 — 多台同型号同名无法区分 → ambiguous.
|
|
30
|
+
|
|
31
|
+
设计来源:
|
|
32
|
+
- tasks: .dev-flow/R020/mcp-bridge-device-discovery-tasks.md BF006 节
|
|
33
|
+
- design: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-backend.md
|
|
34
|
+
§3.4 CrossIdentify + §5.1 全链路 + D7 + D9
|
|
35
|
+
- analysis: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery.md
|
|
36
|
+
§设备身份桥梁字段
|
|
37
|
+
- test: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-test.md §2.1
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from enum import Enum
|
|
43
|
+
|
|
44
|
+
from ..device_pool import DEFAULT_TTL, DeviceRecord
|
|
45
|
+
from .lan_scan import LanCandidate
|
|
46
|
+
from .usb_identity import UsbCandidate
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# MatchReason — 标识每条 record 用了哪一层匹配 (诊断/可观测, 非业务依赖)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MatchReason(str, Enum):
|
|
54
|
+
"""交叉识别命中层级 (D7 分层兜底链).
|
|
55
|
+
|
|
56
|
+
继承 ``str`` 让 ``==`` 与字符串比较直通 (诊断日志友好); 不参与业务逻辑,
|
|
57
|
+
仅用于 ``identify_with_reason`` 返回值供上层 (BF001/BF007) 观测/日志.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
SINGLE_DEVICE = "single_device"
|
|
61
|
+
"""层 1: 单设备 USB 1 + LAN 1 直接合并 (无歧义)."""
|
|
62
|
+
|
|
63
|
+
ANDROID_IP = "android_ip"
|
|
64
|
+
"""层 2: Android android_lan_ip ↔ LanCandidate.host 精确 IP 强匹配."""
|
|
65
|
+
|
|
66
|
+
BRIDGE_FIELD = "bridge_field"
|
|
67
|
+
"""层 3: hardware_name + machine_id (FF001) 桥梁字段精确匹配."""
|
|
68
|
+
|
|
69
|
+
AMBIGUOUS = "ambiguous"
|
|
70
|
+
"""层 5: 无法消歧 (iOS 同型号同名 / 桥梁字段撞车) → 标记不强行猜."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# CrossIdentify 服务
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
#: ambiguous 标记前缀 (label/note). D7 核心: 不强行猜, 交上层决策.
|
|
79
|
+
_AMBIGUOUS_PREFIX = "[ambiguous]"
|
|
80
|
+
|
|
81
|
+
#: ambiguous note 说明 (供上层 AI/开发者识别并降级到 BF007 人指认).
|
|
82
|
+
_AMBIGUOUS_NOTE = (
|
|
83
|
+
"cross-identify ambiguous: 无法区分多设备 (同型号同名 / 桥梁字段撞车), "
|
|
84
|
+
"请用 register_device 人工指认."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CrossIdentify:
|
|
89
|
+
"""USB ⊕ LAN 交叉识别服务 (纯函数, 无 I/O).
|
|
90
|
+
|
|
91
|
+
入口:
|
|
92
|
+
* :meth:`identify` — 返回 ``list[DeviceRecord]`` (主入口)
|
|
93
|
+
* :meth:`identify_with_reason` — 返回 ``list[tuple[DeviceRecord, MatchReason]]``
|
|
94
|
+
(诊断层, 供观测哪一层命中)
|
|
95
|
+
|
|
96
|
+
本服务**不**直接调 ``DevicePool.upsert`` (BF006 只产 DeviceRecord 供调用方
|
|
97
|
+
upsert). ``last_seen`` / ``ttl`` 由 ``DevicePool`` 构造时填 (BF006 留 None
|
|
98
|
+
+ DEFAULT_TTL 默认值, 见 device_pool.DeviceRecord 字段默认).
|
|
99
|
+
|
|
100
|
+
Example::
|
|
101
|
+
|
|
102
|
+
svc = CrossIdentify()
|
|
103
|
+
records = svc.identify(usb_candidates, lan_candidates)
|
|
104
|
+
for rec in records:
|
|
105
|
+
pool.upsert(rec) # 调用方负责入池
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def identify(
|
|
109
|
+
self,
|
|
110
|
+
usb: list[UsbCandidate],
|
|
111
|
+
lan: list[LanCandidate],
|
|
112
|
+
) -> list[DeviceRecord]:
|
|
113
|
+
"""USB ⊕ LAN 交叉识别 → ``DeviceRecord`` 列表 (D7 分层兜底).
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
usb: BF004 产出的 USB 身份候选列表 (frozen, 本方法不改).
|
|
117
|
+
lan: BF005 产出的 LAN 响应候选列表 (frozen, 本方法不改).
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
新建的 ``DeviceRecord`` 列表 (与输入列表独立, 不共享引用).
|
|
121
|
+
``device_id`` = USB 身份 (D9). ``source="auto"``. ambiguous 的 record
|
|
122
|
+
label 加 ``[ambiguous]`` 前缀 + note 说明 (D7 不强行猜).
|
|
123
|
+
"""
|
|
124
|
+
return [rec for rec, _ in self.identify_with_reason(usb, lan)]
|
|
125
|
+
|
|
126
|
+
def identify_with_reason(
|
|
127
|
+
self,
|
|
128
|
+
usb: list[UsbCandidate],
|
|
129
|
+
lan: list[LanCandidate],
|
|
130
|
+
) -> list[tuple[DeviceRecord, MatchReason]]:
|
|
131
|
+
"""交叉识别 (含 MatchReason 诊断层).
|
|
132
|
+
|
|
133
|
+
实现按 D7 分层兜底链逐层抽取已匹配的 (usb, lan) 对, 剩余无法消歧的进
|
|
134
|
+
ambiguous (层 5). **关键**: 每层从剩余池中**移除**已匹配的项, 不重复配.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
``[(DeviceRecord, MatchReason), ...]``. 列表长度 = 成功合并的对数 +
|
|
138
|
+
ambiguous 标记的 record 数 (ambiguous 仍入池供上层决策, 但带标记).
|
|
139
|
+
"""
|
|
140
|
+
# 不动输入列表, 用本地工作副本 (frozen dataclass 本身不可变, 但列表可变;
|
|
141
|
+
# 我们不修改入参 list, 拷贝一份).
|
|
142
|
+
remaining_usb: list[UsbCandidate] = list(usb)
|
|
143
|
+
remaining_lan: list[LanCandidate] = list(lan)
|
|
144
|
+
results: list[tuple[DeviceRecord, MatchReason]] = []
|
|
145
|
+
|
|
146
|
+
# ----- 层 2: Android android_lan_ip ↔ host 强匹配 (先于层 1 判定,
|
|
147
|
+
# 因 Android IP 强匹配比单设备 1对1 兜底更精确, reason 应标 ANDROID_IP) -----
|
|
148
|
+
results.extend(
|
|
149
|
+
self._match_android_ip_strong(remaining_usb, remaining_lan)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# 单边耗尽 (USB 或 LAN 全已配) → 提前返回
|
|
153
|
+
if not remaining_usb or not remaining_lan:
|
|
154
|
+
return results
|
|
155
|
+
|
|
156
|
+
# ----- 层 1: 单设备 1对1 直接合并 (退化兜底, 无 Android IP 强匹配时) -----
|
|
157
|
+
# 注意: Android 有 android_lan_ip 但与所有 LAN host 都不符 (USB 缓存陈旧)
|
|
158
|
+
# 时, 不强行合并 — 让上层降级 (手机切了 WiFi, 等 TTL 重发现).
|
|
159
|
+
# 此处两侧均非空 (上面已判 not remaining_*), 若都是单元素则 1对1 合并;
|
|
160
|
+
# 否则 (如 2+2) 落到层 3 桥梁字段匹配.
|
|
161
|
+
if len(remaining_usb) == 1 and len(remaining_lan) == 1:
|
|
162
|
+
sole_usb = remaining_usb[0]
|
|
163
|
+
if (
|
|
164
|
+
sole_usb.platform == "android"
|
|
165
|
+
and sole_usb.android_lan_ip is not None
|
|
166
|
+
and sole_usb.android_lan_ip != remaining_lan[0].host
|
|
167
|
+
):
|
|
168
|
+
# Android USB 报的 IP 与 LAN host 不符, 不强行猜 → 返回已匹配的
|
|
169
|
+
return results
|
|
170
|
+
results.append(
|
|
171
|
+
self._merge(
|
|
172
|
+
sole_usb,
|
|
173
|
+
remaining_lan[0],
|
|
174
|
+
MatchReason.SINGLE_DEVICE,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
return results
|
|
178
|
+
|
|
179
|
+
# ----- 层 3: 桥梁字段 (hardware_name + machine_id) 精确匹配 -----
|
|
180
|
+
results.extend(
|
|
181
|
+
self._match_bridge_fields(remaining_usb, remaining_lan)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if not remaining_usb or not remaining_lan:
|
|
185
|
+
return results
|
|
186
|
+
|
|
187
|
+
# ----- 层 4/5: iOS 同型号同名 / 无桥梁字段 → ambiguous 不强行猜 -----
|
|
188
|
+
# 多对多剩余无法消歧: 不随机绑定, 把 USB 身份标 ambiguous 入池 (D7 核心).
|
|
189
|
+
# 注意: 只为剩余 USB 建 record (LAN 候选没绑定就用不上), 给 AI/开发者
|
|
190
|
+
# 看到设备存在但需要人指认 IP.
|
|
191
|
+
for cand in remaining_usb:
|
|
192
|
+
results.append((self._make_ambiguous(cand), MatchReason.AMBIGUOUS))
|
|
193
|
+
|
|
194
|
+
return results
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
# 层 2: Android IP 强匹配
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def _match_android_ip_strong(
|
|
201
|
+
self,
|
|
202
|
+
usb_pool: list[UsbCandidate],
|
|
203
|
+
lan_pool: list[LanCandidate],
|
|
204
|
+
) -> list[tuple[DeviceRecord, MatchReason]]:
|
|
205
|
+
"""Android android_lan_ip ↔ LanCandidate.host 精确匹配 (层 2).
|
|
206
|
+
|
|
207
|
+
从两个池中抽出 android_lan_ip == lan.host 的对, 移出池, 返回合并结果.
|
|
208
|
+
Android 独有强匹配层 (iOS android_lan_ip 恒 None, 跳过).
|
|
209
|
+
"""
|
|
210
|
+
matched: list[tuple[DeviceRecord, MatchReason]] = []
|
|
211
|
+
# 收集要移除的索引 (不在迭代中改 list)
|
|
212
|
+
usb_remove: list[int] = []
|
|
213
|
+
lan_remove: list[int] = []
|
|
214
|
+
|
|
215
|
+
for i, u in enumerate(usb_pool):
|
|
216
|
+
if u.platform != "android" or u.android_lan_ip is None:
|
|
217
|
+
continue
|
|
218
|
+
for j, lc in enumerate(lan_pool):
|
|
219
|
+
if j in lan_remove:
|
|
220
|
+
continue
|
|
221
|
+
if lc.host == u.android_lan_ip:
|
|
222
|
+
matched.append(
|
|
223
|
+
self._merge(u, lc, MatchReason.ANDROID_IP)
|
|
224
|
+
)
|
|
225
|
+
usb_remove.append(i)
|
|
226
|
+
lan_remove.append(j)
|
|
227
|
+
break # 一台 USB 只配一台 LAN
|
|
228
|
+
|
|
229
|
+
# 倒序移除避免索引错位
|
|
230
|
+
for i in sorted(set(usb_remove), reverse=True):
|
|
231
|
+
usb_pool.pop(i)
|
|
232
|
+
for j in sorted(set(lan_remove), reverse=True):
|
|
233
|
+
lan_pool.pop(j)
|
|
234
|
+
return matched
|
|
235
|
+
|
|
236
|
+
# ------------------------------------------------------------------
|
|
237
|
+
# 层 3: 桥梁字段 (hardware_name + machine_id) 精确匹配
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
def _match_bridge_fields(
|
|
241
|
+
self,
|
|
242
|
+
usb_pool: list[UsbCandidate],
|
|
243
|
+
lan_pool: list[LanCandidate],
|
|
244
|
+
) -> list[tuple[DeviceRecord, MatchReason]]:
|
|
245
|
+
"""hardware_name + machine_id (FF001) 桥梁字段精确匹配 (层 3).
|
|
246
|
+
|
|
247
|
+
匹配键: ``network_target.hardware_name`` 与 ``UsbCandidate.model`` (机型
|
|
248
|
+
显示名) 或 machine_id 对应. 由于 USB 侧只有 model 显示名 (Android
|
|
249
|
+
``model:SM-G991B`` / iOS ``name:iPhone X``), 用 LAN 报的 hardware_name +
|
|
250
|
+
machine_id 联合匹配, 要求两者都能区分 (任一不同即视为不同设备).
|
|
251
|
+
|
|
252
|
+
简化策略 (避免误绑): 要求 LAN 报的 (hardware_name, machine_id) 在 lan_pool
|
|
253
|
+
中唯一 (不重复), 才视为可区分; 否则归 ambiguous (层 5).
|
|
254
|
+
"""
|
|
255
|
+
matched: list[tuple[DeviceRecord, MatchReason]] = []
|
|
256
|
+
usb_remove: list[int] = []
|
|
257
|
+
lan_remove: list[int] = []
|
|
258
|
+
|
|
259
|
+
# 先统计每个 (hw_name, machine_id) 在 lan_pool 出现次数, 多次出现的不参与
|
|
260
|
+
# (无法区分, 留给层 5 ambiguous).
|
|
261
|
+
key_count: dict[tuple[str | None, str | None], int] = {}
|
|
262
|
+
for lc in lan_pool:
|
|
263
|
+
key = self._bridge_key(lc)
|
|
264
|
+
key_count[key] = key_count.get(key, 0) + 1
|
|
265
|
+
|
|
266
|
+
for i, u in enumerate(usb_pool):
|
|
267
|
+
for j, lc in enumerate(lan_pool):
|
|
268
|
+
if j in lan_remove:
|
|
269
|
+
continue
|
|
270
|
+
key = self._bridge_key(lc)
|
|
271
|
+
if key_count.get(key, 0) != 1:
|
|
272
|
+
continue # 此 key 在 lan_pool 不唯一, 跳过留给 ambiguous
|
|
273
|
+
if not self._bridge_matches(u, lc):
|
|
274
|
+
continue
|
|
275
|
+
matched.append(self._merge(u, lc, MatchReason.BRIDGE_FIELD))
|
|
276
|
+
usb_remove.append(i)
|
|
277
|
+
lan_remove.append(j)
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
for i in sorted(set(usb_remove), reverse=True):
|
|
281
|
+
usb_pool.pop(i)
|
|
282
|
+
for j in sorted(set(lan_remove), reverse=True):
|
|
283
|
+
lan_pool.pop(j)
|
|
284
|
+
return matched
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _bridge_key(lc: LanCandidate) -> tuple[str | None, str | None]:
|
|
288
|
+
"""LAN 候选的桥梁字段联合键 (hardware_name, machine_id)."""
|
|
289
|
+
return (lc.network_target.hardware_name, lc.network_target.machine_id)
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _bridge_matches(usb: UsbCandidate, lan: LanCandidate) -> bool:
|
|
293
|
+
"""USB 候选与 LAN 候选的桥梁字段是否匹配 (层 3 核心).
|
|
294
|
+
|
|
295
|
+
匹配键: ``UsbCandidate.model`` (机型显示名) ↔ ``network_target.machine_id``
|
|
296
|
+
(机型标识, FF001). 归一化后做前缀/子串匹配, 容忍空格与大小写差异:
|
|
297
|
+
|
|
298
|
+
* iOS: USB model ``"iPhone 13"`` ↔ machine_id ``"iPhone13,2"``
|
|
299
|
+
(去空格小写后 ``iphone13`` 是 ``iphone13,2`` 的前缀).
|
|
300
|
+
* Android: USB model ``"SM-G991B"`` ↔ machine_id ``"SM-G991B"``
|
|
301
|
+
(完全相等).
|
|
302
|
+
|
|
303
|
+
``hardware_name`` (用户自定义设备名) **不直接比对** USB model (用户可改),
|
|
304
|
+
但作为 ``_bridge_key`` 联合键参与唯一性判定 (见 ``_match_bridge_fields``).
|
|
305
|
+
|
|
306
|
+
老 app 无桥梁字段 (hardware_name=None & machine_id=None) → 返回 False,
|
|
307
|
+
让上层降级到层 5 ambiguous (多设备) 或层 1 (单设备 1对1 已先判定).
|
|
308
|
+
"""
|
|
309
|
+
mid = lan.network_target.machine_id
|
|
310
|
+
if mid is None:
|
|
311
|
+
# 无 machine_id (老 app 或 iOS 未报机型) → 无法机型级匹配
|
|
312
|
+
return False
|
|
313
|
+
return _normalize_model(usb.model) in _normalize_model(mid)
|
|
314
|
+
|
|
315
|
+
# ------------------------------------------------------------------
|
|
316
|
+
# 合并 (单台 USB + 单台 LAN → DeviceRecord)
|
|
317
|
+
# ------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _merge(
|
|
321
|
+
usb: UsbCandidate,
|
|
322
|
+
lan: LanCandidate,
|
|
323
|
+
reason: MatchReason,
|
|
324
|
+
) -> tuple[DeviceRecord, MatchReason]:
|
|
325
|
+
"""合并 USB 身份 + LAN 响应 → DeviceRecord (D9: device_id=USB 身份).
|
|
326
|
+
|
|
327
|
+
字段填充:
|
|
328
|
+
* device_id = UsbCandidate.device_id (D9, 绝不用 /hello.deviceId)
|
|
329
|
+
* label = LAN hardware_name (FF001 真实设备名) 或 USB model (fallback)
|
|
330
|
+
* source = "auto"
|
|
331
|
+
* last_known_host = LanCandidate.host
|
|
332
|
+
* last_seen = None (时间由 DevicePool 在 upsert 时填, BF006 不持久化时间)
|
|
333
|
+
* ttl = DEFAULT_TTL (DevicePool 默认, BF006 不自定义)
|
|
334
|
+
* platform / hardware_name / machine_id 从 LAN network_target 填
|
|
335
|
+
"""
|
|
336
|
+
nt = lan.network_target
|
|
337
|
+
# label: FF001 hardware_name 优先 (用户可读设备名), 否则 USB model
|
|
338
|
+
label = nt.hardware_name or usb.model
|
|
339
|
+
return (
|
|
340
|
+
DeviceRecord(
|
|
341
|
+
device_id=usb.device_id,
|
|
342
|
+
label=label,
|
|
343
|
+
source="auto",
|
|
344
|
+
# memory-only IP state (BF006 不持久化时间, last_seen 留 None)
|
|
345
|
+
last_known_host=lan.host,
|
|
346
|
+
last_seen=None,
|
|
347
|
+
ttl=DEFAULT_TTL,
|
|
348
|
+
# FF001 桥梁字段 (从 LAN network_target)
|
|
349
|
+
hardware_name=nt.hardware_name,
|
|
350
|
+
machine_id=nt.machine_id,
|
|
351
|
+
platform=usb.platform,
|
|
352
|
+
# network_target 留 None: BF006 只产身份+IP, 运行时标签由 BridgeClient
|
|
353
|
+
# 在第一次 /hello probe 时填 (BF001 节点定义, 不在 BF006 范围).
|
|
354
|
+
),
|
|
355
|
+
reason,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# ------------------------------------------------------------------
|
|
359
|
+
# 层 5: ambiguous 标记 (不强行猜)
|
|
360
|
+
# ------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def _make_ambiguous(usb: UsbCandidate) -> DeviceRecord:
|
|
364
|
+
"""无法消歧的 USB 候选 → ambiguous 标记的 DeviceRecord (D7 核心).
|
|
365
|
+
|
|
366
|
+
device_id 仍来自 USB 身份 (稳定), 但 label 加 ``[ambiguous]`` 前缀 + note
|
|
367
|
+
说明, 让上层 (AI/开发者/BF007) 看到设备存在但需人指认 IP. **不**随机绑定
|
|
368
|
+
LAN 候选 (避免错误绑定, D7 核心原则).
|
|
369
|
+
"""
|
|
370
|
+
return DeviceRecord(
|
|
371
|
+
device_id=usb.device_id,
|
|
372
|
+
label=f"{_AMBIGUOUS_PREFIX} {usb.model}",
|
|
373
|
+
source="auto",
|
|
374
|
+
# 无 LAN 绑定 → host/last_seen 留 None (待 BF007 人指认)
|
|
375
|
+
last_known_host=None,
|
|
376
|
+
last_seen=None,
|
|
377
|
+
ttl=DEFAULT_TTL,
|
|
378
|
+
platform=usb.platform,
|
|
379
|
+
note=_AMBIGUOUS_NOTE,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _normalize_model(s: str) -> str:
|
|
384
|
+
"""机型字符串归一化: 去空格 + 小写, 用于 model ↔ machine_id 模糊匹配.
|
|
385
|
+
|
|
386
|
+
例: ``"iPhone 13"`` → ``"iphone13"``; ``"iPhone13,2"`` → ``"iphone13,2"``;
|
|
387
|
+
``"SM-G991B"`` → ``"sm-g991b"``. iOS model 是 machine_id 的前缀时即视为
|
|
388
|
+
同机型 (用户 buy iPhone 13 → machine_id 是 iPhone13,x 之一).
|
|
389
|
+
"""
|
|
390
|
+
return s.replace(" ", "").lower()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
__all__ = [
|
|
394
|
+
"CrossIdentify",
|
|
395
|
+
"MatchReason",
|
|
396
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""LAN 并发 probe /hello 扫描服务 (R020-BF005).
|
|
2
|
+
|
|
3
|
+
职责 (design §3.4): VPN-immune 网段内并发 probe ``/hello`` (复用
|
|
4
|
+
``endpoint.probe_hello`` / ``discover_targets``), 把命中的 host 包装成
|
|
5
|
+
``LanCandidate`` 返回给上层 (BF006 cross_identify / BF007 manual_registry).
|
|
6
|
+
|
|
7
|
+
设计要点 (design §5.4):
|
|
8
|
+
* **网段来源**: ``VpnImmune.lan_cidr()`` (BF003, 路由表法, VPN TUN 不污染).
|
|
9
|
+
LanScan 完全信任注入的网段来源 — 网段正确性由 VpnImmune 保证, 本服务
|
|
10
|
+
不自带任何 IP 过滤 (职责分离).
|
|
11
|
+
* **并发 + 异常吞**: 复用 ``endpoint.discover_targets`` (BF002 已内置
|
|
12
|
+
``ThreadPoolExecutor`` 并发 + future 异常吞 None). LanScan 不重写并发
|
|
13
|
+
逻辑 (design D8 零重写).
|
|
14
|
+
* **超时短**: 默认 2.5s (design §5.4: 2-3s), 避免扫描 /24 网段时累计太长.
|
|
15
|
+
* **故障注入容错** (test §4.4): probe 超时/拒绝/JSON 错都不崩, 由
|
|
16
|
+
``discover_targets`` 内部吞异常返 None, LanScan 收到空 list.
|
|
17
|
+
|
|
18
|
+
``NetworkTarget`` (BF005 复用, 方案 A): ``from_hello`` 解析全部 /hello 字段,
|
|
19
|
+
含 R020 FF001/FF002 扩展字段 (``hardware_name``/``machine_id``/
|
|
20
|
+
``registered_capabilities``). 老 /hello 无这些字段默认 None, 向后兼容.
|
|
21
|
+
LanCandidate.network_target 是单一真相源, BF006 cross_identify 直接消费,
|
|
22
|
+
不需再独立解析 hello meta.
|
|
23
|
+
|
|
24
|
+
设计来源:
|
|
25
|
+
- tasks: .dev-flow/R020/mcp-bridge-device-discovery-tasks.md BF005 节
|
|
26
|
+
- design: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-backend.md
|
|
27
|
+
§3.4 LanScan 职责 / §5.4 VPN-immune 流程
|
|
28
|
+
- test: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-test.md §4.4
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from ipaddress import IPv4Network
|
|
35
|
+
from typing import TYPE_CHECKING
|
|
36
|
+
|
|
37
|
+
from ..endpoint import (
|
|
38
|
+
Endpoint,
|
|
39
|
+
UrlOpen,
|
|
40
|
+
discover_targets,
|
|
41
|
+
)
|
|
42
|
+
from ..protocol import NetworkTarget
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
45
|
+
from .vpn_immune import VpnImmune
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
#: R019 debug plane HTTP 端口 (mobile app 固定监听此端口).
|
|
49
|
+
DEFAULT_PORT = 18080
|
|
50
|
+
|
|
51
|
+
#: probe /hello 超时 (design §5.4: 2-3s). 短超时避免 /24 网段累计太长;
|
|
52
|
+
#: discover_targets 并发 64 worker, 254 host 实际约 4 轮, 总耗时 < 10s.
|
|
53
|
+
DEFAULT_PROBE_TIMEOUT = 2.5
|
|
54
|
+
|
|
55
|
+
#: discover_targets 并发 worker 数 (与 BF002 endpoint 默认一致).
|
|
56
|
+
DEFAULT_MAX_WORKERS = 64
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class LanCandidate:
|
|
61
|
+
"""单个 LAN 命中候选 (probe /hello 成功的 host).
|
|
62
|
+
|
|
63
|
+
携带 ``host`` + ``port`` + ``network_target`` (from_hello 解析的完整
|
|
64
|
+
/hello 响应, 含 R020 FF001/FF002 扩展字段). BF006 cross_identify 用
|
|
65
|
+
``hardware_name``/``machine_id``/``local_ips`` 做交叉识别.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
host: 命中设备的 IPv4 地址 (来自 endpoint.host).
|
|
69
|
+
port: probe 用的端口 (DEFAULT_PORT 或注入的 port).
|
|
70
|
+
network_target: ``NetworkTarget.from_hello`` 解析结果, 含全部
|
|
71
|
+
/hello 字段 (deviceId/capabilities/localIps + R020 扩展字段).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
host: str
|
|
75
|
+
port: int
|
|
76
|
+
network_target: NetworkTarget
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LanScan:
|
|
80
|
+
"""LAN 并发 probe /hello 扫描服务 (design §3.4 / §5.4).
|
|
81
|
+
|
|
82
|
+
网段来源 ``VpnImmune.lan_cidr()`` (路由表法, VPN TUN 不污染); 并发 probe
|
|
83
|
+
复用 ``endpoint.discover_targets`` (D8 零重写, 已内置 ThreadPoolExecutor
|
|
84
|
+
+ 异常吞). 超时短 (默认 2.5s).
|
|
85
|
+
|
|
86
|
+
Example::
|
|
87
|
+
|
|
88
|
+
from .vpn_immune import VpnImmune
|
|
89
|
+
|
|
90
|
+
scanner = LanScan(VpnImmune())
|
|
91
|
+
candidates = scanner.scan()
|
|
92
|
+
for cand in candidates:
|
|
93
|
+
print(cand.host, cand.network_target.device_id,
|
|
94
|
+
cand.network_target.hardware_name)
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
vpn_immune: BF003 VpnImmune 服务实例 (提供真实 LAN 网段).
|
|
98
|
+
port: probe 端口 (默认 18080, R019 debug plane).
|
|
99
|
+
probe_timeout: 单次 probe /hello 超时秒数 (默认 2.5s, design §5.4).
|
|
100
|
+
urlopen: 可注入的 urlopen (默认 ``endpoint.default_urlopen``, 即
|
|
101
|
+
``urllib.request.urlopen``); 测试时注入 mock (按 host 返不同 body).
|
|
102
|
+
max_workers: 并发 worker 数 (默认 64, 与 endpoint 默认一致).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
vpn_immune: VpnImmune,
|
|
108
|
+
*,
|
|
109
|
+
port: int = DEFAULT_PORT,
|
|
110
|
+
probe_timeout: float = DEFAULT_PROBE_TIMEOUT,
|
|
111
|
+
urlopen: UrlOpen | None = None,
|
|
112
|
+
max_workers: int = DEFAULT_MAX_WORKERS,
|
|
113
|
+
) -> None:
|
|
114
|
+
self._vpn_immune = vpn_immune
|
|
115
|
+
self._port = port
|
|
116
|
+
self._probe_timeout = probe_timeout
|
|
117
|
+
self._urlopen = urlopen
|
|
118
|
+
self.max_workers = max_workers
|
|
119
|
+
|
|
120
|
+
def scan(self) -> list[LanCandidate]:
|
|
121
|
+
"""并发 probe ``VpnImmune.lan_cidr()`` 网段内所有 host 的 /hello.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
``LanCandidate`` 列表 (保持 endpoints 顺序, 即网段 IP 升序);
|
|
125
|
+
空 list 表示无设备响应 (手机离线 / 网段无 host / probe 全失败).
|
|
126
|
+
永不抛异常 — probe 超时/拒绝/JSON 错都由 discover_targets 吞掉.
|
|
127
|
+
|
|
128
|
+
Side effects:
|
|
129
|
+
调用 ``self._vpn_immune.lan_cidr()`` (可能刷新 VpnImmune.last_event).
|
|
130
|
+
"""
|
|
131
|
+
cidrs = self._vpn_immune.lan_cidr()
|
|
132
|
+
endpoints = self._endpoints_from_cidrs(cidrs)
|
|
133
|
+
if not endpoints:
|
|
134
|
+
return []
|
|
135
|
+
kwargs: dict[str, object] = {
|
|
136
|
+
"timeout": self._probe_timeout,
|
|
137
|
+
"max_workers": self.max_workers,
|
|
138
|
+
}
|
|
139
|
+
if self._urlopen is not None:
|
|
140
|
+
kwargs["urlopen"] = self._urlopen
|
|
141
|
+
targets = discover_targets(endpoints, **kwargs)
|
|
142
|
+
return [
|
|
143
|
+
LanCandidate(host=target.host, port=target.port, network_target=target)
|
|
144
|
+
for target in targets
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
def _endpoints_from_cidrs(self, cidrs: list[str]) -> list[Endpoint]:
|
|
148
|
+
"""把 CIDR 列表展开成 ``Endpoint`` 列表 (IP 升序, 去重).
|
|
149
|
+
|
|
150
|
+
复用 ``discover_default_endpoints`` 的 /24 枚举模式 (BF002), 但网段
|
|
151
|
+
来源是注入的 cidrs 而非 socket 出口法 — 保证 VPN TUN 不污染.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
cidrs: CIDR 字符串列表 (如 ``["192.168.1.0/24"]``).
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
``Endpoint`` 列表, 每个 CIDR 用 ``IPv4Network.hosts()`` 枚举
|
|
158
|
+
(去掉网络号 + 广播地址); 跨 CIDR 去重 host.
|
|
159
|
+
"""
|
|
160
|
+
endpoints: list[Endpoint] = []
|
|
161
|
+
seen: set[str] = set()
|
|
162
|
+
for cidr in cidrs:
|
|
163
|
+
try:
|
|
164
|
+
network = IPv4Network(cidr, strict=False)
|
|
165
|
+
except ValueError:
|
|
166
|
+
# 畸形 CIDR 跳过 (VpnImmune 应保证格式, 但容错不崩)
|
|
167
|
+
continue
|
|
168
|
+
for host in network.hosts():
|
|
169
|
+
host_text = str(host)
|
|
170
|
+
if host_text in seen:
|
|
171
|
+
continue
|
|
172
|
+
seen.add(host_text)
|
|
173
|
+
endpoints.append(Endpoint(host_text, self._port))
|
|
174
|
+
return endpoints
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = [
|
|
178
|
+
"DEFAULT_MAX_WORKERS",
|
|
179
|
+
"DEFAULT_PORT",
|
|
180
|
+
"DEFAULT_PROBE_TIMEOUT",
|
|
181
|
+
"LanCandidate",
|
|
182
|
+
"LanScan",
|
|
183
|
+
]
|