pybricks-service 1.0.0b1__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.
- pb_service/__init__.py +13 -0
- pb_service/__main__.py +7 -0
- pb_service/backend.py +178 -0
- pb_service/cli.py +193 -0
- pb_service/errors.py +106 -0
- pb_service/hub/__init__.py +4 -0
- pb_service/hub/ble.py +258 -0
- pb_service/hub/combined.py +219 -0
- pb_service/hub/connection.py +583 -0
- pb_service/hub/events.py +182 -0
- pb_service/hub/fake.py +498 -0
- pb_service/hub/manager.py +421 -0
- pb_service/hub/protocol.py +667 -0
- pb_service/hub/reconnect.py +72 -0
- pb_service/hub/rfcomm/__init__.py +10 -0
- pb_service/hub/rfcomm/fake.py +234 -0
- pb_service/hub/rfcomm/lazy.py +171 -0
- pb_service/hub/rfcomm/port.py +200 -0
- pb_service/hub/rfcomm/transport.py +238 -0
- pb_service/hub/selection.py +106 -0
- pb_service/hub/stream/__init__.py +10 -0
- pb_service/hub/stream/cobs.py +172 -0
- pb_service/hub/stream/link.py +289 -0
- pb_service/hub/stream/pipe.py +45 -0
- pb_service/hub/stream/wire.py +192 -0
- pb_service/hub/transport.py +367 -0
- pb_service/hub/usb/__init__.py +10 -0
- pb_service/hub/usb/fake.py +319 -0
- pb_service/hub/usb/port.py +119 -0
- pb_service/hub/usb/transport.py +297 -0
- pb_service/methods/__init__.py +19 -0
- pb_service/methods/hub.py +714 -0
- pb_service/methods/program.py +335 -0
- pb_service/methods/registry.py +128 -0
- pb_service/methods/service.py +135 -0
- pb_service/methods/stdio.py +337 -0
- pb_service/py.typed +0 -0
- pb_service/service.py +271 -0
- pb_service/session.py +111 -0
- pybricks_service-1.0.0b1.dist-info/METADATA +48 -0
- pybricks_service-1.0.0b1.dist-info/RECORD +44 -0
- pybricks_service-1.0.0b1.dist-info/WHEEL +4 -0
- pybricks_service-1.0.0b1.dist-info/entry_points.txt +3 -0
- pybricks_service-1.0.0b1.dist-info/licenses/LICENSE +21 -0
pb_service/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""The Pybricks service.
|
|
5
|
+
|
|
6
|
+
Owns every hub connection and outlives any single CLI invocation, so a hub
|
|
7
|
+
stays connected between commands and several clients can talk to one hub at
|
|
8
|
+
once.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "1.0.0b1"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
pb_service/__main__.py
ADDED
pb_service/backend.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""Choosing how the service reaches hubs.
|
|
5
|
+
|
|
6
|
+
Imported lazily so that a service running against the simulated backend, as the
|
|
7
|
+
tests and CI do, never loads the Bluetooth stack at all.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import AsyncGenerator
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
from typing import TYPE_CHECKING, cast
|
|
17
|
+
|
|
18
|
+
import anyio
|
|
19
|
+
|
|
20
|
+
from pb_service.hub.transport import HubTransport
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from aio_rfcomm import Bluetooth
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"HUB_BACKEND_ENV_VAR",
|
|
29
|
+
"PERMISSION_NAME",
|
|
30
|
+
"get_hub_transport",
|
|
31
|
+
"open_hub_transport",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
HUB_BACKEND_ENV_VAR = "PB_HUB_BACKEND"
|
|
35
|
+
"""Selects the transport.
|
|
36
|
+
|
|
37
|
+
Left alone, the service uses every transport together, which is what lets a hub
|
|
38
|
+
be found whichever way it happens to be reachable. Set it to ``usb``, ``ble`` or
|
|
39
|
+
``rfcomm`` to use only one, or to ``fake`` for a simulated hub.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
PERMISSION_NAME = "Pybricks RFCOMM Service"
|
|
44
|
+
"""What macOS calls this service when it asks about Bluetooth.
|
|
45
|
+
|
|
46
|
+
Shown in the permission dialog and under Privacy & Security, and what the
|
|
47
|
+
answer is remembered against, so changing it asks every user again. pb-cli
|
|
48
|
+
repeats it in what it tells the user, and a test holds the two together.
|
|
49
|
+
|
|
50
|
+
Not plain "Pybricks": among the other entries in Privacy & Security that
|
|
51
|
+
reads as the Pybricks app or Pybricks Code, and says nothing about what is
|
|
52
|
+
asking or why. This is the part of pb-service that reaches EV3 and NXT hubs
|
|
53
|
+
over Bluetooth Classic, and only that part is asked about: BLE is bleak's,
|
|
54
|
+
inside the service itself.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _usb() -> HubTransport:
|
|
59
|
+
from pb_service.hub.usb import UsbHubTransport
|
|
60
|
+
|
|
61
|
+
return UsbHubTransport()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ble() -> HubTransport:
|
|
65
|
+
from pb_service.hub.ble import BleHubTransport
|
|
66
|
+
|
|
67
|
+
return BleHubTransport()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rfcomm(bluetooth: Bluetooth | None) -> tuple[HubTransport, ...]:
|
|
71
|
+
if bluetooth is None:
|
|
72
|
+
return ()
|
|
73
|
+
|
|
74
|
+
from pb_service.hub.rfcomm import RfcommHubTransport
|
|
75
|
+
|
|
76
|
+
return (RfcommHubTransport(bluetooth),)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _backend() -> str:
|
|
80
|
+
return os.environ.get(HUB_BACKEND_ENV_VAR, "").lower()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_hub_transport(bluetooth: Bluetooth | None = None) -> HubTransport:
|
|
84
|
+
"""Return the transport this service should use.
|
|
85
|
+
|
|
86
|
+
Every import here is inside the branch that needs it, so a service running
|
|
87
|
+
against the simulated backend loads neither the Bluetooth stack nor the
|
|
88
|
+
serial one, and a machine using only one of the real transports does not
|
|
89
|
+
pay for the other.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Always a :class:`~pb_service.hub.combined.CombinedTransport`, holding
|
|
93
|
+
USB and Bluetooth or whichever single transport ``PB_HUB_BACKEND``
|
|
94
|
+
names. Always, even for one transport, so that choosing between
|
|
95
|
+
transports and honouring a request for a particular one are implemented
|
|
96
|
+
in exactly one place rather than once per backend. The simulated backend
|
|
97
|
+
is what lets the whole service be exercised on a CI runner with no radio
|
|
98
|
+
and nothing plugged in.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
bluetooth: Bluetooth Classic, already open, or None to leave RFCOMM
|
|
102
|
+
out. :func:`open_hub_transport` opens it when it is wanted.
|
|
103
|
+
"""
|
|
104
|
+
from pb_service.hub.combined import CombinedTransport
|
|
105
|
+
|
|
106
|
+
backend = _backend()
|
|
107
|
+
|
|
108
|
+
if backend == "fake":
|
|
109
|
+
from pb_service.hub.fake import FakeHub, FakeHubTransport
|
|
110
|
+
|
|
111
|
+
logger.warning("using the simulated hub backend; no real hub will be found")
|
|
112
|
+
return CombinedTransport(
|
|
113
|
+
FakeHubTransport(
|
|
114
|
+
FakeHub(name="Pybricks Hub", address="00:11:22:33:44:55"),
|
|
115
|
+
FakeHub(name="Spare Hub", address="66:77:88:99:AA:BB"),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if backend == "usb":
|
|
120
|
+
return CombinedTransport(_usb())
|
|
121
|
+
|
|
122
|
+
if backend == "ble":
|
|
123
|
+
return CombinedTransport(_ble())
|
|
124
|
+
|
|
125
|
+
if backend == "rfcomm":
|
|
126
|
+
return CombinedTransport(*_rfcomm(bluetooth))
|
|
127
|
+
|
|
128
|
+
if backend:
|
|
129
|
+
logger.warning(
|
|
130
|
+
"unknown %s=%r; using every transport", HUB_BACKEND_ENV_VAR, backend
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# In TRANSPORT_PREFERENCE order, which is what makes USB the one chosen when
|
|
134
|
+
# a hub is reachable both ways. A test holds the two together.
|
|
135
|
+
return CombinedTransport(_usb(), _ble(), *_rfcomm(bluetooth))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@asynccontextmanager
|
|
139
|
+
async def open_hub_transport() -> AsyncGenerator[HubTransport, None]:
|
|
140
|
+
"""Open the transport this service should use, for as long as the block lasts.
|
|
141
|
+
|
|
142
|
+
Bluetooth Classic is held open in this scope when RFCOMM is in use, and
|
|
143
|
+
nowhere else: aio-rfcomm allows it open once per process, and everything
|
|
144
|
+
opened through it stops working when its block ends, so it has to outlive
|
|
145
|
+
every hub. It is opened the first time RFCOMM needs it, not here, so the
|
|
146
|
+
service never waits on it to start (see
|
|
147
|
+
:mod:`pb_service.hub.rfcomm.lazy`). If it cannot be opened, RFCOMM
|
|
148
|
+
reports Bluetooth as unusable and the other transports carry on.
|
|
149
|
+
|
|
150
|
+
Yields:
|
|
151
|
+
What :func:`get_hub_transport` returns.
|
|
152
|
+
"""
|
|
153
|
+
backend = _backend()
|
|
154
|
+
|
|
155
|
+
if backend in ("fake", "usb", "ble"):
|
|
156
|
+
yield get_hub_transport()
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
import aio_rfcomm.macos
|
|
160
|
+
|
|
161
|
+
from pb_service.hub.rfcomm.lazy import LazyBluetooth
|
|
162
|
+
|
|
163
|
+
# macOS asks the responsible process for Bluetooth permission, and a
|
|
164
|
+
# service started from a command line has no application to speak for it,
|
|
165
|
+
# so without this it is refused rather than asked. The answer is
|
|
166
|
+
# remembered under the name, so changing it asks the user again.
|
|
167
|
+
aio_rfcomm.macos.prompt_under_name(
|
|
168
|
+
name=PERMISSION_NAME, reason="to connect to LEGO hubs over Bluetooth"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async with anyio.create_task_group() as tg:
|
|
172
|
+
# Cast: it offers only the calls the RFCOMM transport makes.
|
|
173
|
+
bluetooth = cast("Bluetooth", LazyBluetooth(tg))
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
yield get_hub_transport(bluetooth)
|
|
177
|
+
finally:
|
|
178
|
+
tg.cancel_scope.cancel()
|
pb_service/cli.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""The service's command line.
|
|
5
|
+
|
|
6
|
+
Plain argparse rather than the CLI framework used by ``pb``: the service must not
|
|
7
|
+
depend on the presentation layer, and it is not a program people interact with
|
|
8
|
+
directly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import anyio
|
|
21
|
+
|
|
22
|
+
# Imported for the side effect: importing a method module registers its RPCs,
|
|
23
|
+
# so nothing here reads the names. Said on each line, because a comment is
|
|
24
|
+
# not something a linter can read.
|
|
25
|
+
import pb_service.methods.hub # pyright: ignore[reportUnusedImport]
|
|
26
|
+
import pb_service.methods.program # pyright: ignore[reportUnusedImport]
|
|
27
|
+
import pb_service.methods.service # pyright: ignore[reportUnusedImport]
|
|
28
|
+
import pb_service.methods.stdio # noqa: F401 # pyright: ignore[reportUnusedImport]
|
|
29
|
+
from pb_core import ipc, lock
|
|
30
|
+
from pb_core.ipc import AddressInUseError
|
|
31
|
+
from pb_core.logging import setup_logging
|
|
32
|
+
from pb_core.paths import log_file
|
|
33
|
+
from pb_service import __version__
|
|
34
|
+
from pb_service import service as service_module
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
__all__ = ["main"]
|
|
39
|
+
|
|
40
|
+
EXIT_OK = 0
|
|
41
|
+
EXIT_ERROR = 1
|
|
42
|
+
EXIT_ALREADY_RUNNING = 3
|
|
43
|
+
|
|
44
|
+
_LEVELS = {
|
|
45
|
+
"trace": 5,
|
|
46
|
+
"debug": logging.DEBUG,
|
|
47
|
+
"info": logging.INFO,
|
|
48
|
+
"warning": logging.WARNING,
|
|
49
|
+
"error": logging.ERROR,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parser() -> argparse.ArgumentParser:
|
|
54
|
+
parser = argparse.ArgumentParser(
|
|
55
|
+
prog="pb-service",
|
|
56
|
+
description="The Pybricks service. Normally started automatically.",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--version", action="version", version=f"pb-service {__version__}"
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--endpoint",
|
|
63
|
+
help="Where to listen, for example tcp://127.0.0.1:47823. "
|
|
64
|
+
"Defaults to the platform's socket or named pipe.",
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--foreground",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help="Log to stderr instead of only to the log file.",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--log-level",
|
|
73
|
+
choices=sorted(_LEVELS),
|
|
74
|
+
default="info",
|
|
75
|
+
help="Verbosity (default: info).",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--idle-timeout",
|
|
79
|
+
type=float,
|
|
80
|
+
default=service_module.DEFAULT_IDLE_TIMEOUT,
|
|
81
|
+
help="Seconds with no clients and no hubs before exiting. "
|
|
82
|
+
"Use 0 to run until stopped.",
|
|
83
|
+
)
|
|
84
|
+
return parser
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def _watch_signals(service: service_module.Service) -> None:
|
|
88
|
+
"""Turn termination signals into an orderly shutdown.
|
|
89
|
+
|
|
90
|
+
On Windows ``anyio.open_signal_receiver`` is unavailable, because the
|
|
91
|
+
proactor loop has no ``add_signal_handler``. A plain handler that pokes the
|
|
92
|
+
loop is used instead, and it needs nothing else to make it prompt: asyncio
|
|
93
|
+
sets the signal wakeup socket, so the byte written by the C-level handler
|
|
94
|
+
-- from the thread Windows spawns to deliver a console control event --
|
|
95
|
+
completes the loop's own overlapped read and the wait returns at once.
|
|
96
|
+
Measured on Windows 11 against a loop parked in ``sleep_forever``: the
|
|
97
|
+
handler ran 0.7 ms after the event was sent, with nothing else waking the
|
|
98
|
+
loop.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
if sys.platform == "win32":
|
|
102
|
+
import asyncio
|
|
103
|
+
|
|
104
|
+
loop = asyncio.get_running_loop()
|
|
105
|
+
event = anyio.Event()
|
|
106
|
+
|
|
107
|
+
def handler(signum: int, frame: Any) -> None:
|
|
108
|
+
loop.call_soon_threadsafe(event.set)
|
|
109
|
+
|
|
110
|
+
previous = [
|
|
111
|
+
(sig, signal.signal(sig, handler))
|
|
112
|
+
for sig in (signal.SIGINT, signal.SIGBREAK)
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
await event.wait()
|
|
117
|
+
service.request_shutdown("signal")
|
|
118
|
+
finally:
|
|
119
|
+
for sig, old in previous:
|
|
120
|
+
signal.signal(sig, old)
|
|
121
|
+
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
with anyio.open_signal_receiver(signal.SIGINT, signal.SIGTERM) as signals:
|
|
125
|
+
async for _ in signals:
|
|
126
|
+
service.request_shutdown("signal")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _serve(args: argparse.Namespace) -> int:
|
|
131
|
+
endpoint = ipc.resolve(args.endpoint)
|
|
132
|
+
service_module.log_banner(endpoint, __version__)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
async with service_module.run_service(
|
|
136
|
+
endpoint, idle_timeout=args.idle_timeout
|
|
137
|
+
) as service:
|
|
138
|
+
async with anyio.create_task_group() as tg:
|
|
139
|
+
tg.start_soon(_watch_signals, service)
|
|
140
|
+
await service.wait_for_shutdown()
|
|
141
|
+
tg.cancel_scope.cancel()
|
|
142
|
+
|
|
143
|
+
logger.info("shutting down: %s", service.shutdown_reason)
|
|
144
|
+
except AddressInUseError:
|
|
145
|
+
# The common case is two clients racing to start a service. One wins,
|
|
146
|
+
# and both clients then connect to it, so this is not an error.
|
|
147
|
+
logger.info("another service is already listening on %s", endpoint)
|
|
148
|
+
return EXIT_ALREADY_RUNNING
|
|
149
|
+
|
|
150
|
+
return EXIT_OK
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv: list[str] | None = None) -> int:
|
|
154
|
+
"""Run the service.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
argv: Command line arguments, defaulting to ``sys.argv``.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The process exit code.
|
|
161
|
+
"""
|
|
162
|
+
args = _parser().parse_args(argv)
|
|
163
|
+
level = _LEVELS[args.log_level]
|
|
164
|
+
|
|
165
|
+
setup_logging(
|
|
166
|
+
level=level,
|
|
167
|
+
style="plain" if args.foreground else "file",
|
|
168
|
+
log_file=None if args.foreground else log_file(),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# The first thing written, before anything that could fail or block.
|
|
172
|
+
# A process that gets no further than here still leaves evidence that
|
|
173
|
+
# it existed, which is exactly what a leftover service with nothing in
|
|
174
|
+
# the log did not.
|
|
175
|
+
logger.info(
|
|
176
|
+
"pb-service starting (pid=%d, argv=%s)",
|
|
177
|
+
os.getpid(),
|
|
178
|
+
argv if argv is not None else sys.argv[1:],
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
with lock.single_instance():
|
|
183
|
+
return anyio.run(_serve, args, backend="asyncio")
|
|
184
|
+
except lock.AlreadyRunningError:
|
|
185
|
+
logger.info("another service already holds the lock; exiting")
|
|
186
|
+
return EXIT_ALREADY_RUNNING
|
|
187
|
+
except Exception:
|
|
188
|
+
logger.exception("service failed")
|
|
189
|
+
return EXIT_ERROR
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
raise SystemExit(main())
|
pb_service/errors.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 The Pybricks Authors
|
|
3
|
+
|
|
4
|
+
"""Turning a hub failure into the wire error that describes it.
|
|
5
|
+
|
|
6
|
+
Anything that reaches the dispatcher untranslated becomes ``INTERNAL_ERROR``,
|
|
7
|
+
which tells a user only that something went wrong and leaves the real reason in
|
|
8
|
+
a log they have to be told to go and read. That is the worst outcome for the
|
|
9
|
+
most ordinary situations -- a hub that is busy, or asked to start a program it
|
|
10
|
+
does not have -- so every failure a hub can produce is named here.
|
|
11
|
+
|
|
12
|
+
The raw ``pbio_pybricks_error_t`` value travels in ``error.data``, because the
|
|
13
|
+
client should be able to be exact about what the firmware said rather than
|
|
14
|
+
paraphrasing a paraphrase.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from collections.abc import Generator
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from pb_core.jsonrpc import ErrorCode, JsonRpcError
|
|
25
|
+
from pb_service.hub import protocol as p
|
|
26
|
+
from pb_service.hub.connection import HubDisconnectedError
|
|
27
|
+
from pb_service.hub.manager import HubAlreadyConnectedError, HubNotFoundError
|
|
28
|
+
from pb_service.hub.transport import HubCommandError
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
__all__ = ["HUB_FAILURES", "as_wire_error", "hub_errors"]
|
|
33
|
+
|
|
34
|
+
HUB_FAILURES = (
|
|
35
|
+
HubCommandError,
|
|
36
|
+
HubDisconnectedError,
|
|
37
|
+
HubNotFoundError,
|
|
38
|
+
HubAlreadyConnectedError,
|
|
39
|
+
)
|
|
40
|
+
"""Everything a hub can fail with that has a wire error of its own."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _refusal(exc: HubCommandError, doing: str | None) -> JsonRpcError:
|
|
44
|
+
"""Describe a command the hub said no to."""
|
|
45
|
+
code = (
|
|
46
|
+
ErrorCode.HUB_BUSY
|
|
47
|
+
if exc.error == p.HubError.BUSY
|
|
48
|
+
else ErrorCode.HUB_INVALID_COMMAND
|
|
49
|
+
)
|
|
50
|
+
name = exc.error.name if isinstance(exc.error, p.HubError) else None
|
|
51
|
+
data: dict[str, Any] = {"hub_error": int(exc.error), "hub_error_name": name}
|
|
52
|
+
# "hub refused the command: INVALID_COMMAND" is true and useless. Where the
|
|
53
|
+
# caller said what it was attempting, say that instead.
|
|
54
|
+
message = f"the hub would not {doing}" if doing else str(exc)
|
|
55
|
+
# Worth a line in the log even though the client is told: which code the
|
|
56
|
+
# firmware chose is the difference between "nothing is stored there" and
|
|
57
|
+
# "that program is not valid", and only the log keeps it.
|
|
58
|
+
logger.info("hub refused: %s (%s)", message, name or f"{int(exc.error):#04x}")
|
|
59
|
+
return JsonRpcError(code, message, data)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def as_wire_error(exc: Exception, doing: str | None = None) -> JsonRpcError:
|
|
63
|
+
"""Translate a hub failure into the error to send back.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
exc: What went wrong.
|
|
67
|
+
doing: What was being attempted, phrased to follow "the hub would not"
|
|
68
|
+
-- for example ``"start that program"``. Used only for a refusal,
|
|
69
|
+
where the firmware's own wording is a bare error name.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The equivalent :class:`JsonRpcError`. Anything unrecognised becomes
|
|
73
|
+
``INTERNAL_ERROR``, which is the honest answer for a bug.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
if isinstance(exc, HubCommandError):
|
|
77
|
+
return _refusal(exc, doing)
|
|
78
|
+
|
|
79
|
+
if isinstance(exc, HubNotFoundError):
|
|
80
|
+
return JsonRpcError(ErrorCode.HUB_NOT_FOUND, str(exc))
|
|
81
|
+
|
|
82
|
+
if isinstance(exc, HubAlreadyConnectedError):
|
|
83
|
+
return JsonRpcError(ErrorCode.HUB_ALREADY_CONNECTED, str(exc))
|
|
84
|
+
|
|
85
|
+
if isinstance(exc, HubDisconnectedError):
|
|
86
|
+
return JsonRpcError(ErrorCode.HUB_DISCONNECTED, str(exc))
|
|
87
|
+
|
|
88
|
+
logger.error("unexpected hub failure", exc_info=exc)
|
|
89
|
+
return JsonRpcError(ErrorCode.INTERNAL_ERROR, str(exc))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@contextmanager
|
|
93
|
+
def hub_errors(doing: str | None = None) -> Generator[None]:
|
|
94
|
+
"""Translate any hub failure raised inside the block.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
doing: What is being attempted, as for :func:`as_wire_error`.
|
|
98
|
+
|
|
99
|
+
Yields:
|
|
100
|
+
Nothing; this only shapes what escapes.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
yield
|
|
105
|
+
except HUB_FAILURES as exc:
|
|
106
|
+
raise as_wire_error(exc, doing) from exc
|