Source code for pyhap.characteristic

"""
All things for a HAP characteristic.

A Characteristic is the smallest unit of the smart home, e.g.
a temperature measuring or a device status.
"""
import logging
from uuid import UUID

from pyhap.const import (
    HAP_PERMISSION_READ,
    HAP_REPR_DESC,
    HAP_REPR_FORMAT,
    HAP_REPR_IID,
    HAP_REPR_MAX_LEN,
    HAP_REPR_PERM,
    HAP_REPR_TYPE,
    HAP_REPR_VALID_VALUES,
    HAP_REPR_VALUE,
)

from .util import hap_type_to_uuid, uuid_to_hap_type

logger = logging.getLogger(__name__)

# ### HAP Format ###
HAP_FORMAT_BOOL = "bool"
HAP_FORMAT_INT = "int"
HAP_FORMAT_FLOAT = "float"
HAP_FORMAT_STRING = "string"
HAP_FORMAT_ARRAY = "array"
HAP_FORMAT_DICTIONARY = "dictionary"
HAP_FORMAT_UINT8 = "uint8"
HAP_FORMAT_UINT16 = "uint16"
HAP_FORMAT_UINT32 = "uint32"
HAP_FORMAT_UINT64 = "uint64"
HAP_FORMAT_DATA = "data"
HAP_FORMAT_TLV8 = "tlv8"

HAP_FORMAT_DEFAULTS = {
    HAP_FORMAT_BOOL: False,
    HAP_FORMAT_INT: 0,
    HAP_FORMAT_FLOAT: 0.0,
    HAP_FORMAT_STRING: "",
    HAP_FORMAT_ARRAY: "",
    HAP_FORMAT_DICTIONARY: "",
    HAP_FORMAT_UINT8: 0,
    HAP_FORMAT_UINT16: 0,
    HAP_FORMAT_UINT32: 0,
    HAP_FORMAT_UINT64: 0,
    HAP_FORMAT_DATA: "",
    HAP_FORMAT_TLV8: "",
}

HAP_FORMAT_NUMERICS = {
    HAP_FORMAT_INT,
    HAP_FORMAT_FLOAT,
    HAP_FORMAT_UINT8,
    HAP_FORMAT_UINT16,
    HAP_FORMAT_UINT32,
    HAP_FORMAT_UINT64,
}

DEFAULT_MAX_LENGTH = 64
ABSOLUTE_MAX_LENGTH = 256

# ### HAP Units ###
HAP_UNIT_ARC_DEGREE = "arcdegrees"
HAP_UNIT_CELSIUS = "celsius"
HAP_UNIT_LUX = "lux"
HAP_UNIT_PERCENTAGE = "percentage"
HAP_UNIT_SECONDS = "seconds"

# ### Properties ###
PROP_FORMAT = "Format"
PROP_MAX_VALUE = "maxValue"
PROP_MIN_STEP = "minStep"
PROP_MIN_VALUE = "minValue"
PROP_PERMISSIONS = "Permissions"
PROP_UNIT = "unit"
PROP_VALID_VALUES = "ValidValues"

PROP_NUMERIC = {PROP_MAX_VALUE, PROP_MIN_VALUE, PROP_MIN_STEP, PROP_UNIT}

CHAR_BUTTON_EVENT = UUID("00000126-0000-1000-8000-0026BB765291")
CHAR_PROGRAMMABLE_SWITCH_EVENT = UUID("00000073-0000-1000-8000-0026BB765291")


IMMEDIATE_NOTIFY = {
    CHAR_BUTTON_EVENT,  # Button Event
    CHAR_PROGRAMMABLE_SWITCH_EVENT,  # Programmable Switch Event
}

# Special case, Programmable Switch Event always have a null value
ALWAYS_NULL = {
    CHAR_PROGRAMMABLE_SWITCH_EVENT,  # Programmable Switch Event
}


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}")


