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,369 @@
|
|
|
1
|
+
"""Unified device pool — recognize identity, not address (R020-BF001 / BF003).
|
|
2
|
+
|
|
3
|
+
Implements the "identity stable / IP ephemeral" contract:
|
|
4
|
+
|
|
5
|
+
* **Persistent state** (devices.json): only ``{device_id, label, source, note?}``.
|
|
6
|
+
IP-related fields are NEVER persisted. This is the core invariant (AC6).
|
|
7
|
+
* **In-memory state**: ``last_known_host`` / ``last_seen`` / ``ttl`` plus the
|
|
8
|
+
FF001 bridge fields (``hardware_name`` / ``machine_id``). Lost on process
|
|
9
|
+
restart; re-discovered lazily via :meth:`DevicePool.resolve_ip`.
|
|
10
|
+
|
|
11
|
+
Design references:
|
|
12
|
+
- backend §4.1 (DeviceRecord / DevicePool fields)
|
|
13
|
+
- backend §4.3 (devices.json schema)
|
|
14
|
+
- backend §5.3 (IP TTL re-discovery flow)
|
|
15
|
+
- backend §6 D4 (mixed TTL + on-failure re-discovery) / D8 (reuse NetworkTarget)
|
|
16
|
+
/ D9 (device_id from USB identity, not /hello.deviceId)
|
|
17
|
+
|
|
18
|
+
``NetworkTarget`` (from ``.protocol``) is an OPTIONAL
|
|
19
|
+
runtime tag wrapped by ``DeviceRecord``. The core persistence/TTL logic does
|
|
20
|
+
NOT depend on it — ``NetworkTarget`` is imported lazily so this module works
|
|
21
|
+
standalone (no ``protocol`` loaded at runtime) for unit testing and reuse.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING, Any
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
33
|
+
from .protocol import NetworkTarget
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Constants
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
#: Schema version written to devices.json. Bump only on breaking schema change.
|
|
41
|
+
DEVICES_SCHEMA_VERSION = 1
|
|
42
|
+
|
|
43
|
+
#: Default IP cache TTL in seconds (backend D4). Within this window
|
|
44
|
+
#: ``resolve_ip`` returns the cached host directly; outside it, the caller
|
|
45
|
+
#: must re-discover via BF002 LanScan.
|
|
46
|
+
DEFAULT_TTL: float = 60.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Data model
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class DeviceRecord:
|
|
56
|
+
"""A single device's identity + runtime IP state.
|
|
57
|
+
|
|
58
|
+
Persistent (written to devices.json):
|
|
59
|
+
``device_id``, ``label``, ``source``, ``note``
|
|
60
|
+
|
|
61
|
+
Memory-only (NEVER persisted; reset on restart):
|
|
62
|
+
``last_known_host``, ``last_seen``, ``ttl``,
|
|
63
|
+
``hardware_name``, ``machine_id``, ``platform``,
|
|
64
|
+
``network_target``
|
|
65
|
+
|
|
66
|
+
The split enforces "identity stable / IP ephemeral" (backend §4.1, AC6):
|
|
67
|
+
a device keeps its identity across IP changes (DHCP, WiFi switch), while
|
|
68
|
+
the IP is always treated as a transient runtime fact.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
device_id: str
|
|
72
|
+
"""Stable identity. USB serial (Android) / usbmuxd id (iOS) for auto-discovered
|
|
73
|
+
devices; ``manual-<hash>`` (host-derived) for human-registered devices.
|
|
74
|
+
Never sourced from ``/hello.deviceId`` (R019 fixed string, collides across
|
|
75
|
+
devices — backend D9)."""
|
|
76
|
+
|
|
77
|
+
label: str
|
|
78
|
+
"""Human-readable label (e.g. "Pixel 7 (工位A)")."""
|
|
79
|
+
|
|
80
|
+
source: str
|
|
81
|
+
"""``"auto"`` (discovered via BF002 USB+LAN) or ``"manual"`` (human-registered)."""
|
|
82
|
+
|
|
83
|
+
# --- memory-only IP state ---
|
|
84
|
+
last_known_host: str | None = None
|
|
85
|
+
"""Last confirmed reachable host. Memory-only — re-discovered on restart."""
|
|
86
|
+
last_seen: float | None = None
|
|
87
|
+
"""Unix timestamp of when ``last_known_host`` was last confirmed reachable."""
|
|
88
|
+
ttl: float = DEFAULT_TTL
|
|
89
|
+
"""IP cache TTL in seconds (backend D4). Defaults to 60s."""
|
|
90
|
+
|
|
91
|
+
# --- memory-only FF001 bridge fields (cross-identification, backend §4.1) ---
|
|
92
|
+
hardware_name: str | None = None
|
|
93
|
+
"""Device hardware name (e.g. UIDevice.name / Build.MODEL). FF001 bridge field."""
|
|
94
|
+
machine_id: str | None = None
|
|
95
|
+
"""Machine model id (e.g. "iPhone10,3"). FF001 bridge field."""
|
|
96
|
+
platform: str = ""
|
|
97
|
+
"""``"android"`` / ``"ios"`` / etc. Informational, used for cross-identify."""
|
|
98
|
+
|
|
99
|
+
# --- optional runtime tag ---
|
|
100
|
+
network_target: NetworkTarget | None = field(default=None, repr=False)
|
|
101
|
+
"""Last ``/hello`` parsed target (``.protocol.NetworkTarget``).
|
|
102
|
+
Runtime-only, never persisted. Used to expose capabilities / state to the
|
|
103
|
+
MCP layer (BF001). ``None`` until first /hello probe."""
|
|
104
|
+
|
|
105
|
+
note: str | None = None
|
|
106
|
+
"""Optional human annotation (only meaningful for source="manual").
|
|
107
|
+
Persisted as part of identity (backend §4.3)."""
|
|
108
|
+
|
|
109
|
+
def __post_init__(self) -> None:
|
|
110
|
+
if self.source not in ("auto", "manual"):
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"DeviceRecord.source must be 'auto' or 'manual', got {self.source!r}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def is_ip_fresh(self, *, now: float | None = None) -> bool:
|
|
116
|
+
"""Return True iff the cached IP is within its TTL window.
|
|
117
|
+
|
|
118
|
+
A record with no ``last_known_host`` or no ``last_seen`` is never fresh
|
|
119
|
+
— the caller must discover first.
|
|
120
|
+
"""
|
|
121
|
+
if self.last_known_host is None or self.last_seen is None:
|
|
122
|
+
return False
|
|
123
|
+
current = time.time() if now is None else now
|
|
124
|
+
return (self.last_seen + self.ttl) > current
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class ResolveResult:
|
|
129
|
+
"""Outcome of :meth:`DevicePool.resolve_ip`.
|
|
130
|
+
|
|
131
|
+
Three orthogonal signals let the caller (BF002 LanScan / BridgeClient)
|
|
132
|
+
decide what to do without guessing:
|
|
133
|
+
|
|
134
|
+
* ``found``: whether ``device_id`` is known to the pool at all.
|
|
135
|
+
* ``host``: the cached host (may be ``None`` if never discovered).
|
|
136
|
+
* ``is_stale``: whether the caller MUST re-discover before using ``host``.
|
|
137
|
+
``True`` when the device is unknown, has no host, or its TTL has expired.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
host: str | None
|
|
141
|
+
is_stale: bool
|
|
142
|
+
found: bool
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Pool
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class DevicePool:
|
|
151
|
+
"""In-memory index of devices with identity-only persistence.
|
|
152
|
+
|
|
153
|
+
The pool is the single meeting point of "stable identity" (``device_id``,
|
|
154
|
+
persisted) and "ephemeral IP" (``last_known_host``, memory-only). Every
|
|
155
|
+
capability operation in the MCP layer routes through here to translate a
|
|
156
|
+
``device_id`` into a current host (backend §3.4 / §5.3).
|
|
157
|
+
|
|
158
|
+
Thread-safety: NOT thread-safe. The MCP server is single-threaded stdio;
|
|
159
|
+
if concurrency is introduced later, wrap ``upsert``/``resolve_ip`` in a
|
|
160
|
+
lock at the call site.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def __init__(self, persist_path: Path) -> None:
|
|
164
|
+
self._persist_path: Path = persist_path
|
|
165
|
+
self._store: dict[str, DeviceRecord] = {}
|
|
166
|
+
self._load()
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# Public read API
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def get(self, device_id: str) -> DeviceRecord | None:
|
|
173
|
+
"""Return the record for ``device_id`` or ``None`` if unknown."""
|
|
174
|
+
return self._store.get(device_id)
|
|
175
|
+
|
|
176
|
+
def list_all(self) -> list[DeviceRecord]:
|
|
177
|
+
"""Return all known device records (insertion order)."""
|
|
178
|
+
return list(self._store.values())
|
|
179
|
+
|
|
180
|
+
def resolve_ip(self, device_id: str, *, now: float | None = None) -> ResolveResult:
|
|
181
|
+
"""Resolve ``device_id`` to a current host, applying the TTL policy.
|
|
182
|
+
|
|
183
|
+
This method does NOT perform network/USB discovery — that is BF002
|
|
184
|
+
LanScan's job. It only decides whether the cached host is still
|
|
185
|
+
trustworthy:
|
|
186
|
+
|
|
187
|
+
* Device unknown -> ``ResolveResult(None, True, False)``
|
|
188
|
+
* No host ever recorded -> ``ResolveResult(None, True, True)``
|
|
189
|
+
* Host cached & within TTL -> ``ResolveResult(host, False, True)``
|
|
190
|
+
* Host cached but TTL expired -> ``ResolveResult(host, True, True)``
|
|
191
|
+
|
|
192
|
+
When ``is_stale`` is True the caller MUST re-discover (BF002). The
|
|
193
|
+
(possibly stale) ``host`` is returned for logging / fallback, but
|
|
194
|
+
must not be trusted without reconfirmation.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
device_id: stable device identity.
|
|
198
|
+
now: optional override for ``time.time()`` (testing / clock injection).
|
|
199
|
+
"""
|
|
200
|
+
rec = self._store.get(device_id)
|
|
201
|
+
if rec is None:
|
|
202
|
+
return ResolveResult(host=None, is_stale=True, found=False)
|
|
203
|
+
if rec.is_ip_fresh(now=now):
|
|
204
|
+
return ResolveResult(host=rec.last_known_host, is_stale=False, found=True)
|
|
205
|
+
# Stale: cached host (if any) is informational; caller must re-discover.
|
|
206
|
+
return ResolveResult(host=rec.last_known_host, is_stale=True, found=True)
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
# Public write API
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def upsert(self, rec: DeviceRecord) -> None:
|
|
213
|
+
"""Insert or update ``rec`` in memory, then persist identity-only.
|
|
214
|
+
|
|
215
|
+
Identity fields (``device_id``/``label``/``source``/``note``) are
|
|
216
|
+
written through to devices.json. IP/bridge fields stay in memory and
|
|
217
|
+
are NOT persisted — they are reconstructed on demand via
|
|
218
|
+
:meth:`resolve_ip` + BF002.
|
|
219
|
+
|
|
220
|
+
Updating an existing device_id replaces the whole record (memory state
|
|
221
|
+
included); to preserve IP state across an identity-only refresh, read
|
|
222
|
+
the existing record first and merge.
|
|
223
|
+
"""
|
|
224
|
+
self._store[rec.device_id] = rec
|
|
225
|
+
self._flush()
|
|
226
|
+
|
|
227
|
+
def remove(self, device_id: str) -> bool:
|
|
228
|
+
"""Remove ``device_id`` from memory and persistence.
|
|
229
|
+
|
|
230
|
+
Returns True if a device was removed, False if it was not present.
|
|
231
|
+
"""
|
|
232
|
+
if device_id not in self._store:
|
|
233
|
+
return False
|
|
234
|
+
del self._store[device_id]
|
|
235
|
+
self._flush()
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
# Persistence (identity-only — the core invariant)
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def _load(self) -> None:
|
|
243
|
+
"""Load identity fields from devices.json.
|
|
244
|
+
|
|
245
|
+
IP/bridge fields are intentionally NOT loaded — they remain at their
|
|
246
|
+
dataclass defaults (``None``) and are populated lazily by BF002
|
|
247
|
+
discovery / :meth:`resolve_ip`.
|
|
248
|
+
|
|
249
|
+
Failures:
|
|
250
|
+
* Missing file: empty pool (first run).
|
|
251
|
+
* Corrupt JSON: ``ValueError`` (fail-fast; never silently drop data).
|
|
252
|
+
* Wrong schema version: ``ValueError`` (explicit upgrade gate).
|
|
253
|
+
"""
|
|
254
|
+
if not self._persist_path.exists():
|
|
255
|
+
return
|
|
256
|
+
try:
|
|
257
|
+
raw = self._persist_path.read_text(encoding="utf-8")
|
|
258
|
+
except OSError:
|
|
259
|
+
# Unreadable file is treated as missing — caller can re-register.
|
|
260
|
+
return
|
|
261
|
+
try:
|
|
262
|
+
data = json.loads(raw)
|
|
263
|
+
except json.JSONDecodeError as exc:
|
|
264
|
+
raise ValueError(
|
|
265
|
+
f"devices.json at {self._persist_path} is corrupt: {exc}"
|
|
266
|
+
) from exc
|
|
267
|
+
|
|
268
|
+
if not isinstance(data, dict):
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"devices.json at {self._persist_path}: expected object, got {type(data).__name__}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
version = data.get("version")
|
|
274
|
+
if version != DEVICES_SCHEMA_VERSION:
|
|
275
|
+
raise ValueError(
|
|
276
|
+
f"devices.json schema version {version!r} unsupported "
|
|
277
|
+
f"(expected {DEVICES_SCHEMA_VERSION}) at {self._persist_path}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
entries = data.get("devices", [])
|
|
281
|
+
if not isinstance(entries, list):
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"devices.json 'devices' must be a list at {self._persist_path}"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
for entry in entries:
|
|
287
|
+
if not isinstance(entry, dict):
|
|
288
|
+
continue
|
|
289
|
+
device_id = entry.get("device_id")
|
|
290
|
+
label = entry.get("label")
|
|
291
|
+
source = entry.get("source")
|
|
292
|
+
if not (isinstance(device_id, str) and isinstance(label, str)
|
|
293
|
+
and isinstance(source, str)):
|
|
294
|
+
# Skip malformed entry rather than crash — but log nothing here
|
|
295
|
+
# (no logger dependency); caller validates by list_all().
|
|
296
|
+
continue
|
|
297
|
+
note = entry.get("note") if isinstance(entry.get("note"), str) else None
|
|
298
|
+
# Identity-only load: IP/bridge fields stay at their defaults.
|
|
299
|
+
record = DeviceRecord(
|
|
300
|
+
device_id=device_id,
|
|
301
|
+
label=label,
|
|
302
|
+
source=source,
|
|
303
|
+
note=note,
|
|
304
|
+
)
|
|
305
|
+
self._store[device_id] = record
|
|
306
|
+
|
|
307
|
+
def _flush(self) -> None:
|
|
308
|
+
"""Write identity-only state to devices.json.
|
|
309
|
+
|
|
310
|
+
**Core invariant**: ONLY ``device_id`` / ``label`` / ``source`` /
|
|
311
|
+
``note`` are written. IP fields (``last_known_host`` / ``last_seen`` /
|
|
312
|
+
``ttl``) and bridge fields (``hardware_name`` / ``machine_id`` /
|
|
313
|
+
``platform`` / ``network_target``) MUST NEVER be persisted.
|
|
314
|
+
|
|
315
|
+
The whole file is rewritten atomically (write to .tmp then replace)
|
|
316
|
+
so a partial write never leaves a corrupt store.
|
|
317
|
+
"""
|
|
318
|
+
devices: list[dict[str, Any]] = []
|
|
319
|
+
for rec in self._store.values():
|
|
320
|
+
entry: dict[str, Any] = {
|
|
321
|
+
"device_id": rec.device_id,
|
|
322
|
+
"label": rec.label,
|
|
323
|
+
"source": rec.source,
|
|
324
|
+
}
|
|
325
|
+
if rec.note is not None:
|
|
326
|
+
entry["note"] = rec.note
|
|
327
|
+
devices.append(entry)
|
|
328
|
+
|
|
329
|
+
payload = {"version": DEVICES_SCHEMA_VERSION, "devices": devices}
|
|
330
|
+
|
|
331
|
+
# Atomic write: tmp file + os.replace.
|
|
332
|
+
self._persist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
tmp_path = self._persist_path.with_suffix(self._persist_path.suffix + ".tmp")
|
|
334
|
+
tmp_path.write_text(
|
|
335
|
+
json.dumps(payload, indent=2, ensure_ascii=False),
|
|
336
|
+
encoding="utf-8",
|
|
337
|
+
)
|
|
338
|
+
tmp_path.replace(self._persist_path)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
# Convenience: derive a stable device_id from a manual host
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def manual_device_id(host: str, *, _hash=None) -> str:
|
|
347
|
+
"""Derive a stable device_id for a manually-registered host.
|
|
348
|
+
|
|
349
|
+
Per backend D9, manual entries use ``manual-<sha1(host)>`` so the same
|
|
350
|
+
host re-registered yields the same id (idempotent upsert) without leaking
|
|
351
|
+
the host into the identity string.
|
|
352
|
+
|
|
353
|
+
The hash is SHA-1 truncated to 16 hex chars — collision-resistant for the
|
|
354
|
+
small device counts in scope, and shorter than full SHA-1 for readability.
|
|
355
|
+
"""
|
|
356
|
+
import hashlib
|
|
357
|
+
|
|
358
|
+
digest = hashlib.sha1(host.encode("utf-8")).hexdigest()[:16]
|
|
359
|
+
return f"manual-{digest}"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
__all__ = [
|
|
363
|
+
"DEFAULT_TTL",
|
|
364
|
+
"DEVICES_SCHEMA_VERSION",
|
|
365
|
+
"DevicePool",
|
|
366
|
+
"DeviceRecord",
|
|
367
|
+
"ResolveResult",
|
|
368
|
+
"manual_device_id",
|
|
369
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""device_discovery.discovery — 设备发现逻辑子包 (USB / LAN / 手动 / 交叉识别).
|
|
2
|
+
|
|
3
|
+
BF006 自旧 discovery 目录迁入。原 sibling 平级 flat import (device_pool /
|
|
4
|
+
discovery.X / 协议层 client) 全部改写为同包相对 import。公共符号由父包
|
|
5
|
+
``device_discovery`` re-export, 本子包顶层不重复声明 ``__all__``
|
|
6
|
+
(避免循环 / 命名重复)。
|
|
7
|
+
|
|
8
|
+
模块:
|
|
9
|
+
- lan_scan: LAN /24 并发 probe /hello 扫描 (LanScan / LanCandidate)
|
|
10
|
+
- usb_identity: USB serial → device_id 身份源 (UsbIdentity / UsbCandidate)
|
|
11
|
+
- manual_registry: 手动 / USB 配对注册 (ManualRegistry, AD-B9 DeviceUnreachable)
|
|
12
|
+
- vpn_immune: VPN TUN 不污染的 LAN CIDR 计算 (VpnImmune)
|
|
13
|
+
- cross_identify: USB×LAN 交叉识别合并 (CrossIdentify / MatchReason)
|
|
14
|
+
"""
|