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.
Files changed (44) hide show
  1. pb_service/__init__.py +13 -0
  2. pb_service/__main__.py +7 -0
  3. pb_service/backend.py +178 -0
  4. pb_service/cli.py +193 -0
  5. pb_service/errors.py +106 -0
  6. pb_service/hub/__init__.py +4 -0
  7. pb_service/hub/ble.py +258 -0
  8. pb_service/hub/combined.py +219 -0
  9. pb_service/hub/connection.py +583 -0
  10. pb_service/hub/events.py +182 -0
  11. pb_service/hub/fake.py +498 -0
  12. pb_service/hub/manager.py +421 -0
  13. pb_service/hub/protocol.py +667 -0
  14. pb_service/hub/reconnect.py +72 -0
  15. pb_service/hub/rfcomm/__init__.py +10 -0
  16. pb_service/hub/rfcomm/fake.py +234 -0
  17. pb_service/hub/rfcomm/lazy.py +171 -0
  18. pb_service/hub/rfcomm/port.py +200 -0
  19. pb_service/hub/rfcomm/transport.py +238 -0
  20. pb_service/hub/selection.py +106 -0
  21. pb_service/hub/stream/__init__.py +10 -0
  22. pb_service/hub/stream/cobs.py +172 -0
  23. pb_service/hub/stream/link.py +289 -0
  24. pb_service/hub/stream/pipe.py +45 -0
  25. pb_service/hub/stream/wire.py +192 -0
  26. pb_service/hub/transport.py +367 -0
  27. pb_service/hub/usb/__init__.py +10 -0
  28. pb_service/hub/usb/fake.py +319 -0
  29. pb_service/hub/usb/port.py +119 -0
  30. pb_service/hub/usb/transport.py +297 -0
  31. pb_service/methods/__init__.py +19 -0
  32. pb_service/methods/hub.py +714 -0
  33. pb_service/methods/program.py +335 -0
  34. pb_service/methods/registry.py +128 -0
  35. pb_service/methods/service.py +135 -0
  36. pb_service/methods/stdio.py +337 -0
  37. pb_service/py.typed +0 -0
  38. pb_service/service.py +271 -0
  39. pb_service/session.py +111 -0
  40. pybricks_service-1.0.0b1.dist-info/METADATA +48 -0
  41. pybricks_service-1.0.0b1.dist-info/RECORD +44 -0
  42. pybricks_service-1.0.0b1.dist-info/WHEEL +4 -0
  43. pybricks_service-1.0.0b1.dist-info/entry_points.txt +3 -0
  44. 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
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ from pb_service.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
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
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 The Pybricks Authors
3
+
4
+ """Hub protocol and connections."""