Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions packages/pb-cli/src/pb_cli/commands/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@
"hub that is already connected, or running a program, does not "
"advertise."
),
"rfcomm": (
"Pair the hub with this computer in its Bluetooth settings first. Only "
"the EV3 and NXT are reached this way."
),
}
"""What to suggest when a hub to connect to could not be found.

Expand Down Expand Up @@ -117,7 +121,8 @@ async def scan_cmd(
Args:
timeout: How long to scan, in seconds.
name: Only show hubs with this name.
transport: Only look on this transport, usb or ble. Both by default.
transport: Only look on this transport: usb, ble or rfcomm. All of
them by default.
verbose: Increase log detail.
"""
configure_logging(verbose)
Expand Down Expand Up @@ -157,10 +162,15 @@ async def _scan(seconds: float, name: str | None, transport: str | None) -> int:
seen[device.id] = device

if not seen:
# "In range" is Bluetooth's word for it and means nothing to somebody
# who asked about a cable.
where = "plugged in" if transport == "usb" else "in range"
err.print(f"[yellow]No hubs found.[/] Is the hub switched on and {where}?")
if transport == "rfcomm":
# The listing is the paired set, so an empty one means nothing is
# paired. Switching the hub on would not change it.
err.print("[yellow]No hubs found.[/] Is the hub paired with this computer?")
else:
# "In range" is Bluetooth's word for it and means nothing to
# somebody who asked about a cable.
where = "plugged in" if transport == "usb" else "in range"
err.print(f"[yellow]No hubs found.[/] Is the hub switched on and {where}?")

return EXIT_OK

