diff --git a/CHANGELOG.md b/CHANGELOG.md index 277bd466..3f5ca51c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ Sections ### Developers --> +## [4.2.0] - 2021-09-04 + +### Changed +- Bump zeroconf to 0.36.2. [#380](https://github.com/ikalchev/HAP-python/pull/380) + +### Fixed +- Handle additional cases of invalid values. [#378](https://github.com/ikalchev/HAP-python/pull/378) + +### Added +- Allow passing the zeroconf server name when creating the AccessoryDriver. [#379](https://github.com/ikalchev/HAP-python/pull/379) + ## [4.1.0] - 2021-08-22 ### Added diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index 170f12a8..78acd175 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -120,7 +120,7 @@ def _wrap_service_setter(service, chars, client_addr): class AccessoryMDNSServiceInfo(ServiceInfo): """A mDNS service info representation of an accessory.""" - def __init__(self, accessory, state): + def __init__(self, accessory, state, zeroconf_server=None): self.accessory = accessory self.state = state @@ -131,7 +131,7 @@ def __init__(self, accessory, state): self.state.mac[-8:].replace(":", ""), HAP_SERVICE_TYPE, ) - server = "{}-{}.{}".format( + server = zeroconf_server or "{}-{}.{}".format( self._valid_host_name(), self.state.mac[-8:].replace(":", ""), "local.", @@ -212,7 +212,8 @@ def __init__( listen_address=None, advertised_address=None, interface_choice=None, - async_zeroconf_instance=None + async_zeroconf_instance=None, + zeroconf_server=None ): """ Initialize a new AccessoryDriver object. @@ -259,6 +260,10 @@ def __init__( :param async_zeroconf_instance: An AsyncZeroconf 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. + + :param zeroconf_server: The server name that will be used for the zeroconf + ServiceInfo. + :type zeroconf_server: str """ if loop is None: if sys.platform == "win32": @@ -281,6 +286,7 @@ def __init__( self.accessory = None self.advertiser = async_zeroconf_instance + self.zeroconf_server = zeroconf_server self.interface_choice = interface_choice self.persist_file = os.path.expanduser(persist_file) @@ -384,7 +390,9 @@ async def async_start(self): # Advertise the accessory as a mDNS service. logger.debug("Starting mDNS.") - self.mdns_service_info = AccessoryMDNSServiceInfo(self.accessory, self.state) + self.mdns_service_info = AccessoryMDNSServiceInfo( + self.accessory, self.state, self.zeroconf_server + ) if not self.advertiser: zc_args = {} @@ -609,7 +617,9 @@ def update_advertisement(self): def async_update_advertisement(self): """Updates the mDNS service info for the accessory from the event loop.""" logger.debug("Updating mDNS advertisement") - self.mdns_service_info = AccessoryMDNSServiceInfo(self.accessory, self.state) + self.mdns_service_info = AccessoryMDNSServiceInfo( + self.accessory, self.state, self.zeroconf_server + ) asyncio.ensure_future( self.advertiser.async_update_service(self.mdns_service_info) ) diff --git a/pyhap/characteristic.py b/pyhap/characteristic.py index 9a33c73a..e3a5c948 100644 --- a/pyhap/characteristic.py +++ b/pyhap/characteristic.py @@ -63,6 +63,9 @@ HAP_FORMAT_UINT64, } +DEFAULT_MAX_LENGTH = 64 +ABSOLUTE_MAX_LENGTH = 256 + # ### HAP Units ### HAP_UNIT_ARC_DEGREE = "arcdegrees" HAP_UNIT_CELSIUS = "celsius" @@ -100,6 +103,15 @@ class CharacteristicError(Exception): """Generic exception class for characteristic errors.""" +def _validate_properties(properties): + """Throw an exception on invalid properties.""" + if ( + HAP_REPR_MAX_LEN in properties + and properties[HAP_REPR_MAX_LEN] > ABSOLUTE_MAX_LENGTH + ): + raise ValueError(f"{HAP_REPR_MAX_LEN} may not exceed {ABSOLUTE_MAX_LENGTH}") + + class Characteristic: """Represents a HAP characteristic, the smallest unit of the smart home. @@ -136,6 +148,7 @@ def __init__(self, display_name, type_id, properties): ValidValues, etc. :type properties: dict """ + _validate_properties(properties) self.broker = None self.display_name = display_name self.properties = properties @@ -184,7 +197,9 @@ def to_valid_value(self, value): logger.error(error_msg) raise ValueError(error_msg) elif self.properties[PROP_FORMAT] == HAP_FORMAT_STRING: - value = str(value)[:256] + 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: @@ -194,6 +209,9 @@ def to_valid_value(self, value): ) logger.error(error_msg) raise ValueError(error_msg) + min_step = self.properties.get(PROP_MIN_STEP) + if min_step: + value = value - (value % min_step) 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: @@ -215,6 +233,7 @@ def override_properties(self, properties=None, valid_values=None): raise ValueError("No properties or valid_values specified to override.") if properties: + _validate_properties(properties) self.properties.update(properties) if valid_values: @@ -322,8 +341,9 @@ def to_HAP(self): self.properties[PROP_VALID_VALUES].values() ) elif self.properties[PROP_FORMAT] == HAP_FORMAT_STRING: - if len(value) > 64: - hap_rep[HAP_REPR_MAX_LEN] = min(len(value), 256) + max_length = self.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 diff --git a/pyhap/const.py b/pyhap/const.py index 63dff979..314edacd 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 = 1 +MINOR_VERSION = 2 PATCH_VERSION = 0 __short_version__ = "{}.{}".format(MAJOR_VERSION, MINOR_VERSION) __version__ = "{}.{}".format(__short_version__, PATCH_VERSION) diff --git a/setup.py b/setup.py index c67ffbe0..8b08f00f 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ README = f.read() -REQUIRES = ["cryptography", "zeroconf>=0.32.0", "h11"] +REQUIRES = ["cryptography", "zeroconf>=0.36.2", "h11"] setup( diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index ec87fc1d..35234f0f 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -854,6 +854,35 @@ def test_mdns_service_info(driver): } +def test_mdns_service_info_with_specified_server(driver): + """Test accessory mdns advert when the server is specified.""" + acc = Accessory(driver, "Test Accessory") + driver.add_accessory(acc) + addr = "172.0.0.1" + mac = "00:00:00:00:00:00" + pin = b"123-45-678" + port = 11111 + state = State(address=addr, mac=mac, pincode=pin, port=port) + state.setup_id = "abc" + mdns_info = AccessoryMDNSServiceInfo(acc, state, "hap1.local.") + assert mdns_info.type == "_hap._tcp.local." + assert mdns_info.name == "Test Accessory 000000._hap._tcp.local." + assert mdns_info.server == "hap1.local." + assert mdns_info.port == port + assert mdns_info.addresses == [b"\xac\x00\x00\x01"] + assert mdns_info.properties == { + "md": "Test Accessory", + "pv": "1.1", + "id": "00:00:00:00:00:00", + "c#": "1", + "s#": "1", + "ff": "0", + "ci": "1", + "sf": "1", + "sh": "+KjpzQ==", + } + + @pytest.mark.parametrize( "accessory_name, mdns_name, mdns_server", [ diff --git a/tests/test_characteristic.py b/tests/test_characteristic.py index 7273eea1..34f04fd7 100644 --- a/tests/test_characteristic.py +++ b/tests/test_characteristic.py @@ -104,6 +104,14 @@ def test_override_properties_properties(): assert char.properties["step"] == new_properties["step"] +def test_override_properties_exceed_max_length(): + """Test if overriding the properties with invalid values throws.""" + new_properties = {"minValue": 10, "maxValue": 20, "step": 1, "maxLen": 5000} + char = get_char(PROPERTIES.copy(), min_value=0, max_value=1) + with pytest.raises(ValueError): + char.override_properties(properties=new_properties) + + def test_override_properties_valid_values(): """Test if overriding the properties works for valid values.""" new_valid_values = {"foo2": 2, "bar2": 3} @@ -119,6 +127,38 @@ def test_override_properties_error(): char.override_properties() +@pytest.mark.parametrize("int_format", HAP_FORMAT_INTS) +def test_set_value_invalid_min_step(int_format): + """Test setting the value of a characteristic that is outside the minStep.""" + path = "pyhap.characteristic.Characteristic.notify" + props = PROPERTIES.copy() + props["Format"] = int_format + props["minStep"] = 2 + char = get_char(props, min_value=4, max_value=8) + + with patch(path) as mock_notify: + char.set_value(5.55) + # Ensure floating point is dropped on an int property + # Ensure value is lowered to match minStep + assert char.value == 4 + assert mock_notify.called is False + + char.broker = Mock() + char.set_value(8, should_notify=False) + assert char.value == 8 + assert mock_notify.called is False + + char.set_value(1) + # Ensure value is raised to meet minValue + assert char.value == 4 + assert mock_notify.call_count == 1 + + # No change should not generate another notify + char.set_value(4) + assert char.value == 4 + assert mock_notify.call_count == 1 + + @pytest.mark.parametrize("int_format", HAP_FORMAT_INTS) def test_set_value_int(int_format): """Test setting the value of a characteristic.""" @@ -322,13 +362,34 @@ def test_to_HAP_string(): assert hap_repr["format"] == "string" assert "maxLen" not in hap_repr - char.value = ( + char.set_value( "aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffffgggggggggg" ) with patch.object(char, "broker"): hap_repr = char.to_HAP() - assert hap_repr["maxLen"] == 70 - assert hap_repr["value"] == char.value + assert "maxLen" not in hap_repr + assert hap_repr["value"] == char.value[:64] + + +def test_to_HAP_string_max_length_override(): + """Test created HAP representation for strings.""" + char = get_char(PROPERTIES.copy()) + char.properties["Format"] = "string" + char.properties["maxLen"] = 256 + char.value = "aaa" + with patch.object(char, "broker"): + hap_repr = char.to_HAP() + assert hap_repr["format"] == "string" + assert "maxLen" in hap_repr + longer_than_sixty_four = ( + "aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffffgggggggggg" + ) + + char.set_value(longer_than_sixty_four) + with patch.object(char, "broker"): + hap_repr = char.to_HAP() + assert hap_repr["maxLen"] == 256 + assert hap_repr["value"] == longer_than_sixty_four def test_to_HAP_bool():