pybricks-service 1.0.0b1__tar.gz
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.
- pybricks_service-1.0.0b1/LICENSE +21 -0
- pybricks_service-1.0.0b1/PKG-INFO +48 -0
- pybricks_service-1.0.0b1/README.md +13 -0
- pybricks_service-1.0.0b1/pyproject.toml +63 -0
- pybricks_service-1.0.0b1/pyproject.toml.orig +51 -0
- pybricks_service-1.0.0b1/src/pb_service/__init__.py +13 -0
- pybricks_service-1.0.0b1/src/pb_service/__main__.py +7 -0
- pybricks_service-1.0.0b1/src/pb_service/backend.py +178 -0
- pybricks_service-1.0.0b1/src/pb_service/cli.py +193 -0
- pybricks_service-1.0.0b1/src/pb_service/errors.py +106 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/__init__.py +4 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/ble.py +258 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/combined.py +219 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/connection.py +583 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/events.py +182 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/fake.py +498 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/manager.py +421 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/protocol.py +667 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/reconnect.py +72 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/rfcomm/__init__.py +10 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/rfcomm/fake.py +234 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/rfcomm/lazy.py +171 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/rfcomm/port.py +200 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/rfcomm/transport.py +238 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/selection.py +106 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/stream/__init__.py +10 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/stream/cobs.py +172 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/stream/link.py +289 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/stream/pipe.py +45 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/stream/wire.py +192 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/transport.py +367 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/usb/__init__.py +10 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/usb/fake.py +319 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/usb/port.py +119 -0
- pybricks_service-1.0.0b1/src/pb_service/hub/usb/transport.py +297 -0
- pybricks_service-1.0.0b1/src/pb_service/methods/__init__.py +19 -0
- pybricks_service-1.0.0b1/src/pb_service/methods/hub.py +714 -0
- pybricks_service-1.0.0b1/src/pb_service/methods/program.py +335 -0
- pybricks_service-1.0.0b1/src/pb_service/methods/registry.py +128 -0
- pybricks_service-1.0.0b1/src/pb_service/methods/service.py +135 -0
- pybricks_service-1.0.0b1/src/pb_service/methods/stdio.py +337 -0
- pybricks_service-1.0.0b1/src/pb_service/py.typed +0 -0
- pybricks_service-1.0.0b1/src/pb_service/service.py +271 -0
- pybricks_service-1.0.0b1/src/pb_service/session.py +111 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Pybricks Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pybricks-service
|
|
3
|
+
Version: 1.0.0b1
|
|
4
|
+
Summary: The Pybricks service: manages hub connections
|
|
5
|
+
Keywords: pybricks,lego,micropython,bluetooth,robotics
|
|
6
|
+
Author: The Pybricks Authors
|
|
7
|
+
Author-email: The Pybricks Authors <dev@pybricks.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Education
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: pybricks-core>=1.0.0b1
|
|
18
|
+
Requires-Dist: anyio>=4.14
|
|
19
|
+
Requires-Dist: bleak>=1.1.0
|
|
20
|
+
Requires-Dist: packaging>=22
|
|
21
|
+
Requires-Dist: serialx>=1.9
|
|
22
|
+
Requires-Dist: aio-rfcomm>=1.1
|
|
23
|
+
Requires-Dist: semver>=3
|
|
24
|
+
Requires-Dist: typing-extensions>=4.15
|
|
25
|
+
Maintainer: David Lechner
|
|
26
|
+
Maintainer-email: David Lechner <david@pybricks.com>
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Project-URL: homepage, https://pybricks.com
|
|
29
|
+
Project-URL: repository, https://github.com/pybricks/pybricks-tools
|
|
30
|
+
Project-URL: documentation, https://pybricks.com/pybricks-tools/design/
|
|
31
|
+
Project-URL: Changelog, https://github.com/pybricks/pybricks-tools/blob/main/packages/pb-service/CHANGELOG.md
|
|
32
|
+
Project-URL: Support, https://github.com/pybricks/support/discussions
|
|
33
|
+
Project-URL: Issues, https://github.com/pybricks/support/issues
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# pybricks-service
|
|
37
|
+
|
|
38
|
+
The background service behind the [Pybricks](https://pybricks.com) tools. It
|
|
39
|
+
owns the Bluetooth connection to a hub and outlives any single command, which
|
|
40
|
+
is what lets one `pb run` follow another without waiting to reconnect.
|
|
41
|
+
|
|
42
|
+
It also fans a hub's output out to several clients at once, so an interactive
|
|
43
|
+
session and a program watching the same hub do not get in each other's way.
|
|
44
|
+
|
|
45
|
+
**You do not install this yourself.** It comes with `pybricks-cli`, and it
|
|
46
|
+
starts when something needs it and stops when nothing has for a while.
|
|
47
|
+
|
|
48
|
+
> **Status:** beta. Commands and APIs may still change before 1.0.0.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# pybricks-service
|
|
2
|
+
|
|
3
|
+
The background service behind the [Pybricks](https://pybricks.com) tools. It
|
|
4
|
+
owns the Bluetooth connection to a hub and outlives any single command, which
|
|
5
|
+
is what lets one `pb run` follow another without waiting to reconnect.
|
|
6
|
+
|
|
7
|
+
It also fans a hub's output out to several clients at once, so an interactive
|
|
8
|
+
session and a program watching the same hub do not get in each other's way.
|
|
9
|
+
|
|
10
|
+
**You do not install this yourself.** It comes with `pybricks-cli`, and it
|
|
11
|
+
starts when something needs it and stops when nothing has for a while.
|
|
12
|
+
|
|
13
|
+
> **Status:** beta. Commands and APIs may still change before 1.0.0.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pybricks-service"
|
|
3
|
+
version = "1.0.0b1"
|
|
4
|
+
description = "The Pybricks service: manages hub connections"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
keywords = [
|
|
10
|
+
"pybricks",
|
|
11
|
+
"lego",
|
|
12
|
+
"micropython",
|
|
13
|
+
"bluetooth",
|
|
14
|
+
"robotics",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Intended Audience :: Education",
|
|
20
|
+
"Natural Language :: English",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Topic :: Software Development :: Embedded Systems",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"pybricks-core>=1.0.0b1",
|
|
27
|
+
"anyio>=4.14",
|
|
28
|
+
"bleak>=1.1.0",
|
|
29
|
+
"packaging>=22",
|
|
30
|
+
"serialx>=1.9",
|
|
31
|
+
"aio-rfcomm>=1.1",
|
|
32
|
+
"semver>=3",
|
|
33
|
+
"typing-extensions>=4.15",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[[project.authors]]
|
|
37
|
+
name = "The Pybricks Authors"
|
|
38
|
+
email = "dev@pybricks.com"
|
|
39
|
+
|
|
40
|
+
[[project.maintainers]]
|
|
41
|
+
name = "David Lechner"
|
|
42
|
+
email = "david@pybricks.com"
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
pb-service = "pb_service.cli:main"
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
homepage = "https://pybricks.com"
|
|
49
|
+
repository = "https://github.com/pybricks/pybricks-tools"
|
|
50
|
+
documentation = "https://pybricks.com/pybricks-tools/design/"
|
|
51
|
+
Changelog = "https://github.com/pybricks/pybricks-tools/blob/main/packages/pb-service/CHANGELOG.md"
|
|
52
|
+
Support = "https://github.com/pybricks/support/discussions"
|
|
53
|
+
Issues = "https://github.com/pybricks/support/issues"
|
|
54
|
+
|
|
55
|
+
[build-system]
|
|
56
|
+
requires = ["uv_build>=0.12.7,<0.13.0"]
|
|
57
|
+
build-backend = "uv_build"
|
|
58
|
+
|
|
59
|
+
[tool.uv.build-backend]
|
|
60
|
+
module-name = "pb_service"
|
|
61
|
+
|
|
62
|
+
[tool.uv.sources.pybricks-core]
|
|
63
|
+
workspace = true
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pybricks-service"
|
|
3
|
+
version = "1.0.0b1"
|
|
4
|
+
description = "The Pybricks service: manages hub connections"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "The Pybricks Authors", email = "dev@pybricks.com" }]
|
|
9
|
+
maintainers = [{ name = "David Lechner", email = "david@pybricks.com" }]
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
keywords = ["pybricks", "lego", "micropython", "bluetooth", "robotics"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Intended Audience :: Education",
|
|
16
|
+
"Natural Language :: English",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Software Development :: Embedded Systems",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"pybricks-core>=1.0.0b1",
|
|
23
|
+
"anyio>=4.14",
|
|
24
|
+
"bleak>=1.1.0",
|
|
25
|
+
"packaging>=22",
|
|
26
|
+
"serialx>=1.9",
|
|
27
|
+
"aio-rfcomm>=1.1",
|
|
28
|
+
"semver>=3",
|
|
29
|
+
"typing-extensions>=4.15",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
pb-service = "pb_service.cli:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
homepage = "https://pybricks.com"
|
|
37
|
+
repository = "https://github.com/pybricks/pybricks-tools"
|
|
38
|
+
documentation = "https://pybricks.com/pybricks-tools/design/"
|
|
39
|
+
"Changelog" = "https://github.com/pybricks/pybricks-tools/blob/main/packages/pb-service/CHANGELOG.md"
|
|
40
|
+
"Support" = "https://github.com/pybricks/support/discussions"
|
|
41
|
+
"Issues" = "https://github.com/pybricks/support/issues"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.12.7,<0.13.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[tool.uv.build-backend]
|
|
48
|
+
module-name = "pb_service"
|
|
49
|
+
|
|
50
|
+
[tool.uv.sources]
|
|
51
|
+
pybricks-core = { workspace = true }
|
|
@@ -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__"]
|
|
@@ -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()
|
|
@@ -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())
|