From b0ff23bd7f727f66f89d7126ff416a3cd3b819b1 Mon Sep 17 00:00:00 2001 From: Jim Mussared Date: Fri, 23 Jul 2021 15:13:50 +1000 Subject: [PATCH 1/8] aioble: Add support for write-with-update. Signed-off-by: Jim Mussared --- micropython/bluetooth/aioble/aioble/server.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/micropython/bluetooth/aioble/aioble/server.py b/micropython/bluetooth/aioble/aioble/server.py index 0aeb442c3..7bed42758 100644 --- a/micropython/bluetooth/aioble/aioble/server.py +++ b/micropython/bluetooth/aioble/aioble/server.py @@ -83,11 +83,15 @@ def read(self): return ble.gatts_read(self._value_handle) # Write value to local db. - def write(self, data): + def write(self, data, send_update=False): if self._value_handle is None: self._initial = data else: - ble.gatts_write(self._value_handle, data) + if send_update: + # Send_update arg only added in 1.17, don't pass this arg unless required. + ble.gatts_write(self._value_handle, data, True) + else: + ble.gatts_write(self._value_handle, data) # Wait for a write on this characteristic. # Returns the device that did the write. From da6137551f5bebad7aef302d16ccb7c74e33d2b7 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Tue, 17 Aug 2021 11:17:29 +1000 Subject: [PATCH 2/8] aioble: Add timeout to device.exchange_mtu() --- micropython/bluetooth/aioble/aioble/device.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/micropython/bluetooth/aioble/aioble/device.py b/micropython/bluetooth/aioble/aioble/device.py index 9634f6d65..9d967c7cd 100644 --- a/micropython/bluetooth/aioble/aioble/device.py +++ b/micropython/bluetooth/aioble/aioble/device.py @@ -262,7 +262,7 @@ def is_connected(self): def timeout(self, timeout_ms): return DeviceTimeout(self, timeout_ms) - async def exchange_mtu(self, mtu=None): + async def exchange_mtu(self, mtu=None, timeout_ms=1000): if not self.is_connected(): raise ValueError("Not connected") @@ -271,7 +271,8 @@ async def exchange_mtu(self, mtu=None): self._mtu_event = self._mtu_event or asyncio.ThreadSafeFlag() ble.gattc_exchange_mtu(self._conn_handle) - await self._mtu_event.wait() + with self.timeout(timeout_ms): + await self._mtu_event.wait() return self.mtu # Wait for a connection on an L2CAP connection-oriented-channel. From aa7b070c397c00759c5592620a9bf8a1164733b0 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Tue, 17 Aug 2021 11:28:07 +1000 Subject: [PATCH 3/8] aioble/server: Log warning on out-of-order indication. --- micropython/bluetooth/aioble/aioble/server.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/micropython/bluetooth/aioble/aioble/server.py b/micropython/bluetooth/aioble/aioble/server.py index 7bed42758..2fee36042 100644 --- a/micropython/bluetooth/aioble/aioble/server.py +++ b/micropython/bluetooth/aioble/aioble/server.py @@ -190,10 +190,11 @@ def _indicate_done(conn_handle, value_handle, status): # Timeout. return # See TODO in __init__ to support multiple concurrent indications. - assert connection == characteristic._indicate_connection - characteristic._indicate_status = status - characteristic._indicate_event.set() - + if connection == characteristic._indicate_connection: + characteristic._indicate_status = status + characteristic._indicate_event.set() + else: + log_warn("Received indication for unexpected connection") class BufferedCharacteristic(Characteristic): def __init__(self, service, uuid, max_len=20, append=False): From 12bdcd99083ba02ade2413dfd6ffae352505c638 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Thu, 23 Sep 2021 14:42:37 +1000 Subject: [PATCH 4/8] aioble/security: Only schedule save when needed. Was getting occasional: RuntimeError: schedule queue full --- micropython/bluetooth/aioble/aioble/security.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/micropython/bluetooth/aioble/aioble/security.py b/micropython/bluetooth/aioble/aioble/security.py index 9ca4651d9..7c9d775f1 100644 --- a/micropython/bluetooth/aioble/aioble/security.py +++ b/micropython/bluetooth/aioble/aioble/security.py @@ -106,8 +106,9 @@ def _security_irq(event, data): _secrets[key] = value # Queue up a save (don't synchronously write to flash). - _modified = True - schedule(_save_secrets, None) + if not _modified: + _modified = True + schedule(_save_secrets, None) return True From 0596f17665b57a2a0ea78c6f535e683f2d2ea16c Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Tue, 28 Sep 2021 10:16:47 +1000 Subject: [PATCH 5/8] aioble/security: Add option to limit number of peers stored. --- .../bluetooth/aioble/aioble/security.py | 139 ++++++++++++++---- 1 file changed, 114 insertions(+), 25 deletions(-) diff --git a/micropython/bluetooth/aioble/aioble/security.py b/micropython/bluetooth/aioble/aioble/security.py index 7c9d775f1..432adaccb 100644 --- a/micropython/bluetooth/aioble/aioble/security.py +++ b/micropython/bluetooth/aioble/aioble/security.py @@ -5,7 +5,7 @@ import uasyncio as asyncio import binascii import json - +from . import core from .core import log_info, log_warn, ble, register_irq_handler from .device import DeviceConnection @@ -26,27 +26,58 @@ _DEFAULT_PATH = "ble_secrets.json" +# Maintain list of known keys, newest at the bottom / end. _secrets = {} _modified = False _path = None +# If set, limit the pairing db to this many peers +limit_peers = None + +SEC_TYPES_SELF = (10, ) +SEC_TYPES_PEER = (1, 2, 3, 4) + # Must call this before stack startup. def load_secrets(path=None): - global _path, _secrets + global _path, _secrets, limit_peers # Use path if specified, otherwise use previous path, otherwise use # default path. _path = path or _path or _DEFAULT_PATH # Reset old secrets. - _secrets = {} + _secrets.clear() try: with open(_path, "r") as f: entries = json.load(f) + # Newest entries at at the end, load them first for sec_type, key, value in entries: + if sec_type not in _secrets: + _secrets[sec_type] = [] # Decode bytes from hex. - _secrets[sec_type, binascii.a2b_base64(key)] = binascii.a2b_base64(value) + _secrets[sec_type].append((binascii.a2b_base64(key), binascii.a2b_base64(value))) + + if limit_peers: + # If we need to limit loaded keys, ensure the same addresses of each type are loaded + keep_keys = None + for sec_type in SEC_TYPES_PEER: + if sec_type not in _secrets: + continue + secrets = _secrets[sec_type] + if len(secrets) > limit_peers: + if not keep_keys: + keep_keys = [key for key, _ in secrets[-limit_peers:]] + log_info("Limiting keys to", keep_keys) + + keep_entries = [entry for entry in secrets if entry[0] in keep_keys] + while len(keep_entries) < limit_peers: + for entry in reversed(secrets): + if entry not in keep_entries: + keep_entries.append(entry) + _secrets[sec_type] = keep_entries + _log_peers("loaded") + except: log_warn("No secrets available") @@ -61,17 +92,48 @@ def _save_secrets(arg=None): # Only save if the secrets changed. return + _log_peers('save_secrets') + with open(_path, "w") as f: # Convert bytes to hex strings (otherwise JSON will treat them like # strings). json_secrets = [ (sec_type, binascii.b2a_base64(key), binascii.b2a_base64(value)) - for (sec_type, key), value in _secrets.items() + for sec_type in _secrets for key, value in _secrets[sec_type] ] json.dump(json_secrets, f) _modified = False +def _remove_entry(sec_type, key): + secrets = _secrets[sec_type] + + # Delete existing secrets matching the type and key. + deleted = False + for to_delete in [ + entry for entry in secrets if entry[0] == key + ]: + log_info("Removing existing secret matching key") + secrets.remove(to_delete) + deleted = True + + return deleted + + +def _log_peers(heading=""): + if core.log_level <= 2: + return + log_info("secrets:", heading) + for sec_type in SEC_TYPES_PEER: + log_info("-", sec_type) + + if sec_type not in _secrets: + continue + secrets = _secrets[sec_type] + for key, value in secrets: + log_info(" - %s: %s..." % (key, value[0:16])) + + def _security_irq(event, data): global _modified @@ -90,20 +152,43 @@ def _security_irq(event, data): elif event == _IRQ_SET_SECRET: sec_type, key, value = data - key = sec_type, bytes(key) + key = bytes(key) value = bytes(value) if value else None - log_info("set secret:", key, value) - - if value is None: - # Delete secret. - if key not in _secrets: - return False - - del _secrets[key] - else: - # Save secret. - _secrets[key] = value + is_saving = value is not None + is_deleting = not is_saving + + if core.log_level > 2: + if is_deleting: + log_info("del secret:", key) + else: + shortval = value + if len(value) > 16: + shortval = value[0:16] + b"..." + log_info("set secret:", sec_type, key, shortval) + + if sec_type not in _secrets: + _secrets[sec_type] = [] + secrets = _secrets[sec_type] + + # Delete existing secrets matching the type and key. + removed = _remove_entry(sec_type, key) + + if is_deleting and not removed: + # Delete mode, but no entries were deleted + return False + + if is_saving: + # Save new secret. + if limit_peers and sec_type in SEC_TYPES_PEER and len(secrets) >= limit_peers: + addr, _ = secrets[0] + log_warn("Removing old peer to make space for new one") + ble.gap_unpair(addr) + log_info("Removed:", addr) + # Add new value to database + secrets.append((key, value)) + + _log_peers("set_secret") # Queue up a save (don't synchronously write to flash). if not _modified: @@ -117,19 +202,23 @@ def _security_irq(event, data): log_info("get secret:", sec_type, index, bytes(key) if key else None) + secrets = _secrets.get(sec_type, []) if key is None: # Return the index'th secret of this type. - i = 0 - for (t, _key), value in _secrets.items(): - if t == sec_type: - if i == index: - return value - i += 1 + # This is used when loading "all" secrets at startup + if len(secrets) > index: + key, val = secrets[index] + return val + return None else: # Return the secret for this key (or None). - key = sec_type, bytes(key) - return _secrets.get(key, None) + key = bytes(key) + + for k, v in secrets: + if k == key: + return v + return None elif event == _IRQ_PASSKEY_ACTION: conn_handle, action, passkey = data From 1036d01b4010805689f2967a1743e6d4909b1e2c Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Fri, 8 Oct 2021 17:46:17 +1100 Subject: [PATCH 6/8] aioble/security: Add DeviceConnection.pairing_in_progress flag. Will be True if the pairing process has been started but is waiting for ack from remote device. --- micropython/bluetooth/aioble/aioble/device.py | 3 ++- .../bluetooth/aioble/aioble/security.py | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/micropython/bluetooth/aioble/aioble/device.py b/micropython/bluetooth/aioble/aioble/device.py index 9d967c7cd..d01f297ad 100644 --- a/micropython/bluetooth/aioble/aioble/device.py +++ b/micropython/bluetooth/aioble/aioble/device.py @@ -151,7 +151,7 @@ class DeviceConnection: _connected = {} def __init__(self, device): - self.device = device + self.device: Device = device device._connection = self self.encrypted = False @@ -159,6 +159,7 @@ def __init__(self, device): self.bonded = False self.key_size = False self.mtu = None + self.pairing_in_progress = False self._conn_handle = None diff --git a/micropython/bluetooth/aioble/aioble/security.py b/micropython/bluetooth/aioble/aioble/security.py index 432adaccb..c2cf265eb 100644 --- a/micropython/bluetooth/aioble/aioble/security.py +++ b/micropython/bluetooth/aioble/aioble/security.py @@ -7,7 +7,7 @@ import json from . import core from .core import log_info, log_warn, ble, register_irq_handler -from .device import DeviceConnection +from .device import DeviceConnection, Device _IRQ_ENCRYPTION_UPDATE = const(28) _IRQ_GET_SECRET = const(29) @@ -68,7 +68,7 @@ def load_secrets(path=None): if len(secrets) > limit_peers: if not keep_keys: keep_keys = [key for key, _ in secrets[-limit_peers:]] - log_info("Limiting keys to", keep_keys) + log_warn("Limiting keys to", keep_keys) keep_entries = [entry for entry in secrets if entry[0] in keep_keys] while len(keep_entries) < limit_peers: @@ -134,6 +134,15 @@ def _log_peers(heading=""): log_info(" - %s: %s..." % (key, value[0:16])) +def _get_connection(key) -> DeviceConnection: + if not key: + return None + addr = bytes(reversed(key)) + for connection in DeviceConnection._connected.values(): + if connection.device.addr == addr: + return connection + + def _security_irq(event, data): global _modified @@ -146,6 +155,7 @@ def _security_irq(event, data): connection.authenticated = authenticated connection.bonded = bonded connection.key_size = key_size + connection.pairing_in_progress = False # TODO: Handle failure. if encrypted and connection._pair_event: connection._pair_event.set() @@ -215,6 +225,10 @@ def _security_irq(event, data): # Return the secret for this key (or None). key = bytes(key) + if conn := _get_connection(key): + log_info("pairing started", conn) + conn.pairing_in_progress = True + for k, v in secrets: if k == key: return v From 9ee752a654ccc223daf0f2b0726c2d66d91c2610 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Fri, 8 Oct 2021 17:46:54 +1100 Subject: [PATCH 7/8] aioble/device: Add Device.unpair() function. --- micropython/bluetooth/aioble/aioble/device.py | 7 +++++++ micropython/bluetooth/aioble/aioble/security.py | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/micropython/bluetooth/aioble/aioble/device.py b/micropython/bluetooth/aioble/aioble/device.py index d01f297ad..89ad7cfaf 100644 --- a/micropython/bluetooth/aioble/aioble/device.py +++ b/micropython/bluetooth/aioble/aioble/device.py @@ -256,6 +256,13 @@ async def pair(self, *args, **kwargs): await pair(self, *args, **kwargs) + def unpair(self): + from .security import unpair + ident = bytearray(7) + ident[0] = self.device.addr_type + ident[1:] = bytes(reversed(self.device.addr)) + unpair(ident) + def is_connected(self): return self._conn_handle is not None diff --git a/micropython/bluetooth/aioble/aioble/security.py b/micropython/bluetooth/aioble/aioble/security.py index c2cf265eb..e0804c135 100644 --- a/micropython/bluetooth/aioble/aioble/security.py +++ b/micropython/bluetooth/aioble/aioble/security.py @@ -256,6 +256,10 @@ def _security_irq(event, data): register_irq_handler(_security_irq) +# Use device.unpair() rather than calling this directly. +def unpair(addr): + ble.gap_unpair(addr) + # Use device.pair() rather than calling this directly. async def pair( connection, From adf3dd2671c306ca28cc61646d1ea92400c1a5c1 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Fri, 8 Oct 2021 17:06:21 +1100 Subject: [PATCH 8/8] aioble/device: Add DeviceConnection.indicate_service_changed() --- micropython/bluetooth/aioble/aioble/device.py | 5 +++++ micropython/bluetooth/aioble/aioble/security.py | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/micropython/bluetooth/aioble/aioble/device.py b/micropython/bluetooth/aioble/aioble/device.py index 89ad7cfaf..1ae386940 100644 --- a/micropython/bluetooth/aioble/aioble/device.py +++ b/micropython/bluetooth/aioble/aioble/device.py @@ -263,6 +263,11 @@ def unpair(self): ident[1:] = bytes(reversed(self.device.addr)) unpair(ident) + def indicate_service_changed(self): + handle = self._conn_handle + if handle is not None: + ble.gap_indicate_service_changed(handle) + def is_connected(self): return self._conn_handle is not None diff --git a/micropython/bluetooth/aioble/aioble/security.py b/micropython/bluetooth/aioble/aioble/security.py index e0804c135..981cbf785 100644 --- a/micropython/bluetooth/aioble/aioble/security.py +++ b/micropython/bluetooth/aioble/aioble/security.py @@ -137,7 +137,7 @@ def _log_peers(heading=""): def _get_connection(key) -> DeviceConnection: if not key: return None - addr = bytes(reversed(key)) + addr = bytes(reversed(key[-6:])) for connection in DeviceConnection._connected.values(): if connection.device.addr == addr: return connection @@ -149,7 +149,9 @@ def _security_irq(event, data): if event == _IRQ_ENCRYPTION_UPDATE: # Connection has updated (usually due to pairing). conn_handle, encrypted, authenticated, bonded, key_size = data - log_info("encryption update", conn_handle, encrypted, authenticated, bonded, key_size) + log_info("encryption update - handle:", conn_handle, + "enc:", encrypted, "auth:", authenticated, + "bond:", bonded, "key:", key_size) if connection := DeviceConnection._connected.get(conn_handle, None): connection.encrypted = encrypted connection.authenticated = authenticated @@ -226,7 +228,7 @@ def _security_irq(event, data): key = bytes(key) if conn := _get_connection(key): - log_info("pairing started", conn) + log_info("encryption / pairing started", conn) conn.pairing_in_progress = True for k, v in secrets: