Skip to content

EV3 and NXT hubs over Bluetooth Classic (RFCOMM) - #6

Merged
dlech merged 7 commits into
mainfrom
rfcomm-ev3
Oct 18, 2026
Merged

dlech merged 7 commits into
mainfrom
rfcomm-ev3

Conversation

@dlech

@dlech dlech commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Adds an RFCOMM transport to pb-service, so EV3 and NXT hubs can be reached over Bluetooth Classic on Linux, macOS and Windows through aio-rfcomm 1.0.

  • Shared byte stream: COBS framing, the wire envelope and the link move from hub/usb to hub/stream. USB and RFCOMM share them. No behavior change for USB.
  • The transport (hub/rfcomm): discovery is the set of paired devices. An EV3 matches on its Pybricks service class. An NXT matches on plain Serial Port plus the "(MINDSTORMS NXT)" name suffix.
  • Wiring: the service opens Bluetooth once, under the "Pybricks" name. PB_HUB_BACKEND=rfcomm selects RFCOMM alone. RFCOMM ranks after BLE when a hub is reachable both ways. An unreachable hub reports HUB_NOT_CONNECTED.
  • CLI: rfcomm is a transport to ask for. RFCOMM rows in pb scan say "paired" instead of an empty signal meter. pb status says "Bluetooth Classic". When nothing is found over RFCOMM, it asks about pairing.

Tested against a Pybricks EV3 on firmware 4.1.0:

  • the hardware tests pass over RFCOMM on macOS 26.6.2 (7 of 7, with aio-rfcomm 1.0.0 from PyPI);
  • connect, status, run and disconnect work on Windows 10.0.26200.9711;
  • earlier, on Linux with BlueZ 5.85, the same checks passed, along with switching the hub off mid-session and opening it while off.

The changelogs are not updated here.

dlech added 4 commits October 17, 2026 23:52
The EV3 and NXT speak over an RFCOMM channel exactly what the other hubs speak
over USB: the same COBS variant, the same envelope from Pybricks Profile v1.6.0,
and the same rule of one request at a time. So everything that was about the
byte stream rather than about serial ports moves to hub/stream: cobs and wire
as they were, the link that was _UsbLink as StreamLink, and SerialPort as
BytePipe -- the two methods of a stream the link reads, which an RFCOMM channel
can offer as readily as a serial port can.

Nothing changes in behaviour. The one visible difference is wording: a
characteristic with no equivalent on a stream says so, rather than naming USB.
The EV3 and NXT have no BLE, so they are reached over RFCOMM, through
aio-rfcomm, which gives one asyncio API over BlueZ, IOBluetooth and WinRT. What
crosses the channel is the framed stream USB already carries, so the transport
is mostly a matter of finding hubs and handing the channel to StreamLink.

Finding them is not a scan and cannot be one. Bluetooth Classic pairs in the
operating system, so what is listed is the paired set: a hub appears as soon
as the computer knows it and stays listed whether it is switched on or not.
Only opening it says which, so a hub that is off arrives as ConnectionError
from open() -- the routine way to fail here, not an edge case.

A hub is recognised by its service class. The EV3 has its own, from the 0x01xx
block of the Pybricks UUIDs, which is not GATT attributes. The NXT publishes
only plain Serial Port, which every paired headset offers too, so one of those
counts only when its name ends in the "(MINDSTORMS NXT)" the firmware appends.
The same suffix gives the type for the scan's own column, and is taken off the
name shown.

Bluetooth being off, or this program not being allowed it, is reported as the
transport's one unavailable entry rather than raised, so the other transports
still answer. Discovery follows Bluetooth's availability as well as polling the
paired set every 2 s, so that entry appears and clears as soon as the switch
does.

