Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BLE Broadcast v2 #158

Merged
merged 10 commits into from
May 16, 2023
Merged

BLE Broadcast v2 #158

merged 10 commits into from
May 16, 2023

Conversation

dlech
Copy link
Member

@dlech dlech commented Apr 14, 2023

This is an alternate attempt at hub to hub communication using BLE advertising data similar #80. (Thanks again @NStrijbosch and @laurensvalk for all of the work done there.)

Differences from #80

  • Advertising and scanning and being connected to Pybricks Code and being connected to a remote on the Technic hub all at the same time seems to be working, so there is no background process to switch between advertising and scanning. The previous observed problems could have been caused by bugs in the existing Bluetooth driver code that have (hopefully) been fixed since then.
  • Support for Move hub is added.
  • Since LEGO has announced the discontinuation of MINDSTORMS, compatibility with the official firmware seems less important, so this has a different data encoding scheme and uses non-connectable advertisements.
    • The new encoding scheme allows us to squeeze in more data.
    • Using non-connectable advertisements is the "right" way to do this according to BLE author recommendations and prevents other devices from connecting while broadcasting. It also sends data less frequently (every 100ms) which should reduce radio interference.
  • Different Python API that better fits new encoding scheme.

Limitations

  • Broadcast (sending non-connectable advertisements in any form) does not work on City hub. This appears to be a firmware bug (it has the same chip as Technic hub but different firmware version).
  • Broadcast on Move hub only works when not connected to Pybricks Code, otherwise it raises OSError with EPERM.
  • Using broadcast and observe on the Move hub at the same time has very inconsistent (and usually very high) latency.
  • Using observe while connected to Pybricks Code results in inconsistent latency on smaller hubs. SPIKE hubs seem to work well even when connected to Pybricks Code and city/technic hubs work better when not connected to Pybricks Code.

Python API

This adds a new ble attribute to the Hub object with the following methods and extends the Hub constructor with additional arguments.

class BLE:
    def broadcast(*args: None | bool | int | float | str | bytes) -> None:
        """
        Starts broadcasting advertising data containing *args*.

        Params:
            *args: Zero or more values to be broadcast.

        .. note:: Advertising data size is quite limited. For example, you
            can send one string or byte array that is 25 bytes or 12
            ``True``/``False`` values or 5 floating point values or any
            combination of values as long as the total packed size doesn't
            exceed the available space. The exact technical specification
            can be found at ...

        Example::

            # Broadcast 3 values.
            hub.ble.broadcast(100, "hi", False)

            # Broadcast no values. Useful if you only care about RSSI.
            hub.ble.broadcast()

        Bad example::

            # Dont' do this! The first value will never be broadcast.
            hub.ble.broadcast("first")
            hub.ble.broadcast("second")
        """

    def observe(channel: int) ->  tuple[None | bool | int | float | str | bytes, ...] | None:
        """
        Retrieves the last observed data for a given channel.

        Args:
            channel: The channel to observe (0 to 255).

        Returns:
            A tuple of the most recent RSSI and the advertising data or ``None`` if no data
            has been received yet or it has been more than one second since the last data
            was received.

        Example::

            data = hub.ble.observe(channel=0)

            if data is not None:
                a, b, c = data
                # if the other hub called hub.ble.broadcast(100, "hi", False)
                # then a == 100 and b == "hi" and c is False
                ...

        Bad example::

            # Don't do this! This will fail the first time it is called because no
            # data has been received yet causing unpacking to fail.
            a, b, c = hub.ble.observe(channel=0)
        """

    def signal_strength(channel: int) -> int:
    """
    Gets the average signal strength in dBm for the given channel.

    This is useful for detecting how near the broadcasting device is. A close
    device may have a signal strength around -40 dBm while a far away device
    might have a signal strength around -70 dBm.

    Args:
        channel: The channel number (0 to 255).

    Returns:
        The signal strength or ``-128`` if nothing has been received yet or the
        broadcasts are no longer being received.
    """

    def version() -> str:
        """
        Gets the firmware version from the Bluetooth chip.

        Example::

            print(hub.ble.version())
        """


