diff --git a/.coveragerc b/.coveragerc index 86c77b30..42964bf1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,3 +4,19 @@ include = pyhap/* omit = tests/* pyhap/accessories/* + +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain about missing debug-only code: + def __repr__ + + # Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + + # TYPE_CHECKING and @overload blocks are never executed during pytest run + if TYPE_CHECKING: diff --git a/CHANGELOG.md b/CHANGELOG.md index 226663f7..0289ceed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ Sections ### Developers --> +## [4.9.0] - 2023-10-15 + +- Hashing of accessories no longer includes their values, resulting in more reliable syncs between + devices. [#464](https://github.com/ikalchev/HAP-python/pull/464) + ## [4.8.0] - 2023-10-06 - Add AccessoryInformation:HardwareFinish and NFCAccess characteristics/services. diff --git a/pyhap/accessory.py b/pyhap/accessory.py index 59579e19..f8a95f57 100644 --- a/pyhap/accessory.py +++ b/pyhap/accessory.py @@ -1,10 +1,11 @@ """Module for the Accessory classes.""" import itertools import logging +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional from uuid import UUID -from pyhap import SUPPORT_QR_CODE, util -from pyhap.const import ( +from . import SUPPORT_QR_CODE, util +from .const import ( CATEGORY_BRIDGE, CATEGORY_OTHER, HAP_PROTOCOL_VERSION, @@ -14,14 +15,19 @@ HAP_REPR_VALUE, STANDALONE_AID, ) -from pyhap.iid_manager import IIDManager -from pyhap.service import Service +from .iid_manager import IIDManager +from .service import Service if SUPPORT_QR_CODE: import base36 from pyqrcode import QRCode +if TYPE_CHECKING: + from .accessory_driver import AccessoryDriver + from .characteristic import Characteristic + + HAP_PROTOCOL_INFORMATION_SERVICE_UUID = UUID("000000A2-0000-1000-8000-0026BB765291") logger = logging.getLogger(__name__) @@ -35,7 +41,13 @@ class Accessory: category = CATEGORY_OTHER - def __init__(self, driver, display_name, aid=None, iid_manager=None): + def __init__( + self, + driver: "AccessoryDriver", + display_name: Optional[str], + aid: Optional[int] = None, + iid_manager: Optional[IIDManager] = None, + ) -> None: """Initialise with the given properties. :param display_name: Name to be displayed in the Home app. @@ -47,24 +59,24 @@ def __init__(self, driver, display_name, aid=None, iid_manager=None): will assign the standalone AID to this `Accessory`. :type aid: int """ - self.aid = aid - self.display_name = display_name + self.aid: Optional[int] = aid + self.display_name: Optional[str] = display_name self.driver = driver - self.services = [] + self.services: List[Service] = [] self.iid_manager = iid_manager or IIDManager() - self.setter_callback = None + self.setter_callback: Optional[Callable[[Any], None]] = None self.add_info_service() if aid == STANDALONE_AID: self.add_protocol_version_service() - def __repr__(self): + def __repr__(self) -> str: """Return the representation of the accessory.""" services = [s.display_name for s in self.services] return f"" @property - def available(self): + def available(self) -> bool: """Accessory is available. If available is False, get_characteristics will return @@ -75,7 +87,7 @@ def available(self): """ return True - def add_info_service(self): + def add_info_service(self) -> None: """Helper method to add the required `AccessoryInformation` service. Called in `__init__` to be sure that it is the first service added. @@ -116,7 +128,12 @@ def set_info_service( self.display_name, ) - def add_preload_service(self, service, chars=None, unique_id=None): + def add_preload_service( + self, + service: Service, + chars: Optional[Iterable["Characteristic"]] = None, + unique_id: Optional[str] = None, + ) -> Service: """Create a service with the given name and add it to this acc.""" service = self.driver.loader.get_service(service) if unique_id is not None: @@ -129,12 +146,12 @@ def add_preload_service(self, service, chars=None, unique_id=None): self.add_service(service) return service - def set_primary_service(self, primary_service): + def set_primary_service(self, primary_service: Service) -> None: """Set the primary service of the acc.""" for service in self.services: service.is_primary_service = service.type_id == primary_service.type_id - def add_service(self, *servs): + def add_service(self, *servs: Service) -> None: """Add the given services to this Accessory. This also assigns unique IIDS to the services and their Characteristics. @@ -153,7 +170,7 @@ def add_service(self, *servs): c.broker = self self.iid_manager.assign(c) - def get_service(self, name): + def get_service(self, name: str) -> Optional[Service]: """Return a Service with the given name. A single Service is returned even if more than one Service with the same name @@ -168,7 +185,7 @@ def get_service(self, name): """ return next((s for s in self.services if s.display_name == name), None) - def xhm_uri(self): + def xhm_uri(self) -> str: """Generates the X-HM:// uri (Setup Code URI) :rtype: str @@ -195,7 +212,7 @@ def xhm_uri(self): return "X-HM://" + encoded_payload + self.driver.state.setup_id - def get_characteristic(self, aid, iid): + def get_characteristic(self, aid: int, iid: int) -> Optional["Characteristic"]: """Get the characteristic for the given IID. The AID is used to verify if the search is in the correct accessory. @@ -205,7 +222,7 @@ def get_characteristic(self, aid, iid): return self.iid_manager.get_obj(iid) - def to_HAP(self): + def to_HAP(self, include_value: bool = True) -> Dict[str, Any]: """A HAP representation of this Accessory. :return: A HAP representation of this accessory. For example: @@ -224,7 +241,7 @@ def to_HAP(self): """ return { HAP_REPR_AID: self.aid, - HAP_REPR_SERVICES: [s.to_HAP() for s in self.services], + HAP_REPR_SERVICES: [s.to_HAP(include_value=include_value) for s in self.services], } def setup_message(self): @@ -325,13 +342,18 @@ class Bridge(Accessory): category = CATEGORY_BRIDGE - def __init__(self, driver, display_name, iid_manager=None): + def __init__( + self, + driver: "AccessoryDriver", + display_name: Optional[str], + iid_manager: Optional[IIDManager] = None, + ) -> None: super().__init__( driver, display_name, aid=STANDALONE_AID, iid_manager=iid_manager ) self.accessories = {} # aid: acc - def add_accessory(self, acc): + def add_accessory(self, acc: "Accessory") -> None: """Add the given ``Accessory`` to this ``Bridge``. Every ``Accessory`` in a ``Bridge`` must have an AID and this AID must be @@ -364,14 +386,14 @@ def add_accessory(self, acc): self.accessories[acc.aid] = acc - def to_HAP(self): + def to_HAP(self, include_value: bool = True) -> List[Dict[str, Any]]: """Returns a HAP representation of itself and all contained accessories. .. seealso:: Accessory.to_HAP """ - return [acc.to_HAP() for acc in (super(), *self.accessories.values())] + return [acc.to_HAP(include_value=include_value) for acc in (super(), *self.accessories.values())] - def get_characteristic(self, aid, iid): + def get_characteristic(self, aid: int, iid: int) -> Optional["Characteristic"]: """.. seealso:: Accessory.to_HAP""" if self.aid == aid: return self.iid_manager.get_obj(iid) @@ -382,17 +404,17 @@ def get_characteristic(self, aid, iid): return acc.get_characteristic(aid, iid) - async def run(self): + async def run(self) -> None: """Schedule tasks for each of the accessories' run method.""" for acc in self.accessories.values(): self.driver.async_add_job(acc.run) - async def stop(self): + async def stop(self) -> None: """Calls stop() on all contained accessories.""" await self.driver.async_add_job(super().stop) for acc in self.accessories.values(): await self.driver.async_add_job(acc.stop) -def get_topic(aid, iid): +def get_topic(aid: int, iid: int) -> str: return str(aid) + "." + str(iid) diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 276e5ffc..3cc160fc 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -17,6 +17,7 @@ """ import asyncio import base64 +from collections import defaultdict from concurrent.futures import ThreadPoolExecutor import hashlib import logging @@ -26,14 +27,14 @@ import tempfile import threading import time -from typing import Optional +from typing import Any, Dict, List, Optional, Tuple from zeroconf import ServiceInfo from zeroconf.asyncio import AsyncZeroconf from pyhap import util from pyhap.accessory import Accessory, get_topic -from pyhap.characteristic import CharacteristicError +from pyhap.characteristic import Characteristic, CharacteristicError from pyhap.const import ( HAP_PERMISSION_NOTIFY, HAP_PROTOCOL_SHORT_VERSION, @@ -53,6 +54,7 @@ from pyhap.hsrp import Server as SrpServer from pyhap.loader import Loader from pyhap.params import get_srp_context +from pyhap.service import Service from pyhap.state import State from .const import HAP_SERVER_STATUS @@ -67,12 +69,13 @@ VALID_MDNS_REGEX = re.compile(r"[^A-Za-z0-9\-]+") LEADING_TRAILING_SPACE_DASH = re.compile(r"^[ -]+|[ -]+$") DASH_REGEX = re.compile(r"[-]+") +KEYS_TO_EXCLUDE = {HAP_REPR_IID, HAP_REPR_AID} def _wrap_char_setter(char, value, client_addr): """Process an characteristic setter callback trapping and logging all exceptions.""" try: - result = char.client_update_value(value, client_addr) + response = char.client_update_value(value, client_addr) except Exception: # pylint: disable=broad-except logger.exception( "%s: Error while setting characteristic %s to %s", @@ -81,7 +84,7 @@ def _wrap_char_setter(char, value, client_addr): value, ) return HAP_SERVER_STATUS.SERVICE_COMMUNICATION_FAILURE, None - return HAP_SERVER_STATUS.SUCCESS, result + return HAP_SERVER_STATUS.SUCCESS, response def _wrap_acc_setter(acc, updates_by_service, client_addr): @@ -742,11 +745,18 @@ def setup_srp_verifier(self): @property def accessories_hash(self): """Hash the get_accessories response to track configuration changes.""" + # We pass include_value=False to avoid including the value + # of the characteristics in the hash. This is because the + # value of the characteristics is not used by iOS to determine + # if the accessory configuration has changed. It only uses the + # characteristics metadata. If we included the value in the hash + # then iOS would think the accessory configuration has changed + # every time a characteristic value changed. return hashlib.sha512( - util.to_sorted_hap_json(self.get_accessories()) + util.to_sorted_hap_json(self.get_accessories(include_value=False)) ).hexdigest() - def get_accessories(self): + def get_accessories(self, include_value: bool = True): """Returns the accessory in HAP format. :return: An example HAP representation is: @@ -771,7 +781,7 @@ def get_accessories(self): :rtype: dict """ - hap_rep = self.accessory.to_HAP() + hap_rep = self.accessory.to_HAP(include_value=include_value) if not isinstance(hap_rep, list): hap_rep = [ hap_rep, @@ -859,118 +869,98 @@ def set_characteristics(self, chars_query, client_addr): :type chars_query: dict """ # TODO: Add support for chars that do no support notifications. - updates = {} - setter_results = {} - setter_responses = {} - had_error = False - had_write_response = False - expired = False + queries: List[Dict[str, Any]] = chars_query[HAP_REPR_CHARS] + + self._notify(queries, client_addr) + + updates_by_accessories_services: Dict[ + Accessory, Dict[Service, Dict[Characteristic, Any]] + ] = defaultdict(lambda: defaultdict(dict)) + results: Dict[int, Dict[int, Dict[str, Any]]] = defaultdict( + lambda: defaultdict(dict) + ) + char_to_iid: Dict[Characteristic, int] = {} + + expired = False if HAP_REPR_PID in chars_query: pid = chars_query[HAP_REPR_PID] expire_time = self.prepared_writes.get(client_addr, {}).pop(pid, None) - if expire_time is None or time.time() > expire_time: - expired = True - - for cq in chars_query[HAP_REPR_CHARS]: - aid, iid = cq[HAP_REPR_AID], cq[HAP_REPR_IID] - setter_results.setdefault(aid, {}) + expired = expire_time is None or time.time() > expire_time - if HAP_REPR_WRITE_RESPONSE in cq: - setter_responses.setdefault(aid, {}) - had_write_response = True + primary_accessory = self.accessory + primary_aid = primary_accessory.aid - if expired: - setter_results[aid][iid] = HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST - had_error = True + for query in queries: + if HAP_REPR_VALUE not in query and not expired: continue - if HAP_PERMISSION_NOTIFY in cq: - char_topic = get_topic(aid, iid) - action = "Subscribed" if cq[HAP_PERMISSION_NOTIFY] else "Unsubscribed" - logger.debug( - "%s client %s to topic %s", action, client_addr, char_topic - ) - self.async_subscribe_client_topic( - client_addr, char_topic, cq[HAP_PERMISSION_NOTIFY] - ) + aid = query[HAP_REPR_AID] + iid = query[HAP_REPR_IID] + value = query.get(HAP_REPR_VALUE) + write_response_requested = query.get(HAP_REPR_WRITE_RESPONSE, False) - if HAP_REPR_VALUE not in cq: - continue + if aid == primary_aid: + acc = primary_accessory + else: + acc = self.accessory.accessories.get(aid) + char = acc.get_characteristic(aid, iid) + + set_result = HAP_SERVER_STATUS.INVALID_VALUE_IN_REQUEST + set_result_value = None - updates.setdefault(aid, {})[iid] = cq[HAP_REPR_VALUE] + if value is not None: + set_result, set_result_value = _wrap_char_setter( + char, value, client_addr + ) - for aid, new_iid_values in updates.items(): - if self.accessory.aid == aid: - acc = self.accessory + if set_result_value is not None and write_response_requested: + result = {HAP_REPR_STATUS: set_result, HAP_REPR_VALUE: set_result_value} else: - acc = self.accessory.accessories.get(aid) + result = {HAP_REPR_STATUS: set_result} - updates_by_service = {} - char_to_iid = {} - for iid, value in new_iid_values.items(): - # Characteristic level setter callbacks - char = acc.get_characteristic(aid, iid) - - set_result, set_result_value = _wrap_char_setter(char, value, client_addr) - if set_result != HAP_SERVER_STATUS.SUCCESS: - had_error = True - - setter_results[aid][iid] = set_result - - if set_result_value is not None: - if setter_responses.get(aid, None) is None: - logger.warning( - "Returning write response '%s' when it wasn't requested for %s %s", - set_result_value, aid, iid - ) - had_write_response = True - setter_responses.setdefault(aid, {})[iid] = set_result_value - - if not char.service or ( - not acc.setter_callback and not char.service.setter_callback - ): - continue - char_to_iid[char] = iid - updates_by_service.setdefault(char.service, {}).update({char: value}) + results[aid][iid] = result + char_to_iid[char] = iid + service = char.service + updates_by_accessories_services[acc][service][char] = value + + # Proccess accessory and service level setter callbacks + for acc, updates_by_service in updates_by_accessories_services.items(): + aid = acc.aid + aid_results = results[aid] # Accessory level setter callbacks + acc_set_result = None if acc.setter_callback: - set_result = _wrap_acc_setter(acc, updates_by_service, client_addr) - if set_result != HAP_SERVER_STATUS.SUCCESS: - had_error = True - for iid in updates[aid]: - setter_results[aid][iid] = set_result + acc_set_result = _wrap_acc_setter(acc, updates_by_service, client_addr) # Service level setter callbacks for service, chars in updates_by_service.items(): - if not service.setter_callback: + char_set_result = None + if service.setter_callback: + char_set_result = _wrap_service_setter(service, chars, client_addr) + set_result = char_set_result or acc_set_result + + if not set_result: continue - set_result = _wrap_service_setter(service, chars, client_addr) - if set_result != HAP_SERVER_STATUS.SUCCESS: - had_error = True - for char in chars: - setter_results[aid][char_to_iid[char]] = set_result - if not had_error and not had_write_response: - return None + for char in chars: + aid_results[char_to_iid[char]][HAP_REPR_STATUS] = set_result + + characteristics = [] + nonempty_results_exist = False + for aid, iid_results in results.items(): + for iid, result in iid_results.items(): + result[HAP_REPR_AID] = aid + result[HAP_REPR_IID] = iid + characteristics.append(result) + if ( + result[HAP_REPR_STATUS] != HAP_SERVER_STATUS.SUCCESS + or HAP_REPR_VALUE in result + ): + nonempty_results_exist = True - return { - HAP_REPR_CHARS: [ - { - HAP_REPR_AID: aid, - HAP_REPR_IID: iid, - HAP_REPR_STATUS: status, - **( - {HAP_REPR_VALUE: setter_responses[aid][iid]} - if setter_responses.get(aid, {}).get(iid, None) is not None - else {} - ) - } - for aid, iid_status in setter_results.items() - for iid, status in iid_status.items() - ] - } + return {HAP_REPR_CHARS: characteristics} if nonempty_results_exist else None def prepare(self, prepare_query, client_addr): """Called from ``HAPServerHandler`` when iOS wants to prepare a write. @@ -1013,3 +1003,19 @@ def signal_handler(self, _signal, _frame): except Exception as e: logger.error("Could not stop AccessoryDriver because of error: %s", e) raise + + def _notify( + self, queries: List[Dict[str, Any]], client_addr: Tuple[str, int] + ) -> None: + """Notify the driver that the client has subscribed or unsubscribed.""" + for query in queries: + if HAP_PERMISSION_NOTIFY not in query: + continue + aid = query[HAP_REPR_AID] + iid = query[HAP_REPR_IID] + ev = query[HAP_PERMISSION_NOTIFY] + + char_topic = get_topic(aid, iid) + action = "Subscribed" if ev else "Unsubscribed" + logger.debug("%s client %s to topic %s", action, client_addr, char_topic) + self.async_subscribe_client_topic(client_addr, char_topic, ev) diff --git a/pyhap/characteristic.py b/pyhap/characteristic.py index e75d32c0..3505a92a 100644 --- a/pyhap/characteristic.py +++ b/pyhap/characteristic.py @@ -5,9 +5,10 @@ a temperature measuring or a device status. """ import logging +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Tuple from uuid import UUID -from pyhap.const import ( +from .const import ( HAP_PERMISSION_READ, HAP_REPR_DESC, HAP_REPR_FORMAT, @@ -18,9 +19,12 @@ HAP_REPR_VALID_VALUES, HAP_REPR_VALUE, ) - from .util import hap_type_to_uuid, uuid_to_hap_type +if TYPE_CHECKING: + from .accessory import Accessory + from .service import Service + logger = logging.getLogger(__name__) # ### HAP Format ### @@ -101,7 +105,7 @@ class CharacteristicError(Exception): """Generic exception class for characteristic errors.""" -def _validate_properties(properties): +def _validate_properties(properties: Dict[str, Any]) -> None: """Throw an exception on invalid properties.""" if ( HAP_REPR_MAX_LEN in properties @@ -121,10 +125,10 @@ class Characteristic: __slots__ = ( "broker", - "display_name", - "properties", + "_display_name", + "_properties", "type_id", - "value", + "_value", "getter_callback", "setter_callback", "service", @@ -132,16 +136,19 @@ class Characteristic: "_loader_display_name", "allow_invalid_client_values", "unique_id", + "_to_hap_cache_with_value", + "_to_hap_cache", + "_always_null", ) def __init__( self, - display_name, - type_id, - properties, - allow_invalid_client_values=False, - unique_id=None, - ): + display_name: Optional[str], + type_id: UUID, + properties: Dict[str, Any], + allow_invalid_client_values: bool = False, + unique_id: Optional[str] = None, + ) -> None: """Initialise with the given properties. :param display_name: Name that will be displayed for this @@ -156,7 +163,7 @@ def __init__( :type properties: dict """ _validate_properties(properties) - self.broker = None + self.broker: Optional["Accessory"] = None # # As of iOS 15.1, Siri requests TargetHeatingCoolingState # as Auto reguardless if its a valid value or not. @@ -165,37 +172,71 @@ def __init__( # to True and handle converting the Auto state to Cool or Heat # depending on the device. # + self._always_null = type_id in ALWAYS_NULL self.allow_invalid_client_values = allow_invalid_client_values - self.display_name = display_name - self.properties = properties + self._display_name = display_name + self._properties: Dict[str, Any] = properties self.type_id = type_id - self.value = self._get_default_value() - self.getter_callback = None - self.setter_callback = None - self.service = None + self._value = self._get_default_value() + self.getter_callback: Optional[Callable[[], Any]] = None + self.setter_callback: Optional[Callable[[Any], None]] = None + self.service: Optional["Service"] = None self.unique_id = unique_id self._uuid_str = uuid_to_hap_type(type_id) - self._loader_display_name = None + self._loader_display_name: Optional[str] = None + self._to_hap_cache_with_value: Optional[Dict[str, Any]] = None + self._to_hap_cache: Optional[Dict[str, Any]] = None + + @property + def display_name(self) -> Optional[str]: + """Return the display name of the characteristic.""" + return self._display_name + + @display_name.setter + def display_name(self, value: str) -> None: + """Set the display name of the characteristic.""" + self._display_name = value + self._clear_cache() + + @property + def value(self) -> Any: + """Return the value of the characteristic.""" + return self._value + + @value.setter + def value(self, value: Any) -> None: + """Set the value of the characteristic.""" + self._value = value + self._clear_cache() + + @property + def properties(self) -> Dict[str, Any]: + """Return the properties of the characteristic. + + Properties should not be modified directly. Use override_properties instead. + """ + return self._properties - def __repr__(self): + def __repr__(self) -> str: """Return the representation of the characteristic.""" return ( - f"" + f"" ) - def _get_default_value(self): + def _get_default_value(self) -> Any: """Return default value for format.""" - if self.type_id in ALWAYS_NULL: + if self._always_null: return None - if self.properties.get(PROP_VALID_VALUES): - return min(self.properties[PROP_VALID_VALUES].values()) + valid_values = self._properties.get(PROP_VALID_VALUES) + if valid_values: + return min(valid_values.values()) - value = HAP_FORMAT_DEFAULTS[self.properties[PROP_FORMAT]] + value = HAP_FORMAT_DEFAULTS[self._properties[PROP_FORMAT]] return self.to_valid_value(value) - def get_value(self): + def get_value(self) -> Any: """This is to allow for calling `getter_callback` :return: Current Characteristic Value @@ -203,46 +244,54 @@ def get_value(self): if self.getter_callback: # pylint: disable=not-callable self.value = self.to_valid_value(value=self.getter_callback()) - return self.value + return self._value - def valid_value_or_raise(self, value): + def valid_value_or_raise(self, value: Any) -> None: """Raise ValueError if PROP_VALID_VALUES is set and the value is not present.""" - if self.type_id in ALWAYS_NULL: + if self._always_null: return - valid_values = self.properties.get(PROP_VALID_VALUES) + valid_values = self._properties.get(PROP_VALID_VALUES) if not valid_values: return if value in valid_values.values(): return - error_msg = f"{self.display_name}: value={value} is an invalid value." + error_msg = f"{self._display_name}: value={value} is an invalid value." logger.error(error_msg) raise ValueError(error_msg) - def to_valid_value(self, value): + def to_valid_value(self, value: Any) -> Any: """Perform validation and conversion to valid value.""" - if self.properties[PROP_FORMAT] == HAP_FORMAT_STRING: - value = str(value)[ - : self.properties.get(HAP_REPR_MAX_LEN, DEFAULT_MAX_LENGTH) - ] - elif self.properties[PROP_FORMAT] == HAP_FORMAT_BOOL: - value = bool(value) - elif self.properties[PROP_FORMAT] in HAP_FORMAT_NUMERICS: + properties = self._properties + prop_format = properties[PROP_FORMAT] + + if prop_format == HAP_FORMAT_STRING: + return str(value)[: properties.get(HAP_REPR_MAX_LEN, DEFAULT_MAX_LENGTH)] + + if prop_format == HAP_FORMAT_BOOL: + return bool(value) + + if prop_format in HAP_FORMAT_NUMERICS: if not isinstance(value, (int, float)): error_msg = ( - f"{self.display_name}: value={value} is not a numeric value." + f"{self._display_name}: value={value} is not a numeric value." ) logger.error(error_msg) raise ValueError(error_msg) - min_step = self.properties.get(PROP_MIN_STEP) + min_step = properties.get(PROP_MIN_STEP) if value and min_step: value = round(min_step * round(value / min_step), 14) - value = min(self.properties.get(PROP_MAX_VALUE, value), value) - value = max(self.properties.get(PROP_MIN_VALUE, value), value) - if self.properties[PROP_FORMAT] != HAP_FORMAT_FLOAT: - value = int(value) + value = min(properties.get(PROP_MAX_VALUE, value), value) + value = max(properties.get(PROP_MIN_VALUE, value), value) + if prop_format != HAP_FORMAT_FLOAT: + return int(value) + return value - def override_properties(self, properties=None, valid_values=None): + def override_properties( + self, + properties: Optional[Dict[str, Any]] = None, + valid_values: Optional[Dict[str, Any]] = None, + ) -> None: """Override characteristic property values and valid values. :param properties: Dictionary with values to override the existing @@ -256,24 +305,31 @@ def override_properties(self, properties=None, valid_values=None): if not properties and not valid_values: raise ValueError("No properties or valid_values specified to override.") + self._clear_cache() + if properties: _validate_properties(properties) - self.properties.update(properties) + self._properties.update(properties) if valid_values: - self.properties[PROP_VALID_VALUES] = valid_values + self._properties[PROP_VALID_VALUES] = valid_values - if self.type_id in ALWAYS_NULL: + if self._always_null: self.value = None return try: - self.value = self.to_valid_value(self.value) - self.valid_value_or_raise(self.value) + self.value = self.to_valid_value(self._value) + self.valid_value_or_raise(self._value) except ValueError: self.value = self._get_default_value() - def set_value(self, value, should_notify=True): + def _clear_cache(self) -> None: + """Clear the cached HAP representation.""" + self._to_hap_cache = None + self._to_hap_cache_with_value = None + + def set_value(self, value: Any, should_notify: bool = True) -> None: """Set the given raw value. It is checked if it is a valid value. If not set_value will be aborted and an error message will be @@ -292,47 +348,49 @@ def set_value(self, value, should_notify=True): subscribed clients. Notify will be performed if the broker is set. :type should_notify: bool """ - logger.debug("set_value: %s to %s", self.display_name, value) + logger.debug("set_value: %s to %s", self._display_name, value) value = self.to_valid_value(value) self.valid_value_or_raise(value) - changed = self.value != value + changed = self._value != value self.value = value if changed and should_notify and self.broker: self.notify() - if self.type_id in ALWAYS_NULL: + if self._always_null: self.value = None - def client_update_value(self, value, sender_client_addr=None): + def client_update_value( + self, value: Any, sender_client_addr: Optional[Tuple[str, int]] = None + ) -> None: """Called from broker for value change in Home app. Change self.value to value and call callback. """ original_value = value - if self.type_id not in ALWAYS_NULL or original_value is not None: + if not self._always_null or original_value is not None: value = self.to_valid_value(value) if not self.allow_invalid_client_values: self.valid_value_or_raise(value) logger.debug( "client_update_value: %s to %s (original: %s) from client: %s", - self.display_name, + self._display_name, value, original_value, sender_client_addr, ) - previous_value = self.value + previous_value = self._value self.value = value response = None if self.setter_callback: # pylint: disable=not-callable response = self.setter_callback(value) - changed = self.value != previous_value + changed = self._value != previous_value if changed: self.notify(sender_client_addr) - if self.type_id in ALWAYS_NULL: + if self._always_null: self.value = None return response - def notify(self, sender_client_addr=None): + def notify(self, sender_client_addr: Optional[Tuple[str, int]] = None) -> None: """Notify clients about a value change. Sends the value. .. seealso:: accessory.publish @@ -342,7 +400,7 @@ def notify(self, sender_client_addr=None): self.broker.publish(self.value, self, sender_client_addr, immediate) # pylint: disable=invalid-name - def to_HAP(self): + def to_HAP(self, include_value: bool = True) -> Dict[str, Any]: """Create a HAP representation of this Characteristic. Used for json serialization. @@ -350,45 +408,57 @@ def to_HAP(self): :return: A HAP representation. :rtype: dict """ + if include_value: + if self._to_hap_cache_with_value is not None and not self.getter_callback: + return self._to_hap_cache_with_value + elif self._to_hap_cache is not None: + return self._to_hap_cache + + properties = self._properties + permissions = properties[PROP_PERMISSIONS] + prop_format = properties[PROP_FORMAT] hap_rep = { HAP_REPR_IID: self.broker.iid_manager.get_iid(self), HAP_REPR_TYPE: self._uuid_str, - HAP_REPR_PERM: self.properties[PROP_PERMISSIONS], - HAP_REPR_FORMAT: self.properties[PROP_FORMAT], + HAP_REPR_PERM: permissions, + HAP_REPR_FORMAT: prop_format, } # HAP_REPR_DESC (description) is optional and takes up # quite a bit of space in the payload. Only include it # if it has been changed from the default loader version - if ( - not self._loader_display_name - or self._loader_display_name != self.display_name - ): - hap_rep[HAP_REPR_DESC] = self.display_name - - value = self.get_value() - if self.properties[PROP_FORMAT] in HAP_FORMAT_NUMERICS: + loader_display_name = self._loader_display_name + display_name = self._display_name + if not loader_display_name or loader_display_name != display_name: + hap_rep[HAP_REPR_DESC] = display_name + + if prop_format in HAP_FORMAT_NUMERICS: hap_rep.update( - { - k: self.properties[k] - for k in PROP_NUMERIC.intersection(self.properties) - } + {k: properties[k] for k in PROP_NUMERIC.intersection(properties)} ) - if PROP_VALID_VALUES in self.properties: + if PROP_VALID_VALUES in properties: hap_rep[HAP_REPR_VALID_VALUES] = sorted( - self.properties[PROP_VALID_VALUES].values() + properties[PROP_VALID_VALUES].values() ) - elif self.properties[PROP_FORMAT] == HAP_FORMAT_STRING: - max_length = self.properties.get(HAP_REPR_MAX_LEN, DEFAULT_MAX_LENGTH) + elif prop_format == HAP_FORMAT_STRING: + max_length = properties.get(HAP_REPR_MAX_LEN, DEFAULT_MAX_LENGTH) if max_length != DEFAULT_MAX_LENGTH: hap_rep[HAP_REPR_MAX_LEN] = max_length - if HAP_PERMISSION_READ in self.properties[PROP_PERMISSIONS]: - hap_rep[HAP_REPR_VALUE] = value + if include_value and HAP_PERMISSION_READ in permissions: + hap_rep[HAP_REPR_VALUE] = self.get_value() + + if not include_value: + self._to_hap_cache = hap_rep + elif not self.getter_callback: + # Only cache if there is no getter_callback + self._to_hap_cache_with_value = hap_rep return hap_rep @classmethod - def from_dict(cls, name, json_dict, from_loader=False): + def from_dict( + cls, name: str, json_dict: Dict[str, Any], from_loader: bool = False + ) -> "Characteristic": """Initialize a characteristic object from a dict. :param json_dict: Dictionary containing at least the keys `Format`, diff --git a/pyhap/const.py b/pyhap/const.py index 336e4d2c..e4c72e1c 100644 --- a/pyhap/const.py +++ b/pyhap/const.py @@ -1,6 +1,6 @@ """This module contains constants used by other modules.""" MAJOR_VERSION = 4 -MINOR_VERSION = 8 +MINOR_VERSION = 9 PATCH_VERSION = 0 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyhap/hap_crypto.py b/pyhap/hap_crypto.py index aeba22ea..3299bd85 100644 --- a/pyhap/hap_crypto.py +++ b/pyhap/hap_crypto.py @@ -1,9 +1,10 @@ """This module partially implements crypto for HAP.""" +from functools import partial import logging import struct -from functools import partial -from typing import List from struct import Struct +from typing import List + from chacha20poly1305_reuseable import ChaCha20Poly1305Reusable as ChaCha20Poly1305 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes diff --git a/pyhap/hap_event.py b/pyhap/hap_event.py index 37bf2134..3b063e51 100644 --- a/pyhap/hap_event.py +++ b/pyhap/hap_event.py @@ -1,5 +1,7 @@ """This module implements the HAP events.""" +from typing import Any, Dict + from .const import HAP_REPR_CHARS from .util import to_hap_json @@ -10,13 +12,13 @@ ) -def create_hap_event(data): +def create_hap_event(data: Dict[str, Any]) -> bytes: """Creates a HAP HTTP EVENT response for the given data. @param data: Payload of the request. @type data: bytes """ bytesdata = to_hap_json({HAP_REPR_CHARS: data}) - return ( - EVENT_MSG_STUB + str(len(bytesdata)).encode("utf-8") + b"\r\n" * 2 + bytesdata + return b"".join( + (EVENT_MSG_STUB, str(len(bytesdata)).encode("utf-8"), b"\r\n" * 2, bytesdata) ) diff --git a/pyhap/hap_handler.py b/pyhap/hap_handler.py index 399bfcce..7563e7d1 100644 --- a/pyhap/hap_handler.py +++ b/pyhap/hap_handler.py @@ -5,7 +5,7 @@ import asyncio from http import HTTPStatus import logging -from typing import TYPE_CHECKING, Dict, Optional, Any +from typing import TYPE_CHECKING, Any, Dict, Optional from urllib.parse import ParseResult, parse_qs, urlparse import uuid @@ -51,7 +51,7 @@ def __init__(self): self.headers = [] self.body = b"" self.shared_key = None - self.task = None + self.task: Optional[asyncio.Future] = None self.pairing_changed = False def __repr__(self): diff --git a/pyhap/hap_protocol.py b/pyhap/hap_protocol.py index 0fdd8a4e..b54db5e1 100644 --- a/pyhap/hap_protocol.py +++ b/pyhap/hap_protocol.py @@ -5,6 +5,7 @@ import asyncio import logging import time +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from cryptography.exceptions import InvalidTag import h11 @@ -17,6 +18,9 @@ from .hap_handler import HAPResponse, HAPServerHandler from .util import async_create_background_task +if TYPE_CHECKING: + from .accessory_driver import AccessoryDriver + logger = logging.getLogger(__name__) HIGH_WRITE_BUFFER_SIZE = 2**19 @@ -29,29 +33,35 @@ EVENT_COALESCE_TIME_WINDOW = 0.5 +H11_END_OF_MESSAGE = h11.EndOfMessage() +H11_CONNECTION_CLOSED = h11.ConnectionClosed() + class HAPServerProtocol(asyncio.Protocol): """A asyncio.Protocol implementing the HAP protocol.""" - def __init__(self, loop, connections, accessory_driver) -> None: - self.loop = loop + def __init__( + self, + loop: asyncio.AbstractEventLoop, + connections: Dict[str, "HAPServerProtocol"], + accessory_driver: "AccessoryDriver", + ) -> None: + self.loop: asyncio.AbstractEventLoop = loop self.conn = h11.Connection(h11.SERVER) self.connections = connections self.accessory_driver = accessory_driver - self.handler = None - self.peername = None - self.transport = None - - self.request = None - self.request_body = None - self.response = None + self.handler: Optional[HAPServerHandler] = None + self.peername: Optional[str] = None + self.transport: Optional[asyncio.Transport] = None - self.last_activity = None - self.hap_crypto = None - self._event_timer = None - self._event_queue = {} + self.request: Optional[h11.Request] = None + self.request_body: Optional[bytes] = None + self.response: Optional[HAPResponse] = None - self.start_time = None + self.last_activity: Optional[float] = None + self.hap_crypto: Optional[HAPCrypto] = None + self._event_timer: Optional[asyncio.TimerHandle] = None + self._event_queue: Dict[Tuple[int, int], Dict[str, Any]] = {} def connection_lost(self, exc: Exception) -> None: """Handle connection lost.""" @@ -128,24 +138,29 @@ def send_response(self, response: HAPResponse) -> None: # Force Content-Length as iOS can sometimes # stall if it gets chunked encoding response.headers.append(("Content-Length", str(body_len))) + send = self.conn.send self.write( - self.conn.send( - h11.Response( - status_code=response.status_code, - reason=response.reason, - headers=response.headers, + b"".join( + ( + send( + h11.Response( + status_code=response.status_code, + reason=response.reason, + headers=response.headers, + ) + ), + send(h11.Data(data=response.body)), + send(H11_END_OF_MESSAGE), ) ) - + self.conn.send(h11.Data(data=response.body)) - + self.conn.send(h11.EndOfMessage()) ) - def finish_and_close(self): + def finish_and_close(self) -> None: """Cleanly finish and close the connection.""" - self.conn.send(h11.ConnectionClosed()) + self.conn.send(H11_CONNECTION_CLOSED) self.close() - def check_idle(self, now) -> None: + def check_idle(self, now: float) -> None: """Abort when do not get any data within the timeout.""" if self.last_activity + IDLE_CONNECTION_TIMEOUT_SECONDS >= now: return @@ -193,7 +208,7 @@ def data_received(self, data: bytes) -> None: ) self._process_events() - def _process_events(self): + def _process_events(self) -> None: """Process pending events.""" try: while self._process_one_event(): @@ -203,7 +218,7 @@ def _process_events(self): except h11.ProtocolError as protocol_ex: self._handle_invalid_conn_state(protocol_ex) - def _send_events(self): + def _send_events(self) -> None: """Send any pending events.""" if self._event_timer: self._event_timer.cancel() @@ -215,7 +230,7 @@ def _send_events(self): self.write(create_hap_event(subscribed_events)) self._event_queue.clear() - def _event_queue_with_active_subscriptions(self): + def _event_queue_with_active_subscriptions(self) -> List[Dict[str, Any]]: """Remove any topics that have been unsubscribed after the event was generated.""" topics = self.accessory_driver.topics return [ @@ -256,7 +271,7 @@ def _process_one_event(self) -> bool: return self._handle_invalid_conn_state(f"Unexpected event: {event}") - def _process_response(self, response) -> None: + def _process_response(self, response: HAPResponse) -> None: """Process a response from the handler.""" if response.task: # If there is a task pending we will schedule @@ -298,7 +313,7 @@ def _handle_response_ready(self, task: asyncio.Task) -> None: return self.send_response(response) - def _handle_invalid_conn_state(self, message): + def _handle_invalid_conn_state(self, message: Exception) -> bool: """Log invalid state and close.""" logger.debug( "%s (%s): Invalid state: %s: close the client socket", diff --git a/pyhap/service.py b/pyhap/service.py index 268d80af..e31b3920 100644 --- a/pyhap/service.py +++ b/pyhap/service.py @@ -1,5 +1,8 @@ """This module implements the HAP Service.""" +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional +from uuid import UUID + from pyhap.const import ( HAP_REPR_CHARS, HAP_REPR_IID, @@ -8,8 +11,13 @@ HAP_REPR_TYPE, ) +from .characteristic import Characteristic from .util import hap_type_to_uuid, uuid_to_hap_type +if TYPE_CHECKING: + from .accessory import Accessory + from .loader import Loader + class Service: """A representation of a HAP service. @@ -30,15 +38,20 @@ class Service: "_uuid_str", ) - def __init__(self, type_id, display_name=None, unique_id=None): + def __init__( + self, + type_id: UUID, + display_name: Optional[str] = None, + unique_id: Optional[str] = None, + ) -> None: """Initialize a new Service object.""" - self.broker = None - self.characteristics = [] - self.linked_services = [] + self.broker: Optional["Accessory"] = None + self.characteristics: List[Characteristic] = [] + self.linked_services: List[Service] = [] self.display_name = display_name self.type_id = type_id self.is_primary_service = None - self.setter_callback = None + self.setter_callback: Optional[Callable[[Any], None]] = None self.unique_id = unique_id self._uuid_str = uuid_to_hap_type(type_id) @@ -47,16 +60,16 @@ def __repr__(self): chars_dict = {c.display_name: c.value for c in self.characteristics} return f"" - def add_linked_service(self, service): + def add_linked_service(self, service: "Service") -> None: """Add the given service as "linked" to this Service.""" + iid_manager = self.broker.iid_manager if not any( - self.broker.iid_manager.get_iid(service) - == self.broker.iid_manager.get_iid(original_service) + iid_manager.get_iid(service) == iid_manager.get_iid(original_service) for original_service in self.linked_services ): self.linked_services.append(service) - def add_characteristic(self, *chars): + def add_characteristic(self, *chars: Characteristic) -> None: """Add the given characteristics as "mandatory" for this Service.""" for char in chars: if not any( @@ -66,7 +79,7 @@ def add_characteristic(self, *chars): char.service = self self.characteristics.append(char) - def get_characteristic(self, name): + def get_characteristic(self, name: str) -> Characteristic: """Return a Characteristic object by the given name from this Service. :param name: The name of the characteristic to search for. @@ -84,13 +97,13 @@ def get_characteristic(self, name): def configure_char( self, - char_name, + char_name: str, properties=None, valid_values=None, value=None, setter_callback=None, getter_callback=None, - ): + ) -> Characteristic: """Helper method to return fully configured characteristic.""" char = self.get_characteristic(char_name) if properties or valid_values: @@ -104,7 +117,7 @@ def configure_char( return char # pylint: disable=invalid-name - def to_HAP(self): + def to_HAP(self, include_value: bool = True) -> Dict[str, Any]: """Create a HAP representation of this Service. :return: A HAP representation. @@ -113,23 +126,24 @@ def to_HAP(self): hap = { HAP_REPR_IID: self.broker.iid_manager.get_iid(self), HAP_REPR_TYPE: self._uuid_str, - HAP_REPR_CHARS: [c.to_HAP() for c in self.characteristics], + HAP_REPR_CHARS: [c.to_HAP(include_value) for c in self.characteristics], } if self.is_primary_service is not None: hap[HAP_REPR_PRIMARY] = self.is_primary_service if self.linked_services: - hap[HAP_REPR_LINKED] = [] + linked: List[int] = [] for linked_service in self.linked_services: - hap[HAP_REPR_LINKED].append( - linked_service.broker.iid_manager.get_iid(linked_service) - ) + linked.append(linked_service.broker.iid_manager.get_iid(linked_service)) + hap[HAP_REPR_LINKED] = linked return hap @classmethod - def from_dict(cls, name, json_dict, loader): + def from_dict( + cls, name: str, json_dict: Dict[str, Any], loader: "Loader" + ) -> "Service": """Initialize a service object from a dict. :param json_dict: Dictionary containing at least the keys `UUID` and diff --git a/pyhap/util.py b/pyhap/util.py index 312c61ae..4ee35386 100644 --- a/pyhap/util.py +++ b/pyhap/util.py @@ -3,8 +3,8 @@ import functools import random import socket -from uuid import UUID from typing import Awaitable, Set +from uuid import UUID import async_timeout import orjson @@ -147,7 +147,7 @@ async def event_wait(event, timeout): @functools.lru_cache(maxsize=2048) -def uuid_to_hap_type(uuid): +def uuid_to_hap_type(uuid: UUID) -> str: """Convert a UUID to a HAP type.""" long_type = str(uuid).upper() if not long_type.endswith(BASE_UUID): diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index e6f50e0f..e7234fb1 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -147,7 +147,9 @@ def test_write_response_returned_when_not_requested(driver: AccessoryDriver): bridge = Bridge(driver, "mybridge") acc = Accessory(driver, "TestAcc", aid=2) service = Service(uuid1(), "NFCAccess") - char_nfc_access_control_point = Characteristic("NFCAccessControlPoint", uuid1(), CHAR_PROPS) + char_nfc_access_control_point = Characteristic( + "NFCAccessControlPoint", uuid1(), CHAR_PROPS + ) service.add_characteristic(char_nfc_access_control_point) mock_callback = MagicMock() @@ -159,7 +161,9 @@ def setter_with_write_response(value=0): char_nfc_access_control_point.setter_callback = setter_with_write_response acc.add_service(service) - char_nfc_access_control_point_iid = char_nfc_access_control_point.to_HAP()[HAP_REPR_IID] + char_nfc_access_control_point_iid = char_nfc_access_control_point.to_HAP()[ + HAP_REPR_IID + ] bridge.add_accessory(acc) driver.add_accessory(bridge) @@ -171,22 +175,13 @@ def setter_with_write_response(value=0): HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_nfc_access_control_point_iid, HAP_REPR_VALUE: 0, - HAP_REPR_WRITE_RESPONSE: False + HAP_REPR_WRITE_RESPONSE: False, } ] }, "mock_addr", ) - assert response == { - HAP_REPR_CHARS: [ - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_nfc_access_control_point_iid, - HAP_REPR_STATUS: 0, - HAP_REPR_VALUE: 1 - }, - ] - } + assert response is None response = driver.set_characteristics( { @@ -200,23 +195,16 @@ def setter_with_write_response(value=0): }, "mock_addr", ) - assert response == { - HAP_REPR_CHARS: [ - { - HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_nfc_access_control_point_iid, - HAP_REPR_STATUS: 0, - HAP_REPR_VALUE: 1 - }, - ] - } + assert response is None def test_write_response_returned_when_requested(driver: AccessoryDriver): bridge = Bridge(driver, "mybridge") acc = Accessory(driver, "TestAcc", aid=2) service = Service(uuid1(), "NFCAccess") - char_nfc_access_control_point = Characteristic("NFCAccessControlPoint", uuid1(), CHAR_PROPS) + char_nfc_access_control_point = Characteristic( + "NFCAccessControlPoint", uuid1(), CHAR_PROPS + ) service.add_characteristic(char_nfc_access_control_point) mock_callback = MagicMock() @@ -228,7 +216,9 @@ def setter_with_write_response(value=0): char_nfc_access_control_point.setter_callback = setter_with_write_response acc.add_service(service) - char_nfc_access_control_point_iid = char_nfc_access_control_point.to_HAP()[HAP_REPR_IID] + char_nfc_access_control_point_iid = char_nfc_access_control_point.to_HAP()[ + HAP_REPR_IID + ] bridge.add_accessory(acc) driver.add_accessory(bridge) @@ -240,7 +230,7 @@ def setter_with_write_response(value=0): HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_nfc_access_control_point_iid, HAP_REPR_VALUE: 0, - HAP_REPR_WRITE_RESPONSE: True + HAP_REPR_WRITE_RESPONSE: True, } ] }, @@ -252,7 +242,7 @@ def setter_with_write_response(value=0): HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_nfc_access_control_point_iid, HAP_REPR_STATUS: 0, - HAP_REPR_VALUE: 1 + HAP_REPR_VALUE: 1, }, ] } @@ -1187,3 +1177,59 @@ async def test_bridge_with_multiple_sync_run_at_interval_accessories(async_zeroc assert acc.counter > 2 assert acc2.counter > 2 assert acc3.counter > 2 + + +def test_hash_ignores_values(driver: AccessoryDriver): + """The hash should change when the config changes but not for a value change.""" + bridge = Bridge(driver, "mybridge") + 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.add_characteristic(char_on) + service.add_characteristic(char_brightness) + + switch_service = Service(uuid1(), "Switch") + char_switch_on = Characteristic("On", uuid1(), CHAR_PROPS) + switch_service.add_characteristic(char_switch_on) + + mock_callback = MagicMock() + acc.setter_callback = mock_callback + + acc.add_service(service) + acc.add_service(switch_service) + bridge.add_accessory(acc) + + 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) + + mock_callback2 = MagicMock(side_effect=OSError) + acc2.setter_callback = mock_callback2 + + bridge.add_accessory(acc2) + + driver.add_accessory(bridge) + + original_hash = driver.accessories_hash + + char_on.set_value(False) + char_on2.set_value(False) + char_brightness.set_value(88) + char_switch_on.set_value(True) + + assert driver.accessories_hash == original_hash + + acc2.add_service(service2) + new_hash = driver.accessories_hash + assert new_hash != original_hash + + char_brightness2.set_value(43) + + assert driver.accessories_hash == new_hash diff --git a/tests/test_characteristic.py b/tests/test_characteristic.py index d7e2eca5..12d32713 100644 --- a/tests/test_characteristic.py +++ b/tests/test_characteristic.py @@ -220,6 +220,11 @@ def test_set_value_invalid_min_float(): # Ensure value is not modified assert char.value == 0 + char.value = 99 + assert char.value == 99 + char.set_value(0) + assert char.value == 0 + @pytest.mark.parametrize("int_format", HAP_FORMAT_INTS) def test_set_value_int(int_format): @@ -468,13 +473,16 @@ def test_to_HAP_string_max_length_override(): def test_to_HAP_bool(): """Test created HAP representation for booleans.""" + # pylint: disable=protected-access char = get_char(PROPERTIES.copy()) char.properties["Format"] = "bool" + char._clear_cache() with patch.object(char, "broker"): hap_repr = char.to_HAP() assert hap_repr["format"] == "bool" char.properties["Permissions"] = [] + char._clear_cache() with patch.object(char, "broker"): hap_repr = char.to_HAP() assert "value" not in hap_repr @@ -493,3 +501,74 @@ def test_from_dict(): assert char.display_name == "Test Char" assert char.type_id == uuid assert char.properties == {"Format": "int", "Permissions": "read"} + + +def test_getter_callback(): + """Test getter callback.""" + char = Characteristic( + display_name="Test Char", type_id="A1", properties=PROPERTIES.copy() + ) + char.set_value(3) + char.override_properties({"minValue": 3, "maxValue": 10}) + char.broker = Mock() + assert char.to_HAP() == { + "description": "Test Char", + "format": "int", + "iid": ANY, + "maxValue": 10, + "minValue": 3, + "perms": ["pr"], + "type": "A1", + "value": 3, + } + + assert char.to_HAP(include_value=False) == { + "description": "Test Char", + "format": "int", + "iid": ANY, + "maxValue": 10, + "minValue": 3, + "perms": ["pr"], + "type": "A1", + } + char.override_properties({"minValue": 4, "maxValue": 11}) + assert char.to_HAP() == { + "description": "Test Char", + "format": "int", + "iid": ANY, + "maxValue": 11, + "minValue": 4, + "perms": ["pr"], + "type": "A1", + "value": 4, + } + + assert char.to_HAP(include_value=False) == { + "description": "Test Char", + "format": "int", + "iid": ANY, + "maxValue": 11, + "minValue": 4, + "perms": ["pr"], + "type": "A1", + } + char.getter_callback = lambda: 5 + assert char.to_HAP() == { + "description": "Test Char", + "format": "int", + "iid": ANY, + "maxValue": 11, + "minValue": 4, + "perms": ["pr"], + "type": "A1", + "value": 5, + } + assert char.to_HAP(include_value=False) == { + "description": "Test Char", + "format": "int", + "iid": ANY, + "maxValue": 11, + "minValue": 4, + "perms": ["pr"], + "type": "A1", + } diff --git a/tests/test_service.py b/tests/test_service.py index 46c5f8cc..83852c73 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -125,6 +125,7 @@ def test_add_linked_service(): assert len(service.linked_services) == 0 linked_service = Service(uuid1(), "Test Linked Service") + service.broker = Mock() service.add_linked_service(linked_service) assert len(service.linked_services) == 1 @@ -161,6 +162,7 @@ def test_linked_service_to_HAP(): service = Service(uuid, "Test Service") linked_service = Service(uuid1(), "Test Linked Service") + service.broker = Mock() service.add_linked_service(linked_service) service.characteristics = get_chars() with patch(pyhap_char_to_HAP) as mock_char_HAP, patch.object(