Skip to content

Commit

Permalink
Merge pull request #222 from kvaellning/ble_pairing_strategy
Browse files Browse the repository at this point in the history
Ble pairing strategy
  • Loading branch information
jlusiardi authored Feb 4, 2022
2 parents b31dcde + efa896e commit 6fc86d6
Show file tree
Hide file tree
Showing 17 changed files with 427 additions and 59 deletions.
10 changes: 5 additions & 5 deletions homekit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,17 @@
'Controller', 'BluetoothAdapterError', 'AccessoryDisconnectedError', 'AccessoryNotFoundError',
'AlreadyPairedError', 'AuthenticationError', 'BackoffError', 'BusyError', 'CharacteristicPermissionError',
'ConfigLoadingError', 'ConfigSavingError', 'ConfigurationError', 'FormatError', 'HomeKitException',
'HttpException', 'IncorrectPairingIdError', 'InvalidAuthTagError', 'InvalidError', 'InvalidSignatureError',
'MaxPeersError', 'MaxTriesError', 'ProtocolError', 'RequestRejected', 'UnavailableError', 'UnknownError',
'UnpairedError'
'HttpException', 'IncorrectPairingIdError', 'PairingAuthError', 'InvalidAuthTagError', 'InvalidError',
'InvalidSignatureError', 'MaxPeersError', 'MaxTriesError', 'ProtocolError', 'RequestRejected', 'UnavailableError',
'UnknownError', 'UnpairedError'
]

from homekit.controller import Controller
from homekit.exceptions import BluetoothAdapterError, AccessoryDisconnectedError, AccessoryNotFoundError, \
AlreadyPairedError, AuthenticationError, BackoffError, BusyError, CharacteristicPermissionError, \
ConfigLoadingError, ConfigSavingError, ConfigurationError, FormatError, HomeKitException, HttpException, \
IncorrectPairingIdError, InvalidAuthTagError, InvalidError, InvalidSignatureError, MaxPeersError, MaxTriesError, \
ProtocolError, RequestRejected, UnavailableError, UnknownError, UnpairedError
IncorrectPairingIdError, PairingAuthError, InvalidAuthTagError, InvalidError, InvalidSignatureError, \
MaxPeersError, MaxTriesError, ProtocolError, RequestRejected, UnavailableError, UnknownError, UnpairedError

from homekit.tools import IP_TRANSPORT_SUPPORTED

Expand Down
17 changes: 14 additions & 3 deletions homekit/controller/ble_impl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,12 +592,18 @@ def find_characteristic_by_uuid(device, service_uuid, char_uuid):
if ServicesTypes.get_short(service_uuid.upper()) == ServicesTypes.get_short(possible_service.uuid.upper()):
service_found = possible_service
break
logger.debug('searched service: %s', service_found)

if not service_found:
logging.error('searched service not found.')
try:
srv_desc = f'{ServicesTypes[service_uuid]} '
except KeyError:
srv_desc = ''

logging.error('searched service %snot found.', srv_desc)
return None, None

logger.debug('searched service: %s', service_found.uuid)

result_char = None
result_char_id = None
for characteristic in service_found.characteristics:
Expand All @@ -612,7 +618,12 @@ def find_characteristic_by_uuid(device, service_uuid, char_uuid):
result_char_id = cid

if not result_char:
logging.error('searched char not found.')
try:
char_desc = f'{CharacteristicsTypes[char_uuid]} '
except KeyError:
char_desc = ''

logging.error('searched char %snot found.', char_desc)
return None, None

logger.debug('searched char: %s %s', result_char, result_char_id)
Expand Down
12 changes: 9 additions & 3 deletions homekit/controller/ble_impl/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,20 @@ def connect(self):
except dbus.exceptions.DBusException:
raise AccessoryNotFoundError('Unable to resolve device services + characteristics')

def characteristic_read_value_succeeded(self, characteristic):
logger.debug('read success: %s %s', str(characteristic.uuid))

def characteristic_read_value_failed(self, characteristic, error):
logger.debug('read failed: %s %s', characteristic, error)
logger.debug('read failed: %s %s', str(characteristic.uuid), error)

def characteristic_write_value_succeeded(self, characteristic):
logger.debug('write success: %s', characteristic)
logger.debug('write success: %s', str(characteristic.uuid))

def characteristic_write_value_failed(self, characteristic, error):
logger.debug('write failed: %s %s', characteristic, error)
logger.debug('write failed: %s %s', str(characteristic.uuid), error)

def descriptor_read_value_failed(self, descriptor, error):
logger.debug('read descriptor failed: %s %s', str(descriptor.uuid), error)


