Skip to content

Commit

Permalink
Add remove_pairing support
Browse files Browse the repository at this point in the history
  • Loading branch information
Jc2k committed Jul 31, 2019
1 parent ecc343a commit f20edd2
Show file tree
Hide file tree
Showing 9 changed files with 264 additions and 125 deletions.
28 changes: 27 additions & 1 deletion homekit/aio/controller/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def load_data(self, filename):
if data[pairing_id]['Connection'] == 'IP':
if not IP_TRANSPORT_SUPPORTED:
raise TransportNotSupportedError('IP')
self.pairings[pairing_id] = IpPairing(data[pairing_id])
self.pairings[pairing_id] = IpPairing(self, data[pairing_id])
elif data[pairing_id]['Connection'] == 'BLE':
if not BLE_TRANSPORT_SUPPORTED:
raise TransportNotSupportedError('BLE')
Expand Down Expand Up @@ -190,3 +190,29 @@ 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.')


async def remove_pairing(self, alias):
"""
Remove a pairing between the controller and the accessory. The pairing data is delete on both ends, on the
accessory and the controller.
Important: no automatic saving of the pairing data is performed. If you don't do this, the accessory seems still
to be paired on the next start of the application.
:param alias: the controller's alias for the accessory
:raises AuthenticationError: if the controller isn't authenticated to the accessory.
:raises AccessoryNotFoundError: if the device can not be found via zeroconf
:raises UnknownError: on unknown errors
"""
if alias not in self.pairings:
raise AccessoryNotFoundError('Alias "{a}" is not found.'.format(a=alias))

pairing = self.pairings[alias]

primary_pairing_id = pairing.pairing_data['iOSPairingId']
await pairing.remove_pairing(primary_pairing_id)

await pairing.close()

del self.pairings[alias]
23 changes: 16 additions & 7 deletions homekit/aio/controller/ip/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def connection_made(self, transport):
def connection_lost(self, exception):
self.connection._connection_lost(exception)

def send_bytes(self, payload):
async def send_bytes(self, payload):
if self.transport.is_closing():
# FIXME: It would be nice to try and wait for the reconnect in future.
# In that case we need to make sure we do it at a layer above send_bytes otherwise
Expand All @@ -67,14 +67,16 @@ def send_bytes(self, payload):
# queued writes can happy.
raise AccessoryDisconnectedError('Transport is closed')

print('transport.write', payload)
self.transport.write(payload)

# We return a future so that our caller can block on a reply
# We can send many requests and dispatch the results in order
# Should mean we don't need locking around request/reply cycles
result = asyncio.Future()
self.result_cbs.append(result)
return result

return await asyncio.wait_for(result, 10)

def data_received(self, data):
while data:
Expand All @@ -94,13 +96,15 @@ def data_received(self, data):
self.current_response = HttpResponse()

def eof_received(self):
self.close()
return False

def close(self):
# If the connection is closed then any pending callbacks will never
# fire, so set them to an error state.
while self.result_cbs:
result = self.result_cbs.pop(0)
result.set_exception(RuntimeError('Connection closed'))

return False
result.set_exception(AccessoryDisconnectedError('Connection closed'))


class SecureHomeKitProtocol(InsecureHomeKitProtocol):
Expand All @@ -116,7 +120,7 @@ def __init__(self, connection, a2c_key, c2a_key):
self.a2c_key = a2c_key
self.c2a_key = c2a_key

def send_bytes(self, payload):
async def send_bytes(self, payload):
buffer = b''

while len(payload) > 0:
Expand All @@ -137,7 +141,7 @@ def send_bytes(self, payload):

buffer += len_bytes + data + tag

return super().send_bytes(buffer)
return await super().send_bytes(buffer)

def data_received(self, data):
"""
Expand Down Expand Up @@ -379,6 +383,8 @@ def close(self):
"""
self.closing = True

print(id(self), 'close(): closing', self.closing)

if self.transport:
self.transport.close()

Expand All @@ -388,6 +394,8 @@ def _connection_lost(self, exception):
"""
logger.info("Connection %r lost.", self)

print(id(self), '_connection_lost', exception)

if not self.when_connected.done():
self.when_connected.set_exception(
AccessoryDisconnectedError(
Expand Down Expand Up @@ -416,6 +424,7 @@ async def _reconnect(self):
# There is aiozeroconf but that doesn't work on Windows until python 3.9
# In HASS, zeroconf is a service provided by HASS itself and want to be able to
# leverage that instead.
print(id(self), '_reconnect', self.closing)
while not self.closing:
try:
await self._connect_once()
Expand Down
2 changes: 1 addition & 1 deletion homekit/aio/controller/ip/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ async def finish_pairing(pin):
pairing['AccessoryPort'] = self.port
pairing['Connection'] = 'IP'

obj = self.controller.pairings[alias] = IpPairing(pairing)
obj = self.controller.pairings[alias] = IpPairing(self.controller, pairing)

self.connection.close()

Expand Down
51 changes: 48 additions & 3 deletions homekit/aio/controller/ip/pairing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
import time
import logging

from homekit.controller.tools import AbstractPairing, check_convert_value
from homekit.controller.tools import check_convert_value
from homekit.protocol.statuscodes import HapStatusCodes
from homekit.exceptions import UnknownError, UnpairedError
from homekit.exceptions import AuthenticationError, UnknownError, UnpairedError
from homekit.protocol.tlv import TLV
from homekit.model.characteristics import CharacteristicsTypes
from homekit.model.services import ServicesTypes

from homekit.aio.controller.pairing import AbstractPairing

from .connection import SecureHomeKitConnection


Expand All @@ -38,13 +40,14 @@ class IpPairing(AbstractPairing):
This represents a paired HomeKit IP accessory.
"""

def __init__(self, pairing_data):
def __init__(self, controller, pairing_data):
"""
Initialize a Pairing by using the data either loaded from file or obtained after calling
Controller.perform_pairing().
:param pairing_data:
"""
self.controller = controller
self.pairing_data = pairing_data
self.connection = None
self.connect_lock = asyncio.Lock()
Expand All @@ -65,10 +68,13 @@ async def close(self):
"""
Close the pairing's communications. This closes the session.
"""
print(self.close)
if self.connection:
self.connection.close()
self.connection = None

await asyncio.sleep(0)

async def list_accessories_and_characteristics(self):
"""
This retrieves a current set of accessories and characteristics behind this pairing.
Expand Down Expand Up @@ -373,3 +379,42 @@ async def identify(self):
if not await self.put_characteristics([(aid, iid, True)]):
return True
return False

async def remove_pairing(self, pairingId):
"""
Remove a pairing between the controller and the accessory. The pairing data is delete on both ends, on the
accessory and the controller.
Important: no automatic saving of the pairing data is performed. If you don't do this, the accessory seems still
to be paired on the next start of the application.
:param alias: the controller's alias for the accessory
:param pairingId: the pairing id to be removed
:raises AuthenticationError: if the controller isn't authenticated to the accessory.
:raises AccessoryNotFoundError: if the device can not be found via zeroconf
:raises UnknownError: on unknown errors
"""
await self._ensure_connected()

request_tlv = [
(TLV.kTLVType_State, TLV.M1),
(TLV.kTLVType_Method, TLV.RemovePairing),
(TLV.kTLVType_Identifier, pairingId.encode('utf-8'))
]

data = await self.connection.post_tlv('/pairings', request_tlv)

# act upon the response (the same is returned for IP and BLE accessories)
# handle the result, spec says, if it has only one entry with state == M2 we unpaired, else its an error.
logging.debug('response data: %s', data)
print(data)

if len(data) == 1 and data[0][0] == TLV.kTLVType_State and data[0][1] == TLV.M2:
return True

self.transport.close()

if data[1][0] == TLV.kTLVType_Error and data[1][1] == TLV.kTLVError_Authentication:
raise AuthenticationError('Remove pairing failed: missing authentication')

raise UnknownError('Remove pairing failed: unknown error')
17 changes: 17 additions & 0 deletions homekit/aio/controller/pairing.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,20 @@ async def identify(self):
:return True, if the identification was run, False otherwise
"""
pass

@abc.abstractmethod
async def remove_pairing(self, pairingId):
"""
Remove a pairing between the controller and the accessory. The pairing data is delete on both ends, on the
accessory and the controller.
Important: no automatic saving of the pairing data is performed. If you don't do this, the accessory seems still
to be paired on the next start of the application.
:param alias: the controller's alias for the accessory
:param pairingId: the pairing id to be removed
:raises AuthenticationError: if the controller isn't authenticated to the accessory.
:raises AccessoryNotFoundError: if the device can not be found via zeroconf
:raises UnknownError: on unknown errors
"""
pass
130 changes: 130 additions & 0 deletions tests/aio/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import tempfile
import threading

import pytest