[docs]class Characteristic: """Represents a HAP characteristic, the smallest unit of the smart home. A HAP characteristic is some measurement or state, like battery status or the current temperature. Characteristics are contained in services. Each characteristic has a unique type UUID and a set of properties, like format, min and max values, valid values and others. """ __slots__ = ( "broker", "display_name", "properties", "type_id", "value", "getter_callback", "setter_callback", "service", "_uuid_str", "_loader_display_name", "allow_invalid_client_values", "unique_id", ) def __init__( self, display_name, type_id, properties, allow_invalid_client_values=False, unique_id=None, ): """Initialise with the given properties. :param display_name: Name that will be displayed for this characteristic, i.e. the `description` in the HAP representation. :type display_name: str :param type_id: UUID unique to this type of characteristic. :type type_id: uuid.UUID :param properties: A dict of properties, such as Format, ValidValues, etc. :type properties: dict """ _validate_properties(properties) self.broker = None # # As of iOS 15.1, Siri requests TargetHeatingCoolingState # as Auto reguardless if its a valid value or not. # # Consumers of this api may wish to set allow_invalid_client_values # to True and handle converting the Auto state to Cool or Heat # depending on the device. # self.allow_invalid_client_values = allow_invalid_client_values self.display_name = display_name self.properties = properties self.type_id = type_id self.value = self._get_default_value() self.getter_callback = None self.setter_callback = None self.service = None self.unique_id = unique_id self._uuid_str = uuid_to_hap_type(type_id) self._loader_display_name = None def __repr__(self): """Return the representation of the characteristic.""" return ( f"<characteristic display_name={self.display_name} unique_id={self.unique_id} " f"value={self.value} properties={self.properties}>" ) def _get_default_value(self): """Return default value for format.""" if self.type_id in ALWAYS_NULL: return None if self.properties.get(PROP_VALID_VALUES): return min(self.properties[PROP_VALID_VALUES].values()) value = HAP_FORMAT_DEFAULTS[self.properties[PROP_FORMAT]] return self.to_valid_value(value)
[docs] def get_value(self): """This is to allow for calling `getter_callback` :return: Current Characteristic Value """ if self.getter_callback: # pylint: disable=not-callable self.value = self.to_valid_value(value=self.getter_callback()) return self.value
[docs] def valid_value_or_raise(self, value): """Raise ValueError if PROP_VALID_VALUES is set and the value is not present.""" if self.type_id in ALWAYS_NULL: return 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." logger.error(error_msg) raise ValueError(error_msg)
[docs] def to_valid_value(self, value): """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: if not isinstance(value, (int, float)): error_msg = ( 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) 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) return value
[docs] def override_properties(self, properties=None, valid_values=None): """Override characteristic property values and valid values. :param properties: Dictionary with values to override the existing properties. Only changed values are required. :type properties: dict :param valid_values: Dictionary with values to override the existing valid_values. Valid values will be set to new dictionary. :type valid_values: dict """ if not properties and not valid_values: raise ValueError("No properties or valid_values specified to override.") if properties: _validate_properties(properties) self.properties.update(properties) if valid_values: self.properties[PROP_VALID_VALUES] = valid_values if self.type_id in ALWAYS_NULL: self.value = None return try: self.value = self.to_valid_value(self.value) self.valid_value_or_raise(self.value) except ValueError: self.value = self._get_default_value()
[docs] def set_value(self, value, should_notify=True): """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 displayed. `Characteristic.setter_callback` You may also define a `setter_callback` on the `Characteristic`. This will be called with the value being set as the arg. .. seealso:: Characteristic.value :param value: The value to assign as this Characteristic's value. :type value: Depends on properties["Format"] :param should_notify: Whether a the change should be sent to 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) value = self.to_valid_value(value) self.valid_value_or_raise(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: self.value = None
[docs] def client_update_value(self, value, sender_client_addr=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: 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, value, original_value, sender_client_addr, ) previous_value = self.value self.value = value if self.setter_callback: # pylint: disable=not-callable self.setter_callback(value) changed = self.value != previous_value if changed: self.notify(sender_client_addr) if self.type_id in ALWAYS_NULL: self.value = None
[docs] def notify(self, sender_client_addr=None): """Notify clients about a value change. Sends the value. .. seealso:: accessory.publish .. seealso:: accessory_driver.publish """ immediate = self.type_id in IMMEDIATE_NOTIFY self.broker.publish(self.value, self, sender_client_addr, immediate)
# pylint: disable=invalid-name
[docs] def to_HAP(self): """Create a HAP representation of this Characteristic. Used for json serialization. :return: A HAP representation. :rtype: dict """ 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_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: hap_rep.update( { k: self.properties[k] for k in PROP_NUMERIC.intersection(self.properties) } ) if PROP_VALID_VALUES in self.properties: hap_rep[HAP_REPR_VALID_VALUES] = sorted( self.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) 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 return hap_rep
[docs] @classmethod def from_dict(cls, name, json_dict, from_loader=False): """Initialize a characteristic object from a dict. :param json_dict: Dictionary containing at least the keys `Format`, `Permissions` and `UUID` :type json_dict: dict """ type_id = hap_type_to_uuid(json_dict.pop("UUID")) char = cls(name, type_id, properties=json_dict) if from_loader: char._loader_display_name = ( # pylint: disable=protected-access char.display_name ) return char