warpedpinball.transports

Transport layer: the abstract interface shared by HTTP and USB transports.

  1"""Transport layer: the abstract interface shared by HTTP and USB transports."""
  2
  3from __future__ import annotations
  4
  5import json
  6from abc import ABC, abstractmethod
  7from typing import Any, Iterator, Optional
  8
  9from ..exceptions import (
 10    AuthenticationError,
 11    CooldownError,
 12    UnsupportedFirmwareError,
 13    VectorServerError,
 14)
 15
 16#: Documented server-side cooldowns (seconds) used to enrich CooldownError.
 17ROUTE_COOLDOWNS = {
 18    "/api/logs": 10.0,
 19    "/api/update/check": 10.0,
 20    "/api/adjustments/restore": 5.0,
 21}
 22
 23
 24def serialize_body(body: Any) -> Optional[str]:
 25    """Serialize a request body to the exact string sent (and signed).
 26
 27    Strings pass through untouched; dicts/lists are JSON-encoded compactly,
 28    exactly once: the same string must be both signed and transmitted.
 29    """
 30    if body is None:
 31        return None
 32    if isinstance(body, str):
 33        return body
 34    return json.dumps(body, separators=(",", ":"))
 35
 36
 37def parse_body(text: Any) -> Any:
 38    """Parse a response body: JSON when it looks like JSON, else the raw text."""
 39    if not isinstance(text, str):
 40        return text
 41    stripped = text.strip()
 42    if not stripped:
 43        return None
 44    if stripped[0] in "{[" or stripped in ("true", "false", "null") or _is_number(stripped):
 45        try:
 46            return json.loads(stripped)
 47        except ValueError:
 48            pass
 49    return text
 50
 51
 52def _is_number(s: str) -> bool:
 53    try:
 54        float(s)
 55        return True
 56    except ValueError:
 57        return False
 58
 59
 60def extract_error_detail(body: Any) -> str:
 61    """Pull the device's error string out of a response body."""
 62    parsed = parse_body(body)
 63    if isinstance(parsed, dict):
 64        for key in ("error", "message", "detail"):
 65            if key in parsed:
 66                return str(parsed[key])
 67    if parsed is None:
 68        return ""
 69    return str(parsed)
 70
 71
 72def raise_for_status(status: int, body: Any, path: str) -> None:
 73    """Map a non-2xx device response to a typed exception.
 74
 75    Note: 429 on the challenge route is handled separately by the HTTP
 76    transport (RateLimitedError with internal retry); a 429 that reaches here
 77    is a route cooldown.
 78    """
 79    if 200 <= status < 300:
 80        return
 81    detail = extract_error_detail(body)
 82    if status == 401:
 83        raise AuthenticationError(detail or "Unauthorized")
 84    if status == 404:
 85        raise UnsupportedFirmwareError(path)
 86    if status in (409, 429):
 87        hint = ROUTE_COOLDOWNS.get(path.split("?", 1)[0])
 88        msg = detail or ("Already running" if status == 409 else "Rate limited")
 89        raise CooldownError(f"{path}: {msg}", retry_after=hint)
 90    if status >= 500:
 91        raise VectorServerError(detail or f"Device error on {path}", status=status)
 92    raise VectorServerError(
 93        detail or f"Unexpected status {status} from {path}", status=status
 94    )
 95
 96
 97class Transport(ABC):
 98    """Abstract transport. HTTP and USB implement the same interface, so all
 99    ``Machine`` methods work identically over both."""
100
101    #: True when authenticated routes need a password on this transport.
102    #: (USB bypasses HMAC entirely; the firmware trusts physical access.)
103    requires_password: bool = True
104
105    #: Password used for HMAC signing (ignored by transports that don't sign).
106    password: Optional[str] = None
107
108    @abstractmethod
109    def request(
110        self, path: str, body: Any = None, authenticated: bool = False
111    ) -> Any:
112        """Perform one request; return the parsed response body.
113
114        Raises a typed exception from ``warpedpinball.exceptions`` on error.
115        """
116
117    @abstractmethod
118    def stream(
119        self, path: str, body: Any = None, authenticated: bool = False
120    ) -> Iterator[bytes]:
121        """Perform a streaming request; yield raw body chunks as bytes."""
122
123    @abstractmethod
124    def close(self) -> None:
125        """Release sockets / serial ports."""
126
127    @property
128    @abstractmethod
129    def description(self) -> str:
130        """Human-readable target, e.g. ``http://192.168.1.42`` or ``/dev/ttyACM0``."""
131
132    def __enter__(self) -> "Transport":
133        return self
134
135    def __exit__(self, *exc) -> None:
136        self.close()
ROUTE_COOLDOWNS = {'/api/logs': 10.0, '/api/update/check': 10.0, '/api/adjustments/restore': 5.0}
def serialize_body(body: Any) -> Optional[str]:
25def serialize_body(body: Any) -> Optional[str]:
26    """Serialize a request body to the exact string sent (and signed).
27
28    Strings pass through untouched; dicts/lists are JSON-encoded compactly,
29    exactly once: the same string must be both signed and transmitted.
30    """
31    if body is None:
32        return None
33    if isinstance(body, str):
34        return body
35    return json.dumps(body, separators=(",", ":"))

