Compare commits

...

7 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod 49b2c13e69 only use 1M parameters by default 2023-04-09 17:57:11 -07:00
Lucas Abel 962737a97b Merge pull request #173 from zxzxwu/smp
SMP: Determine initiator by direction instead of role
2023-04-07 09:50:06 -07:00
Josh Wu 85496aaff5 SMP: Determine initiator by direction instead of role
Though in the spec this is not allowed, but in some ambiguous cases such
as SMP over BR/EDR or we want to test how remote devices handle invalid
pairing requests, we still need to allow this behavior, with some logs
to let users know it's invalid.
2023-04-08 00:02:48 +08:00
Lucas Abel a95e601a5c Merge pull request #172 from qiaoccolato/main
LICENCE: recorde colors.py licence
2023-04-05 13:31:13 -07:00
Qiao Yang df218b5370 LICENCE: recorde colors.py licence 2023-04-05 19:02:44 +00:00
Alan Rosenthal 0f737244b5 Merge pull request #169 from AlanRosenthal/alan/remote-values
Add `show remote-values`
2023-04-05 09:00:48 -04:00
Alan Rosenthal a258ba383a Add show remote-values
`gatt_client` caches values read/notifications/indications and displays the most recent value to the user
2023-04-04 18:15:42 +00:00
6 changed files with 126 additions and 12 deletions
+19
View File
@@ -200,3 +200,22 @@
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
---
Files: bumble/colors.py
Copyright (c) 2012 Giorgos Verigakis <verigak@gmail.com>
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+48 -2
View File
@@ -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):
+3 -4
View File
@@ -529,6 +529,7 @@ class Connection(CompositeEventEmitter):
authenticated: bool
sc: bool
link_key_type: int
gatt_client: gatt_client.Client
@composite_listener
class Listener:
@@ -1610,7 +1611,7 @@ class Device(CompositeEventEmitter):
pending connection.
connection_parameters_preferences: (BLE only, ignored for BR/EDR)
* None: use all PHYs with default parameters
* None: use the 1M PHY with default parameters
* map: each entry has a PHY as key and a ConnectionParametersPreferences
object as value
@@ -1679,9 +1680,7 @@ class Device(CompositeEventEmitter):
if connection_parameters_preferences is None:
if connection_parameters_preferences is None:
connection_parameters_preferences = {
HCI_LE_1M_PHY: ConnectionParametersPreferences.default,
HCI_LE_2M_PHY: ConnectionParametersPreferences.default,
HCI_LE_CODED_PHY: ConnectionParametersPreferences.default,
HCI_LE_1M_PHY: ConnectionParametersPreferences.default
}
self.connect_own_address_type = own_address_type
+47 -2
View File
@@ -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,
)
+8 -4
View File
@@ -645,7 +645,7 @@ class Session:
},
}
def __init__(self, manager, connection, pairing_config):
def __init__(self, manager, connection, pairing_config, is_initiator):
self.manager = manager
self.connection = connection
self.preq = None
@@ -684,7 +684,7 @@ class Session:
self.ctkd_task = None
# Decide if we're the initiator or the responder
self.is_initiator = connection.role == BT_CENTRAL_ROLE
self.is_initiator = is_initiator
self.is_responder = not self.is_initiator
# Listen for connection events
@@ -1680,6 +1680,8 @@ class Manager(EventEmitter):
def on_smp_pdu(self, connection, pdu):
# Look for a session with this connection, and create one if none exists
if not (session := self.sessions.get(connection.handle)):
if connection.role == BT_CENTRAL_ROLE:
logger.warning('Remote starts pairing as Peripheral!')
pairing_config = self.pairing_config_factory(connection)
if pairing_config is None:
# Pairing disabled
@@ -1688,7 +1690,7 @@ class Manager(EventEmitter):
SMP_Pairing_Failed_Command(reason=SMP_PAIRING_NOT_SUPPORTED_ERROR),
)
return
session = Session(self, connection, pairing_config)
session = Session(self, connection, pairing_config, is_initiator=False)
self.sessions[connection.handle] = session
# Parse the L2CAP payload into an SMP Command object
@@ -1709,10 +1711,12 @@ class Manager(EventEmitter):
async def pair(self, connection):
# TODO: check if there's already a session for this connection
if connection.role != BT_CENTRAL_ROLE:
logger.warning('Start pairing as Peripheral!')
pairing_config = self.pairing_config_factory(connection)
if pairing_config is None:
raise ValueError('pairing config must not be None when initiating')
session = Session(self, connection, pairing_config)
session = Session(self, connection, pairing_config, is_initiator=True)
self.sessions[connection.handle] = session
return await session.pair()
+1
View File
@@ -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 =