Compare commits

..

8 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
368e7eff05 update tests to look at side effects instead of internals 2022-08-12 12:32:36 -07:00
Gilles Boccon-Gibod
55b813bbf5 don't use a lambda as a subscriber 2022-08-12 12:06:08 -07:00
Michael Mogenson
14dfc1a501 Add subscribe and unsubscribe commands to console.py (#22)
Subscribe command will enable notify or indicate events from the
characteristic, depending on supported characteristic properties, and
print received values to the output window.

Unsubscribe will stop notify or indicate events.

Rename find_attribute() to find_characteristic() and return a
characteristic for a set of UUIDS, a characteristic for an attribute
handle, or None.

Print read and received values has a hex string.

Add an unsubscribe implementation to gatt_client.py. Reset the CCCD bits
to 0x0000. Remove a matching subsciber, if one is provided. Otherwise
remove all subscribers for a characteristic, since no more notify or
indicates events will be comming.

authored-by: Michael Mogenson <mogenson@google.com>
2022-08-12 11:49:01 -07:00
Gilles Boccon-Gibod
938282e961 Update python-publish.yml 2022-08-04 14:40:40 -07:00
Gilles Boccon-Gibod
900c15b151 Update python-publish.yml
trigger on published release
2022-08-04 14:30:25 -07:00
Gilles Boccon-Gibod
9ea93be723 add missing package entry (#21) 2022-08-04 14:27:21 -07:00
Gilles Boccon-Gibod
894ab023c7 Update python-publish.yml
don't run on PRs
2022-08-04 10:50:28 -07:00
Gilles Boccon-Gibod
7bbb37b2da Merge pull request #20 from google/gbg/test-gatt-long-read
add long read self test
2022-08-04 10:33:27 -07:00
6 changed files with 141 additions and 42 deletions

View File

@@ -1,11 +1,9 @@
name: Upload Python Package
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
release:
types: [published]
permissions:
contents: read
@@ -32,7 +30,7 @@ jobs:
- name: Build package
run: python -m build
- name: Publish package to PyPI
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__

View File

@@ -122,6 +122,8 @@ class ConsoleApp:
},
'read': LiveCompleter(self.known_attributes),
'write': LiveCompleter(self.known_attributes),
'subscribe': LiveCompleter(self.known_attributes),
'unsubscribe': LiveCompleter(self.known_attributes),
'quit': None,
'exit': None
})
@@ -331,7 +333,7 @@ class ConsoleApp:
await self.show_attributes(attributes)
def find_attribute(self, param):
def find_characteristic(self, param):
parts = param.split('.')
if len(parts) == 2:
service_uuid = UUID(parts[0]) if parts[0] != '*' else None
@@ -344,7 +346,10 @@ class ConsoleApp:
elif len(parts) == 1:
if parts[0].startswith('#'):
attribute_handle = int(f'{parts[0][1:]}', 16)
return attribute_handle
for service in self.connected_peer.services:
for characteristic in service.characteristics:
if characteristic.handle == attribute_handle:
return characteristic
async def command(self, command):
try:
@@ -457,13 +462,13 @@ class ConsoleApp:
self.show_error('invalid syntax', 'expected read <attribute>')
return
attribute = self.find_attribute(params[0])
if attribute is None:
characteristic = self.find_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
return
value = await self.connected_peer.read_value(attribute)
self.append_to_output(f'VALUE: {value}')
value = await characteristic.read_value()
self.append_to_output(f'VALUE: 0x{value.hex()}')
async def do_write(self, params):
if not self.connected_peer:
@@ -482,21 +487,48 @@ class ConsoleApp:
except ValueError:
value = str.encode(params[1]) # must be a string
attribute = self.find_attribute(params[0])
if attribute is None:
characteristic = self.find_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
return
# use write with response if supported
with_response = (
(attribute.properties & Characteristic.WRITE)
if hasattr(attribute, "properties")
else False
with_response = characteristic.properties & Characteristic.WRITE
await characteristic.write_value(value, with_response=with_response)
async def do_subscribe(self, params):
if not self.connected_peer:
self.show_error('not connected')
return
if len(params) != 1:
self.show_error('invalid syntax', 'expected subscribe <attribute>')
return
characteristic = self.find_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
return
await characteristic.subscribe(
lambda value: self.append_to_output(f"{characteristic} VALUE: 0x{value.hex()}"),
)
await self.connected_peer.write_value(
attribute, value, with_response=with_response
)
async def do_unsubscribe(self, params):
if not self.connected_peer:
self.show_error('not connected')
return
if len(params) != 1:
self.show_error('invalid syntax', 'expected subscribe <attribute>')
return
characteristic = self.find_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
return
await characteristic.unsubscribe()
async def do_exit(self, params):
self.ui.exit()

View File

@@ -123,6 +123,9 @@ class Peer:
async def subscribe(self, characteristic, subscriber=None):
return await self.gatt_client.subscribe(characteristic, subscriber)
async def unsubscribe(self, characteristic, subscriber=None):
return await self.gatt_client.unsubscribe(characteristic, subscriber)
async def read_value(self, attribute):
return await self.gatt_client.read_value(attribute)

View File

@@ -110,6 +110,9 @@ class CharacteristicProxy(AttributeProxy):
async def subscribe(self, subscriber=None):
return await self.client.subscribe(self, subscriber)
async def unsubscribe(self, subscriber=None):
return await self.client.unsubscribe(self, subscriber)
def __str__(self):
return f'Characteristic(handle=0x{self.handle:04X}, uuid={self.uuid}, properties={Characteristic.properties_as_string(self.properties)})'
@@ -544,10 +547,36 @@ class Client:
for subscriber_set in subscriber_sets:
if subscriber is not None:
subscriber_set.add(subscriber)
subscriber_set.add(lambda value: characteristic.emit('update', self.connection, value))
# Add the characteristic as a subscriber, which will result in the characteristic
# emitting an 'update' event when a notification or indication is received
subscriber_set.add(characteristic)
await self.write_value(cccd, struct.pack('<H', bits), with_response=True)
async def unsubscribe(self, characteristic, subscriber=None):
# If we haven't already discovered the descriptors for this characteristic, do it now
if not characteristic.descriptors_discovered:
await self.discover_descriptors(characteristic)
# Look for the CCCD descriptor
cccd = characteristic.get_descriptor(GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR)
if not cccd:
logger.warning('unsubscribing from characteristic with no CCCD descriptor')
return
if subscriber is not None:
# Remove matching subscriber from subscriber sets
for subscriber_set in (self.notification_subscribers, self.indication_subscribers):
subscribers = subscriber_set.get(characteristic.handle, [])
if subscriber in subscribers:
subscribers.remove(subscriber)
else:
# Remove all subscribers for this attribute from the sets!
self.notification_subscribers.pop(characteristic.handle, None)
self.indication_subscribers.pop(characteristic.handle, None)
await self.write_value(cccd, b'\x00\x00', with_response=True)
async def read_value(self, attribute, no_long_read=False):
'''
See Vol 3, Part G - 4.8.1 Read Characteristic Value
@@ -714,7 +743,10 @@ class Client:
if not subscribers:
logger.warning('!!! received notification with no subscriber')
for subscriber in subscribers:
subscriber(notification.attribute_value)
if callable(subscriber):
subscriber(notification.attribute_value)
else:
subscriber.emit('update', notification.attribute_value)
def on_att_handle_value_indication(self, indication):
# Call all subscribers
@@ -722,7 +754,10 @@ class Client:
if not subscribers:
logger.warning('!!! received indication with no subscriber')
for subscriber in subscribers:
subscriber(indication.attribute_value)
if callable(subscriber):
subscriber(indication.attribute_value)
else:
subscriber.emit('update', indication.attribute_value)
# Confirm that we received the indication
self.send_confirmation(ATT_Handle_Value_Confirmation())

View File

@@ -24,7 +24,7 @@ url = https://github.com/google/bumble
[options]
python_requires = >=3.8
packages = bumble, bumble.transport, bumble.apps, bumble.apps.link_relay
packages = bumble, bumble.transport, bumble.profiles, bumble.apps, bumble.apps.link_relay
package_dir =
bumble = bumble
bumble.apps = apps

View File

@@ -419,10 +419,12 @@ async def test_subscribe_notify():
assert(len(c) == 1)
c3 = c[0]
c1._called = False
c1._last_update = None
def on_c1_update(connection, value):
c1._last_update = (connection, value)
def on_c1_update(value):
c1._called = True
c1._last_update = value
c1.on('update', on_c1_update)
await peer.subscribe(c1)
@@ -434,44 +436,73 @@ async def test_subscribe_notify():
assert(not characteristic1._last_subscription[2])
await server.indicate_subscribers(characteristic1)
await async_barrier()
assert(c1._last_update is None)
assert(not c1._called)
await server.notify_subscribers(characteristic1)
await async_barrier()
assert(c1._last_update is not None)
assert(c1._last_update[1] == characteristic1.value)
assert(c1._called)
assert(c1._last_update == characteristic1.value)
c1._called = False
await peer.unsubscribe(c1)
await server.notify_subscribers(characteristic1)
assert(not c1._called)
c2._called = False
c2._last_update = None
def on_c2_update(value):
c2._last_update = (connection, value)
c2._called = True
c2._last_update = value
await peer.subscribe(c2, on_c2_update)
await async_barrier()
await server.notify_subscriber(characteristic2._last_subscription[0], characteristic2)
await async_barrier()
assert(c2._last_update is None)
assert(not c2._called)
await server.indicate_subscriber(characteristic2._last_subscription[0], characteristic2)
await async_barrier()
assert(c2._last_update is not None)
assert(c2._last_update[1] == characteristic2.value)
assert(c2._called)
assert(c2._last_update == characteristic2.value)
c3._last_update = None
c2._called = False
await peer.unsubscribe(c2, on_c2_update)
await server.indicate_subscriber(characteristic2._last_subscription[0], characteristic2)
await async_barrier()
assert(not c2._called)
def on_c3_update(connection, value):
c3._last_update = (connection, value)
def on_c3_update(value):
c3._called = True
c3._last_update = value
def on_c3_update_2(value):
c3._called_2 = True
c3._last_update_2 = value
c3.on('update', on_c3_update)
await peer.subscribe(c3)
await peer.subscribe(c3, on_c3_update_2)
await async_barrier()
await server.notify_subscriber(characteristic3._last_subscription[0], characteristic3)
await async_barrier()
assert(c3._last_update is not None)
assert(c3._last_update[1] == characteristic3.value)
assert(c3._called)
assert(c3._last_update == characteristic3.value)
assert(c3._called_2)
assert(c3._last_update_2 == characteristic3.value)
characteristic3.value = bytes([1, 2, 3])
await server.indicate_subscriber(characteristic3._last_subscription[0], characteristic3)
await async_barrier()
assert(c3._last_update is not None)
assert(c3._last_update[1] == characteristic3.value)
assert(c3._called)
assert(c3._last_update == characteristic3.value)
assert(c3._called_2)
assert(c3._last_update_2 == characteristic3.value)
c3._called = False
c3._called_2 = False
await peer.unsubscribe(c3)
await server.notify_subscriber(characteristic3._last_subscription[0], characteristic3)
await server.indicate_subscriber(characteristic3._last_subscription[0], characteristic3)
await async_barrier()
assert(not c3._called)
assert(not c3._called_2)
# -----------------------------------------------------------------------------