class Hub(..., broadcast_channel=1, observe_channels=[]):
    """
    Params:
        broadcast_channel:
            A value from 0 to 255 indicating which channel ``hub.ble.broadcast()``
            will use. Default is channel 0.
        observe_channels:
            A list of channels channel to listen to when
            ``hub.ble.observe()`` is called. Listening to more channels
            requires more memory. Default is an empty list (no channels).

    Example::

        # Broadcast on channel 1. Observe channels 2 and 3.
        hub = PrimeHub(broadcast_channel=1, observe_channel=[2, 3])
    """
    ...
    ble: BLE

Ideas for changes to current implementation

  • (fixed) Known issue: RSSI is initially 0 instead of -127.
  • (done) RSSI should change to -127 after some timeout of not receiving new advertisement data. How long should this be? 1 second, 10 seconds?
  • (done) last_observe_channel should be changed observe_channels and take an iterable of channels. The range of allowable channel values for both broadcast and observe can then be expanded to 0 to 255 instead of 0 to 15. This would be nice to make it easier to avoid using the same channels as your neighbor.
  • (done) Furthermore, BLE scanning could be enabled at construction time rather than the first time hub.ble.observer() is called.
  • State in documentation that broadcasting is not supported on Move hub and City hub and recommend using nRF UART instead if needed on those hubs. Broadcast would be modified to to simply always raise an error on those hubs (would probably save quite a bit of firmware size on Move hub which is always a good thing).

@dlech
Copy link
Member Author

dlech commented Apr 14, 2023

Here are the programs I was using during testing:

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

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

print("radio fw", hub.ble.version())

hub.light.on(Color.GREEN)

prev = None

while True:
    data = hub.ble.observe(1)
    
    if data is not None:
        target = data[0]
        motor.track_target(target)

        if target != prev:
            print(*data)
            prev = target
    else:
        motor.track_target(0)

    wait(10)
from pybricks.hubs import ThisHub
from pybricks.pupdevices import Remote, Motor
from pybricks.tools import wait
from pybricks.parameters import Color, Button, Port

hub = ThisHub(broadcast_channel=1)
motor = Motor(Port.A)

print("radio fw", hub.ble.version())

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

hub.light.on(Color.GREEN)
remote.light.on(Color.GREEN)


def button_oneshot(button: Button):
    prev = False
    oneshot = None

    while True:
        new = yield oneshot
        
        if new is None:
            continue

        now = button in new

        if now == prev:
            oneshot = None
        else:
            oneshot = prev = now

def make_button_oneshot(button):
    gen = button_oneshot(button)
    gen.send(None)
    return gen


left_plus_oneshot = make_button_oneshot(Button.LEFT_PLUS)
left_minus_oneshot = make_button_oneshot(Button.LEFT_MINUS)

right_plus_oneshot = make_button_oneshot(Button.RIGHT_PLUS)
right_minus_oneshot = make_button_oneshot(Button.RIGHT_MINUS)

local_target = 0
remote_target = 0

while True:
    motor.track_target(local_target)

    pressed = remote.buttons.pressed()

    if left_plus_oneshot.send(pressed):
        local_target += 45

    if left_minus_oneshot.send(pressed):
        local_target -= 45

    if right_plus_oneshot.send(pressed):
        remote_target += 45
        hub.ble.broadcast(remote_target)

    if right_minus_oneshot.send(pressed):
        remote_target -= 45
        hub.ble.broadcast(remote_target)

    wait(10)

This uses the LEGO Powered Up remote to control 2 motors, one connected to each of two different hubs.

The left side of the controller controls the motor on the hub the remote is connected to and the right side of the controller controls the motor connected to the secondary hub.

@dlech
Copy link
Member Author

dlech commented Apr 14, 2023

For higher-level documentation, we should also explain proper usage of this. The main point to get across is that this type of communication is best suited to sending output state rather than commands/events or input state. (The technical reason being this is unreliable (delivery is not guaranteed), low-bandwidth communication.) This is why in my test program, it is sending the target motor angle rather than button press events or button state.

@coveralls
Copy link

coveralls commented Apr 14, 2023

Coverage Status

Coverage: 52.167% (-0.4%) from 52.576% when pulling 3d8fa4f on ble-broadcast-2 into e0cb011 on master.

@laurensvalk
Copy link
Member

Nice work!

Would it be better if the received data is packed the same as the sent data? In this example the sender has:

hub.ble.broadcast(target)

but the receiver has:

target = data[0]   # Why is unpacking needed here, but packing not needed on sender?

It would be nice if packing and unpacking was the same, or even absent altogether for single-valued data.

Originally posted by @laurensvalk in a02c18c#r108901780

last_observe_channel should be changed observe_channels and take an iterable of channels

👍

@dlech
Copy link
Member Author

dlech commented Apr 20, 2023

Would it be better if the received data is packed the same as the sent data?

According to intellesense, it is the same. The packing in a tuple is just implicit because of the *args.

image

@dlech dlech force-pushed the ble-broadcast-2 branch 3 times, most recently from ee4345c to 400bebf Compare April 24, 2023 20:17
@dlech
Copy link
Member Author

dlech commented Apr 24, 2023

updated.

(builds now require Pybricks Beta >= v2.2.0-beta.3 released 2023-04-24)

@dlech dlech force-pushed the ble-broadcast-2 branch 2 times, most recently from 38727c3 to e50a5fa Compare April 24, 2023 20:56
@laurensvalk
Copy link
Member

laurensvalk commented Apr 25, 2023

Would it be better if the received data is packed the same as the sent data?

According to intellesense, it is the same.

If it's the same in a technical sense, it would be nice to pick the simplest for users (which might not be the * operator).

I've added a proposal to get it up the same level as #80. So allowing int/float/bool/str or a tuple thereof. It is quite a small change, without adding any extra encoding fields.

I've also added a 1 second timeout so that you can easily detect that the sender has stopped.

Do we want a way to stop broadcasting? Since None is already used as valid data, we can't use that. Perhaps using no args (the empty tuple) is the most intuitive way to achieve that? This matches what you see on the receiving end when there was no data to begin with. If you really want to send "nothing" just to provide RSSI, you could always just send None.

Personally, I would perhaps drop None as an allowed data type, and use it to indicate no data (e.g. if data is None), but either will work.

@dlech
Copy link
Member Author

dlech commented Apr 25, 2023

I've added a proposal to get it up the same level as #80. So allowing int/float/bool/str or a tuple thereof. It is quite a small change, without adding any extra encoding fields.

Thank you for the suggestion. However, I am really very happy with the simplicity of the the way I had implemented it. I disagree that hub.ble.broadcast(["a", "b"]) is simpler than hub.ble.broadcast("a", "b") because the former requires one to remember an extra rule that you have to enclose the args in a list. Likewise, treating the one-arg case different from the multiple arg case requires one to have to remember another extra rule. I find writing code much more enjoyable when I don't have to remember so many rules. And practically, fewer rules makes explaining and documenting it simpler as well.

Do we want a way to stop broadcasting?

If we find there is demand for this, we can always add it later. I think we should just add a new method for this if we need it rather than trying to put two separate functions into a single method.

@laurensvalk
Copy link
Member

laurensvalk commented Apr 25, 2023

Thank you for the suggestion. However, I am really very happy with the simplicity of the the way I had implemented it.

Having implemented the MicroPython module side of #80 originally, so was I 😄

I've moved the explorations to a separate branch and restored this one.

It still doesn't seem that intuitive to me to treat data on the receiving end different (two unpacking) from the sending end (no packings). Even if intellisense says it is the same, I usually look at it in terms of the resulting code. I'll explore a bit further on this one, including some suggestions for rssi.

I find writing code much more enjoyable when I don't have to remember so many rules.

Right, in this variant the one rule is: object goes in, same object comes out, instead of having to remember to unpack kwargs with * or do multiple unpackings on the receiving end. ( So data_rx=data_tx, as opposed to data_rx = data_tx[1][0])

@laurensvalk
Copy link
Member