Expand All @@ -184,8 +194,9 @@ async def connect_cmd(
scan: Watch for hubs and choose one from the list. There is no time
limit, so the hub can be switched on whenever you like, and
Ctrl-C gives up without connecting to anything.
transport: Reach the hub this way, usb or ble. By default whichever
works, preferring USB where a hub is reachable both ways.
transport: Reach the hub this way: usb, ble or rfcomm. By default
whichever works, preferring USB where a hub is reachable more than
one way.
verbose: Increase log detail.
"""

Expand Down
8 changes: 4 additions & 4 deletions packages/pb-cli/src/pb_cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,12 +201,12 @@ def serial_port_advice(platform: str = sys.platform) -> str:
message="The hub's button interrupted this.",
),
# Transport and lifecycle.
# The service repeats what the system said, which is what tells a radio
# that is switched off from one this program may not use.
ErrorCode.BLUETOOTH_UNAVAILABLE: _Rule(
"Connect or enable a Bluetooth Low Energy adapter, and check that this "
"computer has permission to use it.",
"Switch Bluetooth on, or plug in an adapter, and check that this "
"computer lets pb use Bluetooth.",
exit_code=EXIT_ERROR,
message="No Bluetooth adapter could be found, or permission to use it "
"has not been granted.",
),
ErrorCode.SCAN_TIMEOUT: _Rule(
"Switch the hub on and press its button so that its light flashes."
Expand Down
16 changes: 12 additions & 4 deletions packages/pb-cli/src/pb_cli/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,16 @@ def usage(message: str, hint: str | None = None) -> int:

_TRANSPORT_NAMES: Final[Mapping[str, str]] = {
"ble": "Bluetooth",
"rfcomm": "Bluetooth Classic",
"usb": "USB",
"fake": "simulated",
}
"""What to call each way of reaching a hub.

The service's words are ``ble`` and ``usb``, which are the right names on the
wire and the wrong ones on a screen: nobody calls it BLE except us.
The service's words are ``ble``, ``rfcomm`` and ``usb``, which are the right
names on the wire and the wrong ones on a screen: nobody calls it BLE or RFCOMM
except us. Classic is Bluetooth's own name for the older radio the EV3 and NXT
use, and it keeps the two apart where both are in use.
"""


Expand Down Expand Up @@ -316,18 +319,23 @@ def signal_cell(device: pb_client.DeviceInfo, connected: Collection[str] = ()) -
connected: The ids of hubs the service is holding open.

Returns:
A meter for a hub that was heard over Bluetooth, and a word for the two
A meter for a hub that was heard over Bluetooth, and a word for the
cases where a meter would be a lie. A connected hub is not advertising,
so there is no signal to draw; a hub on a cable has no signal at all,
and drawing an empty meter for one would read as a hub that can barely
be heard when it is in fact the best-connected hub on the list.
be heard when it is in fact the best-connected hub on the list. A hub
listed over Bluetooth Classic was not heard at all: it is there because
it is paired, which says nothing about whether it is switched on.
"""
if device.id in connected:
return "[green]connected[/]"

if device.transport == "usb":
return "[green]USB[/]"

if device.transport == "rfcomm":
return "[dim]paired[/]"

return signal_bars(device.rssi)


Expand Down
15 changes: 15 additions & 0 deletions packages/pb-cli/tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,21 @@ def test_a_refused_serial_port_keeps_the_port_in_the_message() -> None:
assert problems[0].hint


def test_unusable_bluetooth_keeps_the_systems_words() -> None:
# They are what tells a radio switched off from one pb may not use, and
# the advice covers Bluetooth Classic as well as Low Energy.
exc = JsonRpcError(
ErrorCode.BLUETOOTH_UNAVAILABLE,
"Bluetooth cannot be used: Bluetooth is switched off",
)
problems = errors.explain(exc)

assert problems is not None
assert "switched off" in problems[0].message
assert problems[0].hint
assert "Low Energy" not in problems[0].hint


@pytest.mark.parametrize(
("platform", "expected"),
[
Expand Down
28 changes: 25 additions & 3 deletions packages/pb-cli/tests/test_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,27 @@ def test_a_hub_that_does_not_say_what_it_is_still_gets_a_row() -> None:
assert "Pybricks Hub" in console.export_text()


def test_a_paired_hub_is_not_drawn_as_a_faint_one() -> None:
# Bluetooth Classic lists the paired set, so nothing was heard. An empty
# meter would read as a hub at the edge of range rather than as one that
# may simply be switched off.
paired = pb_client.DeviceInfo(
id="rfcomm:00:16:53:4A:75:15",
name="Ostrich",
address="00:16:53:4A:75:15",
rssi=None,
transport="rfcomm",
hub_type="EV3 brick",
)
assert ui.signal_cell(paired) == "[dim]paired[/]"

console = _terminal()
console.print(ui.scan_table([paired]))
drawn = console.export_text()
assert "paired" in drawn
assert ui.SIGNAL_EMPTY not in drawn


def test_the_chooser_marks_the_row_it_is_on() -> None:
console = _terminal()
console.print(ui.chooser_table([_device("First"), _device("Second")], 1))
Expand Down Expand Up @@ -199,6 +220,7 @@ def test_a_version_in_a_message_is_drawn_in_one_piece(err: bool) -> None:
# "BLE" is our word, not a user's; nobody with a hub on a desk calls
# the radio that.
("ble", "Bluetooth"),
("rfcomm", "Bluetooth Classic"),
("usb", "USB"),
("fake", "simulated"),
],
Expand All @@ -214,6 +236,6 @@ def test_a_transport_the_service_did_not_report_names_nothing() -> None:


def test_an_unrecognised_transport_is_passed_through() -> None:
# RFCOMM is planned and not built. A name we do not have yet is still a
# better answer than none, and it cannot be wrong the way a guess could.
assert ui.transport_name("rfcomm") == "rfcomm"
# A newer service's transport. A name we do not have yet is still a better
# answer than none, and it cannot be wrong the way a guess could.
assert ui.transport_name("carrier-pigeon") == "carrier-pigeon"
1 change: 1 addition & 0 deletions packages/pb-service/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies = [
"bleak>=1.1.0",
"packaging>=22",
"serialx>=1.9",
"aio-rfcomm>=1.0",
"semver>=3",
"typing-extensions>=4.15",
]
Expand Down
85 changes: 78 additions & 7 deletions packages/pb-service/src/pb_service/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,27 @@

import logging
import os
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, cast

import anyio

from pb_service.hub.transport import HubTransport

if TYPE_CHECKING:
from aio_rfcomm import Bluetooth

logger = logging.getLogger(__name__)

__all__ = ["HUB_BACKEND_ENV_VAR", "get_hub_transport"]
__all__ = ["HUB_BACKEND_ENV_VAR", "get_hub_transport", "open_hub_transport"]

HUB_BACKEND_ENV_VAR = "PB_HUB_BACKEND"
"""Selects the transport.

Left alone, the service uses USB and Bluetooth together, which is what lets a
hub be found whichever way it happens to be reachable. Set it to ``usb`` or
``ble`` to use only one, or to ``fake`` for a simulated hub.
Left alone, the service uses every transport together, which is what lets a hub
be found whichever way it happens to be reachable. Set it to ``usb``, ``ble`` or
``rfcomm`` to use only one, or to ``fake`` for a simulated hub.
"""


Expand All @@ -39,7 +47,20 @@ def _ble() -> HubTransport:
return BleHubTransport()


def get_hub_transport() -> HubTransport:
def _rfcomm(bluetooth: Bluetooth | None) -> tuple[HubTransport, ...]:
if bluetooth is None:
return ()

from pb_service.hub.rfcomm import RfcommHubTransport

return (RfcommHubTransport(bluetooth),)


def _backend() -> str:
return os.environ.get(HUB_BACKEND_ENV_VAR, "").lower()


def get_hub_transport(bluetooth: Bluetooth | None = None) -> HubTransport:
"""Return the transport this service should use.

Every import here is inside the branch that needs it, so a service running
Expand All @@ -55,10 +76,14 @@ def get_hub_transport() -> HubTransport:
in exactly one place rather than once per backend. The simulated backend
is what lets the whole service be exercised on a CI runner with no radio
and nothing plugged in.

Args:
bluetooth: Bluetooth Classic, already open, or None to leave RFCOMM
out. :func:`open_hub_transport` opens it when it is wanted.
"""
from pb_service.hub.combined import CombinedTransport

backend = os.environ.get(HUB_BACKEND_ENV_VAR, "").lower()
backend = _backend()

if backend == "fake":
from pb_service.hub.fake import FakeHub, FakeHubTransport
Expand All @@ -77,11 +102,57 @@ def get_hub_transport() -> HubTransport:
if backend == "ble":
return CombinedTransport(_ble())

if backend == "rfcomm":
return CombinedTransport(*_rfcomm(bluetooth))

if backend:
logger.warning(
"unknown %s=%r; using every transport", HUB_BACKEND_ENV_VAR, backend
)

# In TRANSPORT_PREFERENCE order, which is what makes USB the one chosen when
# a hub is reachable both ways. A test holds the two together.
return CombinedTransport(_usb(), _ble())
return CombinedTransport(_usb(), _ble(), *_rfcomm(bluetooth))


@asynccontextmanager
async def open_hub_transport() -> AsyncGenerator[HubTransport, None]:
"""Open the transport this service should use, for as long as the block lasts.

Bluetooth Classic is held open in this scope when RFCOMM is in use, and
nowhere else: aio-rfcomm allows it open once per process, and everything
opened through it stops working when its block ends, so it has to outlive
every hub. It is opened the first time RFCOMM needs it, not here, so the
service never waits on it to start (see
:mod:`pb_service.hub.rfcomm.lazy`). If it cannot be opened, RFCOMM
reports Bluetooth as unusable and the other transports carry on.

Yields:
What :func:`get_hub_transport` returns.
"""
backend = _backend()

if backend in ("fake", "usb", "ble"):
yield get_hub_transport()
return

import aio_rfcomm.macos

from pb_service.hub.rfcomm.lazy import LazyBluetooth

# macOS asks the responsible process for Bluetooth permission, and a
# service started from a command line has no application to speak for it,
# so without this it is refused rather than asked. The answer is
# remembered under the name, so changing it asks the user again.
aio_rfcomm.macos.prompt_under_name(
name="Pybricks", reason="to connect to LEGO hubs over Bluetooth"
)

async with anyio.create_task_group() as tg:
# Cast: it offers only the calls the RFCOMM transport makes.
bluetooth = cast("Bluetooth", LazyBluetooth(tg))

try:
yield get_hub_transport(bluetooth)
finally:
tg.cancel_scope.cancel()
26 changes: 25 additions & 1 deletion packages/pb-service/src/pb_service/hub/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
__all__ = [
"PYBRICKS_COMMAND_EVENT_UUID",
"PYBRICKS_HUB_CAPABILITIES_UUID",
"PYBRICKS_RFCOMM_SERVICE_UUID",
"PYBRICKS_SERVICE_UUID",
"Capabilities",
"Command",
Expand All @@ -48,6 +49,7 @@
"encode_write_stdin",
"encode_write_user_program_meta",
"encode_write_user_ram",
"hub_kind_name",
"hub_type_name",
"parse_status_report",
"parse_version",
Expand All @@ -73,6 +75,14 @@ def _standard_uuid(short: int) -> str:
PYBRICKS_COMMAND_EVENT_UUID: Final = _pybricks_uuid(0x0002)
PYBRICKS_HUB_CAPABILITIES_UUID: Final = _pybricks_uuid(0x0003)

PYBRICKS_RFCOMM_SERVICE_UUID: Final = _pybricks_uuid(0x0101)
"""Service class of the hub's Bluetooth Classic serial port.

From the 0x01xx block, which is not GATT attributes. A dedicated class rather
than plain Serial Port, which would match every paired headset and GPS, or the
GATT service UUID, which would match every BLE-only hub.
"""

DEVICE_INFORMATION_SERVICE_UUID: Final = _standard_uuid(0x180A)
DEVICE_NAME_UUID: Final = _standard_uuid(0x2A00)
FIRMWARE_REVISION_UUID: Final = _standard_uuid(0x2A26)
Expand All @@ -97,7 +107,7 @@ def _standard_uuid(short: int) -> str:

The firmware header declares v1.6.0, whose addition was a USB interface: the
same commands and events inside a message envelope a byte stream needs, which
belongs to :mod:`pb_service.hub.usb.wire` rather than here. v1.7.0 is the
belongs to :mod:`pb_service.hub.stream.wire` rather than here. v1.7.0 is the
telemetry pair below, where the two upstreams disagree -- the firmware carries
it as unreleased, Pybricks Code already calls it v1.7.0 -- and the higher
number is the safer of the two to claim, since claiming it only ever admits
Expand Down Expand Up @@ -617,6 +627,20 @@ class VendorIdSource(IntEnum):
"""


def hub_kind_name(kind: HubType) -> str | None:
"""Name a kind of hub that is known without a PnP ID.

Args:
kind: Which hub.

Returns:
Its name, or ``None`` where the name depends on the product version
and so cannot be told from the kind alone, as for the large and small
Technic hubs.
"""
return _HUB_TYPE_NAMES.get((kind, None))


def hub_type_name(pnp: PnpId | None) -> str | None:
"""Name the kind of hub a PnP ID describes.

Expand Down
10 changes: 10 additions & 0 deletions packages/pb-service/src/pb_service/hub/rfcomm/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 The Pybricks Authors

"""RFCOMM transport: a hub reached over Bluetooth Classic serial."""

from __future__ import annotations

from pb_service.hub.rfcomm.transport import RfcommHubTransport, device_id_for

__all__ = ["RfcommHubTransport", "device_id_for"]
Loading
Loading