Skip to content

drv/bluetooth: Scan at a 50% duty cycle so broadcasting works. - #500

Merged
dlech merged 1 commit into
pybricks:masterfrom
dlech:bluetooth-scan-duty-cycle
Sep 12, 2026
Merged

drv/bluetooth: Scan at a 50% duty cycle so broadcasting works.#500
dlech merged 1 commit into
pybricks:masterfrom
dlech:bluetooth-scan-duty-cycle

Conversation

@dlech

@dlech dlech commented Sep 9, 2026

Copy link
Copy Markdown
Member

All three Bluetooth drivers scanned with the window equal to the interval, 30ms of every 30ms, so the radio was never free and a hub that broadcasts while observing could hardly transmit. Measurements in https://github.com/orgs/pybricks/discussions/2822 show a City Hub dropping from 28 packets/s to under 4 when it broadcasts and observes at the same time.

Curiously it was fine when connected to a computer. The reason is that GAP_DeviceDiscoveryRequest() picks TGAP_CONN_SCAN_INT and _WIND when a connection exists and the general discovery parameters when it does not. We only set the latter, so the chip fell back to TI's defaults of 300ms interval and 150ms window, a 50% duty cycle, whenever a computer was connected. Using a 50% duty cycle always is what this does.

The scan interval is also kept shorter than the advertising interval of a peer hub. When the two periods are close, the phase between them drifts slowly, misses come in runs and reception latency is spiky rather than merely averaging out.

Broadcasting on the BTstack hubs went from every 100ms to every 30ms. 100ms was the Bluetooth 4.x minimum for non-connectable advertising, but the CC2564C is 5.1, where the minimum is 20ms, so the old value only limited how quickly other hubs could hear it. The Move Hub keeps 100ms because BlueNRG-MS is a 4.1 controller and cannot go faster; it is the most limited hub, so it is allowed to be the slow one rather than holding the others back.

Expected reception latency is now roughly 50 to 60ms between any pair of hubs, except from the Move Hub at about 200ms.

@dlech
dlech marked this pull request as ready for review September 9, 2026 01:00
@laurensvalk

Copy link
Copy Markdown
Member

It sounds like this was quite extensively tested. Thank you! I would be happy to merge without further testing from my side.

Measurements in https://github.com/orgs/pybricks/discussions/2822 show a City Hub dropping from 28 packets/s to under 4 when it broadcasts and observes at the same time.

I've been trying to find the video corresponding to this test somewhere in the issue pages but haven't found it yet.

It was showing that using the Powered Up remote with one City train and broadcasting to the other was essentially instant. It would be nice to reproduce something like that as a quick extra test. Maybe it as just that fast because each hub had only one role?

All three Bluetooth drivers scanned with the window equal to the interval,
30ms of every 30ms, so the radio was never free and a hub that broadcasts
while observing could hardly transmit. Measurements in
https://github.com/orgs/pybricks/discussions/2822 show a City Hub dropping
from 28 packets/s to under 4 when it does both at once.

Halving the window is not enough on its own: a 20ms interval with a 10ms
window only reached 5.7 packets/s. Duty cycle does not set throughput by
itself, because starting and ending a scan costs radio turnaround and
scheduler time, and a 20ms period pays that fifty times a second. Use a 70ms
interval with a 35ms window, which measured 12.3 to 13.0 packets/s. 70ms is
not a multiple of the 25 to 30ms advertising interval of the faster hubs nor
of the Move Hub's 100ms, so reception does not settle into a pattern of
repeatedly missing a peer.

Broadcasting on the BTstack hubs goes from every 100ms to every 30ms. 100ms
was the Bluetooth 4.x minimum for non-connectable advertising, but the
CC2564C is 5.1, where the minimum is 20ms, so the old value only limited how
quickly other hubs could hear it. The Move Hub keeps 100ms because the
BlueNRG-MS is a 4.1 controller and cannot go faster; it is the most limited
hub, so it is allowed to be the slow one rather than holding the others
back.

City Hub and Technic Hub need one more setting. TI documents
TGAP_CONN_SCAN_INT and _WIND as the scan parameters for the Link Layer
Initiating state, in other words for connecting to a device, so we set only
the general discovery pair. But GAP_DeviceDiscoveryRequest() uses the
connection pair in place of the discovery pair whenever a connection exists,
which is ordinary observing and not initiating at all. With a computer
connected the chip therefore used TI's defaults of a 150ms window every
300ms. That is also a 50% duty cycle, so it looked harmless, and transmit
throughput even appeared better when connected.

Timestamping every advertisement received shows why it was not harmless. A
City Hub observing a Technic Hub that only broadcasts received 4.9 packets/s
while connected against 16.5 standalone, and the gaps between updates were
either under 40ms or over 150ms with nothing in between: one short burst of
reception every 300ms. The window never ran to completion either, because a
connection event cuts the scan short and the remainder is discarded rather
than resumed, leaving about 48ms of the 150ms. Transmit throughput looked
good for the same reason the hub could barely hear anything.

Worse, 300ms is a whole number of connection intervals for every interval a
host is likely to choose, so the offset between a scan window and the next
connection event never drifts and the same slice is lost every time. Apple
hosts use 15ms, which would cap the effective window at a 5% duty cycle.