This is good by the way --- heated API debates remind me of 2018, and we've made some pretty good stuff since then 😄

@dlech dlech force-pushed the ble-broadcast-2 branch 3 times, most recently from 5dc72d5 to c0f581a Compare April 27, 2023 00:51
@dlech
Copy link
Member Author

dlech commented May 2, 2023

Updated with some suggestions from Laurens.

Known issue:

On Technic hub, if observing is started before creating a Remote() object, creating the Remote() object will fail with RuntimeError. Probably because the Bluetooth stack is already scanning and needs to do a different scan to connect to the remote. To fix this we could make the Bluetooth driver "smart" and stop and restore the observer scanning when connecting to the remote.

@dlech dlech force-pushed the ble-broadcast-2 branch from df58454 to 8f7ba06 Compare May 2, 2023 17:26
@laurensvalk
Copy link
Member

laurensvalk commented May 2, 2023

pybricks.common.BLE: return None if no observed data

Nitpick, this gets somewhat confusing:

hub.ble.broadcast(None)
data = hub.ble.observe(0)

if data is None:
    # It's not none? 🤔

I think we could perhaps do without 8f7ba06.

Since the empty set is used for no data, users can still do if data to check for presence of data.

@laurensvalk
Copy link
Member

Idea: How about merging it up to 20ba223 already and use a follow up PR for the next round of improvements like:

  • Detect/handle no data as in 8f7ba06
  • Decide what to do with City Hub
  • Decide what to do with Move Hub
  • Known issue about technic hub combined with Remote

This would be a convenient way to bring it into day-to-day testing and the next beta round.

On the topic of City/Move Hub, even if we decide to have only scanning, we could make it work intuitive enough in the docs and autocomplete similar to the IMU being a superset (inherited) of the simpler Accelerometer of the Move Hub: Only the available methods are be documented/autocompleted.

@dlech
Copy link
Member Author

dlech commented May 7, 2023

Simpler example where motor on Hub 1 mirrors motor that is manually turned on Hub 2.

Hub 1:

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

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

print("radio fw", hub.ble.version())

prev = None

while True:
    data = hub.ble.observe(2)
    
    if data is None:
        hub.light.on(Color.RED)
        motor.track_target(0)
    else:
        hub.light.on(Color.GREEN)

        target = data[0]

        motor.track_target(target)

        if target != prev:
            print(target)
            prev = target

    # broadcasts cannot be sent/received faster than once every 100ms
    wait(100)

Hub 2:

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

hub = ThisHub(broadcast=2)
motor = Motor(Port.A)

print("radio fw", hub.ble.version())

prev = None

while True:
    target = motor.angle()

    hub.ble.broadcast(target)

    if target != prev:
        print(target)
        prev = target

    # broadcasts cannot be sent/received faster than once every 100ms
    wait(100)

@laurensvalk
Copy link
Member

I think we could perhaps do without 8f7ba06.

For what it's worth, I'm fine keeping this in also after thinking about it some more.

So, if there are no technical hurdles that could make other Bluetooth developments more complicated, I vote to merge.

Move Hub (...) City Hub (...)

Maybe we could start by having broadcast disabled on these?

dlech added a commit to pybricks/pybricks-api that referenced this pull request May 10, 2023
This adds a new BLE class that is used for connectionless broadcasting/
observing on hubs with built-in Bluetooth Low Energy.

Also see pybricks/pybricks-micropython#158.
dlech added a commit to pybricks/pybricks-api that referenced this pull request May 10, 2023
This adds a new BLE class that is used for connectionless broadcasting/
observing on hubs with built-in Bluetooth Low Energy.

Also see pybricks/pybricks-micropython#158.
dlech added a commit to pybricks/pybricks-api that referenced this pull request May 10, 2023
This adds a new BLE class that is used for connectionless broadcasting/
observing on hubs with built-in Bluetooth Low Energy.

Also see pybricks/pybricks-micropython#158.
dlech added a commit to pybricks/pybricks-api that referenced this pull request May 10, 2023
This adds a new BLE class that is used for connectionless broadcasting/
observing on hubs with built-in Bluetooth Low Energy.

