From b0ff23bd7f727f66f89d7126ff416a3cd3b819b1 Mon Sep 17 00:00:00 2001 From: Jim Mussared Date: Fri, 23 Jul 2021 15:13:50 +1000 Subject: [PATCH 1/5] 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/5] 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 7e6e9ce31611a1730ebb12e58486024d67aafe01 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Tue, 17 Aug 2021 11:24:46 +1000 Subject: [PATCH 3/5] aioble/security: Store secrets in list. Ensure newest keys are stored at top to be used/loaded first. --- .../bluetooth/aioble/aioble/security.py | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/micropython/bluetooth/aioble/aioble/security.py b/micropython/bluetooth/aioble/aioble/security.py index 9ca4651d9..5d38bf993 100644 --- a/micropython/bluetooth/aioble/aioble/security.py +++ b/micropython/bluetooth/aioble/aioble/security.py @@ -26,7 +26,8 @@ _DEFAULT_PATH = "ble_secrets.json" -_secrets = {} +# Maintain list of known keys, newest at the top. +_secrets = [] _modified = False _path = None @@ -40,13 +41,13 @@ def load_secrets(path=None): _path = path or _path or _DEFAULT_PATH # Reset old secrets. - _secrets = {} + _secrets = [] try: with open(_path, "r") as f: entries = json.load(f) for sec_type, key, value in entries: # Decode bytes from hex. - _secrets[sec_type, binascii.a2b_base64(key)] = binascii.a2b_base64(value) + _secrets.append(((sec_type, binascii.a2b_base64(key)), binascii.a2b_base64(value))) except: log_warn("No secrets available") @@ -66,7 +67,7 @@ def _save_secrets(arg=None): # strings). json_secrets = [ (sec_type, binascii.b2a_base64(key), binascii.b2a_base64(value)) - for (sec_type, key), value in _secrets.items() + for (sec_type, key), value in _secrets ] json.dump(json_secrets, f) _modified = False @@ -97,13 +98,16 @@ def _security_irq(event, data): if value is None: # Delete secret. - if key not in _secrets: - return False + i = None + for i, k, v in enumerate(_secrets): + if k == key: + break + if i is not None: + _secrets.pop(i) - del _secrets[key] else: # Save secret. - _secrets[key] = value + _secrets.insert(0, (key, value)) # Queue up a save (don't synchronously write to flash). _modified = True @@ -128,7 +132,11 @@ def _security_irq(event, data): else: # Return the secret for this key (or None). key = sec_type, bytes(key) - return _secrets.get(key, None) + + for k, v in _secrets: + if k == key: + return v + return None elif event == _IRQ_PASSKEY_ACTION: conn_handle, action, passkey = data From 32b08a0ea58b703fea32cdf8e851d067cb06e3c5 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Tue, 17 Aug 2021 11:28:07 +1000 Subject: [PATCH 4/5] 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 486b9a1519d2975f5b04318ac641d0b0e1b6b537 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Tue, 17 Aug 2021 13:20:47 +1000 Subject: [PATCH 5/5] aioble/service_changed: Add support for BLE service_changed. Also present database_hash for newer devices. --- .../bluetooth/aioble/aioble/security.py | 45 +++++++---- micropython/bluetooth/aioble/aioble/server.py | 6 +- .../aioble/aioble/services/__init__.py | 0 .../services/generic_attribute_service.py | 75 +++++++++++++++++++ 4 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 micropython/bluetooth/aioble/aioble/services/__init__.py create mode 100644 micropython/bluetooth/aioble/aioble/services/generic_attribute_service.py diff --git a/micropython/bluetooth/aioble/aioble/security.py b/micropython/bluetooth/aioble/aioble/security.py index 5d38bf993..ab5fa0aa2 100644 --- a/micropython/bluetooth/aioble/aioble/security.py +++ b/micropython/bluetooth/aioble/aioble/security.py @@ -4,6 +4,7 @@ from micropython import const, schedule import uasyncio as asyncio import binascii +import ustruct import json from .core import log_info, log_warn, ble, register_irq_handler @@ -31,6 +32,9 @@ _modified = False _path = None +connected_sec = None +gatt_svc = None + # Must call this before stack startup. def load_secrets(path=None): @@ -45,9 +49,10 @@ def load_secrets(path=None): try: with open(_path, "r") as f: entries = json.load(f) - for sec_type, key, value in entries: + for sec_type, key, value, *digest in entries: + digest = digest[0] or None # Decode bytes from hex. - _secrets.append(((sec_type, binascii.a2b_base64(key)), binascii.a2b_base64(value))) + _secrets.append(((sec_type, binascii.a2b_base64(key)), binascii.a2b_base64(value), digest)) except: log_warn("No secrets available") @@ -66,15 +71,15 @@ def _save_secrets(arg=None): # 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 + (sec_type, binascii.b2a_base64(key), binascii.b2a_base64(value), digest) + for (sec_type, key), value, digest in _secrets ] json.dump(json_secrets, f) _modified = False def _security_irq(event, data): - global _modified + global _modified, connected_sec, gatt_svc if event == _IRQ_ENCRYPTION_UPDATE: # Connection has updated (usually due to pairing). @@ -89,6 +94,19 @@ def _security_irq(event, data): if encrypted and connection._pair_event: connection._pair_event.set() + if bonded and \ + None not in (gatt_svc, connected_sec) and \ + connected_sec[2] != gatt_svc.hexdigest: + gatt_svc.send_changed(connection) + + # Update the hash in the database + _secrets.remove(connected_sec) + updated_sec = connected_sec[:-1] + (gatt_svc.hexdigest,) + _secrets.insert(0, updated_sec) + # Queue up a save (don't synchronously write to flash). + _modified = True + schedule(_save_secrets, None) + elif event == _IRQ_SET_SECRET: sec_type, key, value = data key = sec_type, bytes(key) @@ -98,16 +116,15 @@ def _security_irq(event, data): if value is None: # Delete secret. - i = None - for i, k, v in enumerate(_secrets): - if k == key: - break - if i is not None: - _secrets.pop(i) + for to_delete in [ + entry for entry in _secrets if entry[0] == key + ]: + _secrets.remove(to_delete) else: # Save secret. - _secrets.insert(0, (key, value)) + current_digest = gatt_svc.hexdigest if gatt_svc else None + _secrets.insert(0, (key, value, current_digest)) # Queue up a save (don't synchronously write to flash). _modified = True @@ -123,7 +140,7 @@ def _security_irq(event, data): if key is None: # Return the index'th secret of this type. i = 0 - for (t, _key), value in _secrets.items(): + for (t, _key), value, digest in _secrets: if t == sec_type: if i == index: return value @@ -133,7 +150,7 @@ def _security_irq(event, data): # Return the secret for this key (or None). key = sec_type, bytes(key) - for k, v in _secrets: + for k, v, d in _secrets: if k == key: return v return None diff --git a/micropython/bluetooth/aioble/aioble/server.py b/micropython/bluetooth/aioble/aioble/server.py index 2fee36042..8f62599e3 100644 --- a/micropython/bluetooth/aioble/aioble/server.py +++ b/micropython/bluetooth/aioble/aioble/server.py @@ -228,9 +228,13 @@ def __init__(self, characteristic, uuid, read=False, write=False, initial=None): # Turn the Service/Characteristic/Descriptor classes into a registration tuple # and then extract their value handles. -def register_services(*services): +def register_services(*services, include_gatt_svc=True): ensure_active() _registered_characteristics.clear() + if include_gatt_svc: + from .services.generic_attribute_service import GenericAttributeService + gatt_svc = GenericAttributeService(services) + services = (gatt_svc,) + services handles = ble.gatts_register_services(tuple(s._tuple() for s in services)) for i in range(len(services)): service_handles = handles[i] diff --git a/micropython/bluetooth/aioble/aioble/services/__init__.py b/micropython/bluetooth/aioble/aioble/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/micropython/bluetooth/aioble/aioble/services/generic_attribute_service.py b/micropython/bluetooth/aioble/aioble/services/generic_attribute_service.py new file mode 100644 index 000000000..3998e39c0 --- /dev/null +++ b/micropython/bluetooth/aioble/aioble/services/generic_attribute_service.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# +# @file +# @brief: Bluetooth Generic Attribute Service +# +# Copyright (c) 2021, Planet Innovation +# 436 Elgar Road, Box Hill, 3128, VIC, Australia +# Phone: +61 3 9945 7510 +# +# The copyright to the computer program(s) herein is the property of +# Planet Innovation, Australia. +# The program(s) may be used and/or copied only with the written permission +# of Planet Innovation or in accordance with the terms and conditions +# stipulated in the agreement/contract under which the program(s) have been +# supplied. +# + +import ustruct +import bluetooth +from aioble import Service, Characteristic, security +from aioble.core import ble, log_info +from hashlib import md5 +from ubinascii import hexlify +try: + from utyping import * +except: + pass + + +class GenericAttributeService(Service): + # Generic Attribute service UUID + SERVICE_UUID = bluetooth.UUID(0x1801) + + # Service Changed Characteristic + UUID_SERVICE_CHANGED = bluetooth.UUID(0x2A05) + # Database Hash Characteristic (New in BLE 5.1) + UUID_DATABASE_HASH = bluetooth.UUID(0x2B2A) + + def __init__(self, services: Tuple[Service]): + + super().__init__(self.SERVICE_UUID) + + # Database hash is typically a 128bit AES-CMAC value, however + # is generally only monitored for change as an opaque value. + # MD5 is also 128 bit, faster and builtin + hasher = md5() + for service in services: + for char in service.characteristics: + hasher.update(char.uuid) + hasher.update(str(char.flags)) + self.digest = hasher.digest() + self.hexdigest = hexlify(self.digest).decode() + log_info("BLE: DB Hash=", self.hexdigest) + security.current_digest = self.hexdigest + security.gatt_svc = self + + self.SERVICE_CHANGED = Characteristic( + service=self, + uuid=self.UUID_SERVICE_CHANGED, + read=True, + indicate=True, + initial='' + ) + + self.DATABASE_HASH = Characteristic( + service=self, + uuid=self.UUID_DATABASE_HASH, + read=True, + initial=self.digest + ) + + def send_changed(self, connection, start=0, end=0xFFFF): + self.SERVICE_CHANGED.write(ustruct.pack('!HH', start, end)) + log_info("Indicate Service Changed") + ble.gatts_indicate(connection._conn_handle, self.SERVICE_CHANGED._value_handle)