Setting the pair to the same 70ms and 35ms brings reception while connected
to 11.5 packets/s, and the typical wait for an update from 192ms down to
71ms against 64ms standalone. The worst case is unchanged at about 300ms,
now an occasional stall rather than the normal cycle. A connected hub
broadcasting and observing at once still transmits about 19 packets/s, so
none of this was paid for out of the first fix.
@dlech
dlech force-pushed the bluetooth-scan-duty-cycle branch from 58edc4d to e814ff0 Compare September 11, 2026 23:30
@dlech

dlech commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

Maybe it was just that fast because each hub had only one role?

Yes, things worked really great before if one hub was only broadcasting and one hub was only observing.

And broadcasting while connected worked really good before too.

So that demo was doing all of the things that work really well. It was all of the other cases that didn't work so well.

@dlech

dlech commented Sep 12, 2026

Copy link
Copy Markdown
Member Author
VID_20260911_185614967.mp4
VID_20260911_185648552.mp4

@dlech

dlech commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

In one of these videos, both hubs are connected to Pybricks Code and in one they are not. Can anyone tell the difference?

(And yes, the remote is connected to the technic hub, which is broadcasting and the city hub is only observing.)

@dlech

dlech commented Sep 12, 2026

Copy link
Copy Markdown
Member Author
# Remote control over broadcast, the sending half. Broadcasts only.
#
# This hub is connected to a Powered Up Handset and broadcasts the motor speed
# the buttons are asking for. It never observes. Run remote_motor_rx.py on a
# second hub, which drives the motor.
#
# Worth knowing what this exercises: the Handset is a real Bluetooth
# connection, so this hub is broadcasting while connected even though no
# computer is involved. That is the case the connected scan settings fix
# addresses, and pressing a button is a good way to feel the difference.
#
# Buttons:
#   left +/-    full speed forwards or backwards
#   right +/-   a third of that, for finer control
#   nothing     stop

from pybricks.hubs import ThisHub
from pybricks.parameters import Button, Color
from pybricks.pupdevices import Remote
from pybricks.tools import wait

CHANNEL = 1
SPEED = 900

hub = ThisHub(broadcast_channel=CHANNEL)

hub.light.on(Color.YELLOW)
remote = Remote()
hub.light.on(Color.GREEN)

last = None

while True:
    pressed = remote.buttons.pressed()

    if Button.LEFT_PLUS in pressed:
        speed = SPEED
    elif Button.LEFT_MINUS in pressed:
        speed = -SPEED
    elif Button.RIGHT_PLUS in pressed:
        speed = SPEED // 3
    elif Button.RIGHT_MINUS in pressed:
        speed = -SPEED // 3
    else:
        speed = 0

    # Only send on a change. The radio keeps advertising the last value on its
    # own, so repeating it would just spend Bluetooth commands to say the same
    # thing, and setting the remote's light every cycle would do the same.
    if speed != last:
        hub.ble.broadcast(speed)
        remote.light.on(
            Color.GREEN if speed > 0 else Color.RED if speed < 0 else Color.NONE
        )
        last = speed

    wait(10)
# Remote control over broadcast, the receiving half. Observes only.
#
# Drives a motor at whatever speed remote_motor_tx.py is broadcasting. This hub
# never transmits, so all of its radio time goes to listening.
#
# The status light shows what the link is doing: green while driving, yellow
# while stopped on request, red when nothing has been heard for a second.

from pybricks.hubs import ThisHub
from pybricks.parameters import Color, Port
from pybricks.pupdevices import Motor
from pybricks.tools import wait

CHANNEL = 1

hub = ThisHub(observe_channels=[CHANNEL])
motor = Motor(Port.A)

hub.light.on(Color.RED)
applied = None

while True:
    speed = hub.ble.observe(CHANNEL)

    if speed is None:
        # observe() gives None when nothing has arrived for a second, which
        # means the link is gone rather than someone asking for a stop. Coast
        # instead of braking, so a moving robot is not stopped dead by a hub
        # going out of range.
        if applied is not None:
            motor.stop()
            hub.light.on(Color.RED)
            applied = None

    elif isinstance(speed, int) and speed != applied:
        # Anyone can broadcast on this channel, so ignore anything that is not
        # a speed rather than raising in the middle of a movement.
        if speed == 0:
            motor.brake()
            hub.light.on(Color.YELLOW)
        else:
            motor.run(speed)
            hub.light.on(Color.GREEN)
        applied = speed

    wait(10)

Note: These are AI generated programs (because time is valuable). I think the first one is pretty good. The second one is mostly good but I would just do else: instead of elif isinstance(speed, int) and speed != applied: (and remove all of the applied stuff). It seems a bit extra. It doesn't hurt to call motor or light methods over and over. The hub has nothing better to do with it's time anyway. And silently ignoring the wrong type of data isn't great. Wrong data should fail loudly, IMHO.

@BertLindeman

Copy link
Copy Markdown
Contributor

WOW!

@dlech
dlech merged commit 9d2a823 into pybricks:master Sep 12, 2026
15 checks passed
@dlech
dlech deleted the bluetooth-scan-duty-cycle branch September 12, 2026 14:52
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.

3 participants