Also see pybricks/pybricks-micropython#158.
dlech and others added 9 commits May 16, 2023 12:14
This adds a new function to the Bluetooth drivers to get the firmware
version from of the Bluetooth chip.
This adds a new ble module for Bluetooth Low Energy functions and adds
it to supported hubs. To start, this contains one function to get the
firmware version from the Bluetooth chip. Additional functions will be
added later.
This adds new Bluetooth driver APIs to enable broadcasting and
observing.

Technically speaking, we aren't using the BLE Broadcaster Mode or
Observer Mode since those are not available on all of the Bluetooth
chips. However, the end result is essentially the same - the same
data is going over the air.

There appears to be a firmware bug in the Bluetooth chip on the city
hub that prevents broadcasting non-connectable advertisements from
being transmitted over the air even though the commands succeed without
error, so this is noted in the code comments.
This adds new methods to broadcast and observe advertising data using
Bluetooth Low Energy. The communication protocol uses the LEGO
manufacturer-specific data similar to the experimental protocol
from the official Robot Inventor firmware. Multiple Python objects
are serialized using a compressed format to squeeze as many objects
into the 31 byte advertising data as possible.

Since the BLE object is initialized on the hub object, the
initialization parameters are added to the Hub constructors.

Each hub can only broadcast on a single "channel" and can receive on
multiple channels.
This provides a way for the receiver to know that the sender has stopped.
Now that we have an explicit set of channels to observe, it makes sense
to start observing when the class is initialized, instead of starting
to scan all channels when calling observe on channel X.
Now that the RSSI is filtered, it no longer belongs to a particular
observe event, so there is no benefit of returning it from the same
method.

This simplifies the observe method to deal only with data, so it
is a bit closer to what the broadcast is doing.
This modifies ble.observe() to return None if no data has been observed
yet or there has been a time out since the last received data. This way
there is an unambiguous way to check for valid data.
@dlech dlech force-pushed the ble-broadcast-2 branch from 8f7ba06 to b0821dc Compare May 16, 2023 19:20
If observing has already started when connecting to another Bluetooth
device, we need to pause observing (which is passive scanning) so that
we can do active scanning to find the device we want to connect to.
Then we need to restore passive scanning for observing once active
scanning is complete.

This is done on CC2540 and BTStack. On BlueNRG, the chip can't handle
observing while being connected to another device so we just raise
an error instead.
@dlech dlech force-pushed the ble-broadcast-2 branch from b0821dc to 3d8fa4f Compare May 16, 2023 19:32
@dlech dlech merged commit 3d8fa4f into master May 16, 2023
@dlech dlech deleted the ble-broadcast-2 branch May 16, 2023 19:46
dlech added a commit to pybricks/pybricks-api that referenced this pull request May 16, 2023
This adds a new BLE class that is used for connectionless broadcasting/
observing on hubs with built-in Bluetooth Low Energy.

Also see pybricks/pybricks-micropython#158.
@michaelwareman
Copy link

michaelwareman commented May 20, 2023