class DeviceManager(gatt.DeviceManager):
Expand Down
2 changes: 1 addition & 1 deletion homekit/controller/ble_impl/gatt.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def read_value(self, offset=0):
return val
except dbus.exceptions.DBusException as e:
error = _error_from_dbus_error(e)
self.service.device.descriptor_read_value_failed(self, error=error)
self.characteristic.service.device.descriptor_read_value_failed(self, error=error)


class Characteristic(gatt.Characteristic):
Expand Down
130 changes: 117 additions & 13 deletions homekit/controller/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,18 @@
import re
import tlv8

from enum import IntEnum

from homekit.exceptions import AccessoryNotFoundError, ConfigLoadingError, UnknownError, \
AuthenticationError, ConfigSavingError, AlreadyPairedError, TransportNotSupportedError, MalformedPinError
AuthenticationError, ConfigSavingError, AlreadyPairedError, TransportNotSupportedError, \
MalformedPinError, PairingAuthError
from homekit.protocol import States, Methods, Errors, TlvTypes
from homekit.http_impl import HomeKitHTTPConnection
from homekit.protocol.statuscodes import HapStatusCodes
from homekit.protocol import perform_pair_setup_part1, perform_pair_setup_part2, create_ip_pair_setup_write
from homekit.model.services.service_types import ServicesTypes
from homekit.model.characteristics.characteristic_types import CharacteristicsTypes
from homekit.model.feature_flags import FeatureFlags
from homekit.protocol.opcodes import HapBleOpCodes
from homekit.tools import IP_TRANSPORT_SUPPORTED, BLE_TRANSPORT_SUPPORTED
from homekit.controller.tools import NotSupportedPairing
Expand All @@ -41,7 +45,7 @@
from .ble_impl.discovery import DiscoveryDeviceManager

if IP_TRANSPORT_SUPPORTED:
from homekit.zeroconf_impl import discover_homekit_devices, find_device_ip_and_port
from homekit.zeroconf_impl import discover_homekit_devices, find_device_ip_port_props
from homekit.controller.ip_implementation import IpPairing, IpSession


Expand All @@ -60,6 +64,17 @@ def __init__(self, ble_adapter='hci0'):
self.ble_adapter = ble_adapter
self.logger = logging.getLogger('homekit.controller.Controller')

class PairingAuth(IntEnum):
"""
Types of pairing authentication strategies
Auto: try pairing with hardware authentication , fall back to software authentication if necessary
HwAuth: only try hardware authentication
SwAuth: only try software authentication
"""
Auto = 0
HwAuth = 1
SwAuth = 2

@staticmethod
def discover(max_seconds=10):
"""
Expand Down Expand Up @@ -152,7 +167,7 @@ def identify(accessory_id):
"""
if not IP_TRANSPORT_SUPPORTED:
raise TransportNotSupportedError('IP')
connection_data = find_device_ip_and_port(accessory_id)
connection_data = find_device_ip_port_props(accessory_id)
if connection_data is None:
raise AccessoryNotFoundError('Cannot find accessory with id "{i}".'.format(i=accessory_id))

Expand Down Expand Up @@ -324,7 +339,34 @@ def check_pin_format(pin):
if not re.match(r'^\d\d\d-\d\d-\d\d\d$', pin):
raise MalformedPinError('The pin must be of the following XXX-XX-XXX where X is a digit between 0 and 9.')

def perform_pairing(self, alias, accessory_id, pin):
def _get_pair_method(self, auth_method: PairingAuth, feature_flags: int):
"""
Returns the used pairing method based on the supported features
A feature flag of 0 will be treated as pairing with software authentication support, to allow uncertified
accessories which are not MFi certified.
:param auth_method: user specified authentication method
:param feature_flags: represents the supported features. 0 is treated as "software authentication allowed".
:returns:
:raises PairingAuthError: if pairing authentication method is not supported
"""
pair_method = Methods.PairSetup

if auth_method == Controller.PairingAuth.Auto:
if feature_flags & FeatureFlags.APPLE_MFI_COPROCESSOR:
pair_method = Methods.PairSetupWithAuth
elif auth_method == Controller.PairingAuth.HwAuth:
pair_method = Methods.PairSetupWithAuth
elif auth_method == Controller.PairingAuth.SwAuth:
pass
else:
raise PairingAuthError(f'auth_method: invalid value "{str(auth_method)}"')

logging.debug('using pairing method %s', pair_method)
return pair_method

