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,525 @@
1
+ """HTTP forwarding client — translate MCP layer to phone debug plane 18080.
2
+
3
+ (R020-BF008)
4
+
5
+ Role (design §3.4 BridgeClient / §4.1 / §4.2.2 / §4.2.3):
6
+ A thin HTTP client that translates a stable ``device_id`` (chosen by the
7
+ caller) into the phone's *current* IP via :class:`DevicePool.resolve_ip`,
8
+ then forwards the request to the phone's R019 debug plane on port 18080.
9
+ It performs **byte-level pass-through** of the phone's 8 endpoints and
10
+ never alters the phone protocol.
11
+
12
+ Three orthogonal concerns are kept separate (design §3.4 单向依赖):
13
+
14
+ * **Identity** (``device_id`` from USB) is stable — persisted by
15
+ :class:`DevicePool` (BF001).
16
+ * **IP** (``host``) is ephemeral — TTL-cached in DevicePool, re-discovered
17
+ via BF002 LanScan when stale. This module never caches IPs itself; it
18
+ always asks the pool on every call.
19
+ * **HTTP forwarding** (this module) is the only outbound-to-phone arrow.
20
+
21
+ Public surface:
22
+ * :class:`BridgeClient` — the service (invoke / read / hello / events).
23
+ * :class:`BridgeError` + subclasses — failure taxonomy (caller dispatches
24
+ on exception type; ``DeviceHttpError`` keeps the phone's original HTTP
25
+ status code so the AI sees e.g. 409 ``real_controller_active``).
26
+
27
+ Design decisions (resolved during BF008):
28
+ * **httpx** (not stdlib urllib) is the HTTP client. httpx 0.28+ ships
29
+ ``MockTransport`` (clean unit-test isolation) and a streaming SSE reader
30
+ via ``Client.stream()``. The "pure stdlib" constraint from BF001-BF005
31
+ applies only to discovery/device_pool (they don't need HTTP); BF008 IS
32
+ the HTTP forwarding core, so httpx is legitimate.
33
+ * **path = list[str]** (URL segments). ``["virtual", "connect"]`` becomes
34
+ ``/virtual/connect``. ``method`` is a free-form HTTP verb string
35
+ (``"GET"`` / ``"POST"`` / ...); the caller is responsible for picking
36
+ one the phone accepts (R019 8 endpoints).
37
+ * **resolve() three-state** (BF001 ResolveResult semantics, AC7 TTL):
38
+ - ``found=False`` → :class:`DeviceUnreachable` (unknown device)
39
+ - ``is_stale=True`` → :class:`DeviceStale` (TTL expired; caller —
40
+ BF009/BF011 — must re-discover via BF002 LanScan). We never silently
41
+ use a stale IP (invariant: "认身份不认地址").
42
+ - fresh → return host, proceed with forwarding
43
+ * **SSE → DebugEvent** structured: ``events()`` is a generator that reads
44
+ the SSE stream line-by-line (``data: <json>\\n\\n`` per event, optional
45
+ ``event: <type>`` prefix) and yields :class:`DebugEvent` parsed via
46
+ ``DebugEvent.from_json``. ``event_types`` filters server-side.
47
+
48
+ Refs:
49
+ - tasks: .dev-flow/R020/mcp-bridge-device-discovery-tasks.md BF008
50
+ - design: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-backend.md
51
+ §3.4 BridgeClient / §4.1 / §4.2.2 / §4.2.3
52
+ - test: .dev-flow/R020/analysis/2026-08-08--mcp-bridge-device-discovery-test.md §2.1/§4.4
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ from collections.abc import Iterator
58
+ from typing import TYPE_CHECKING, Any
59
+
60
+ import httpx
61
+
62
+ # BF007: 跨包 import 改 device_discovery.protocol(BF006 已迁);
63
+ # AD-B9: DeviceUnreachable forward import(本文件 line 99 旧定义删,
64
+ # device_discovery.protocol 下沉;直继承 Exception 脱离 BridgeError 反向依赖)。
65
+ from debug_control_plane.device_discovery.protocol import (
66
+ DebugEvent,
67
+ DeviceUnreachable,
68
+ NetworkTarget,
69
+ )
70
+
71
+ if TYPE_CHECKING: # pragma: no cover - typing only
72
+ from debug_control_plane.device_discovery.device_pool import DevicePool
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Constants
77
+ # ---------------------------------------------------------------------------
78
+
79
+ #: R019 debug plane HTTP port (mobile app listens here).
80
+ DEFAULT_PORT = 18080
81
+
82
+ #: Default request timeout for one-shot invoke/read/hello. Generous enough
83
+ #: for the phone's local processing but bounded so a hung phone surfaces fast.
84
+ DEFAULT_REQUEST_TIMEOUT = 5.0
85
+
86
+ #: Default SSE stream read timeout. Long-ish because the stream is expected
87
+ #: to stay open between events; the caller can override per-call.
88
+ DEFAULT_STREAM_TIMEOUT = 30.0
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Error taxonomy
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ class BridgeError(Exception):
97
+ """Base class for all BridgeClient failures.
98
+
99
+ Callers (BF009/BF011) dispatch on the concrete subclass to decide the
100
+ MCP error code / recovery action. Keeping the taxonomy narrow lets the
101
+ AI receive the *phone's* original signals (e.g. 409 real_controller_active)
102
+ rather than a generic "something failed".
103
+ """
104
+
105
+
106
+ class DeviceStale(BridgeError):
107
+ """The cached IP for device_id is past its TTL (``is_stale=True``).
108
+
109
+ The caller MUST re-discover via BF002 LanScan before retrying. We refuse
110
+ to forward on a stale IP — see BF001 ``DevicePool.resolve_ip`` semantics
111
+ and AC7 TTL re-discovery contract.
112
+ """
113
+
114
+
115
+ class DeviceHttpError(BridgeError):
116
+ """The phone returned an HTTP error (status >= 400) OR the connection failed.
117
+
118
+ The phone's original status code is preserved verbatim so the AI can act
119
+ on it (e.g. 409 ``real_controller_active`` → "let the real gamepad win").
120
+
121
+ For transport-level failures (connect refused / timeout) we still raise
122
+ this with a synthetic status (0 by convention) so the caller only catches
123
+ one type for "phone-side problem".
124
+
125
+ Attributes:
126
+ status_code: HTTP status from the phone (0 if the request never got
127
+ an HTTP response — transport failure).
128
+ body: parsed body (dict/list for JSON, str for text, None if empty).
129
+ Kept verbatim so structured error payloads (R019 ``errorCode``)
130
+ survive to the AI.
131
+ """
132
+
133
+ def __init__(self, status_code: int, body: Any, message: str = "") -> None:
134
+ self.status_code = status_code
135
+ self.body = body
136
+ prefix = f"[HTTP {status_code}]" if status_code else "[transport]"
137
+ detail = message or (f" {body}" if body else "")
138
+ super().__init__(f"{prefix}{detail}")
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Service
143
+ # ---------------------------------------------------------------------------
144
+
145
+
146
+ class BridgeClient:
147
+ """HTTP forwarding client: device_id → phone debug plane 18080.
148
+
149
+ The BridgeClient is intentionally stateless w.r.t. IPs — every call asks
150
+ :meth:`DevicePool.resolve_ip` for the current host, so IP TTL changes
151
+ (AC7) are picked up immediately without restart. The injected
152
+ :class:`httpx.Client` is shared across calls (connection pooling) and
153
+ owned by the caller (so the server can close it on shutdown).
154
+
155
+ Endpoint surface (design §4.2.2 / §4.2.3 — high-level):
156
+ * :meth:`invoke` — generic verb/path/body forwarding (used by
157
+ ``invoke_command`` meta-tool and the gamepad semantic sugar).
158
+ * :meth:`read` — GET convenience for ``read_resource`` / ``get_state``.
159
+ * :meth:`hello` — typed ``NetworkTarget`` for CapabilityMirror
160
+ (BF009) — parses ``/hello`` including the FF001/FF002 extension
161
+ fields (hardwareName/machineId/registeredCapabilities).
162
+ * :meth:`events` — SSE → ``DebugEvent`` generator for
163
+ ``subscribe_events``.
164
+
165
+ Args:
166
+ pool: BF001 DevicePool — identity→IP translation (never ``None``).
167
+ port: phone debug plane port (default 18080, R019 fixed).
168
+ client: optional pre-built :class:`httpx.Client` (tests inject
169
+ ``httpx.MockTransport`` here). If omitted, a default client is
170
+ created with the request/stream timeouts below. Either way the
171
+ client is NOT closed by this class — caller owns the lifecycle.
172
+ request_timeout: one-shot request timeout in seconds.
173
+ stream_timeout: SSE stream read timeout in seconds.
174
+ """
175
+
176
+ def __init__(
177
+ self,
178
+ pool: DevicePool,
179
+ *,
180
+ port: int = DEFAULT_PORT,
181
+ client: httpx.Client | None = None,
182
+ request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
183
+ stream_timeout: float = DEFAULT_STREAM_TIMEOUT,
184
+ ) -> None:
185
+ self._pool = pool
186
+ self._port = port
187
+ if client is not None:
188
+ self._client = client
189
+ self._owns_client = False
190
+ else:
191
+ self._client = httpx.Client(timeout=request_timeout)
192
+ self._owns_client = True
193
+ self._request_timeout = request_timeout
194
+ self._stream_timeout = stream_timeout
195
+
196
+ # ------------------------------------------------------------------
197
+ # Lifecycle
198
+ # ------------------------------------------------------------------
199
+
200
+ def close(self) -> None:
201
+ """Close the underlying httpx.Client if this instance owns it.
202
+
203
+ If a client was injected (tests / server-managed client) it is left
204
+ open — the caller manages its lifetime.
205
+ """
206
+ if self._owns_client:
207
+ self._client.close()
208
+
209
+ def __enter__(self) -> BridgeClient:
210
+ return self
211
+
212
+ def __exit__(self, *exc: object) -> None:
213
+ self.close()
214
+
215
+ # ------------------------------------------------------------------
216
+ # Identity → IP translation (BF001 ResolveResult three-state)
217
+ # ------------------------------------------------------------------
218
+
219
+ def resolve(self, device_id: str) -> str:
220
+ """Resolve ``device_id`` to a current, TTL-fresh host.
221
+
222
+ Implements the BF001 ``ResolveResult`` three-state contract (AC7):
223
+
224
+ * ``found=False`` → :class:`DeviceUnreachable`
225
+ * ``is_stale=True`` → :class:`DeviceStale` (caller re-discovers)
226
+ * fresh → returns ``host``
227
+
228
+ We never return a stale host: forwarding on a stale IP would violate
229
+ "认身份不认地址" and silently target the wrong device after a DHCP/WiFi
230
+ switch. The caller (BF009/BF011) catches :class:`DeviceStale` and
231
+ triggers BF002 LanScan re-discovery.
232
+ """
233
+ result = self._pool.resolve_ip(device_id)
234
+ if not result.found:
235
+ raise DeviceUnreachable(f"unknown device_id: {device_id!r}")
236
+ if result.is_stale:
237
+ raise DeviceStale(
238
+ f"TTL expired for {device_id!r} "
239
+ f"(last_known_host={result.host!r}); caller must re-discover"
240
+ )
241
+ # found and fresh — host is guaranteed non-None here, but be defensive.
242
+ if not result.host:
243
+ raise DeviceUnreachable(f"no host recorded for {device_id!r}")
244
+ return result.host
245
+
246
+ # ------------------------------------------------------------------
247
+ # Forwarding primitives
248
+ # ------------------------------------------------------------------
249
+
250
+ def invoke(
251
+ self,
252
+ device_id: str,
253
+ method: str,
254
+ path: list[str],
255
+ body: Any = None,
256
+ ) -> Any:
257
+ """Forward ``method path body`` to the phone (byte-level pass-through).
258
+
259
+ Used by ``invoke_command`` (meta-tool) and the gamepad semantic sugar
260
+ (``POST /virtual/connect`` etc.). The phone's R019 protocol is
261
+ untouched — we only translate URL segments to a path and forward.
262
+
263
+ Args:
264
+ device_id: stable device identity (resolved via the pool).
265
+ method: HTTP verb (``"GET"`` / ``"POST"`` / ...). The caller is
266
+ responsible for matching R019 endpoint verbs.
267
+ path: URL segments joined with ``/``. ``["virtual", "connect"]``
268
+ → ``/virtual/connect``. Empty list → ``/`` (root).
269
+ body: request body. If it's a dict/list it's sent as JSON
270
+ (``json=``); otherwise it's sent raw (``content=``) and may
271
+ be ``None``.
272
+
273
+ Returns:
274
+ The phone's response body, parsed: dict/list for JSON, ``str``
275
+ for non-JSON text, ``None`` for empty.
276
+
277
+ Raises:
278
+ DeviceUnreachable: unknown device_id.
279
+ DeviceStale: cached IP past TTL — caller re-discovers.
280
+ DeviceHttpError: phone returned status >= 400 (status_code +
281
+ body preserved) OR transport failure (status_code=0).
282
+ """
283
+ host = self.resolve(device_id)
284
+ url = self._build_url(host, path)
285
+ try:
286
+ if isinstance(body, (dict, list)):
287
+ resp = self._client.request(method, url, json=body)
288
+ else:
289
+ resp = self._client.request(method, url, content=body)
290
+ except httpx.HTTPError as exc:
291
+ # Connect refused / DNS / timeout / etc — surface as transport
292
+ # failure so the caller catches a single exception type.
293
+ raise DeviceHttpError(0, None, f"transport: {exc!s}") from exc
294
+ if resp.status_code >= 400:
295
+ raise DeviceHttpError(resp.status_code, _safe_body(resp))
296
+ return _safe_body(resp)
297
+
298
+ def read(self, device_id: str, path: list[str]) -> Any:
299
+ """GET convenience for ``read_resource`` / ``get_state``.
300
+
301
+ Equivalent to ``invoke(device_id, "GET", path, None)`` but signals
302
+ intent at the call site. Returns the parsed phone body.
303
+ """
304
+ return self.invoke(device_id, "GET", path, None)
305
+
306
+ def hello(self, device_id: str) -> NetworkTarget:
307
+ """Fetch ``/hello`` and parse to a typed :class:`NetworkTarget`.
308
+
309
+ Used by CapabilityMirror (BF009) to build the tool list from the
310
+ phone's runtime capability schema (FF001/FF002 extension fields
311
+ included). The host/port are recorded on the returned target so the
312
+ caller doesn't need to thread them separately.
313
+
314
+ Raises:
315
+ DeviceUnreachable / DeviceStale: see :meth:`resolve`.
316
+ DeviceHttpError: phone returned non-200 or transport failure.
317
+ """
318
+ host = self.resolve(device_id)
319
+ url = self._build_url(host, ["hello"])
320
+ try:
321
+ resp = self._client.get(url)
322
+ except httpx.HTTPError as exc:
323
+ raise DeviceHttpError(0, None, f"transport: {exc!s}") from exc
324
+ if resp.status_code != 200:
325
+ raise DeviceHttpError(resp.status_code, _safe_body(resp))
326
+ data = resp.json()
327
+ if not isinstance(data, dict):
328
+ raise DeviceHttpError(
329
+ resp.status_code,
330
+ data,
331
+ f"/hello payload not an object: {type(data).__name__}",
332
+ )
333
+ return NetworkTarget.from_hello(data, host=host, port=self._port)
334
+
335
+ def events(
336
+ self,
337
+ device_id: str,
338
+ event_types: list[str] | None = None,
339
+ ) -> Iterator[DebugEvent]:
340
+ """Subscribe to the phone's ``/events`` SSE stream as DebugEvents.
341
+
342
+ This is a generator: the caller iterates to consume events, and
343
+ closing/exhausting the generator closes the underlying stream.
344
+
345
+ The phone's R019 SSE format is standard Server-Sent Events:
346
+
347
+ event: input\\n
348
+ data: {"type":"input","sequence":42,...}\\n
349
+ \\n
350
+
351
+ We tolerate missing ``event:`` lines (the ``type`` lives inside the
352
+ JSON ``data`` payload anyway, per ``DebugEvent.from_json``) and
353
+ non-JSON data lines (yielded as ``event_type="unknown"`` — same
354
+ fallback ``DebugEvent.from_json`` uses).
355
+
356
+ Args:
357
+ device_id: stable device identity.
358
+ event_types: optional server-side filter. If ``None``, all event
359
+ types are yielded. The match is against
360
+ ``DebugEvent.event_type`` (parsed from the JSON ``type`` key).
361
+
362
+ Yields:
363
+ :class:`DebugEvent` instances, one per SSE event in stream order.
364
+
365
+ Raises:
366
+ DeviceUnreachable / DeviceStale: see :meth:`resolve`.
367
+ DeviceHttpError: phone returned status >= 400 before streaming,
368
+ or transport failure mid-stream (raised from inside the
369
+ generator).
370
+ """
371
+ host = self.resolve(device_id)
372
+ url = self._build_url(host, ["events"])
373
+ type_set = set(event_types) if event_types else None
374
+ try:
375
+ with self._client.stream("GET", url, timeout=self._stream_timeout) as resp:
376
+ if resp.status_code >= 400:
377
+ # Read the body so the caller sees the error payload.
378
+ resp.read()
379
+ raise DeviceHttpError(resp.status_code, _safe_body(resp))
380
+ for raw_data, _event_field in _iter_sse(resp):
381
+ try:
382
+ payload = _parse_json_object(raw_data)
383
+ except (ValueError, TypeError):
384
+ # Not valid JSON — DebugEvent.from_json handles by
385
+ # producing event_type="unknown". Wrap so it parses.
386
+ payload = {"type": "unknown", "data": raw_data}
387
+ evt = DebugEvent.from_json(payload)
388
+ if type_set is None or evt.event_type in type_set:
389
+ yield evt
390
+ except DeviceHttpError:
391
+ raise
392
+ except httpx.HTTPError as exc:
393
+ raise DeviceHttpError(0, None, f"transport: {exc!s}") from exc
394
+
395
+ # ------------------------------------------------------------------
396
+ # Internal helpers
397
+ # ------------------------------------------------------------------
398
+
399
+ def _build_url(self, host: str, path: list[str]) -> str:
400
+ """Assemble ``http://{host}:{port}/{seg1}/{seg2}...``.
401
+
402
+ Path segments are joined as-is. Empty path → ``/``. Trailing slashes
403
+ are not added (R019 endpoints don't expect them).
404
+ """
405
+ if path:
406
+ joined = "/".join(_quote_segment(seg) for seg in path)
407
+ return f"http://{host}:{self._port}/{joined}"
408
+ return f"http://{host}:{self._port}/"
409
+
410
+
411
+ # ---------------------------------------------------------------------------
412
+ # Module-level helpers (free functions for testability + reuse)
413
+ # ---------------------------------------------------------------------------
414
+
415
+
416
+ def _safe_body(resp: httpx.Response) -> Any:
417
+ """Parse an HTTP response body defensively.
418
+
419
+ R019 endpoints return JSON for structured data but may return plain text
420
+ or empty bodies for some error paths. We never want to crash the caller
421
+ on a parse error — instead we degrade to the raw text.
422
+
423
+ Returns:
424
+ * dict/list if the body is valid JSON and is a container,
425
+ * str/int/... if the body is valid JSON but a scalar,
426
+ * str (raw text) if the body is not valid JSON,
427
+ * None if the body is empty.
428
+ """
429
+ if not resp.content:
430
+ return None
431
+ try:
432
+ return resp.json()
433
+ except (ValueError, TypeError):
434
+ return resp.text
435
+
436
+
437
+ def _quote_segment(seg: str) -> str:
438
+ """URL-encode a single path segment (preserving ``/`` within a segment).
439
+
440
+ We don't allow segments to contain ``/`` (caller splits first); if they
441
+ do, we percent-encode so it stays one segment. This keeps ``invoke``
442
+ semantics predictable: one list element == one URL segment.
443
+ """
444
+ # httpx/urllib quote: '/' encoded so it doesn't get treated as a separator.
445
+ from urllib.parse import quote
446
+
447
+ return quote(str(seg), safe="")
448
+
449
+
450
+ def _iter_sse(resp: httpx.Response) -> Iterator[tuple[str, str | None]]:
451
+ """Yield ``(data, event_field)`` tuples from an SSE response stream.
452
+
453
+ Standard SSE framing (W3C EventSource):
454
+ * Lines separated by ``\\n`` or ``\\r\\n``.
455
+ * A blank line dispatches the buffered event.
456
+ * ``data: <text>`` appends to the data buffer (multiple data lines
457
+ are joined with ``\\n``).
458
+ * ``event: <name>`` sets the event type for the next dispatch.
459
+ * ``: comment`` and unknown fields are ignored.
460
+
461
+ Yields:
462
+ ``(data, event_field)`` where ``data`` is the joined data payload
463
+ (str) and ``event_field`` is the last ``event:`` value seen for
464
+ this dispatch (or ``None``). The consumer (``events()``) only uses
465
+ ``data``; ``event_field`` is returned for completeness/future use.
466
+ """
467
+ data_lines: list[str] = []
468
+ event_field: str | None = None
469
+
470
+ for raw_line in resp.iter_lines():
471
+ # iter_lines strips the trailing newline; empty str == blank line.
472
+ line = raw_line.rstrip("\r")
473
+ if line == "":
474
+ if data_lines:
475
+ yield "\n".join(data_lines), event_field
476
+ data_lines = []
477
+ event_field = None
478
+ continue
479
+ if line.startswith(":"):
480
+ # Comment line — ignore per SSE spec.
481
+ continue
482
+ if ":" in line:
483
+ field, _, value = line.partition(":")
484
+ if value.startswith(" "):
485
+ value = value[1:]
486
+ else:
487
+ field = line
488
+ value = ""
489
+ if field == "data":
490
+ data_lines.append(value)
491
+ elif field == "event":
492
+ event_field = value or None
493
+ # Other fields (id:, retry:) are ignored.
494
+
495
+ # Flush a trailing event if the stream closed without a blank line.
496
+ if data_lines:
497
+ yield "\n".join(data_lines), event_field
498
+
499
+
500
+ def _parse_json_object(raw: str) -> dict[str, Any]:
501
+ """Parse a JSON object string, raising on invalid input.
502
+
503
+ A thin wrapper around :func:`json.loads` that constrains the result to a
504
+ dict — SSE data lines that aren't JSON objects are not useful to
505
+ ``DebugEvent.from_json`` and the caller falls back to ``{"type":"unknown"}``.
506
+ """
507
+ import json
508
+
509
+ parsed = json.loads(raw)
510
+ if not isinstance(parsed, dict):
511
+ raise ValueError(f"SSE data is not a JSON object: {type(parsed).__name__}")
512
+ return parsed
513
+
514
+
515
+ __all__ = [
516
+ "DEFAULT_PORT",
517
+ "DEFAULT_REQUEST_TIMEOUT",
518
+ "DEFAULT_STREAM_TIMEOUT",
519
+ "BridgeClient",
520
+ "BridgeError",
521
+ "DeviceHttpError",
522
+ "DeviceStale",
523
+ # AD-B9: DeviceUnreachable 已下沉 device_discovery.protocol,
524
+ # 本模块 forward import(BF007),不 re-export。
525
+ ]