I had a change to play with my Johnny 5 robot and using the broadcast feature. It all started when I wanted to test the robot without first connecting to the PC (Windows 10) The robot did nothing. I thought maybe it was due to the print statements in the code. So, I commented them out and the robot does not do anything. If I uncomment the statements the robot moves its arms. I then got a message that there was a new beta.pybricks.com so I restarted both browser tabs. No change. I then also thought there might be a new beta firmware version. I downloaded the pybricks-code-2.2.0-beta.3.zip file. When I add that to the beta.pybricks.com for firmware using the advanced feature I get this error message:
FirmwareReaderError: missing firmware-base.bin
at new t (https://beta.pybricks.com/static/js/main.14781b98.js:2:794746)
at Function. (https://beta.pybricks.com/static/js/main.14781b98.js:2:795612)
at https://beta.pybricks.com/static/js/main.14781b98.js:2:793287
at Object.next (https://beta.pybricks.com/static/js/main.14781b98.js:2:793392)
at a (https://beta.pybricks.com/static/js/main.14781b98.js:2:792105
The code for the two hubs is listed below. Why does the code work when the print statements are active but not when they are commented out? Also, it is real annoying to have to restart the computer every couple of runs because I cannot connect the bluetooth to the one or more hubs. Sometimes the one of the hubs will turn off or not stop the program.

Something else I have not figured out, but that is minor, is how to find out what position the arms are in when the robot starts up? I am using the Lego Spike/Inventor motors for the arms.
Mike

'''
#Upper hub with arms
from pybricks.hubs import InventorHub
from pybricks.pupdevices import Remote, Motor
from pybricks.tools import wait
from pybricks.parameters import Color, Button, Port, Direction

hub = InventorHub(broadcast_channel=1, observe_channels=[1, 2])
left_arm_motor = Motor(Port.E, Direction.COUNTERCLOCKWISE, [20, 60])
right_arm_motor = Motor(Port.F, Direction.CLOCKWISE, [20, 60])

hub.light.on(Color.YELLOW)

while True:
left_arm_target = left_arm_motor.angle()
right_arm_target = right_arm_motor.angle()
hub.ble.broadcast(left_arm_target, right_arm_target)
print(left_arm_target, right_arm_target)

data = hub.ble.observe(2)

if data is not None:
    left_arm_target = data[0]
    right_arm_target = data[1]
    #arms move one at a time
    #left_arm_motor.run_target(75, left_arm_target)
    #right_arm_motor.run_target(75, right_arm_target)
    #arms move together
    left_arm_motor.run_target(75, left_arm_target, wait=False)
    right_arm_motor.run_target(75, right_arm_target, wait=False)

    #print(left_arm_target, right_arm_target)

Drive Hub (lower hub)

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

hub = InventorHub(broadcast_channel=2, observe_channels=[1])

right_arm_target_angle = 0
right_arm_angle = 0
left_arm_angle = 0
left_arm_target_angle = 0

hub.light.on(Color.GREEN)

while True:
data = hub.ble.observe(1)

if data is not None:
    left_arm_angle = data[0]
    right_arm_angle = data[1]

    #move arms to down ititialization position
    if left_arm_angle < 10:
        left_arm_target_angle = 300

    if right_arm_angle < 10:
        right_arm_target_angle = 300

print(left_arm_angle, right_arm_angle)
hub.ble.broadcast(left_arm_target_angle, right_arm_target_angle)

'''

@dlech
Copy link
Member Author

dlech commented May 21, 2023

I downloaded the pybricks-code-2.2.0-beta.3.zip file

This is not a firmware file which is why it fails to flash to the hub. The current beta has support for BLE broadcasting so you no longer need a special firmware.zip file, just flash using https://beta.pybricks.com.

In the future, if you need support the best place to ask is https://github.com/orgs/pybricks/discussions.

@vardayzsolt
Copy link

I tested the new example programs included in the documentation of hub.ble.broadcast / observe functions included in latest 2.2.0-beta.4 release. I'm really happy to see it finally implemented, thank you for that!
While connected to Pybricks API, everything goes well, just as expected. But on Technic Hub there is a consistent problem of not broadcasting any data when the code is run directly from the hub (after disconnecting from pc). The other Techic Hub acting as receiver keeps receiving ble signal when disconnected from pc and code run from directly hub. When code is adapted to Inventor Hub and started directly from Hub everything goes fine. So the problem occurs only on Tecyhnic Hub and only in broadcasting mode. I tested on several Technic Hubs as broadcaster and various hubs as receiver, and the problem is 100% persistent.
Another observation: there is much less latency in receiver Technic Hub motor folowing the broadcaster Hub's motor movements when the broadcaster is Inventor Hub (it is close to 100ms as observed). It seems as Technic Hub has much more than 100ms intervals (rather 800-1000ms as observed) between data transmission session.

@laurensvalk
Copy link
Member

Thanks @vardayzsolt! Let's follow up in pybricks/support#1086

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.

[Feature] Access RSSI (Received Signal Strength Indicator) from nearby hubs (or other devices)
5 participants