def perform_pairing(self, alias, accessory_id, pin, auth_method=PairingAuth.HwAuth):
"""
This performs a pairing attempt with the IP accessory identified by its id.
Expand All @@ -338,6 +380,7 @@ def perform_pairing(self, alias, accessory_id, pin):
:param alias: the alias for the accessory in the controllers data
:param accessory_id: the accessory's id
:param pin: function to return the accessory's pin
:param auth_method: defines the used pairing auth_method
:raises AccessoryNotFoundError: if no accessory with the given id can be found
:raises AlreadyPairedError: if the alias was already used
:raises UnavailableError: if the device is already paired
Expand All @@ -347,12 +390,14 @@ def perform_pairing(self, alias, accessory_id, pin):
:raises MaxPeersError: if the device cannot accept an additional pairing
:raises UnavailableError: on wrong pin
:raises MalformedPinError: if the pin is malformed
:raises PairingAuthError: if pairing authentication method is not supported
"""
Controller.check_pin_format(pin)
finish_pairing = self.start_pairing(alias, accessory_id)

finish_pairing = self.start_pairing(alias, accessory_id, auth_method)
return finish_pairing(pin)

def start_pairing(self, alias, accessory_id):
def start_pairing(self, alias, accessory_id, auth_method=PairingAuth.Auto):
"""
This starts a pairing attempt with the IP accessory identified by its id.
It returns a callable (finish_pairing) which you must call with the pairing pin.
Expand All @@ -368,6 +413,7 @@ def start_pairing(self, alias, accessory_id):
:param alias: the alias for the accessory in the controllers data
:param accessory_id: the accessory's id
:param pin: function to return the accessory's pin
:param auth_method: defines the used pairing auth_method
:raises AccessoryNotFoundError: if no accessory with the given id can be found
:raises AlreadyPairedError: if the alias was already used
:raises UnavailableError: if the device is already paired
Expand All @@ -376,21 +422,24 @@ def start_pairing(self, alias, accessory_id):
:raises AuthenticationError: if the verification of the device's SRP proof fails
:raises MaxPeersError: if the device cannot accept an additional pairing
:raises UnavailableError: on wrong pin
:raises PairingAuthError: if pairing authentication method is not supported
"""
if not IP_TRANSPORT_SUPPORTED:
raise TransportNotSupportedError('IP')
if alias in self.pairings:
raise AlreadyPairedError('Alias "{a}" is already paired.'.format(a=alias))

connection_data = find_device_ip_and_port(accessory_id)
connection_data = find_device_ip_port_props(accessory_id)
if connection_data is None:
raise AccessoryNotFoundError('Cannot find accessory with id "{i}".'.format(i=accessory_id))
conn = HomeKitHTTPConnection(connection_data['ip'], port=connection_data['port'])

pair_method = self._get_pair_method(auth_method, connection_data['properties']['ff'])

try:
write_fun = create_ip_pair_setup_write(conn)

state_machine = perform_pair_setup_part1()
state_machine = perform_pair_setup_part1(pair_method)
request, expected = state_machine.send(None)
while True:
try:
Expand Down Expand Up @@ -426,7 +475,52 @@ def finish_pairing(pin):

return finish_pairing

def perform_pairing_ble(self, alias, accessory_mac, pin, adapter='hci0'):
def _read_feature_flags_ble(self, device) -> int:
"""helper function to read out the feature flags of the accessory
:param device: device connected to the accessory used for the request. Should be connected.
:return: 1 byte integer representing the feature flags
"""
pair_features_char, pair_features_char_id = find_characteristic_by_uuid(device,
ServicesTypes.PAIRING_SERVICE,
CharacteristicsTypes.PAIRING_FEATURES)
logging.debug('setup char: %s %s', str(pair_features_char.uuid), pair_features_char.service.device)

assert pair_features_char and pair_features_char_id, 'pairing feature characteristic not defined'

# prepare request
transaction_id = random.randrange(0, 255)
wdata = bytearray([0x00, HapBleOpCodes.CHAR_READ, transaction_id])
wdata.extend(pair_features_char_id.to_bytes(length=2, byteorder='little'))
logging.debug('sent %s', bytes(wdata).hex())

logging.debug('reading pairing features')

pair_features_char.write_value(value=wdata)
rdata = []
import time
while len(rdata) == 0:
time.sleep(1)
rdata = pair_features_char.read_value()

resp_data = [b for b in rdata]
expected_length = int.from_bytes(bytes(resp_data[3:5]), byteorder='little')

logging.debug(
'control field: {c:x}, tid: {t:x}, status: {s:x}, length: {length}'.format(c=resp_data[0], t=resp_data[1],
s=resp_data[2],
length=expected_length))
# 3 for uint8 TLV
assert expected_length == 3, 'invalid return length, expecting uint8'
assert transaction_id == resp_data[1], 'transaction id mismatch'
assert HapStatusCodes.SUCCESS == resp_data[2], f'hap status code: {HapStatusCodes[resp_data[2]]}'