from homekit import AccessoryServer
from homekit.model import Accessory
from homekit.model.services import LightBulbService
from homekit.model import mixin as model_mixin
from homekit.aio.controller import Controller


@pytest.fixture
def controller_and_unpaired_accessory(request, event_loop):
config_file = tempfile.NamedTemporaryFile()
config_file.write("""{
"accessory_ltpk": "7986cf939de8986f428744e36ed72d86189bea46b4dcdc8d9d79a3e4fceb92b9",
"accessory_ltsk": "3d99f3e959a1f93af4056966f858074b2a1fdec1c5fd84a51ea96f9fa004156a",
"accessory_pairing_id": "12:34:56:00:01:0A",
"accessory_pin": "031-45-154",
"c#": 1,
"category": "Lightbulb",
"host_ip": "127.0.0.1",
"host_port": 51842,
"name": "unittestLight",
"unsuccessful_tries": 0
}""".encode())
config_file.flush()

# Make sure get_id() numbers are stable between tests
model_mixin.id_counter = 0

httpd = AccessoryServer(config_file.name, None)
accessory = Accessory('Testlicht', 'lusiardi.de', 'Demoserver', '0001', '0.1')
lightBulbService = LightBulbService()
accessory.services.append(lightBulbService)
httpd.add_accessory(accessory)

t = threading.Thread(target=httpd.serve_forever)
t.start()

controller = Controller()

# This syntax is awkward. We can't use the syntax proposed by the pytest-asyncio
# docs because we have to support python 3.5
def cleanup():
async def async_cleanup():
await controller.shutdown()
event_loop.run_until_complete(async_cleanup())
request.addfinalizer(cleanup)

yield controller

httpd.shutdown()

t.join()


@pytest.fixture
def controller_and_paired_accessory(request, event_loop):
config_file = tempfile.NamedTemporaryFile()
config_file.write("""{
"accessory_ltpk": "7986cf939de8986f428744e36ed72d86189bea46b4dcdc8d9d79a3e4fceb92b9",
"accessory_ltsk": "3d99f3e959a1f93af4056966f858074b2a1fdec1c5fd84a51ea96f9fa004156a",
"accessory_pairing_id": "12:34:56:00:01:0A",
"accessory_pin": "031-45-154",
"c#": 1,
"category": "Lightbulb",
"host_ip": "127.0.0.1",
"host_port": 51842,
"name": "unittestLight",
"peers": {
"decc6fa3-de3e-41c9-adba-ef7409821bfc": {
"admin": true,
"key": "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed8"
}
},
"unsuccessful_tries": 0
}""".encode())
config_file.flush()

# Make sure get_id() numbers are stable between tests
model_mixin.id_counter = 0

httpd = AccessoryServer(config_file.name, None)
accessory = Accessory('Testlicht', 'lusiardi.de', 'Demoserver', '0001', '0.1')
lightBulbService = LightBulbService()
accessory.services.append(lightBulbService)
httpd.add_accessory(accessory)

t = threading.Thread(target=httpd.serve_forever)
t.start()

controller_file = tempfile.NamedTemporaryFile()
controller_file.write("""{
"alias": {
"Connection": "IP",
"iOSDeviceLTPK": "d708df2fbf4a8779669f0ccd43f4962d6d49e4274f88b1292f822edc3bcf8ed8",
"iOSPairingId": "decc6fa3-de3e-41c9-adba-ef7409821bfc",
"AccessoryLTPK": "7986cf939de8986f428744e36ed72d86189bea46b4dcdc8d9d79a3e4fceb92b9",
"AccessoryPairingID": "12:34:56:00:01:0A",
"AccessoryPort": 51842,
"AccessoryIP": "127.0.0.1",
"iOSDeviceLTSK": "fa45f082ef87efc6c8c8d043d74084a3ea923a2253e323a7eb9917b4090c2fcc"
}
}""".encode())
controller_file.flush()

controller = Controller()
controller.load_data(controller_file.name)
config_file.close()

# This syntax is awkward. We can't use the syntax proposed by the pytest-asyncio
# docs because we have to support python 3.5
def cleanup():
async def async_cleanup():
await controller.shutdown()
event_loop.run_until_complete(async_cleanup())
request.addfinalizer(cleanup)

yield controller

httpd.shutdown()

t.join()


@pytest.fixture
def pairing(controller_and_paired_accessory):
return controller_and_paired_accessory.get_pairings()['alias']
Loading

0 comments on commit f20edd2

Please sign in to comment.