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.
@@ -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
+ """