diff --git a/apps/console.py b/apps/console.py index 1522f08d..0ea9e5b9 100644 --- a/apps/console.py +++ b/apps/console.py @@ -24,6 +24,7 @@ import logging import os import random import re +import humanize from typing import Optional, Union from collections import OrderedDict @@ -165,6 +166,7 @@ class ConsoleApp: 'local-services': None, 'remote-services': None, 'local-values': None, + 'remote-values': None, }, 'filter': { 'address': None, @@ -212,6 +214,7 @@ class ConsoleApp: get_cursor_position=lambda: Point(0, max(0, len(self.log_lines) - 1)) ) self.local_values_text = FormattedTextControl() + self.remote_values_text = FormattedTextControl() self.log_height = Dimension(min=7, weight=4) self.log_max_lines = 100 self.log_lines = [] @@ -234,6 +237,10 @@ class ConsoleApp: Frame(Window(self.remote_services_text), title='Remote Services'), filter=Condition(lambda: self.top_tab == 'remote-services'), ), + ConditionalContainer( + Frame(Window(self.remote_values_text), title='Remote Values'), + filter=Condition(lambda: self.top_tab == 'remote-values'), + ), ConditionalContainer( Frame(Window(self.log_text, height=self.log_height), title='Log'), filter=Condition(lambda: self.top_tab == 'log'), @@ -737,6 +744,7 @@ class ConsoleApp: 'local-services', 'remote-services', 'local-values', + 'remote-values', }: self.top_tab = params[0] self.ui.invalidate() @@ -745,6 +753,10 @@ class ConsoleApp: await self.do_show_local_values() await asyncio.sleep(1) + while self.top_tab == 'remote-values': + await self.do_show_remote_values() + await asyncio.sleep(1) + async def do_show_local_values(self): prettytable = PrettyTable() field_names = ["Service", "Characteristic", "Descriptor"] @@ -800,6 +812,40 @@ class ConsoleApp: self.local_values_text.text = prettytable.get_string() self.ui.invalidate() + async def do_show_remote_values(self): + prettytable = PrettyTable( + field_names=[ + "Connection", + "Service", + "Characteristic", + "Descriptor", + "Time", + "Value", + ] + ) + for connection in self.device.connections.values(): + for handle, (time, value) in connection.gatt_client.cached_values.items(): + row = [connection.handle] + attribute = connection.gatt_client.get_attributes(handle) + if not attribute: + continue + if len(attribute) == 3: + row.extend( + [attribute[0].uuid, attribute[1].uuid, attribute[2].type] + ) + elif len(attribute) == 2: + row.extend([attribute[0].uuid, attribute[1].uuid, ""]) + elif len(attribute) == 1: + row.extend([attribute[0].uuid, "", ""]) + else: + continue + + row.extend([humanize.naturaltime(time), value]) + prettytable.add_row(row) + + self.remote_values_text.text = prettytable.get_string() + self.ui.invalidate() + async def do_get_phy(self, _): if not self.connected_peer: self.show_error('not connected') @@ -899,9 +945,9 @@ class ConsoleApp: # send data to any subscribers if isinstance(attribute, Characteristic): attribute.write_value(None, value) - if attribute.has_properties([Characteristic.NOTIFY]): + if attribute.has_properties(Characteristic.NOTIFY): await self.device.gatt_server.notify_subscribers(attribute) - if attribute.has_properties([Characteristic.INDICATE]): + if attribute.has_properties(Characteristic.INDICATE): await self.device.gatt_server.indicate_subscribers(attribute) async def do_subscribe(self, params): diff --git a/bumble/device.py b/bumble/device.py index 843d78f3..72bbd631 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -529,6 +529,7 @@ class Connection(CompositeEventEmitter): authenticated: bool sc: bool link_key_type: int + gatt_client: gatt_client.Client @composite_listener class Listener: diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py index 1c8d6aad..35d8eb7b 100644 --- a/bumble/gatt_client.py +++ b/bumble/gatt_client.py @@ -27,7 +27,8 @@ from __future__ import annotations import asyncio import logging import struct -from typing import List, Optional, Dict, Any, Callable +from datetime import datetime +from typing import List, Optional, Dict, Tuple, Callable, Union, Any from pyee import EventEmitter @@ -167,7 +168,9 @@ class CharacteristicProxy(AttributeProxy): async def discover_descriptors(self): return await self.client.discover_descriptors(self) - async def subscribe(self, subscriber=None, prefer_notify=True): + async def subscribe( + self, subscriber: Optional[Callable] = None, prefer_notify=True + ): if subscriber is not None: if subscriber in self.subscribers: # We already have a proxy subscriber @@ -221,6 +224,7 @@ class ProfileServiceProxy: # ----------------------------------------------------------------------------- class Client: services: List[ServiceProxy] + cached_values: Dict[int, Tuple[datetime, bytes]] def __init__(self, connection): self.connection = connection @@ -233,6 +237,7 @@ class Client: ) # Notification subscribers, by attribute handle self.indication_subscribers = {} # Indication subscribers, by attribute handle self.services = [] + self.cached_values = {} def send_gatt_pdu(self, pdu): self.connection.send_l2cap_pdu(ATT_CID, pdu) @@ -317,6 +322,35 @@ class Client: if c.uuid == uuid ] + def get_attribute_grouping( + self, attribute_handle: int + ) -> Optional[ + Union[ + ServiceProxy, + Tuple[ServiceProxy, CharacteristicProxy], + Tuple[ServiceProxy, CharacteristicProxy, DescriptorProxy], + ] + ]: + """ + Get the attribute(s) associated with an attribute handle + """ + for service in self.services: + if service.handle == attribute_handle: + return service + if service.handle <= attribute_handle <= service.end_group_handle: + for characteristic in service.characteristics: + if characteristic.handle == attribute_handle: + return (service, characteristic) + if ( + characteristic.handle + <= attribute_handle + <= characteristic.end_group_handle + ): + for descriptor in characteristic.descriptors: + if descriptor.handle == attribute_handle: + return (service, characteristic, descriptor) + return None + def on_service_discovered(self, service): '''Add a service to the service list if it wasn't already there''' already_known = False @@ -808,6 +842,7 @@ class Client: offset += len(part) + self.cache_value(attribute_handle, attribute_value) # Return the value as bytes return attribute_value @@ -942,6 +977,8 @@ class Client: ) if not subscribers: logger.warning('!!! received notification with no subscriber') + + self.cache_value(notification.attribute_handle, notification.attribute_value) for subscriber in subscribers: if callable(subscriber): subscriber(notification.attribute_value) @@ -953,6 +990,8 @@ class Client: subscribers = self.indication_subscribers.get(indication.attribute_handle, []) if not subscribers: logger.warning('!!! received indication with no subscriber') + + self.cache_value(indication.attribute_handle, indication.attribute_value) for subscriber in subscribers: if callable(subscriber): subscriber(indication.attribute_value) @@ -961,3 +1000,9 @@ class Client: # Confirm that we received the indication self.send_confirmation(ATT_Handle_Value_Confirmation()) + + def cache_value(self, attribute_handle: int, value: bytes): + self.cached_values[attribute_handle] = ( + datetime.now(), + value, + ) diff --git a/setup.cfg b/setup.cfg index 57d39753..8a4bbe1d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,6 +44,7 @@ install_requires = pyusb >= 1.2; platform_system!='Emscripten' websockets >= 8.1; platform_system!='Emscripten' prettytable >= 3.6.0 + humanize >= 4.6.0 [options.entry_points] console_scripts =