├── .vscode ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── bleradio.py └── examples ├── basic1.py ├── basic2.py └── custom_irq.py /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run with mpremote", 6 | "type": "shell", 7 | "command": "mpremote cp bleradio.py :lib/bleradio.py + run ${file}", 8 | "group": { 9 | "kind": "build", 10 | "isDefault": true 11 | }, 12 | "presentation": { 13 | "reveal": "always" 14 | }, 15 | "problemMatcher": [] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2024 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Connectionless messaging via Bluetooth Low Energy (BLE) with MicroPython 2 | 3 | This MicroPython library allows boards with BLE to broadcast (advertise) and 4 | observe (scan) small amounts of data without setting up any connections. 5 | 6 | This allows very simple many-to-many communication between broadcasters 7 | and multiple observers. Each board can broadcast on one channel (0-255), and 8 | observer on multiple channels. 9 | 10 | The starting order does not matter, and you can add or remove boards to and 11 | from the network as you go. 12 | 13 | It matches the protocol used on LEGO hubs that run Pybricks. This means you 14 | can also communicate with these LEGO hubs from any MicroPython board with BLE. 15 | 16 | ## What can you send and receive? 17 | 18 | You can send signed integer values, floating point numbers, booleans, strings, 19 | or bytes. Or a list/tuple of these objects. 20 | 21 | For example, you can broadcast one of the following: 22 | 23 | ```python 24 | 25 | data = 12345 26 | 27 | data = "Hello, world!" 28 | 29 | data = b"\x01\x02\x03" 30 | 31 | data = (123, 3.14, True, "Hello World") 32 | ``` 33 | 34 | Boolean values are packed into one byte. All other types are packed into their 35 | respective sizes plus one byte for the type. 36 | 37 | Since advertisements payloads are limited to 31 bytes by the Bluetooth spec and 38 | there are 5 bytes of overhead, the combined size of all type headers and values 39 | is limited to 26 bytes. 40 | 41 | When no data is observed, the `observe` method returns `None`. 42 | 43 | To stop broadcasting, use `broadcast(None)`. 44 | 45 | The full specification is available in the [protocol](https://github.com/pybricks/technical-info/blob/master/pybricks-ble-broadcast-observe.md) file. 46 | 47 | ## States versus events 48 | 49 | Due to the nature of communication, this technique works best for sending 50 | _states_, not _events_. Values are broadcast all the time until you change the 51 | data, but there is no guarantee that one single value will be received. 52 | 53 | For example, if you want to use a button to incrementally turn a motor by 90 54 | degrees, you should not broadcast a message for each button press. Instead, you could 55 | maintain the target angle on the broadcaster, and broadcast `90`, `180`, `270`, 56 | and so on, incrementing every time the button is pressed. 57 | 58 | ## Installation 59 | 60 | Copy [bleradio.py](https://raw.githubusercontent.com/pybricks/micropython-bleradio/master/bleradio.py) to your board manually. 61 | 62 | Or use the `mpremote` tool to install it directly from GitHub: 63 | 64 | ``` 65 | mpremote mip install https://raw.githubusercontent.com/pybricks/micropython-bleradio/master/bleradio.py 66 | ``` 67 | 68 | ## Example (run this one one board...) 69 | 70 | ```python 71 | # Basic usage of the radio module. 72 | 73 | from time import sleep_ms 74 | from bleradio import BLERadio 75 | 76 | # A board can broadcast small amounts of data on one channel. Here we broadcast 77 | # on channel 5. This board will listen for other boards on channels 4 and 18. 78 | radio = BLERadio(broadcast_channel=5, observe_channels=[4, 18]) 79 | 80 | # You can run a variant of this script on another board, and have it broadcast 81 | # on channel 4 or 18, for example. This board will then receive it. 82 | 83 | counter = 0 84 | 85 | while True: 86 | 87 | # Data observed on channel 4, as broadcast by another board. 88 | # It gives None if no data is detected. 89 | observed = radio.observe(4) 90 | print(observed) 91 | 92 | # Broadcast some data on our channel, which is 5. 93 | radio.broadcast(["hello, world!", 3.14, counter]) 94 | counter += 1 95 | sleep_ms(100) 96 | ``` 97 | 98 | ## Example (... run this on any number of other boards) 99 | 100 | ```python 101 | from bleradio import BLERadio 102 | 103 | radio = BLERadio(observe_channels=[5]) 104 | 105 | old_data = None 106 | 107 | while True: 108 | 109 | new_data = radio.observe(5) 110 | strength = radio.signal_strength(5) 111 | 112 | if new_data == old_data: 113 | continue 114 | 115 | print(strength, "dBm:", new_data) 116 | old_data = new_data 117 | ``` 118 | 119 | ## Mixing it with other BLE code 120 | 121 | See [examples/custom_irq](examples/custom_irq.py) to see how you can set up the 122 | observe IRQ manually so you can mix it with other BLE code. 123 | 124 | ## Mixing it with LEGO Hubs that run Pybricks 125 | 126 | You can use this library to communicate with LEGO hubs that run Pybricks. For 127 | example, you can use a MicroPython board to control a LEGO hub. 128 | 129 | Hubs running Pybricks already have this functionality built in so you won't 130 | need to install this library on those hubs. The API is mostly the same, but the 131 | channels are specified [during hub 132 | setup](https://docs.pybricks.com/en/latest/hubs/primehub.html). 133 | 134 | See [this video for an example](https://www.youtube.com/watch?v=WzmcihSV2YE). 135 | Any hub shown here could be replaced by a MicroPython board with BLE. 136 | 137 | ## Local development 138 | 139 | If you use `vscode`, run the build task (`Ctrl+Shift+B`) to automatically 140 | upload the local library to the board and then run the currently open example. 141 | This method requires `mpremote`. 142 | 143 | ## Contributing 144 | 145 | You can use the library as shown [here](./LICENSE), but we kindly ask you to 146 | suggest changes to the protocol here in an issue so we don't end up with too 147 | many incompatible versions. 148 | -------------------------------------------------------------------------------- /bleradio.py: -------------------------------------------------------------------------------- 1 | from micropython import const 2 | from time import ticks_ms 3 | import bluetooth 4 | from struct import pack_into, unpack 5 | 6 | 7 | _IRQ_SCAN_RESULT = const(5) 8 | 9 | _LEGO_ID_MSB = const(0x03) 10 | _LEGO_ID_LSB = const(0x97) 11 | _MANUFACTURER_DATA = const(0xFF) 12 | 13 | _DURATION = const(0) 14 | _INTERVAL_US = const(30000) 15 | _WINDOW_US = const(30000) 16 | _RSSI_FILTER_WINDOW_MS = const(512) 17 | _OBSERVED_DATA_TIMEOUT_MS = const(1000) 18 | _RSSI_MIN = const(-128) 19 | 20 | _ADVERTISING_OBJECT_SINGLE = const(0x00) 21 | _ADVERTISING_OBJECT_TRUE = const(0x01) 22 | _ADVERTISING_OBJECT_FALSE = const(0x02) 23 | _ADVERTISING_OBJECT_INT = const(0x03) 24 | _ADVERTISING_OBJECT_FLOAT = const(0x04) 25 | _ADVERTISING_OBJECT_STRING = const(0x05) 26 | _ADVERTISING_OBJECT_BYTES = const(0x06) 27 | 28 | _ADV_MAX_SIZE = const(31) 29 | _ADV_HEADER_SIZE = const(5) 30 | _ADV_COPY_FMT = const("31s") 31 | 32 | _LEN = const(0) 33 | _DATA = const(1) 34 | _TIME = const(2) 35 | _RSSI = const(3) 36 | 37 | INT_FORMATS = { 38 | 1: "b", 39 | 2: "h", 40 | 4: "i", 41 | } 42 | 43 | observed_data = {} 44 | 45 | 46 | def observe_irq(event, data): 47 | if event != _IRQ_SCAN_RESULT: 48 | return 49 | 50 | addr_type, addr, adv_type, rssi, adv_data = data 51 | 52 | # Analyze only advertisements matching Pybricks scheme. 53 | if ( 54 | len(adv_data) <= _ADV_HEADER_SIZE 55 | or adv_data[1] != _MANUFACTURER_DATA 56 | or adv_data[2] != _LEGO_ID_LSB 57 | or adv_data[3] != _LEGO_ID_MSB 58 | ): 59 | return 60 | 61 | if len(adv_data) - 1 != adv_data[0]: 62 | return 63 | 64 | # Get channel buffer, if allocated. 65 | channel = adv_data[4] 66 | if channel not in observed_data: 67 | return 68 | info = observed_data[channel] 69 | 70 | # Update time interval. 71 | diff = ticks_ms() - info[_TIME] 72 | info[_TIME] += diff 73 | if diff > _RSSI_FILTER_WINDOW_MS: 74 | diff = _RSSI_FILTER_WINDOW_MS 75 | 76 | # Approximate a slow moving average to make RSSI more stable. 77 | info[_RSSI] = ( 78 | info[_RSSI] * (_RSSI_FILTER_WINDOW_MS - diff) + rssi * diff 79 | ) // _RSSI_FILTER_WINDOW_MS 80 | 81 | # Copy advertising data without allocation. 82 | info[_LEN] = len(adv_data) - _ADV_HEADER_SIZE 83 | pack_into(_ADV_COPY_FMT, info[_DATA], 0, adv_data) 84 | 85 | # Allow handler to run other callback code on successfully 86 | # receiving a broadcasted message. 87 | return channel 88 | 89 | 90 | def get_data_info(info_byte: int): 91 | data_type = info_byte >> 5 92 | data_length = info_byte & 0x1F 93 | return data_type, data_length 94 | 95 | 96 | def unpack_one(data_type: int, data: memoryview): 97 | if data_type == _ADVERTISING_OBJECT_TRUE: 98 | return True 99 | elif data_type == _ADVERTISING_OBJECT_FALSE: 100 | return False 101 | elif data_type == _ADVERTISING_OBJECT_SINGLE: 102 | return None 103 | 104 | # Remaining types require data. 105 | if len(data) == 0: 106 | return None 107 | 108 | elif data_type == _ADVERTISING_OBJECT_INT and len(data) in INT_FORMATS: 109 | return unpack(INT_FORMATS[len(data)], data)[0] 110 | elif data_type == _ADVERTISING_OBJECT_FLOAT: 111 | return unpack("f", data)[0] 112 | elif data_type == _ADVERTISING_OBJECT_STRING: 113 | return bytes(data).decode("utf-8") 114 | elif data_type == _ADVERTISING_OBJECT_BYTES: 115 | return data 116 | else: 117 | return None 118 | 119 | 120 | def decode(data: memoryview): 121 | first_type, _ = get_data_info(data[0]) 122 | 123 | # Case of one value instead of tuple. 124 | if first_type == _ADVERTISING_OBJECT_SINGLE: 125 | # Only proceed if this has some data. 126 | if len(data) < 2: 127 | return None 128 | 129 | value_type, value_length = get_data_info(data[1]) 130 | return unpack_one(value_type, data[2 : 2 + value_length]) 131 | 132 | # Unpack iteratively. 133 | unpacked = [] 134 | index = 0 135 | 136 | while index < len(data): 137 | data_type, data_length = get_data_info(data[index]) 138 | 139 | # Check if there is enough data left. 140 | if index + 1 + data_length > len(data): 141 | break 142 | 143 | # Unpack the value. 144 | data_value = data[index + 1 : index + 1 + data_length] 145 | unpacked.append(unpack_one(data_type, data_value)) 146 | index += 1 + data_length 147 | 148 | return unpacked 149 | 150 | 151 | def smallest_format(n): 152 | if -(1 << 7) <= n < (1 << 7): 153 | return "b", 1 154 | elif -(1 << 15) <= n < (1 << 15): 155 | return "h", 2 156 | else: 157 | return "i", 4 158 | 159 | 160 | def get_data_info(info_byte: int): 161 | data_type = info_byte >> 5 162 | data_length = info_byte & 0x1F 163 | return data_type, data_length 164 | 165 | 166 | def encode_one_object(obj, buffer, offset): 167 | if isinstance(obj, bool): 168 | buffer[offset] = ( 169 | _ADVERTISING_OBJECT_TRUE if obj else _ADVERTISING_OBJECT_FALSE 170 | ) << 5 171 | return 1 172 | 173 | if isinstance(obj, int): 174 | format, size = smallest_format(obj) 175 | buffer[offset] = (_ADVERTISING_OBJECT_INT << 5) + size 176 | pack_into(format, buffer, offset + 1, obj) 177 | return 1 + size 178 | 179 | if isinstance(obj, float): 180 | buffer[offset] = (_ADVERTISING_OBJECT_FLOAT << 5) + 4 181 | pack_into("f", buffer, offset + 1, obj) 182 | return 1 + 4 183 | 184 | if isinstance(obj, (bytes, bytearray, str)): 185 | if isinstance(obj, str): 186 | buffer[offset] = _ADVERTISING_OBJECT_STRING << 5 187 | data = obj.encode("utf-8") 188 | else: 189 | buffer[offset] = _ADVERTISING_OBJECT_BYTES << 5 190 | data = obj 191 | buffer[offset] += len(data) 192 | pack_into(str(len(data)) + "s", buffer, offset + 1, data) 193 | return 1 + len(data) 194 | 195 | raise ValueError("Data type not supported") 196 | 197 | 198 | class BLERadio: 199 | 200 | def __init__(self, broadcast_channel: int = None, observe_channels=[], ble=None): 201 | 202 | for channel in observe_channels: 203 | if not isinstance(channel, int) or 0 < channel > 255: 204 | raise ValueError( 205 | "Observe channel must be list of integers from 0 to 255." 206 | ) 207 | if broadcast_channel is not None and ( 208 | not isinstance(broadcast_channel, int) or 0 < broadcast_channel > 255 209 | ): 210 | raise ValueError("Broadcast channel must be None or integer from 0 to 255.") 211 | 212 | global observed_data 213 | observed_data = { 214 | ch: [0, bytearray(_ADV_MAX_SIZE), 0, _RSSI_MIN] for ch in observe_channels 215 | } 216 | 217 | self.broadcast_channel = broadcast_channel 218 | self.send_buffer = memoryview(bytearray(_ADV_MAX_SIZE)) 219 | 220 | if ble is None: 221 | # BLE not given, so initialize our own instance. 222 | self.ble = bluetooth.BLE() 223 | self.ble.active(True) 224 | self.ble.irq(observe_irq) 225 | self.ble.gap_scan(_DURATION, _INTERVAL_US, _WINDOW_US) 226 | else: 227 | # Use externally provided BLE, configured and 228 | # controlled by user. 229 | self.ble = ble 230 | 231 | def observe(self, channel: int): 232 | if channel not in observed_data: 233 | raise ValueError("Channel not allocated.") 234 | 235 | info = observed_data[channel] 236 | 237 | if ticks_ms() - info[_TIME] > _OBSERVED_DATA_TIMEOUT_MS: 238 | info[_RSSI] = _RSSI_MIN 239 | 240 | if info[_RSSI] == _RSSI_MIN: 241 | return None 242 | 243 | data = memoryview(info[_DATA]) 244 | return decode(data[_ADV_HEADER_SIZE : info[_LEN] + _ADV_HEADER_SIZE]) 245 | 246 | def signal_strength(self, channel: int): 247 | if channel not in observed_data: 248 | raise ValueError("Channel not allocated.") 249 | 250 | info = observed_data[channel] 251 | 252 | if ticks_ms() - info[_TIME] > _OBSERVED_DATA_TIMEOUT_MS: 253 | info[_RSSI] = _RSSI_MIN 254 | 255 | return info[_RSSI] 256 | 257 | def broadcast(self, data): 258 | 259 | if self.broadcast_channel is None: 260 | raise RuntimeError("Broadcast channel not configured.") 261 | 262 | if data is None: 263 | self.ble.gap_advertise(None) 264 | return 265 | 266 | send_buffer = self.send_buffer 267 | 268 | size = _ADV_HEADER_SIZE 269 | 270 | if isinstance(data, (int, float, bool, str, bytes, bytearray)): 271 | send_buffer[_ADV_HEADER_SIZE] = _ADVERTISING_OBJECT_SINGLE 272 | size += 1 + encode_one_object(data, send_buffer, _ADV_HEADER_SIZE + 1) 273 | else: 274 | for value in data: 275 | size += encode_one_object(value, send_buffer, size) 276 | 277 | send_buffer[0] = size - 1 278 | send_buffer[1] = _MANUFACTURER_DATA 279 | send_buffer[2] = _LEGO_ID_LSB 280 | send_buffer[3] = _LEGO_ID_MSB 281 | send_buffer[4] = self.broadcast_channel 282 | 283 | self.ble.gap_advertise(40000, send_buffer[0:size]) 284 | -------------------------------------------------------------------------------- /examples/basic1.py: -------------------------------------------------------------------------------- 1 | # Basic usage of the radio module. 2 | 3 | from time import sleep_ms 4 | from bleradio import BLERadio 5 | 6 | # A board can broadcast small amounts of data on one channel. Here we broadcast 7 | # on channel 5. This board will listen for other boards on channels 4 and 18. 8 | radio = BLERadio(broadcast_channel=5, observe_channels=[4, 18]) 9 | 10 | # You can run a variant of this script on another board, and have it broadcast 11 | # on channel 4 or 18, for example. This board will then receive it. 12 | 13 | counter = 0 14 | 15 | while True: 16 | 17 | # Data observed on channel 4, as broadcast by another board. 18 | # It gives None if no data is detected. 19 | observed = radio.observe(4) 20 | print(observed) 21 | 22 | # Broadcast some data on our channel, which is 5. 23 | radio.broadcast(["hello, world!", 3.14, counter]) 24 | counter += 1 25 | sleep_ms(100) 26 | -------------------------------------------------------------------------------- /examples/basic2.py: -------------------------------------------------------------------------------- 1 | from bleradio import BLERadio 2 | 3 | radio = BLERadio(observe_channels=[5]) 4 | 5 | old_data = None 6 | 7 | while True: 8 | 9 | new_data = radio.observe(5) 10 | strength = radio.signal_strength(5) 11 | 12 | if new_data == old_data: 13 | continue 14 | 15 | print(strength, "dBm:", new_data) 16 | old_data = new_data 17 | -------------------------------------------------------------------------------- /examples/custom_irq.py: -------------------------------------------------------------------------------- 1 | # Basic usage of the radio module, combined with your own BLE handler. 2 | 3 | from time import sleep_ms 4 | from bleradio import BLERadio, observe_irq 5 | import bluetooth 6 | 7 | 8 | def your_ble_irq(event, data): 9 | # Processes advertising data matching Pybricks scheme, if any. 10 | channel = observe_irq(event, data) 11 | if channel is not None: 12 | # Something was observed on this channel. You could handle this 13 | # event further here if you like. 14 | pass 15 | 16 | # Add rest of your conventional BLE handler here. 17 | 18 | 19 | # Manual control of BLE so you can combine it with other BLE logic. 20 | ble = bluetooth.BLE() 21 | ble.active(True) 22 | ble.irq(your_ble_irq) 23 | ble.gap_scan(0, 30000, 30000) 24 | 25 | # Allocate the channels but don't reconfigure BLE. 26 | radio = BLERadio(observe_channels=[4, 18], broadcast_channel=5, ble=ble) 27 | 28 | counter = 0 29 | 30 | while True: 31 | # Receive data on channel 4. 32 | data = radio.observe(4) 33 | 34 | # Broadcast a counter and some constant data. 35 | radio.broadcast([counter, "hello, world!", 3.14]) 36 | counter += 1 37 | 38 | sleep_ms(100) 39 | --------------------------------------------------------------------------------