aio-rfcomm comes from PyPI. It adds rubicon-objc on macOS and the WinRT RFCOMM
projection on Windows. On Linux it needs only dbus-fast, which bleak already
brings.
The service now opens Bluetooth Classic itself and hands it to the RFCOMM
transport, next to USB and BLE. aio-rfcomm allows it open once per process,
and everything opened through it stops when its block ends, so it is opened
around the hub manager and outlives every hub. PB_HUB_BACKEND=rfcomm uses it
alone, and is then the one case where failing to open it is an error; with
every transport in use, RFCOMM is left out with a warning and the rest work.

On macOS the service asks for Bluetooth under the name "Pybricks". A service
started from a command line has no application to speak for it, so without a
name it is refused rather than asked, and macOS remembers the answer under the
name, so it must not change.

RFCOMM ranks after BLE when a hub is reachable more than one way, because its
listing includes hubs that are switched off, where hearing a hub advertise
says it is there. In practice they rarely compete: a hub speaks one radio or
the other.

Connecting to a hub that cannot be reached now fails as HUB_NOT_CONNECTED with
the transport's reason in the log, rather than as an internal error. It was
reachable on USB by unplugging between the scan and the connect, and on RFCOMM
it is the ordinary result for a paired hub that is switched off.
pb scan and pb connect pass --transport through to the service unchecked, so
--transport rfcomm worked as soon as the service knew it. What the CLI says
around it had not caught up:

- The help named two transports, and now names three.
- A hub found over Bluetooth Classic had an empty signal meter, which reads
  as a hub at the edge of range. Nothing was heard at all: the listing is the
  paired set, and the hub may be switched off. Its row now says "paired".
- pb status showed the service's own word, "rfcomm". It is now "Bluetooth
  Classic", Bluetooth's name for the older radio, which keeps it apart from
  the BLE hubs shown as "Bluetooth".
- Finding nothing over RFCOMM means nothing is paired, so pb scan and
  pb connect --transport rfcomm now ask about pairing rather than suggesting
  the hub be switched on or its button pressed.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Handshake disconnects can produce internal errors, and Bluetooth adapter failures receive misleading USB troubleshooting advice.

2 open issues
What changed in this PR

Adds Bluetooth Classic support for EV3 and NXT hubs, sharing the existing USB byte-stream protocol.

Changes:

  • Adds paired-device discovery and RFCOMM connections through aio-rfcomm.
  • Extracts shared framing and link handling from USB.
  • Integrates transport selection, CLI labels, pairing guidance, and tests.
File Description
uv.lock Locks RFCOMM dependencies.
packages/​pb-service/​tests/​test_usb_link.py Updates shared-stream imports and expectations.
packages/​pb-service/​tests/​test_transport_preference.py Checks updated transport ordering.
packages/​pb-service/​tests/​test_rfcomm_transport.py Tests discovery and connection behavior.
packages/​pb-service/​tests/​test_rfcomm_pipe.py Tests channel adaptation and failures.
packages/​pb-service/​tests/​test_rfcomm_naming.py Tests hub-name suffix recognition.
packages/​pb-service/​tests/​test_cobs.py Uses the shared framing module.
packages/​pb-service/​src/​pb_service/​service.py Manages transport resource lifetime.
packages/​pb-service/​src/​pb_service/​methods/​hub.py Maps connection failures to service errors.
packages/​pb-service/​src/​pb_service/​hub/​usb/​transport.py Reuses the shared stream link.
packages/​pb-service/​src/​pb_service/​hub/​usb/​port.py Uses the shared byte-pipe interface.
packages/​pb-service/​src/​pb_service/​hub/​usb/​fake.py Adapts USB simulation to shared interfaces.
packages/​pb-service/​src/​pb_service/​hub/​transport.py Adds RFCOMM to transport preferences.
packages/​pb-service/​src/​pb_service/​hub/​stream/​wire.py Relocates the shared wire envelope.
packages/​pb-service/​src/​pb_service/​hub/​stream/​pipe.py Defines the byte-pipe interface.
packages/​pb-service/​src/​pb_service/​hub/​stream/​link.py Extracts reusable stream-link handling.
packages/​pb-service/​src/​pb_service/​hub/​stream/​cobs.py Relocates shared framing.
packages/​pb-service/​src/​pb_service/​hub/​stream/​__init__.py Documents the shared stream package.
packages/​pb-service/​src/​pb_service/​hub/​rfcomm/​transport.py Implements RFCOMM discovery and connections.
packages/​pb-service/​src/​pb_service/​hub/​rfcomm/​port.py Adapts Bluetooth channels and identifies hubs.
packages/​pb-service/​src/​pb_service/​hub/​rfcomm/​fake.py Provides simulated Bluetooth devices.
packages/​pb-service/​src/​pb_service/​hub/​rfcomm/​__init__.py Exports the RFCOMM transport.
packages/​pb-service/​src/​pb_service/​hub/​protocol.py Adds the service UUID and hub-kind naming.
packages/​pb-service/​src/​pb_service/​backend.py Opens Bluetooth and selects transports.
packages/​pb-service/​pyproject.toml Declares the RFCOMM dependency.
packages/​pb-cli/​tests/​test_ui.py Tests paired labels and transport names.
packages/​pb-cli/​src/​pb_cli/​ui.py Displays Bluetooth Classic and paired status.
packages/​pb-cli/​src/​pb_cli/​commands/​hub.py Adds pairing guidance and transport documentation.

