Source code for circuitpython_homie.recipes

"""The :mod:`circuitpython_homie.recipes` module holds any suggested recipes for easily
implementing common node properties.

.. important::
    Callback methods are not templated for these properties. Users are advised to
    write their own callback methods and set them to the desired property's
    :attr:`~circuitpython_homie.HomieProperty.callback` attribute.

.. |param_mutable| replace:: (can be overridden with a keyword argument)
.. |param_immutable| replace:: (shall not be overridden)
.. |param_intro| replace:: The parameters here follow the `HomieProperty` constructor
    signature, but with a few exceptions:
.. _ISO 8601: https://en.wikipedia.org/wiki/ISO_8601
.. _ISO 8601 Duration: https://en.wikipedia.org/wiki/ISO_8601#Durations
"""
import time

try:
    from typing import Sequence, List, Union
except ImportError:  # pragma: no cover
    pass  # do not type check on CircuitPython firmware

from . import HomieProperty


class _PropertyColor(HomieProperty):
    def __init__(self, name: str, property_id: str = None, **extra_attributes):
        extra_attributes.pop("datatype", None)
        super().__init__(
            name,
            "color",
            property_id=property_id,
            init_value=self.validate(extra_attributes.pop("init_value", "0,0,0")),
            **extra_attributes,
        )

    def validate(self, color: Union[str, Sequence[int]]) -> List[int]:
        """Translate a color string into a valid 3-tuple of integers.

        :param color: The color as a string in which the elements are delimited by
            commas (``,``).
        :throws: An `AssertionError` is raised when the given color string is malformed
            or the color's components are out of bounds.
        :returns: A 3 `tuple` consisting of the color's 3 components.
        """
        if isinstance(color, str):
            elements = [int(x) for x in color.split(",")]
        else:
            elements = list(color)
        assert len(elements) == 3, "expected 3 color components, got {}".format(
            len(elements)
        )
        return elements

    def _set(self, value: Union[str, Sequence[int]]) -> List[int]:
        return super()._set(self.validate(value))


