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,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
|
+
]
|