Serialize a request body to the exact string sent (and signed).

Strings pass through untouched; dicts/lists are JSON-encoded compactly, exactly once: the same string must be both signed and transmitted.

def parse_body(text: Any) -> Any:
38def parse_body(text: Any) -> Any:
39    """Parse a response body: JSON when it looks like JSON, else the raw text."""
40    if not isinstance(text, str):
41        return text
42    stripped = text.strip()
43    if not stripped:
44        return None
45    if stripped[0] in "{[" or stripped in ("true", "false", "null") or _is_number(stripped):
46        try:
47            return json.loads(stripped)
48        except ValueError:
49            pass
50    return text

Parse a response body: JSON when it looks like JSON, else the raw text.

def extract_error_detail(body: Any) -> str:
61def extract_error_detail(body: Any) -> str:
62    """Pull the device's error string out of a response body."""
63    parsed = parse_body(body)
64    if isinstance(parsed, dict):
65        for key in ("error", "message", "detail"):
66            if key in parsed:
67                return str(parsed[key])
68    if parsed is None:
69        return ""
70    return str(parsed)

Pull the device's error string out of a response body.

def raise_for_status(status: int, body: Any, path: str) -> None:
73def raise_for_status(status: int, body: Any, path: str) -> None:
74    """Map a non-2xx device response to a typed exception.
75
76    Note: 429 on the challenge route is handled separately by the HTTP
77    transport (RateLimitedError with internal retry); a 429 that reaches here
78    is a route cooldown.
79    """
80    if 200 <= status < 300:
81        return
82    detail = extract_error_detail(body)
83    if status == 401:
84        raise AuthenticationError(detail or "Unauthorized")
85    if status == 404:
86        raise UnsupportedFirmwareError(path)
87    if status in (409, 429):
88        hint = ROUTE_COOLDOWNS.get(path.split("?", 1)[0])
89        msg = detail or ("Already running" if status == 409 else "Rate limited")
90        raise CooldownError(f"{path}: {msg}", retry_after=hint)
91    if status >= 500:
92        raise VectorServerError(detail or f"Device error on {path}", status=status)
93    raise VectorServerError(
94        detail or f"Unexpected status {status} from {path}", status=status
95    )

Map a non-2xx device response to a typed exception.

Note: 429 on the challenge route is handled separately by the HTTP transport (RateLimitedError with internal retry); a 429 that reaches here is a route cooldown.

class Transport(abc.ABC):
 98class Transport(ABC):
 99    """Abstract transport. HTTP and USB implement the same interface, so all
100    ``Machine`` methods work identically over both."""
101
102    #: True when authenticated routes need a password on this transport.
103    #: (USB bypasses HMAC entirely; the firmware trusts physical access.)
104    requires_password: bool = True
105
106    #: Password used for HMAC signing (ignored by transports that don't sign).
107    password: Optional[str] = None
108
109    @abstractmethod
110    def request(
111        self, path: str, body: Any = None, authenticated: bool = False
112    ) -> Any:
113        """Perform one request; return the parsed response body.
114
115        Raises a typed exception from ``warpedpinball.exceptions`` on error.
116        """
117
118    @abstractmethod
119    def stream(
120        self, path: str, body: Any = None, authenticated: bool = False
121    ) -> Iterator[bytes]:
122        """Perform a streaming request; yield raw body chunks as bytes."""
123
124    @abstractmethod
125    def close(self) -> None:
126        """Release sockets / serial ports."""
127
128    @property
129    @abstractmethod
130    def description(self) -> str:
131        """Human-readable target, e.g. ``http://192.168.1.42`` or ``/dev/ttyACM0``."""
132
133    def __enter__(self) -> "Transport":
134        return self
135
136    def __exit__(self, *exc) -> None:
137        self.close()

Abstract transport. HTTP and USB implement the same interface, so all Machine methods work identically over both.

requires_password: bool = True
password: Optional[str] = None
@abstractmethod
def request(self, path: str, body: Any = None, authenticated: bool = False) -> Any:
109    @abstractmethod
110    def request(
111        self, path: str, body: Any = None, authenticated: bool = False
112    ) -> Any:
113        """Perform one request; return the parsed response body.
114
115        Raises a typed exception from ``warpedpinball.exceptions`` on error.
116        """

Perform one request; return the parsed response body.

Raises a typed exception from warpedpinball.exceptions on error.

@abstractmethod
def stream( self, path: str, body: Any = None, authenticated: bool = False) -> Iterator[bytes]:
118    @abstractmethod
119    def stream(
120        self, path: str, body: Any = None, authenticated: bool = False
121    ) -> Iterator[bytes]:
122        """Perform a streaming request; yield raw body chunks as bytes."""

Perform a streaming request; yield raw body chunks as bytes.

@abstractmethod
def close(self) -> None:
124    @abstractmethod
125    def close(self) -> None:
126        """Release sockets / serial ports."""

Release sockets / serial ports.

description: str
128    @property
129    @abstractmethod
130    def description(self) -> str:
131        """Human-readable target, e.g. ``http://192.168.1.42`` or ``/dev/ttyACM0``."""

Human-readable target, e.g. http://192.168.1.42 or /dev/ttyACM0.