Review effort: Balanced

Comment thread packages/pb-service/src/pb_service/hub/rfcomm/transport.py
Comment thread packages/pb-service/src/pb_service/methods/hub.py
dlech added 3 commits October 18, 2026 10:27
… open

With Bluetooth switched off or not allowed, RFCOMM cannot read the paired
set, and it reported the adapter as an unavailable device. Finding nothing
then raised SERIAL_PORT_UNAVAILABLE, and the CLI told the user to join
dialout or unplug a hub. An unavailable device now says whether it is a
port or the adapter. An adapter gives BLUETOOTH_UNAVAILABLE with the
system's own words, and the CLI's advice covers Bluetooth Classic too.
…error

Both stream transports read the link in a task group beside the caller's
block, and a group wraps whatever leaves it. A channel or cable that
dropped before the handshake finished came out as an ExceptionGroup,
which connecting did not catch and reported as INTERNAL_ERROR rather than
HUB_NOT_CONNECTED. StreamLink.running now owns that task group, and a
failure in it comes out as itself.
The service opened Bluetooth before it started listening, and opening it
does not return until the system says whether Bluetooth is usable. On
CI's macOS runners that never happened, so the service never started and
every end-to-end test that runs one timed out, USB and BLE included. A
Mac whose user has not yet answered the permission prompt would do the
same.

It is now opened the first time a scan or a connection over RFCOMM asks,
in a task of the transport's own scope, as bleak does for BLE. A service
used only over USB never starts aio-rfcomm's helper. If opening fails,
RFCOMM reports Bluetooth as unusable and the other transports carry on.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Cross-platform Bluetooth permissions and lifecycle handling warrant human review, and the name-preservation defect remains unresolved.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Preserve hub name whitespace when removing firmware suffix

packages/​pb-service/​src/​pb_service/​hub/​rfcomm/​port.py:106

strip() removes whitespace belonging to the hub's actual name, not just the firmware separator. For example, " Bumblebee (MINDSTORMS EV3)" becomes "Bumblebee", while the handshake preserves " Bumblebee ". RFCOMM discovery compares names by exact equality, so connecting by the original name fails. Remove only the single separator space before the suffix, and add a regression case with leading and trailing whitespace.

2 resolved since last review

Review effort: Balanced

@dlech
dlech merged commit ac67b65 into main Oct 18, 2026
46 checks passed
@dlech
dlech deleted the rfcomm-ev3 branch October 18, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants