diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..e29ad863 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,31 @@ +name: CI + +on: + - push + - pull_request + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.5, 3.6, 3.7, 3.8] + + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox + - name: Lint with tox + run: TOXENV=lint tox + - name: pylint with tox + run: TOXENV=pylint tox + - name: Docs with tox + run: TOXENV=docs tox \ No newline at end of file diff --git a/pyhap/accessory.py b/pyhap/accessory.py index 67f26760..578ac42b 100644 --- a/pyhap/accessory.py +++ b/pyhap/accessory.py @@ -1,12 +1,17 @@ """Module for the Accessory classes.""" import itertools import logging -import struct from pyhap import util, SUPPORT_QR_CODE from pyhap.const import ( - STANDALONE_AID, HAP_REPR_AID, HAP_REPR_IID, HAP_REPR_SERVICES, - HAP_REPR_VALUE, CATEGORY_OTHER, CATEGORY_BRIDGE) + STANDALONE_AID, + HAP_REPR_AID, + HAP_REPR_IID, + HAP_REPR_SERVICES, + HAP_REPR_VALUE, + CATEGORY_OTHER, + CATEGORY_BRIDGE, +) from pyhap.iid_manager import IIDManager if SUPPORT_QR_CODE: @@ -51,13 +56,14 @@ def __init__(self, driver, display_name, aid=None): def __repr__(self): """Return the representation of the accessory.""" services = [s.display_name for s in self.services] - return "" \ - .format(self.display_name, services) + return "".format( + self.display_name, services + ) def __getstate__(self): state = self.__dict__.copy() - state['driver'] = None - state['run_sentinel'] = None + state["driver"] = None + state["run_sentinel"] = None return state def _set_services(self): @@ -85,29 +91,31 @@ def add_info_service(self): Called in `__init__` to be sure that it is the first service added. May be overridden. """ - serv_info = self.driver.loader.get_service('AccessoryInformation') - serv_info.configure_char('Name', value=self.display_name) - serv_info.configure_char('SerialNumber', value='default') + serv_info = self.driver.loader.get_service("AccessoryInformation") + serv_info.configure_char("Name", value=self.display_name) + serv_info.configure_char("SerialNumber", value="default") self.add_service(serv_info) - def set_info_service(self, firmware_revision=None, manufacturer=None, - model=None, serial_number=None): + def set_info_service( + self, firmware_revision=None, manufacturer=None, model=None, serial_number=None + ): """Quick assign basic accessory information.""" - serv_info = self.get_service('AccessoryInformation') + serv_info = self.get_service("AccessoryInformation") if firmware_revision: - serv_info.configure_char( - 'FirmwareRevision', value=firmware_revision) + serv_info.configure_char("FirmwareRevision", value=firmware_revision) if manufacturer: - serv_info.configure_char('Manufacturer', value=manufacturer) + serv_info.configure_char("Manufacturer", value=manufacturer) if model: - serv_info.configure_char('Model', value=model) + serv_info.configure_char("Model", value=model) if serial_number: if len(serial_number) >= 1: - serv_info.configure_char('SerialNumber', value=serial_number) + serv_info.configure_char("SerialNumber", value=serial_number) else: logger.warning( "Couldn't add SerialNumber for %s. The SerialNumber must " - "be at least one character long.", self.display_name) + "be at least one character long.", + self.display_name, + ) def add_preload_service(self, service, chars=None): """Create a service with the given name and add it to this acc.""" @@ -123,8 +131,7 @@ def add_preload_service(self, service, chars=None): def set_primary_service(self, primary_service): """Set the primary service of the acc.""" for service in self.services: - service.is_primary_service = service.type_id == \ - primary_service.type_id + service.is_primary_service = service.type_id == primary_service.type_id def config_changed(self): """Notify the accessory about configuration changes. @@ -142,7 +149,8 @@ def config_changed(self): Deprecated. Use `driver.config_changed()` instead. """ logger.warning( - 'This method is now deprecated. Use \'driver.config_changed\' instead.') + "This method is now deprecated. Use 'driver.config_changed' instead." + ) self.driver.config_changed() def add_service(self, *servs): @@ -184,24 +192,27 @@ def xhm_uri(self): :rtype: str """ - buffer = bytearray(b'\x00\x00\x00\x00\x00\x00\x00\x00') + payload = 0 + payload |= 0 & 0x7 # version - value_low = int(self.driver.state.pincode.replace(b'-', b''), 10) - value_low |= 1 << 28 - struct.pack_into('>L', buffer, 4, value_low) + payload <<= 4 + payload |= 0 & 0xF # reserved bits - if self.category == CATEGORY_OTHER: - buffer[4] = buffer[4] | 1 << 7 + payload <<= 8 + payload |= self.category & 0xFF # category - value_high = self.category >> 1 - struct.pack_into('>L', buffer, 0, value_high) + payload <<= 4 + payload |= 2 & 0xF # flags - encoded_payload = base36.dumps( - struct.unpack_from('>L', buffer, 4)[0] + - (struct.unpack_from('>L', buffer, 0)[0] * (1 << 32))).upper() - encoded_payload = encoded_payload.rjust(9, '0') + payload <<= 27 + payload |= ( + int(self.driver.state.pincode.replace(b"-", b""), 10) & 0x7FFFFFFF + ) # pincode - return 'X-HM://' + encoded_payload + self.driver.state.setup_id + encoded_payload = base36.dumps(payload).upper() + encoded_payload = encoded_payload.rjust(9, "0") + + return "X-HM://" + encoded_payload + self.driver.state.setup_id def get_characteristic(self, aid, iid): """Get the characteristic for the given IID. @@ -244,17 +255,27 @@ def setup_message(self): pincode = self.driver.state.pincode.decode() if SUPPORT_QR_CODE: xhm_uri = self.xhm_uri() - print('Setup payload: {}'.format(xhm_uri), flush=True) - print('Scan this code with your HomeKit app on your iOS device:', - flush=True) + print("Setup payload: {}".format(xhm_uri), flush=True) + print( + "Scan this code with your HomeKit app on your iOS device:", flush=True + ) print(QRCode(xhm_uri).terminal(quiet_zone=2), flush=True) - print('Or enter this code in your HomeKit app on your iOS device: ' - '{}'.format(pincode), flush=True) + print( + "Or enter this code in your HomeKit app on your iOS device: " + "{}".format(pincode), + flush=True, + ) else: - print('To use the QR Code feature, use \'pip install ' - 'HAP-python[QRCode]\'', flush=True) - print('Enter this code in your HomeKit app on your iOS device: {}' - .format(pincode), flush=True) + print( + "To use the QR Code feature, use 'pip install " "HAP-python[QRCode]'", + flush=True, + ) + print( + "Enter this code in your HomeKit app on your iOS device: {}".format( + pincode + ), + flush=True, + ) @staticmethod def run_at_interval(seconds): @@ -272,14 +293,16 @@ def run(self): Determines the interval on which the decorated method will be called. :type seconds: float """ + def _repeat(func): async def _wrapper(self, *args): while True: await self.driver.async_add_job(func, self, *args) - if await util.event_wait( - self.driver.aio_stop_event, seconds): + if await util.event_wait(self.driver.aio_stop_event, seconds): break + return _wrapper + return _repeat async def run(self): @@ -351,8 +374,11 @@ def add_accessory(self, acc): if acc.aid is None: # For some reason AID=7 gets unsupported. See issue #61 - acc.aid = next(aid for aid in itertools.count(2) - if aid != 7 and aid not in self.accessories) + acc.aid = next( + aid + for aid in itertools.count(2) + if aid != 7 and aid not in self.accessories + ) elif acc.aid == self.aid or acc.aid in self.accessories: raise ValueError("Duplicate AID found when attempting to add accessory") @@ -389,4 +415,4 @@ async def stop(self): def get_topic(aid, iid): - return str(aid) + '.' + str(iid) + return str(aid) + "." + str(iid) diff --git a/pyhap/camera.py b/pyhap/camera.py index 7a5b3e66..404efbaf 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -212,7 +212,6 @@ FFMPEG_CMD = ( - # pylint: disable=bad-continuation '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 ' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..6a97c073 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,113 @@ +[tool.black] +target-version = ["py37", "py38"] +exclude = 'generated' + +[tool.isort] +# https://github.com/PyCQA/isort/wiki/isort-Settings +profile = "black" +# will group `import x` and `from x import` of the same module. +force_sort_within_sections = true +known_first_party = [ + "pyhap", +] +combine_as_imports = true + +[tool.pylint.MASTER] +ignore = [ + "tests", +] +# Use a conservative default here; 2 should speed up most setups and not hurt +# any too bad. Override on command line as appropriate. +# Disabled for now: https://github.com/PyCQA/pylint/issues/3584 +#jobs = 2 +load-plugins = [ + "pylint_strict_informational", +] +persistent = false +extension-pkg-whitelist = [ + "ciso8601", + "cv2", +] + +[tool.pylint.BASIC] +good-names = [ + "_", + "ev", + "ex", + "fp", + "i", + "id", + "j", + "k", + "Run", + "T", +] + +[tool.pylint."MESSAGES CONTROL"] +# Reasons disabled: +# format - handled by black +# locally-disabled - it spams too much +# duplicate-code - unavoidable +# cyclic-import - doesn't test if both import on load +# abstract-class-little-used - prevents from setting right foundation +# unused-argument - generic callbacks and setup methods create a lot of warnings +# too-many-* - are not enforced for the sake of readability +# too-few-* - same as too-many-* +# abstract-method - with intro of async there are always methods missing +# inconsistent-return-statements - doesn't handle raise +# too-many-ancestors - it's too strict. +# wrong-import-order - isort guards this +disable = [ + "format", + "abstract-class-little-used", + "abstract-method", + "cyclic-import", + "duplicate-code", + "inconsistent-return-statements", + "locally-disabled", + "not-context-manager", + "too-few-public-methods", + "too-many-ancestors", + "too-many-arguments", + "too-many-branches", + "too-many-instance-attributes", + "too-many-lines", + "too-many-locals", + "too-many-public-methods", + "too-many-return-statements", + "too-many-statements", + "too-many-boolean-expressions", + "unused-argument", + "wrong-import-order", +] +enable = [ + #"useless-suppression", # temporarily every now and then to clean them up + "use-symbolic-message-instead", +] + +[tool.pylint.REPORTS] +score = false + +[tool.pylint.TYPECHECK] +ignored-classes = [ + "_CountingAttr", # for attrs +] + +[tool.pylint.FORMAT] +expected-line-ending-format = "LF" + +[tool.pylint.EXCEPTIONS] +overgeneral-exceptions = [ + "BaseException", + "Exception", + "HomeAssistantError", +] + +[tool.pytest.ini_options] +testpaths = [ + "tests", +] +norecursedirs = [ + ".git", + "testing_config", +] diff --git a/requirements_test.txt b/requirements_test.txt index c96eb41a..02ffdc15 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,3 +1,4 @@ +base36 flake8 flake8-docstrings pydocstyle @@ -5,4 +6,5 @@ pylint pytest pytest-cov pytest-timeout>=1.2.1 +pyqrcode tox diff --git a/tests/test_accessory.py b/tests/test_accessory.py index 3b9b9770..65a2297b 100644 --- a/tests/test_accessory.py +++ b/tests/test_accessory.py @@ -2,79 +2,112 @@ import pytest from pyhap.accessory import Accessory, Bridge -from pyhap.const import STANDALONE_AID - +from pyhap.const import ( + STANDALONE_AID, + CATEGORY_CAMERA, + CATEGORY_TELEVISION, + CATEGORY_TARGET_CONTROLLER, +) +from pyhap.state import State # #### Accessory ###### # execute with `-k acc` # ##################### + def test_acc_init(mock_driver): - Accessory(mock_driver, 'Test Accessory') + Accessory(mock_driver, "Test Accessory") def test_acc_publish_no_broker(mock_driver): - acc = Accessory(mock_driver, 'Test Accessory') - service = acc.driver.loader.get_service('TemperatureSensor') - char = service.get_characteristic('CurrentTemperature') + acc = Accessory(mock_driver, "Test Accessory") + service = acc.driver.loader.get_service("TemperatureSensor") + char = service.get_characteristic("CurrentTemperature") acc.add_service(service) char.set_value(25, should_notify=True) def test_acc_set_services_compatible(mock_driver): """Test deprecated method _set_services.""" + class Acc(Accessory): def _set_services(self): super()._set_services() - serv = self.driver.loader.get_service('TemperatureSensor') + serv = self.driver.loader.get_service("TemperatureSensor") self.add_service(serv) - acc = Acc(mock_driver, 'Test Accessory') - assert acc.get_service('AccessoryInformation') is not None - assert acc.get_service('TemperatureSensor') is not None + + acc = Acc(mock_driver, "Test Accessory") + assert acc.get_service("AccessoryInformation") is not None + assert acc.get_service("TemperatureSensor") is not None def test_acc_set_primary_service(mock_driver): """Test method set_primary_service.""" - acc = Accessory(mock_driver, 'Test Accessory') - service = acc.driver.loader.get_service('Television') + acc = Accessory(mock_driver, "Test Accessory") + service = acc.driver.loader.get_service("Television") acc.add_service(service) - linked_service = acc.driver.loader.get_service('TelevisionSpeaker') + linked_service = acc.driver.loader.get_service("TelevisionSpeaker") acc.add_service(linked_service) - assert acc.get_service('Television').is_primary_service is None - assert acc.get_service('TelevisionSpeaker').is_primary_service is None + assert acc.get_service("Television").is_primary_service is None + assert acc.get_service("TelevisionSpeaker").is_primary_service is None acc.set_primary_service(service) - assert acc.get_service('Television').is_primary_service is True - assert acc.get_service('TelevisionSpeaker').is_primary_service is False + assert acc.get_service("Television").is_primary_service is True + assert acc.get_service("TelevisionSpeaker").is_primary_service is False # #### Bridge ############ # execute with `-k bridge` # ######################## + def test_bridge_init(mock_driver): - Bridge(mock_driver, 'Test Bridge') + Bridge(mock_driver, "Test Bridge") def test_bridge_add_accessory(mock_driver): - bridge = Bridge(mock_driver, 'Test Bridge') - acc = Accessory(mock_driver, 'Test Accessory', aid=2) + bridge = Bridge(mock_driver, "Test Bridge") + acc = Accessory(mock_driver, "Test Accessory", aid=2) bridge.add_accessory(acc) - acc2 = Accessory(mock_driver, 'Test Accessory 2') + acc2 = Accessory(mock_driver, "Test Accessory 2") bridge.add_accessory(acc2) assert acc2.aid != STANDALONE_AID and acc2.aid != acc.aid def test_bridge_n_add_accessory_bridge_aid(mock_driver): - bridge = Bridge(mock_driver, 'Test Bridge') - acc = Accessory(mock_driver, 'Test Accessory', aid=STANDALONE_AID) + bridge = Bridge(mock_driver, "Test Bridge") + acc = Accessory(mock_driver, "Test Accessory", aid=STANDALONE_AID) with pytest.raises(ValueError): bridge.add_accessory(acc) def test_bridge_n_add_accessory_dup_aid(mock_driver): - bridge = Bridge(mock_driver, 'Test Bridge') - acc_1 = Accessory(mock_driver, 'Test Accessory 1', aid=2) - acc_2 = Accessory(mock_driver, 'Test Accessory 2', aid=acc_1.aid) + bridge = Bridge(mock_driver, "Test Bridge") + acc_1 = Accessory(mock_driver, "Test Accessory 1", aid=2) + acc_2 = Accessory(mock_driver, "Test Accessory 2", aid=acc_1.aid) bridge.add_accessory(acc_1) with pytest.raises(ValueError): bridge.add_accessory(acc_2) + + +def test_xhm_uri(mock_driver): + acc_1 = Accessory(mock_driver, "Test Accessory 1", aid=2) + acc_1.category = CATEGORY_CAMERA + mock_driver.state = State( + address="1.2.3.4", mac="AA::BB::CC::DD::EE", pincode=b"653-32-1211", port=44 + ) + mock_driver.state.setup_id = "AAAA" + assert acc_1.xhm_uri() == "X-HM://00H708WSBAAAA" + + acc_1.category = CATEGORY_TELEVISION + mock_driver.state = State( + address="1.2.3.4", mac="AA::BB::CC::DD::EE", pincode=b"323-23-1212", port=44 + ) + mock_driver.state.setup_id = "BBBB" + assert acc_1.xhm_uri() == "X-HM://00UQBOTF0BBBB" + + acc_1.category = CATEGORY_TARGET_CONTROLLER + mock_driver.state = State( + address="1.2.3.4", mac="AA::BB::CC::DD::EE", pincode=b"323-23-1212", port=44 + ) + mock_driver.state.setup_id = "BBBB" + assert acc_1.xhm_uri() == "X-HM://00VPU8UEKBBBB" diff --git a/tox.ini b/tox.ini index f9837213..bbbc77b0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,17 @@ [tox] -envlist = py35, py36, py37, docs, lint, pylint +envlist = py35, py36, py37, py38, docs, lint, pylint skip_missing_interpreters = True +[gh-actions] +python = + 3.5: py36 + 3.6: py36 + 3.7: py37 + 3.8: py38, mypy + [testenv] deps = + -r{toxinidir}/requirements_all.txt -r{toxinidir}/requirements_test.txt commands = pytest --timeout=2 --cov --cov-report= {posargs} @@ -32,7 +40,7 @@ basepython = {env:PYTHON3_PATH:python3} deps = -r{toxinidir}/requirements_test.txt commands = - flake8 pyhap tests --ignore=D10,D205,D4,E501,E126,E128,W504 + flake8 pyhap tests --ignore=D10,D205,D4,E501,E126,E128,W504,W503,E203 [testenv:pylint] basepython = {env:PYTHON3_PATH:python3} @@ -41,7 +49,7 @@ deps = -r{toxinidir}/requirements_all.txt -r{toxinidir}/requirements_test.txt commands = - pylint pyhap tests --disable=missing-docstring,empty-docstring,invalid-name,fixme + pylint pyhap tests --disable=missing-docstring,empty-docstring,invalid-name,fixme --max-line-length=120 [testenv:doc-errors]