From 92d2035547ae961213bb624a6caa831d037958b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 May 2020 23:47:21 +0000 Subject: [PATCH 01/10] Share a single zeroconf instances --- pyhap/accessory_driver.py | 11 +++++++++-- tests/test_accessory_driver.py | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 48f4536c..e4284d1f 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -133,7 +133,8 @@ class AccessoryDriver: def __init__(self, *, address=None, port=51234, persist_file='accessory.state', pincode=None, encoder=None, loader=None, loop=None, mac=None, - listen_address=None, advertised_address=None, interface_choice=None): + listen_address=None, advertised_address=None, interface_choice=None, + zeroconf_instance=None): """ Initialize a new AccessoryDriver object. @@ -175,6 +176,10 @@ def __init__(self, *, address=None, port=51234, :param interface_choice: The zeroconf interfaces to listen on. :type InterfacesType: [InterfaceChoice.Default, InterfaceChoice.All] + + :param zeroconf_instance: A Zeroconf instance. When running multiple accessories or + bridges a single zeroconf instance can be shared to avoid the overhead + of processing the same data multiple times. """ if sys.platform == 'win32': self.loop = loop or asyncio.ProactorEventLoop() @@ -190,7 +195,9 @@ def __init__(self, *, address=None, port=51234, self.accessory = None self.http_server_thread = None - if interface_choice is not None: + if zeroconf_instance is not None: + self.advertiser = zeroconf_instance + elif interface_choice is not None: self.advertiser = Zeroconf(interfaces=interface_choice) else: self.advertiser = Zeroconf() diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 75de5a0c..85857370 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -54,6 +54,14 @@ def test_persist_load(): assert driver.state.public_key == pk +def test_external_zeroconf(): + zeroconf = MagicMock() + with patch('pyhap.accessory_driver.HAPServer'), \ + patch('pyhap.accessory_driver.AccessoryDriver.persist'): + driver = AccessoryDriver(port=51234, zeroconf_instance=zeroconf) + assert driver.advertiser == zeroconf + + def test_service_callbacks(driver): bridge = Bridge(driver, "mybridge") acc = Accessory(driver, 'TestAcc', aid=2) From 6319e4aff5f8cf200b4671d22933515e0d638069 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 May 2020 23:30:29 -0500 Subject: [PATCH 02/10] Fix random disconnect after upgrade to encrypted (#253) After server restarts accessories would sometimes go unavailable because after reconnecting and switching to encrypted transport part of the unencrypted response would not have been sent down the wire. After the upgrade to encrypted happens that response gets lost which means the server starts sending encrypted data when the hap client/controller is still expecting to finish reading the unencrypted data. The net result is that the controller/client has to try again to get another connection to the server where it does not get unluckly and hit this race condition. Before the change "Cleaning connection" was very common in the log. Its now rare. Now that Apple has published more information, we can improve conformance to HAP spec. Content-Length is no longer sent on empty content as it could lead to a loop on the client/controlller side The Connection, Date, and Server headers are not expected to be sent according to the spec, and they are now suppressed. --- pyhap/hap_server.py | 23 +++++++++++++++++++---- tests/test_hap_server.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/pyhap/hap_server.py b/pyhap/hap_server.py index 622e8a74..1784e7c0 100644 --- a/pyhap/hap_server.py +++ b/pyhap/hap_server.py @@ -157,6 +157,7 @@ def __init__(self, sock, client_addr, server, accessory_handler): # it can be painfully slow and lead to lock up on the # client side as well as non-responsive devices self.protocol_version = 'HTTP/1.1' + self.status_code = None # Redirect separate handlers to the dispatch method self.do_GET = self.do_POST = self.do_PUT = self.dispatch @@ -198,6 +199,11 @@ def _upgrade_to_encrypted(self): @note: Replaces self.request, self.wfile and self.rfile. """ + # Important: We must flush before switching to encrypted + # as there may still be data in the buffer which will be + # lost we switch to encrypted which will result in the + # HAP client/controller having to reconnect and try again. + self.wfile.flush() self.request = self.server.upgrade_to_encrypted(self.client_address, self.enc_context["shared_key"]) # Recreate the file handles over the socket @@ -207,12 +213,21 @@ def _upgrade_to_encrypted(self): self.wfile = self.connection.makefile('wb') self.is_encrypted = True + def send_response(self, code, message=None): + """Add the response header to the headers buffer and log the + response code. + Does not add Server or Date + """ + self.log_request(code) + self.send_response_only(code, message) + self.status_code = code + def end_response(self, bytesdata, close_connection=False): """Combines adding a length header and actually sending the data.""" - self.send_header("Content-Length", len(bytesdata)) - # Setting this head will take care of setting - # self.close_connection to the right value - self.send_header("Connection", ("close" if close_connection else "keep-alive")) + if self.status_code != HTTPStatus.NO_CONTENT: + self.send_header("Content-Length", len(bytesdata)) + # All HAP server requests are implicit keep alive + self.close_connection = False # Important: we need to send the headers and the # content in a single write to avoid homekit # on the client side stalling and making diff --git a/tests/test_hap_server.py b/tests/test_hap_server.py index e313cd18..f95ebb2a 100644 --- a/tests/test_hap_server.py +++ b/tests/test_hap_server.py @@ -74,6 +74,33 @@ def getsent(self): handler = hap_server.HAPServerHandler("mocksock", "mockclient_addr", "mockserver", amock) handler.request_version = 'HTTP/1.1' handler.connection = ConnectionMock() + handler.requestline = "GET / HTTP/1.1" + handler.send_response(200) handler.end_response(b"body") - assert handler.connection.getsent() == [[b'Content-Length: 4\r\nConnection: keep-alive\r\n\r\nbody']] + assert handler.connection.getsent() == [[b'HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nbody']] + assert handler._headers_buffer == [] + + +def test_http_204_has_no_content_length(): + """Test that and HTTP 204 has no content length.""" + class ConnectionMock(): + sent_bytes = [] + + def sendall(self, bytesdata): + self.sent_bytes.append([bytesdata]) + return 1 + + def getsent(self): + return self.sent_bytes + + amock = Mock() + + with patch('pyhap.hap_server.HAPServerHandler.setup'), patch('pyhap.hap_server.HAPServerHandler.handle_one_request'), patch('pyhap.hap_server.HAPServerHandler.finish'): + handler = hap_server.HAPServerHandler("mocksock", "mockclient_addr", "mockserver", amock) + handler.request_version = 'HTTP/1.1' + handler.connection = ConnectionMock() + handler.requestline = "PUT / HTTP/1.1" + handler.send_response(204) + handler.end_response(b"") + assert handler.connection.getsent() == [[b'HTTP/1.1 204 No Content\r\n\r\n']] assert handler._headers_buffer == [] From ff10fc15e4e7ffa40f2f694e2d523bb010179538 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 May 2020 23:31:54 -0500 Subject: [PATCH 03/10] Ensure exception is still thrown after (#254) Add logging of exceptions in finish_request --- pyhap/hap_server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyhap/hap_server.py b/pyhap/hap_server.py index 1784e7c0..22066605 100644 --- a/pyhap/hap_server.py +++ b/pyhap/hap_server.py @@ -925,6 +925,7 @@ def finish_request(self, request, client_address): logger.debug('Connection timeout') except Exception as e: logger.debug('finish_request: %s', e, exc_info=True) + raise finally: logger.debug('Cleaning connection to %s', client_address) conn_sock = self.connections.pop(client_address, None) From cad86a454b1af25e7703323cbee0ad16e13e788b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 May 2020 01:45:27 -0500 Subject: [PATCH 04/10] Only stringify the characteristic uuid once (#256) --- pyhap/characteristic.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyhap/characteristic.py b/pyhap/characteristic.py index bdd2888f..065e4b6a 100644 --- a/pyhap/characteristic.py +++ b/pyhap/characteristic.py @@ -80,7 +80,7 @@ class Characteristic: """ __slots__ = ('broker', 'display_name', 'properties', 'type_id', - 'value', 'getter_callback', 'setter_callback', 'service') + 'value', 'getter_callback', 'setter_callback', 'service', '_uuid_str') def __init__(self, display_name, type_id, properties): """Initialise with the given properties. @@ -104,6 +104,7 @@ def __init__(self, display_name, type_id, properties): self.getter_callback = None self.setter_callback = None self.service = None + self._uuid_str = str(type_id).upper() def __repr__(self): """Return the representation of the characteristic.""" @@ -233,7 +234,7 @@ def to_HAP(self): """ hap_rep = { HAP_REPR_IID: self.broker.iid_manager.get_iid(self), - HAP_REPR_TYPE: str(self.type_id).upper(), + HAP_REPR_TYPE: self._uuid_str, HAP_REPR_DESC: self.display_name, HAP_REPR_PERM: self.properties[PROP_PERMISSIONS], HAP_REPR_FORMAT: self.properties[PROP_FORMAT], From 1e3db0cd9c958350b031be3810c08853c9ff1056 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 22 May 2020 14:21:02 -0500 Subject: [PATCH 05/10] Add support for accessory unavailability (#252) * Add support for accessory unavailability * Remove unused self.reachable --- pyhap/accessory.py | 13 ++- pyhap/accessory_driver.py | 28 ++++-- tests/test_accessory_driver.py | 154 ++++++++++++++++++++++----------- 3 files changed, 137 insertions(+), 58 deletions(-) diff --git a/pyhap/accessory.py b/pyhap/accessory.py index f46de0a9..79e7534c 100644 --- a/pyhap/accessory.py +++ b/pyhap/accessory.py @@ -47,7 +47,6 @@ def __init__(self, driver, display_name, aid=None): self.aid = aid self.display_name = display_name self.driver = driver - self.reachable = True self.services = [] self.iid_manager = IIDManager() @@ -74,6 +73,18 @@ def _set_services(self): """ pass + @property + def available(self): + """Accessory is available. + + If available is False, get_characteristics will return + SERVICE_COMMUNICATION_FAILURE for the accessory which will + show as unavailable. + + Expected to be overridden. + """ + return True + def add_info_service(self): """Helper method to add the required `AccessoryInformation` service. diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index e4284d1f..d6354151 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -626,16 +626,30 @@ def get_characteristics(self, char_ids): :rtype: dict """ chars = [] - for id in char_ids: - aid, iid = (int(i) for i in id.split('.')) - rep = {HAP_REPR_AID: aid, HAP_REPR_IID: iid} - char = self.accessory.get_characteristic(aid, iid) + for aid_iid in char_ids: + aid, iid = (int(i) for i in aid_iid.split(".")) + rep = { + HAP_REPR_AID: aid, + HAP_REPR_IID: iid, + HAP_REPR_STATUS: SERVICE_COMMUNICATION_FAILURE, + } + try: - rep[HAP_REPR_VALUE] = char.get_value() - rep[HAP_REPR_STATUS] = CHAR_STAT_OK + if aid == STANDALONE_AID: + char = self.accessory.iid_manager.get_obj(iid) + available = True + else: + acc = self.accessory.accessories.get(aid) + available = acc.available + char = acc.iid_manager.get_obj(iid) + + if available: + rep[HAP_REPR_VALUE] = char.get_value() + rep[HAP_REPR_STATUS] = CHAR_STAT_OK except CharacteristicError: logger.error("Error getting value for characteristic %s.", id) - rep[HAP_REPR_STATUS] = SERVICE_COMMUNICATION_FAILURE + except Exception: # pylint: disable=broad-except + logger.exception("Unexpected error getting value for characteristic %s.", id) chars.append(rep) logger.debug("Get chars response: %s", chars) diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 85857370..2e6829e2 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -7,9 +7,13 @@ from pyhap.accessory import STANDALONE_AID, Accessory, Bridge from pyhap.accessory_driver import AccessoryDriver -from pyhap.characteristic import (HAP_FORMAT_INT, HAP_PERMISSION_READ, - PROP_FORMAT, PROP_PERMISSIONS, - Characteristic) +from pyhap.characteristic import ( + HAP_FORMAT_INT, + HAP_PERMISSION_READ, + PROP_FORMAT, + PROP_PERMISSIONS, + Characteristic, +) from pyhap.const import HAP_REPR_IID, HAP_REPR_CHARS, HAP_REPR_AID, HAP_REPR_VALUE from pyhap.service import Service @@ -19,31 +23,40 @@ } +class UnavailableAccessory(Accessory): + """An accessory that is not available.""" + + @property + def available(self): + return False + + @pytest.fixture def driver(): - with patch('pyhap.accessory_driver.HAPServer'), \ - patch('pyhap.accessory_driver.Zeroconf'), \ - patch('pyhap.accessory_driver.AccessoryDriver.persist'): + with patch("pyhap.accessory_driver.HAPServer"), patch( + "pyhap.accessory_driver.Zeroconf" + ), patch("pyhap.accessory_driver.AccessoryDriver.persist"): yield AccessoryDriver() def test_auto_add_aid_mac(driver): - acc = Accessory(driver, 'Test Accessory') + acc = Accessory(driver, "Test Accessory") driver.add_accessory(acc) assert acc.aid == STANDALONE_AID assert driver.state.mac is not None def test_not_standalone_aid(driver): - acc = Accessory(driver, 'Test Accessory', aid=STANDALONE_AID + 1) + acc = Accessory(driver, "Test Accessory", aid=STANDALONE_AID + 1) with pytest.raises(ValueError): driver.add_accessory(acc) def test_persist_load(): - with tempfile.NamedTemporaryFile(mode='r+') as file: - with patch('pyhap.accessory_driver.HAPServer'), \ - patch('pyhap.accessory_driver.Zeroconf'): + with tempfile.NamedTemporaryFile(mode="r+") as file: + with patch("pyhap.accessory_driver.HAPServer"), patch( + "pyhap.accessory_driver.Zeroconf" + ): driver = AccessoryDriver(port=51234, persist_file=file.name) driver.persist() pk = driver.state.public_key @@ -56,20 +69,21 @@ def test_persist_load(): def test_external_zeroconf(): zeroconf = MagicMock() - with patch('pyhap.accessory_driver.HAPServer'), \ - patch('pyhap.accessory_driver.AccessoryDriver.persist'): + with patch("pyhap.accessory_driver.HAPServer"), patch( + "pyhap.accessory_driver.AccessoryDriver.persist" + ): driver = AccessoryDriver(port=51234, zeroconf_instance=zeroconf) assert driver.advertiser == zeroconf def test_service_callbacks(driver): bridge = Bridge(driver, "mybridge") - acc = Accessory(driver, 'TestAcc', aid=2) - acc2 = Accessory(driver, 'TestAcc2', aid=3) + acc = Accessory(driver, "TestAcc", aid=2) + acc2 = UnavailableAccessory(driver, "TestAcc2", aid=3) - service = Service(uuid1(), 'Lightbulb') - char_on = Characteristic('On', uuid1(), CHAR_PROPS) - char_brightness = Characteristic('Brightness', uuid1(), CHAR_PROPS) + service = Service(uuid1(), "Lightbulb") + char_on = Characteristic("On", uuid1(), CHAR_PROPS) + char_brightness = Characteristic("Brightness", uuid1(), CHAR_PROPS) service.add_characteristic(char_on) service.add_characteristic(char_brightness) @@ -80,9 +94,9 @@ def test_service_callbacks(driver): acc.add_service(service) bridge.add_accessory(acc) - service2 = Service(uuid1(), 'Lightbulb') - char_on2 = Characteristic('On', uuid1(), CHAR_PROPS) - char_brightness2 = Characteristic('Brightness', uuid1(), CHAR_PROPS) + service2 = Service(uuid1(), "Lightbulb") + char_on2 = Characteristic("On", uuid1(), CHAR_PROPS) + char_brightness2 = Characteristic("Brightness", uuid1(), CHAR_PROPS) service2.add_characteristic(char_on2) service2.add_characteristic(char_brightness2) @@ -100,28 +114,67 @@ def test_service_callbacks(driver): driver.add_accessory(bridge) - driver.set_characteristics({ - HAP_REPR_CHARS: [{ - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_on_iid, - HAP_REPR_VALUE: True - }, { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_iid, - HAP_REPR_VALUE: 88 - }, { - HAP_REPR_AID: acc2.aid, - HAP_REPR_IID: char_on2_iid, - HAP_REPR_VALUE: True - }, { - HAP_REPR_AID: acc2.aid, - HAP_REPR_IID: char_brightness2_iid, - HAP_REPR_VALUE: 12 - }] - }, "mock_addr") - - mock_callback2.assert_called_with({'On': True, 'Brightness': 12}) - mock_callback.assert_called_with({'On': True, 'Brightness': 88}) + driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_on_iid, + HAP_REPR_VALUE: True, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_iid, + HAP_REPR_VALUE: 88, + }, + { + HAP_REPR_AID: acc2.aid, + HAP_REPR_IID: char_on2_iid, + HAP_REPR_VALUE: True, + }, + { + HAP_REPR_AID: acc2.aid, + HAP_REPR_IID: char_brightness2_iid, + HAP_REPR_VALUE: 12, + }, + ] + }, + "mock_addr", + ) + + mock_callback2.assert_called_with({"On": True, "Brightness": 12}) + mock_callback.assert_called_with({"On": True, "Brightness": 88}) + + get_chars = driver.get_characteristics( + ["{}.{}".format(acc.aid, char_on_iid), "{}.{}".format(acc2.aid, char_on2_iid)] + ) + assert get_chars == { + "characteristics": [ + {"aid": acc.aid, "iid": char_on_iid, "status": 0, "value": True}, + {"aid": acc2.aid, "iid": char_on2_iid, "status": -70402}, + ] + } + + def _fail_func(): + raise ValueError + + char_brightness.getter_callback = _fail_func + get_chars = driver.get_characteristics( + [ + "{}.{}".format(acc.aid, char_on_iid), + "{}.{}".format(acc2.aid, char_on2_iid), + "{}.{}".format(acc2.aid, char_brightness_iid), + "{}.{}".format(acc.aid, char_brightness2_iid), + ] + ) + assert get_chars == { + "characteristics": [ + {"aid": acc.aid, "iid": char_on_iid, "status": 0, "value": True}, + {"aid": acc2.aid, "iid": char_on2_iid, "status": -70402}, + {"aid": acc2.aid, "iid": char_brightness2_iid, "status": -70402}, + {"aid": acc.aid, "iid": char_brightness_iid, "status": -70402}, + ] + } def test_start_stop_sync_acc(driver): @@ -136,7 +189,7 @@ def run(self): def setup_message(self): pass - acc = Acc(driver, 'TestAcc') + acc = Acc(driver, "TestAcc") driver.add_accessory(acc) driver.start() assert not acc.running @@ -144,7 +197,6 @@ def setup_message(self): def test_start_stop_async_acc(driver): class Acc(Accessory): - @Accessory.run_at_interval(0) async def run(self): driver.stop() @@ -152,14 +204,14 @@ async def run(self): def setup_message(self): pass - acc = Acc(driver, 'TestAcc') + acc = Acc(driver, "TestAcc") driver.add_accessory(acc) driver.start() assert driver.loop.is_closed() def test_send_events(driver): - class LoopMock(): + class LoopMock: runcount = 0 def is_closed(self): @@ -168,7 +220,7 @@ def is_closed(self): return True return False - class HapServerMock(): + class HapServerMock: pushed_events = [] def push_event(self, bytedata, client_addr): @@ -185,5 +237,7 @@ def get_pushed_events(self): driver.send_events() # Only client2 and client3 get the event when client1 sent it - assert (driver.http_server.get_pushed_events() == - [["bytedata", "client2"], ["bytedata", "client3"]]) + assert driver.http_server.get_pushed_events() == [ + ["bytedata", "client2"], + ["bytedata", "client3"], + ] From 7c1cddf3b021dbaed0336bb76916c41eff8e892b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 May 2020 08:25:35 -0500 Subject: [PATCH 06/10] Cleanup and fixes for py37/py38. Enable pylint in travis (#255) --- .travis.yml | 8 +++- pyhap/accessory.py | 5 +-- pyhap/accessory_driver.py | 13 ++++++- pyhap/hap_server.py | 34 ++++++++--------- pyhap/hsrp.py | 21 ++++------- pyhap/tlv.py | 4 +- pyhap/util.py | 4 +- tests/conftest.py | 13 ++++++- tests/test_accessory_driver.py | 23 +++--------- tests/test_camera.py | 2 +- tests/test_hap_server.py | 68 +++++++++++++++++++++------------- tests/test_hap_socket.py | 4 +- 12 files changed, 109 insertions(+), 90 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2e3ad1ef..a7861d5f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,12 +10,16 @@ matrix: include: - python: "3.5" env: TOXENV=lint - # - python: "3.5" - # env: TOXENV=pylint + - python: "3.5" + env: TOXENV=pylint - python: "3.5" env: TOXENV=py35 - python: "3.6" env: TOXENV=py36 + - python: "3.7" + env: TOXENV=py37 + - python: "3.8" + env: TOXENV=py38 - python: "3.6" env: TOXENV=docs before-install: diff --git a/pyhap/accessory.py b/pyhap/accessory.py index 79e7534c..5bf7f49d 100644 --- a/pyhap/accessory.py +++ b/pyhap/accessory.py @@ -71,7 +71,6 @@ def _set_services(self): .. deprecated:: 2.0 Initialize the service inside the accessory `init` method instead. """ - pass @property def available(self): @@ -281,7 +280,7 @@ def run(self): def _repeat(func): async def _wrapper(self, *args): while True: - self.driver.async_add_job(func, self, *args) + await self.driver.async_add_job(func, self, *args) if await util.event_wait( self.driver.aio_stop_event, seconds): break @@ -294,14 +293,12 @@ async def run(self): Called when HAP server is running, advertising is set, etc. Can be overridden with a normal or async method. """ - pass async def stop(self): """Called when the Accessory should stop what is doing and clean up any resources. Can be overridden with a normal or async method. """ - pass # Driver diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index d6354151..fb387d81 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -462,10 +462,19 @@ def send_events(self): # topic, bytedata, sender_client_addr = self.event_queue.get() subscribed_clients = self.topics.get(topic, []) - logger.debug('Send event: topic(%s), data(%s), sender_client_addr(%s)', topic, bytedata, sender_client_addr) + logger.debug( + 'Send event: topic(%s), data(%s), sender_client_addr(%s)', + topic, + bytedata, + sender_client_addr + ) for client_addr in subscribed_clients.copy(): if sender_client_addr and sender_client_addr == client_addr: - logger.debug('Skip sending event to client since its the client that made the characteristic change: %s', client_addr) + logger.debug( + 'Skip sending event to client since ' + 'its the client that made the characteristic change: %s', + client_addr + ) continue logger.debug('Sending event to client: %s', client_addr) pushed = self.http_server.push_event(bytedata, client_addr) diff --git a/pyhap/hap_server.py b/pyhap/hap_server.py index 22066605..a5c83128 100644 --- a/pyhap/hap_server.py +++ b/pyhap/hap_server.py @@ -163,7 +163,7 @@ def __init__(self, sock, client_addr, server, accessory_handler): super(HAPServerHandler, self).__init__(sock, client_addr, server) - def log_message(self, format, *args): + def log_message(self, format, *args): # pylint: disable=redefined-builtin logger.info("%s - %s", self.address_string(), format % args) def _set_encryption_ctx(self, client_public, private_key, public_key, shared_key, @@ -208,9 +208,9 @@ def _upgrade_to_encrypted(self): self.enc_context["shared_key"]) # Recreate the file handles over the socket # TODO: consider calling super().setup(), although semantically not correct - self.connection = self.request - self.rfile = self.connection.makefile('rb', self.rbufsize) - self.wfile = self.connection.makefile('wb') + self.connection = self.request # pylint: disable=attribute-defined-outside-init + self.rfile = self.connection.makefile('rb', self.rbufsize) # pylint: disable=attribute-defined-outside-init + self.wfile = self.connection.makefile('wb') # pylint: disable=attribute-defined-outside-init self.is_encrypted = True def send_response(self, code, message=None): @@ -222,12 +222,12 @@ def send_response(self, code, message=None): self.send_response_only(code, message) self.status_code = code - def end_response(self, bytesdata, close_connection=False): + def end_response(self, bytesdata): """Combines adding a length header and actually sending the data.""" if self.status_code != HTTPStatus.NO_CONTENT: self.send_header("Content-Length", len(bytesdata)) # All HAP server requests are implicit keep alive - self.close_connection = False + self.close_connection = False # pylint: disable=attribute-defined-outside-init # Important: we need to send the headers and the # content in a single write to avoid homekit # on the client side stalling and making @@ -242,7 +242,7 @@ def end_response(self, bytesdata, close_connection=False): # touching _headers_buffer ? # self.connection.sendall(b"".join(self._headers_buffer) + b"\r\n" + bytesdata) - self._headers_buffer = [] + self._headers_buffer = [] # pylint: disable=attribute-defined-outside-init def dispatch(self): """Dispatch the request to the appropriate handler method.""" @@ -440,7 +440,7 @@ def handle_pair_verify(self): elif sequence == b'\x03': self._pair_verify_two(tlv_objects) else: - raise + raise ValueError def _pair_verify_one(self, tlv_objects): """Generate new session key pair and send a proof to the client. @@ -558,10 +558,10 @@ def handle_get_characteristics(self): def handle_set_characteristics(self): """Handles a client request to update certain characteristics.""" if not self.is_encrypted: - logger.warning('Attemp to access unauthorised content from %s', + logger.warning('Attempt to access unauthorised content from %s', self.client_address) self.send_response(HTTPStatus.UNAUTHORIZED) - self.end_response(b'', close_connection=True) + self.end_response(b'') data_len = int(self.headers['Content-Length']) requested_chars = json.loads( @@ -572,7 +572,7 @@ def handle_set_characteristics(self): try: self.accessory_handler.set_characteristics(requested_chars, self.client_address) - except Exception as e: + except Exception as e: # pylint: disable=broad-except logger.exception('Exception in set_characteristics: %s', e) self.send_response(HTTPStatus.BAD_REQUEST) self.end_response(b'') @@ -695,11 +695,11 @@ def __getattr__(self, attribute_name): def _get_io_refs(self): """Get `socket._io_refs`.""" - return self.socket._io_refs + return self.socket._io_refs # pylint: disable=protected-access def _set_io_refs(self, value): """Set `socket._io_refs`.""" - self.socket._io_refs = value + self.socket._io_refs = value # pylint: disable=protected-access _io_refs = property(_get_io_refs, _set_io_refs) """`socket.makefile` uses a `SocketIO` to wrap the socket stream. Internally, @@ -728,11 +728,11 @@ def _set_ciphers(self): # socket.socket interface - def _with_out_lock(func): + def _with_out_lock(func): # pylint: disable=no-self-argument """Return a function that acquires the outbound lock and executes func.""" def _wrapper(self, *args, **kwargs): with self.out_lock: - return func(self, *args, **kwargs) + return func(self, *args, **kwargs) # pylint: disable=not-callable return _wrapper def recv_into(self, buffer, nbytes=None, flags=0): @@ -794,7 +794,7 @@ def recv(self, buflen=1042, flags=0): self.in_count += 1 self.curr_in_block = None break - elif not actual_len: + if not actual_len: # Connection likely dropped return b"" @@ -871,7 +871,7 @@ def __init__(self, self.connections = {} # (address, port): socket self.accessory_handler = accessory_handler - def _close_socket(self, sock): + def _close_socket(self, sock): # pylint: disable=no-self-use """Shutdown and close the given socket.""" try: sock.shutdown(socket.SHUT_RDWR) diff --git a/pyhap/hsrp.py b/pyhap/hsrp.py index 93525584..0bf75485 100644 --- a/pyhap/hsrp.py +++ b/pyhap/hsrp.py @@ -3,6 +3,7 @@ # as a guideline. # TODO: make it a complete implementation. import os +from .util import long_to_bytes # # s - bytes @@ -30,19 +31,6 @@ def bytes_to_long(s): return int.from_bytes(s, byteorder="big") -def long_to_bytes(n): - byteList = list() - x = 0 - off = 0 - while x != n: - b = (n >> off) & 0xFF - byteList.append(b) - x = x | (b << off) - off += 8 - byteList.reverse() - return bytes(byteList) - - def get_x(u, p, s, ctx): hf = ctx["hashfunc"]() hf.update(u + b":" + p) @@ -69,7 +57,7 @@ def get_session_key(S, ctx): return int(hf.hexdigest(), 16) -class Server(object): +class Server(): def __init__(self, ctx, u, p, s=None, v=None): self.ctx = ctx @@ -80,6 +68,11 @@ def __init__(self, ctx, u, p, s=None, v=None): self.k = get_k(ctx) self.b = bytes_to_long(os.urandom(256)) # TODO: specify length self.B = self.derive_B() + self.A = None + self.S = None + self.K = None + self.M = None + self.HAMK = None def derive_B(self): return (self.k * self.v + pow(self.ctx["g"], self.b, self.ctx["N"])) \ diff --git a/pyhap/tlv.py b/pyhap/tlv.py index af888db4..7f1df931 100644 --- a/pyhap/tlv.py +++ b/pyhap/tlv.py @@ -29,8 +29,8 @@ def encode(*args, to_base64=False): encoded = tag + struct.pack("B", total_length) + data else: encoded = b"" - for x in range(0, total_length // 255): - encoded = encoded + tag + b'\xFF' + data[x * 255: (x + 1) * 255] + for y in range(0, total_length // 255): + encoded = encoded + tag + b'\xFF' + data[y * 255: (y + 1) * 255] remaining = total_length % 255 encoded = encoded + tag + struct.pack("B", remaining) \ + data[-remaining:] diff --git a/pyhap/util.py b/pyhap/util.py index 774843a3..31680f2c 100644 --- a/pyhap/util.py +++ b/pyhap/util.py @@ -99,14 +99,14 @@ def b2hex(bts): return binascii.hexlify(bts).decode("ascii") -def hex2b(hex): +def hex2b(hex_str): """Produce bytes from the given hex string representation. :param hex: hex string :type hex: str :rtype: bytes """ - return binascii.unhexlify(hex.encode("ascii")) + return binascii.unhexlify(hex_str.encode("ascii")) tohex = bytes.hex if sys.version_info >= (3, 5) else b2hex diff --git a/tests/conftest.py b/tests/conftest.py index 84803a72..b5a17361 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,12 @@ """Test fictures and mocks.""" +from unittest.mock import patch + import asyncio import pytest from pyhap.loader import Loader +from pyhap.accessory_driver import AccessoryDriver @pytest.fixture(scope='session') @@ -11,6 +14,14 @@ def mock_driver(): yield MockDriver() +@pytest.fixture +def driver(): + with patch("pyhap.accessory_driver.HAPServer"), patch( + "pyhap.accessory_driver.Zeroconf" + ), patch("pyhap.accessory_driver.AccessoryDriver.persist"): + yield AccessoryDriver() + + class MockDriver(): def __init__(self): @@ -19,5 +30,5 @@ def __init__(self): def publish(self, data, client_addr=None): pass - def add_job(self, target, *args): + def add_job(self, target, *args): # pylint: disable=no-self-use asyncio.get_event_loop().run_until_complete(target(*args)) diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 2e6829e2..de67c23f 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -7,14 +7,11 @@ from pyhap.accessory import STANDALONE_AID, Accessory, Bridge from pyhap.accessory_driver import AccessoryDriver -from pyhap.characteristic import ( - HAP_FORMAT_INT, - HAP_PERMISSION_READ, - PROP_FORMAT, - PROP_PERMISSIONS, - Characteristic, -) -from pyhap.const import HAP_REPR_IID, HAP_REPR_CHARS, HAP_REPR_AID, HAP_REPR_VALUE +from pyhap.characteristic import (HAP_FORMAT_INT, HAP_PERMISSION_READ, + PROP_FORMAT, PROP_PERMISSIONS, + Characteristic) +from pyhap.const import (HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, + HAP_REPR_VALUE) from pyhap.service import Service CHAR_PROPS = { @@ -31,14 +28,6 @@ def available(self): return False -@pytest.fixture -def driver(): - with patch("pyhap.accessory_driver.HAPServer"), patch( - "pyhap.accessory_driver.Zeroconf" - ), patch("pyhap.accessory_driver.AccessoryDriver.persist"): - yield AccessoryDriver() - - def test_auto_add_aid_mac(driver): acc = Accessory(driver, "Test Accessory") driver.add_accessory(acc) @@ -182,7 +171,7 @@ class Acc(Accessory): running = True @Accessory.run_at_interval(0) - def run(self): + def run(self): # pylint: disable=invalid-overridden-method self.running = False driver.stop() diff --git a/tests/test_camera.py b/tests/test_camera.py index c25fa886..df67a095 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -91,7 +91,7 @@ async def wait(): process_mock = Mock() # Mock for asyncio.create_subprocess_exec - async def subprocess_exec(*args, **kwargs): + async def subprocess_exec(*args, **kwargs): # pylint: disable=unused-argument process_mock.id = 42 process_mock.communicate = communicate process_mock.wait = wait diff --git a/tests/test_hap_server.py b/tests/test_hap_server.py index f95ebb2a..9eb7177e 100644 --- a/tests/test_hap_server.py +++ b/tests/test_hap_server.py @@ -5,19 +5,21 @@ import pytest from pyhap import hap_server +from pyhap.const import __version__ -@patch('pyhap.hap_server.HAPServer.server_bind', new=MagicMock()) -@patch('pyhap.hap_server.HAPServer.server_activate', new=MagicMock()) +@patch("pyhap.hap_server.HAPServer.server_bind", new=MagicMock()) +@patch("pyhap.hap_server.HAPServer.server_activate", new=MagicMock()) def test_finish_request_pops_socket(): """Test that ``finish_request`` always clears the connection after a request.""" amock = Mock() - client_addr = ('192.168.1.1', 55555) - server_addr = ('', 51826) + client_addr = ("192.168.1.1", 55555) + server_addr = ("", 51826) # Positive case: The request is handled - server = hap_server.HAPServer(server_addr, amock, - handler_type=lambda *args: MagicMock()) + server = hap_server.HAPServer( + server_addr, amock, handler_type=lambda *args: MagicMock() + ) server.connections[client_addr] = amock server.finish_request(amock, client_addr) @@ -27,16 +29,15 @@ def test_finish_request_pops_socket(): # Negative case: The request fails with a timeout def raises(*args): raise timeout() - server = hap_server.HAPServer(server_addr, amock, - handler_type=raises) + + server = hap_server.HAPServer(server_addr, amock, handler_type=raises) server.connections[client_addr] = amock server.finish_request(amock, client_addr) assert len(server.connections) == 0 # Negative case: The request raises some other exception - server = hap_server.HAPServer(server_addr, amock, - handler_type=lambda *args: 1 / 0) + server = hap_server.HAPServer(server_addr, amock, handler_type=lambda *args: 1 / 0) server.connections[client_addr] = amock with pytest.raises(Exception): @@ -48,17 +49,21 @@ def raises(*args): def test_uses_http11(): """Test that ``HAPServerHandler`` uses HTTP/1.1.""" amock = Mock() - from pyhap.const import __version__ - with patch('pyhap.hap_server.HAPServerHandler.setup'), patch('pyhap.hap_server.HAPServerHandler.handle_one_request'), patch('pyhap.hap_server.HAPServerHandler.finish'): - handler = hap_server.HAPServerHandler("mocksock", "mockclient_addr", "mockserver", amock) + with patch("pyhap.hap_server.HAPServerHandler.setup"), patch( + "pyhap.hap_server.HAPServerHandler.handle_one_request" + ), patch("pyhap.hap_server.HAPServerHandler.finish"): + handler = hap_server.HAPServerHandler( + "mocksock", "mockclient_addr", "mockserver", amock + ) assert handler.protocol_version == "HTTP/1.1" - assert handler.server_version == 'pyhap/' + __version__ + assert handler.server_version == "pyhap/" + __version__ def test_end_response_is_one_send(): """Test that ``HAPServerHandler`` sends the whole response at once.""" - class ConnectionMock(): + + class ConnectionMock: sent_bytes = [] def sendall(self, bytesdata): @@ -70,20 +75,27 @@ def getsent(self): amock = Mock() - with patch('pyhap.hap_server.HAPServerHandler.setup'), patch('pyhap.hap_server.HAPServerHandler.handle_one_request'), patch('pyhap.hap_server.HAPServerHandler.finish'): - handler = hap_server.HAPServerHandler("mocksock", "mockclient_addr", "mockserver", amock) - handler.request_version = 'HTTP/1.1' + with patch("pyhap.hap_server.HAPServerHandler.setup"), patch( + "pyhap.hap_server.HAPServerHandler.handle_one_request" + ), patch("pyhap.hap_server.HAPServerHandler.finish"): + handler = hap_server.HAPServerHandler( + "mocksock", "mockclient_addr", "mockserver", amock + ) + handler.request_version = "HTTP/1.1" handler.connection = ConnectionMock() handler.requestline = "GET / HTTP/1.1" handler.send_response(200) handler.end_response(b"body") - assert handler.connection.getsent() == [[b'HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nbody']] - assert handler._headers_buffer == [] + assert handler.connection.getsent() == [ + [b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nbody"] + ] + assert handler._headers_buffer == [] # pylint: disable=protected-access def test_http_204_has_no_content_length(): """Test that and HTTP 204 has no content length.""" - class ConnectionMock(): + + class ConnectionMock: sent_bytes = [] def sendall(self, bytesdata): @@ -95,12 +107,16 @@ def getsent(self): amock = Mock() - with patch('pyhap.hap_server.HAPServerHandler.setup'), patch('pyhap.hap_server.HAPServerHandler.handle_one_request'), patch('pyhap.hap_server.HAPServerHandler.finish'): - handler = hap_server.HAPServerHandler("mocksock", "mockclient_addr", "mockserver", amock) - handler.request_version = 'HTTP/1.1' + with patch("pyhap.hap_server.HAPServerHandler.setup"), patch( + "pyhap.hap_server.HAPServerHandler.handle_one_request" + ), patch("pyhap.hap_server.HAPServerHandler.finish"): + handler = hap_server.HAPServerHandler( + "mocksock", "mockclient_addr", "mockserver", amock + ) + handler.request_version = "HTTP/1.1" handler.connection = ConnectionMock() handler.requestline = "PUT / HTTP/1.1" handler.send_response(204) handler.end_response(b"") - assert handler.connection.getsent() == [[b'HTTP/1.1 204 No Content\r\n\r\n']] - assert handler._headers_buffer == [] + assert handler.connection.getsent() == [[b"HTTP/1.1 204 No Content\r\n\r\n"]] + assert handler._headers_buffer == [] # pylint: disable=protected-access diff --git a/tests/test_hap_socket.py b/tests/test_hap_socket.py index 4a903208..f2334b61 100644 --- a/tests/test_hap_socket.py +++ b/tests/test_hap_socket.py @@ -8,6 +8,6 @@ def test_iorefs(): """Test that the _io_refs are correct when creating/closing a fileio from it.""" sock = hap_server.HAPSocket(socket.socket(), b'\x00' * 64) fileio = sock.makefile('rb') - assert sock._io_refs == 1 + assert sock._io_refs == 1 # pylint: disable=protected-access fileio.close() - assert sock._io_refs == 0 + assert sock._io_refs == 0 # pylint: disable=protected-access From 71891f9125034a64d968a801733aaf2f5deb15f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 May 2020 01:14:25 -0500 Subject: [PATCH 07/10] Fix pairing after py37/py38 cleanup (#259) --- pyhap/hap_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyhap/hap_server.py b/pyhap/hap_server.py index a5c83128..df865bff 100644 --- a/pyhap/hap_server.py +++ b/pyhap/hap_server.py @@ -293,7 +293,7 @@ def _pairing_one(self): self.send_response(200) self.send_header("Content-Type", self.PAIRING_RESPONSE_TYPE) - self.end_response(data, False) + self.end_response(data) def _pairing_two(self, tlv_objects): """Obtain the challenge from the client (A) and client's proof that it From f0f761489d11677d219dff407e49d74b068de360 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 May 2020 01:19:22 -0500 Subject: [PATCH 08/10] Split read/write encryption upgrade (#258) Once the final response is sent data can come back over the write before the reader is swapped out to the encrypted reader. This causes us to loose the data and result in a disconnect. Ensure we flush the buffer after every response. --- pyhap/hap_server.py | 35 ++++++++++++++++++++++++++--------- tests/test_hap_server.py | 4 ++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/pyhap/hap_server.py b/pyhap/hap_server.py index df865bff..809afb90 100644 --- a/pyhap/hap_server.py +++ b/pyhap/hap_server.py @@ -194,22 +194,29 @@ def _set_encryption_ctx(self, client_public, private_key, public_key, shared_key "pre_session_key": pre_session_key } - def _upgrade_to_encrypted(self): + def _upgrade_reader_to_encrypted(self): """Set encryption for the underlying transport. - @note: Replaces self.request, self.wfile and self.rfile. + Call BEFORE sending the final unencrypted + response. + + @note: Replaces self.request and self.rfile. """ - # Important: We must flush before switching to encrypted - # as there may still be data in the buffer which will be - # lost we switch to encrypted which will result in the - # HAP client/controller having to reconnect and try again. - self.wfile.flush() self.request = self.server.upgrade_to_encrypted(self.client_address, self.enc_context["shared_key"]) # Recreate the file handles over the socket # TODO: consider calling super().setup(), although semantically not correct + self.rfile = self.request.makefile('rb', self.rbufsize) # pylint: disable=attribute-defined-outside-init + + def _upgrade_writer_to_encrypted(self): + """Set encryption for the underlying transport. Step 2 + + Call AFTER sending the final unencrypted + response. + + @note: Replaces self.connection and self.wfile + """ self.connection = self.request # pylint: disable=attribute-defined-outside-init - self.rfile = self.connection.makefile('rb', self.rbufsize) # pylint: disable=attribute-defined-outside-init self.wfile = self.connection.makefile('wb') # pylint: disable=attribute-defined-outside-init self.is_encrypted = True @@ -243,6 +250,15 @@ def end_response(self, bytesdata): # self.connection.sendall(b"".join(self._headers_buffer) + b"\r\n" + bytesdata) self._headers_buffer = [] # pylint: disable=attribute-defined-outside-init + # Important: We must flush before switching to encrypted + # as there may still be data in the buffer which will be + # lost we switch to encrypted which will result in the + # HAP client/controller having to reconnect and try again. + # + # Additionally if we do not flush after each response iOS + # seem to reschedule a request to subscribe over and over + # again. + self.wfile.flush() def dispatch(self): """Dispatch the request to the appropriate handler method.""" @@ -526,8 +542,9 @@ def _pair_verify_two(self, tlv_objects): data = tlv.encode(HAP_TLV_TAGS.SEQUENCE_NUM, b'\x04') self.send_response(200) self.send_header("Content-Type", self.PAIRING_RESPONSE_TYPE) + self._upgrade_reader_to_encrypted() self.end_response(data) - self._upgrade_to_encrypted() + self._upgrade_writer_to_encrypted() del self.enc_context def handle_accessories(self): diff --git a/tests/test_hap_server.py b/tests/test_hap_server.py index 9eb7177e..50405173 100644 --- a/tests/test_hap_server.py +++ b/tests/test_hap_server.py @@ -85,11 +85,13 @@ def getsent(self): handler.connection = ConnectionMock() handler.requestline = "GET / HTTP/1.1" handler.send_response(200) + handler.wfile = MagicMock() handler.end_response(b"body") assert handler.connection.getsent() == [ [b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nbody"] ] assert handler._headers_buffer == [] # pylint: disable=protected-access + assert handler.wfile.called_once() def test_http_204_has_no_content_length(): @@ -117,6 +119,8 @@ def getsent(self): handler.connection = ConnectionMock() handler.requestline = "PUT / HTTP/1.1" handler.send_response(204) + handler.wfile = MagicMock() handler.end_response(b"") assert handler.connection.getsent() == [[b"HTTP/1.1 204 No Content\r\n\r\n"]] assert handler._headers_buffer == [] # pylint: disable=protected-access + assert handler.wfile.called_once() From 5756e6fe06cf94988ad6d6b47cb834c1b38016ef Mon Sep 17 00:00:00 2001 From: Adam Langley Date: Fri, 29 May 2020 17:56:07 +1200 Subject: [PATCH 09/10] "-framerate" is required to control avfoundation (#260) --- pyhap/camera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyhap/camera.py b/pyhap/camera.py index d02cff71..be373c19 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -213,7 +213,7 @@ FFMPEG_CMD = ( # pylint: disable=bad-continuation - 'ffmpeg -re -f avfoundation -i 0:0 -threads 0 ' + 'ffmpeg -re -f avfoundation -framerate {fps} -i 0:0 -threads 0 ' '-vcodec libx264 -an -pix_fmt yuv420p -r {fps} -f rawvideo -tune zerolatency ' '-vf scale={width}:{height} -b:v {v_max_bitrate}k -bufsize {v_max_bitrate}k ' '-payload_type 99 -ssrc {v_ssrc} -f rtp ' From 42850a9640d1d6abdc1dac307534a0b4861e8795 Mon Sep 17 00:00:00 2001 From: Ivan Kalchev Date: Fri, 29 May 2020 23:01:48 +0300 Subject: [PATCH 10/10] v2.9.0 --- CHANGELOG.md | 14 ++++++++++++++ pyhap/const.py | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b45eeee..05585b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,20 @@ Sections ### Developers --> +## [2.9.0] - 2020-05-29 + +### Fixed +- Fix random disconnect after upgrade to encrypted. [#253](https://github.com/ikalchev/HAP-python/pull/253) +- Convert the characteristic UUID to string only once. [#256](https://github.com/ikalchev/HAP-python/pull/256) +- Fix pairing failure - split read/write encryption upgrade. [#258](https://github.com/ikalchev/HAP-python/pull/258) +- Allow negotiated framerate to be used - add "-framerate" parameterto avfoundation. [#260](https://github.com/ikalchev/HAP-python/pull/260) + +### Added +- Add support for unavailable accessories. [#252](https://github.com/ikalchev/HAP-python/pull/252) + +### Developers +- Cleanup and fixes for python 3.7 and 3.8. Enable pylint in Travis. [#255](https://github.com/ikalchev/HAP-python/pull/255) + ## [2.8.4] - 2020-05-12 ### Fixed diff --git a/pyhap/const.py b/pyhap/const.py index 6828e62c..07776436 100644 --- a/pyhap/const.py +++ b/pyhap/const.py @@ -1,7 +1,7 @@ """This module contains constants used by other modules.""" MAJOR_VERSION = 2 -MINOR_VERSION = 8 -PATCH_VERSION = 4 +MINOR_VERSION = 9 +PATCH_VERSION = 0 __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5)