resp_data = tlv8.decode(bytes([int(a) for a in resp_data[5:]]),
expected={AdditionalParameterTypes.Value: tlv8.DataType.INTEGER})
assert 0 < len(resp_data), 'received data is not an integer'

return resp_data.first_by_id(AdditionalParameterTypes.Value).data

def perform_pairing_ble(self, alias, accessory_mac, pin, adapter='hci0', auth_method=PairingAuth.Auto):
"""
This performs a pairing attempt with the Bluetooth LE accessory identified by its mac address.
Expand All @@ -442,14 +536,17 @@ def perform_pairing_ble(self, alias, accessory_mac, pin, adapter='hci0'):
:param accessory_mac: the accessory's mac address
:param pin: function to return the accessory's pin
:param adapter: the bluetooth adapter to be used (defaults to hci0)
:param auth_method: defines the used pairing auth_method
:raises MalformedPinError: if the pin is malformed
:raises PairingAuthError: if pairing authentication method is not supported
# TODO add raised exceptions
"""
Controller.check_pin_format(pin)
finish_pairing = self.start_pairing_ble(alias, accessory_mac, adapter)

finish_pairing = self.start_pairing_ble(alias, accessory_mac, adapter, auth_method)
return finish_pairing(pin)

def start_pairing_ble(self, alias, accessory_mac, adapter='hci0'):
def start_pairing_ble(self, alias, accessory_mac, adapter='hci0', auth_method=PairingAuth.Auto):
"""
This starts a pairing attempt with the Bluetooth LE accessory identified by its mac address.
It returns a callable (finish_pairing) which you must call with the pairing pin.
Expand All @@ -464,6 +561,8 @@ def start_pairing_ble(self, alias, accessory_mac, adapter='hci0'):
:param alias: the alias for the accessory in the controllers data
:param accessory_mac: the accessory's mac address
:param adapter: the bluetooth adapter to be used (defaults to hci0)
:param auth_method: defines the used pairing auth_method
:raises PairingAuthError: if pairing authentication method is not supported
# TODO add raised exceptions
"""
if not BLE_TRANSPORT_SUPPORTED:
Expand All @@ -479,13 +578,18 @@ def start_pairing_ble(self, alias, accessory_mac, adapter='hci0'):
device.connect()
logging.debug('connected to device')

# --- read pairing features to determine if the authentication method is actually supported
feature_flags = self._read_feature_flags_ble(device)
pair_method = self._get_pair_method(auth_method, feature_flags)

# --- perform pairing
pair_setup_char, pair_setup_char_id = find_characteristic_by_uuid(device, ServicesTypes.PAIRING_SERVICE,
CharacteristicsTypes.PAIR_SETUP)
logging.debug('setup char: %s %s', pair_setup_char, pair_setup_char.service.device)
logging.debug('setup char: %s %s', str(pair_setup_char.uuid), pair_setup_char.service.device)

write_fun = create_ble_pair_setup_write(pair_setup_char, pair_setup_char_id)

state_machine = perform_pair_setup_part1()
state_machine = perform_pair_setup_part1(pair_method)
request, expected = state_machine.send(None)
while True:
try:
Expand Down
4 changes: 2 additions & 2 deletions homekit/controller/ip_implementation.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from homekit.protocol import get_session_keys, create_ip_pair_verify_write
from homekit.protocol import States, Methods, Errors, TlvTypes
from homekit.model.characteristics import CharacteristicsTypes
from homekit.zeroconf_impl import find_device_ip_and_port
from homekit.zeroconf_impl import find_device_ip_port_props
from homekit.model.services import ServicesTypes


Expand Down Expand Up @@ -476,7 +476,7 @@ def __init__(self, pairing_data):
if not connected:
# no connection yet, so ip / port might have changed and we need to fall back to slow zeroconf lookup
device_id = pairing_data['AccessoryPairingID']
connection_data = find_device_ip_and_port(device_id)
connection_data = find_device_ip_port_props(device_id)

# update pairing data with the IP/port we elaborated above, perhaps next time they are valid
pairing_data['AccessoryIP'] = connection_data['ip']
Expand Down
9 changes: 8 additions & 1 deletion homekit/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
# limitations under the License.
#


class HomeKitException(Exception):
"""Generic HomeKit exception.
Attributes:
Expand Down Expand Up @@ -66,6 +65,14 @@ class AuthenticationError(ProtocolError):
pass


class PairingAuthError(ProtocolError):
"""
Raised in on pairing if the current pairing method is not supported.
This can happen if an accessory does not support a specific authentication method.
"""
pass


class BackoffError(ProtocolError):
"""
Raised upon receipt of a back off error. It seems unclear when this is raised, must be related to
Expand Down
Loading

0 comments on commit 6fc86d6

Please sign in to comment.