[docs]class PropertyRGB(_PropertyColor): """A property that can be used to represent node's color in RGB format. |param_intro| - ``settable`` attribute is set to `True` |param_mutable| - ``init_value`` is set to black :python:`"0,0,0"` |param_mutable| - `datatype` attribute is set to :python:`"color"` |param_immutable| - ``format`` attribute is set to :python:`"rgb"` |param_immutable| """ def __init__(self, name: str, property_id: str = None, **extra_attributes): extra_attributes.pop("format", None) super().__init__( name, property_id=property_id, format="rgb", **extra_attributes, )
[docs] def validate(self, color: Union[str, Sequence[int]]) -> List[int]: elements = super().validate(color) for elem in elements: assert 0 <= elem <= 255, "{} is not in range [0, 255]".format(elem) return elements
[docs]class PropertyHSV(_PropertyColor): """A property that can be used to represent node's color in HSV format. |param_intro| - ``init_value`` is set to black :python:`"0,0,0"` |param_mutable| - `datatype` attribute is set to :python:`"color"` |param_immutable| - ``format`` attribute is set to :python:`"hsv"` |param_immutable| """ def __init__(self, name: str, property_id: str = None, **extra_attributes): super().__init__( name, property_id=property_id, format=extra_attributes.pop("format", "hsv"), **extra_attributes, )
[docs] def validate(self, color: Union[str, Sequence[int]]) -> List[int]: elements = super().validate(color) for i, elem in enumerate(elements): if not i: assert 0 <= elem <= 360, "{} is not a valid Hue value".format(elem) else: assert 0 <= elem <= 100, "{} is not in range [0, 100]".format(elem) return elements
[docs]class PropertyDateTime(HomieProperty): """A property that represents a data and time in `ISO 8601`_ format. |param_intro| - `datatype` attribute is set to :python:`"datetime"` |param_immutable| - ``init_value`` is set to :python:`"2000-01-01T00:00:00"` |param_mutable| .. hint:: Validation of the payload format can be done using the `datetime` library or the `adafruit_datetime` library. """ def __init__( self, name: str, property_id: str = None, init_value="2000-01-01T00:00:00", **extra_attributes ): extra_attributes.pop("datatype", None) super().__init__(name, "datetime", property_id, init_value, **extra_attributes)
[docs] @staticmethod def convert(value: time.struct_time) -> str: """Takes a :class:`~time.struct_time` object and returns a `str` in compliance with `ISO 8601`_ standards. :param value: The `named tuple` to translate. :returns: A `ISO 8601`_ compliant formatted string in the form ``YYYY-MM-DDTHH:MM:SS``. """ return "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}".format( value.tm_year, value.tm_mon, value.tm_mday, value.tm_hour, value.tm_min, value.tm_sec, )
def _set(self, value: Union[str, time.struct_time]) -> str: """Set the property's value. :param value: This parameter can be: - A `str` in `ISO 8601`_ format. To validate the format of this string, use the `datetime` library or the `adafruit_datetime` library. - A `time.struct_time` object which will be converted to `ISO 8601`_ datetime format (via `convert()`). :returns: The `str` form of the given value. """ if isinstance(value, time.struct_time): return super()._set(self.convert(value)) assert value, "a payload representing time cannot be an empty string." return super()._set(value)
[docs]class PropertyDuration(HomieProperty): """A property that represents a duration of time in `ISO 8601 Duration`_ format. |param_intro| - `datatype` attribute is set to :python:`"duration"` |param_immutable| - ``init_value`` is set to :python:`"PT0S"` |param_mutable| .. hint:: Validation of the payload format can be done using the `datetime` library or the `adafruit_datetime` library. """ def __init__( self, name: str, property_id: str = None, init_value="PT0S", **extra_attributes ): extra_attributes.pop("datatype", None) super().__init__(name, "duration", property_id, init_value, **extra_attributes)
[docs] @staticmethod def convert(value: Union[int, float]) -> str: """Takes a a number of seconds and returns a `str` in compliance with `ISO 8601 Duration`_ standards. .. note:: For minimality, this function will only convert a number of seconds into units of hours, minutes, and seconds. :param value: The number of seconds that describe a duration. If a `float` object is passed, then the fractional seconds are truncated. :returns: A `ISO 8601 Duration`_ compliant formatted string in the form ``PTnHnMnS``. Only units with a non-zero value are represented. For instance, a value of :python:`59` will return :python:`"PT59S"` (representing 59 seconds), and a value of :python:`3609` will return :python:`"PT1H9S"` (representing 1 hour and 9 seconds). """ if isinstance(value, float): value = int(value) second = value % 60 minute = int((value % 3600) / 60) hour = int(value / 3600) time_duration = "PT" if hour: time_duration += "{}H".format(hour) if minute: time_duration += "{}M".format(minute) if second or (not hour and not minute): time_duration += "{}S".format(second) return time_duration
def _set(self, value: Union[str, int]) -> str: """Set the property's value. :param value: This parameter can be: - A `str` in `ISO 8601`_ format. To validate the format of this string, use the `datetime` library or the `adafruit_datetime` library. - An `int` number of seconds which will be converted to `ISO 8601`_ duration format (via `convert()`). :returns: The `str` form of the given value. """ if isinstance(value, (int, float)): return super()._set(self.convert(value)) assert value, "a payload representing time cannot be an empty string." return super()._set(value)
[docs]class PropertyBool(HomieProperty): """A property to represent boolean data. |param_intro| - `datatype` attribute is set to :python:`"boolean"` |param_immutable| - ``init_value`` is set to `False` |param_mutable| """ def __init__( self, name: str, property_id: str = None, init_value=False, **extra_attributes ): extra_attributes.pop("datatype", None) super().__init__( name, "boolean", property_id, self.validate(init_value), **extra_attributes )
[docs] @staticmethod def validate(value: Union[str, bool]) -> bool: """Validates a `str` that describes a boolean. :param value: The boolean's description. According to the Homie specifications, this string value can only be :python:`"true` or :python:`"false"` (case-sensitive). This function will convert the given `str` to lowercase form. If a `bool` is passed, then this function simply returns it. :returns: A `bool` object. :throws: An `AssertionError` is raised if the given string value is not in compliance with Homie specifications. """ if isinstance(value, bool): return value value = value.lower() assert value in ( "true", "false", ), "{} is not a valid boolean description".format(value) return value == "true"
def _set(self, value: Union[bool, str]) -> bool: return super()._set(self.validate(value))
class _PropertyNumber(HomieProperty): def __init__( self, name: str, datatype: str, property_id: str = None, init_value=0, **extra_attributes ): assert datatype in ("integer", "float") self.datatype = datatype # needs to be set for using validate() if "format" in extra_attributes: setattr(self, "format", extra_attributes["format"]) super().__init__( name, datatype, property_id, self.validate(init_value), **extra_attributes ) def validate(self, value: Union[str, int, float]) -> Union[int, float]: """Make assertions that a given value is in the ``format`` range. :param value: The value to validate. If this value is a `str`, then it is converted to an `int` or `float` according to the `datatype` attribute. :throws: An `AssertionError` is raised when the given ``value`` is malformed. :returns: The validated value (as specified by the ``value`` parameter). """ is_float = self.datatype == "float" if isinstance(value, str): value = int(value) if not is_float else float(value) if hasattr(self, "format"): fmt = getattr(self, "format").split(":") # type: List[str] assert len(fmt) == 2, "expected `<min>:<max>` form, got {}.".format(value) low = int(fmt[0]) if not is_float else float(fmt[0]) high = int(fmt[1]) if not is_float else float(fmt[1]) if low > high: low, high = (high, low) assert low <= value <= high, "{} is not in range of [{}, {}]".format( value, low, high ) return value def _set(self, value: Union[str, int, float]) -> Union[int, float]: return super()._set(self.validate(value))
[docs]class PropertyPercent(_PropertyNumber): """A property that represents a percentage. The parameters here follow the `HomieProperty` constructor signature, but with a few exceptions: - ``unit`` attribute is set to :python:`"%"` |param_immutable| - `datatype` attribute is constrained to :python:`"integer"` or its default :python:`"float"` values |param_mutable| - ``format`` attribute is set to :python:`"0:100"`, which describes an inclusive range from 0 to 100, but it is not have to be this range |param_mutable| - ``init_value`` defaults to :python:`0` because percentage type payloads cannot be empty rings |param_mutable| """ def __init__( self, name: str, datatype: str = "float", property_id: str = None, init_value=0, **extra_attributes ): extra_attributes.pop("unit", None) super().__init__( name, datatype, property_id, init_value, format=extra_attributes.pop("format", "0:100"), unit="%", **extra_attributes, )
[docs]class PropertyInt(_PropertyNumber): """A property to represent an integer. |param_intro| - `datatype` attribute is set to :python:`"integer"` |param_immutable| - ``init_value`` is set to :python:`0` |param_mutable| - ``format`` attribute can optionally be used to define the constraining range. By default, the ``format`` attribute is unspecified |param_mutable|. """ def __init__( self, name: str, property_id: str = None, init_value=0, **extra_attributes ): extra_attributes.pop("datatype", None) super().__init__(name, "integer", property_id, init_value, **extra_attributes)
[docs]class PropertyFloat(_PropertyNumber): """A property to represent an float. |param_intro| - `datatype` attribute is set to :python:`"float"` |param_immutable| - ``init_value`` is set to :python:`0.0` |param_mutable| - ``format`` attribute can optionally be used to define the constraining range. By default, the ``format`` attribute is unspecified |param_mutable|. """ def __init__( self, name: str, property_id: str = None, init_value=0.0, **extra_attributes ): extra_attributes.pop("datatype", None) super().__init__(name, "float", property_id, init_value, **extra_attributes)
class PropertyEnum(HomieProperty): """A property that represent an option amongst a defined list of valid options. |param_intro| - `datatype` attribute is set to :python:`"enum"` |param_immutable| - ``format`` attribute is required and must be a `list` or `tuple`. - ``init_value`` will be the first item in ``format`` |param_mutable| """ def __init__( self, name: str, format: Union[list, tuple], # pylint: disable=redefined-builtin property_id: str = None, init_value="", **extra_attributes ): extra_attributes.pop("datatype", None) if not isinstance(format, (list, tuple)): raise ValueError("`format` shall be a list or tuple of values.") assert format, "`format` cannot be an empty sequence." if not init_value: init_value = format[0] else: assert init_value in format, "init_value is not in {}".format(format) super().__init__( name, "enum", property_id, init_value, format=format, **extra_attributes ) def validate(self, value: Union[str, int, float]): """Ensure that the given ``value`` is part of the defined ``format`` attribute. :param value: The specified value. This `type` should correspond to the `type` used in the defined ``format``. :returns: The valid value. :throws: - An `AssertionError` if the given value is not in the defined ``format``. """ fmt = getattr(self, "format") # type: Union[List, tuple] assert value in fmt, "{} is not in {}".format(value, fmt) return value def _set(self, value): return super()._set(self.validate(value))