def test_exception_in_with(self):
     """Test clean exit after exception."""
     bluetooth_if = BluetoothInterface(MockBackend)
     self.assertFalse(bluetooth_if.is_connected())
     with self.assertRaises(ValueError):
         with bluetooth_if.connect('abc'):
             raise ValueError('some test exception')
     self.assertFalse(bluetooth_if.is_connected())
    def test_context_manager_locking(self):
        """Test the usage of the with statement."""
        bluetooth_if = BluetoothInterface(MockBackend)
        self.assertFalse(bluetooth_if.is_connected())

        with bluetooth_if.connect('abc'):  # as connection:
            self.assertTrue(bluetooth_if.is_connected())

        self.assertFalse(bluetooth_if.is_connected())
Ejemplo n.º 3
0
class BtlewrapWorker(threading.Thread):
    """
    Controllers appear to sometimes get into states where making a connection
    can take an extremely large number of attempts.  Making keep-alive connections
    without sending any data on a regular basis seems to mitigate this.
    """
    def __init__(self, address, keepalive_interval, attempts, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.address = address
        self.keepalive_interval = keepalive_interval
        self.attempts = attempts
        self.interface = BluetoothInterface(BluepyBackend)
        self.queue = queue.Queue()
        self.failure_count = 0
        self.success_count = 0
        self.loop_count = 0
        self.empty_count = 0

    def run(self):
        while True:
            self.loop_count += 1
            try:
                event = self.queue.get(timeout=self.keepalive_interval)
            except queue.Empty:
                self.empty_count += 1
                event = None
            self.write(event)

    def write(self, event):
        for i in range(self.attempts):
            start = time.time()
            try:
                with self.interface.connect(self.address) as connection:
                    if event:
                        connection.write_handle(*event)
                self.success_count += 1
                break
            except BluetoothBackendException as e:
                LOGGER.info(f'Bluetooth connection failed: {e}')
                self.failure_count += 1
            elapsed = time.time() - start
            if elapsed > 10:
                LOGGER.info(f'Bluetooth connection took {elapsed}s')
        if i > 10:
            LOGGER.warning(
                f'Bluetooth connection to {self.address} took {i + 1} attempts'
            )
Ejemplo n.º 4
0
    def update_sensor(self):
        """
        Get data from device.

        This method reads the handle 0x003f that contains temperature, humidity
        and battery level.
        """
        # bt_interface = BluetoothInterface(self._backend, "hci0")
        bt_interface = BluetoothInterface(
            backend=self._backend, adapter="hci0")

        try:
            with bt_interface.connect(self._mac) as connection:
                raw = connection.read_handle(
                    0x003F)  # pylint: disable=no-member

            if not raw:
                raise BluetoothBackendException("Could not read 0x003f handle")

            raw_bytes = bytearray(raw)

            temp = int.from_bytes(raw_bytes[1:3], "little") / 10.0
            if temp >= 32768:
                temp = temp - 65535

            humidity = int(raw_bytes[4])
            battery = int(raw_bytes[9])

            self._temp = temp
            self._humidity = humidity
            self._battery = battery
            self._last_update = datetime.now()

            _LOGGER.debug("%s: Find temperature with value: %s",
                          self._mac, self._temp)
            _LOGGER.debug("%s: Find humidity with value: %s",
                          self._mac, self._humidity)
            _LOGGER.debug("%s: Find battery with value: %s",
                          self._mac, self._battery)
        except BluetoothBackendException:
            return
Ejemplo n.º 5
0
class MijiaV2Poller:
    """"
    A class to read data from Mi Temp plant sensors.
    """
    def __init__(self,
                 mac,
                 backend,
                 cache_timeout=300,
                 retries=3,
                 adapter='hci0'):
        """
        Initialize a Mi Temp Poller for the given MAC address.
        """

        self._mac = mac
        self._bt_interface = BluetoothInterface(backend, adapter=adapter)
        self._cache = None
        self._cache_timeout = timedelta(seconds=cache_timeout)
        self._last_read = None
        self._fw_last_read = None
        self.retries = retries
        self.ble_timeout = 10
        self.lock = Lock()
        self._firmware_version = None
        self.battery = None

    def name(self):
        """Return the name of the sensor."""
        with self._bt_interface.connect(self._mac) as connection:
            name = connection.read_handle(_HANDLE_READ_NAME)  # pylint: disable=no-member

        if not name:
            raise BluetoothBackendException(
                "Could not read NAME using handle %s from Mi Temp sensor %s" %
                (hex(_HANDLE_READ_NAME), self._mac))
        return ''.join(chr(n) for n in name)

    def fill_cache(self):
        """Fill the cache with new data from the sensor."""
        _LOGGER.debug('Filling cache with new sensor data.')
        try:
            self.firmware_version()
        except BluetoothBackendException:
            # If a sensor doesn't work, wait 2 minutes before retrying
            self._last_read = datetime.now() - self._cache_timeout + timedelta(
                seconds=120)
            raise

        with self._bt_interface.connect(self._mac) as connection:
            try:
                connection.wait_for_notification(
                    _HANDLE_READ_WRITE_SENSOR_DATA, self, self.ble_timeout)  # pylint: disable=no-member
                # If a sensor doesn't work, wait 2 minutes before retrying
            except BluetoothBackendException:
                self._last_read = datetime.now(
                ) - self._cache_timeout + timedelta(seconds=120)
                return

    def battery_level(self):
        """Return the battery level.

        The battery level is updated when reading the firmware version. This
        is done only once every 24h
        """
        self.firmware_version()
        return self.battery

    def firmware_version(self):
        """Return the firmware version."""
        if (self._firmware_version is None) or (
                datetime.now() - timedelta(hours=24) > self._fw_last_read):
            self._fw_last_read = datetime.now()
            with self._bt_interface.connect(self._mac) as connection:
                res_firmware = connection.read_handle(
                    _HANDLE_READ_FIRMWARE_VERSION)  # pylint: disable=no-member
                _LOGGER.debug('Received result for handle %s: %s',
                              _HANDLE_READ_FIRMWARE_VERSION, res_firmware)
                res_battery = connection.read_handle(
                    _HANDLE_READ_BATTERY_LEVEL)  # pylint: disable=no-member
                _LOGGER.debug('Received result for handle %s: %d',
                              _HANDLE_READ_BATTERY_LEVEL, res_battery)

            if res_firmware is None:
                self._firmware_version = None
            else:
                self._firmware_version = res_firmware.decode("utf-8")

            if res_battery is None:
                self.battery = 0
            else:
                self.battery = int(ord(res_battery))
        return self._firmware_version

    def parameter_value(self, parameter, read_cached=True):
        """Return a value of one of the monitored paramaters.

        This method will try to retrieve the data from cache and only
        request it by bluetooth if no cached value is stored or the cache is
        expired.
        This behaviour can be overwritten by the "read_cached" parameter.
        """
        # Special handling for battery attribute
        if parameter == MI_BATTERY:
            return self.battery_level()

        # Use the lock to make sure the cache isn't updated multiple times
        with self.lock:
            if (read_cached is False) or \
                    (self._last_read is None) or \
                    (datetime.now() - self._cache_timeout > self._last_read):
                self.fill_cache()
            else:
                _LOGGER.debug("Using cache (%s < %s)",
                              datetime.now() - self._last_read,
                              self._cache_timeout)

        if self.cache_available():
            return self._parse_data()[parameter]
        raise BluetoothBackendException(
            "Could not read data from Mi Temp sensor %s" % self._mac)

    def _check_data(self):
        """Ensure that the data in the cache is valid.

        If it's invalid, the cache is wiped.
        """
        if not self.cache_available():
            return

        parsed = self._parse_data()
        _LOGGER.debug(
            'Received new data from sensor: Temp=%.1f, Humidity=%.1f',
            parsed[MI_TEMPERATURE], parsed[MI_HUMIDITY])

        if parsed[MI_HUMIDITY] > 100:  # humidity over 100 procent
            self.clear_cache()
            return

    def clear_cache(self):
        """Manually force the cache to be cleared."""
        self._cache = None
        self._last_read = None

    def cache_available(self):
        """Check if there is data in the cache."""
        return self._cache is not None

    def _parse_data(self):
        data = self._cache
        res = dict()
        res[MI_TEMPERATURE] = round(
            int.from_bytes([data[0], data[1]], "little") / 100.0, 1)
        res[MI_HUMIDITY] = int.from_bytes([data[2]], "little")
        return res

    @staticmethod
    def _format_bytes(raw_data):
        """Prettyprint a byte array."""
        if raw_data is None:
            return 'None'
        return ' '.join([format(c, "02x") for c in raw_data]).upper()

    def handleNotification(self, handle, raw_data):  # pylint: disable=unused-argument,invalid-name
        """ gets called by the bluepy backend when using wait_for_notification
        """
        if raw_data is None:
            return

        self._cache = raw_data
        self._check_data()
        if self.cache_available():
            self._last_read = datetime.now()
        else:
            # If a sensor doesn't work, wait 2 minutes before retrying
            self._last_read = datetime.now() - self._cache_timeout + timedelta(
                seconds=120)
class ParrotFlowerPoller(object):
    """"
    A class to read data from Mi Flora plant sensors.
    """
    def __init__(self, mac, backend, cache_timeout=600, adapter='hci0'):
        """
        Initialize a Mi Flora Poller for the given MAC address.
        """

        self._mac = mac
        self._bt_interface = BluetoothInterface(backend, adapter)
        self._cache = None
        self._cache_timeout = timedelta(seconds=cache_timeout)
        self._last_read = None
        self._fw_last_read = None
        self.ble_timeout = 10
        self.lock = Lock()

    def name(self):
        """Return the name of the sensor."""
        with self._bt_interface.connect(self._mac) as connection:
            name = connection.read_handle(_HANDLE_READ_NAME)  # pylint: disable=no-member

        if not name:
            raise BluetoothBackendException(
                "Could not read data from sensor %s" % self._mac)
        return ''.join(chr(n) for n in name)

    def firmware_version(self):
        """Return the firmware version."""
        with self._bt_interface.connect(self._mac) as connection:
            firmwareRevision = connection.read_handle(_HANDLE_READ_VERSION)  # pylint: disable=no-member

        if not firmwareRevision:
            raise BluetoothBackendException(
                "Could not read data from sensor %s" % self._mac)
        return firmwareRevision.decode("utf-8").split('_')[1].split('-')[1]

    def fill_cache(self):
        """Fill the cache with new data from the sensor."""
        _LOGGER.debug('Filling cache with new sensor data.')
        with self._bt_interface.connect(self._mac) as connection:

            # init cache
            self._cache = {}

            # battery
            battery = connection.read_handle(_HANDLE_READ_BATTERY)  # pylint: disable=no-member
            if not battery:
                raise BluetoothBackendException(
                    "Could not read data from sensor %s" % self._mac)
            self._cache[P_BATTERY] = ord(battery)

            # temperature
            temperature = connection.read_handle(_HANDLE_READ_TEMPERATURE)  # pylint: disable=no-member
            if not temperature:
                raise BluetoothBackendException(
                    "Could not read data from sensor %s" % self._mac)
            self._cache[P_TEMPERATURE] = round(unpack("<f", temperature)[0], 1)

            # moisture
            moisture = connection.read_handle(_HANDLE_READ_MOISTURE)  # pylint: disable=no-member
            if not moisture:
                raise BluetoothBackendException(
                    "Could not read data from sensor %s" % self._mac)
            self._cache[P_MOISTURE] = round(unpack("<f", moisture)[0], 1)

            # light
            light = connection.read_handle(_HANDLE_READ_LIGHT)  # pylint: disable=no-member
            if not light:
                raise BluetoothBackendException(
                    "Could not read data from sensor %s" % self._mac)
            self._cache[P_LIGHT] = round(unpack("<f", light)[0], 1)

            # conductivity
            conductivity = connection.read_handle(_HANDLE_READ_CONDUCTIVITY)  # pylint: disable=no-member
            if not conductivity:
                raise BluetoothBackendException(
                    "Could not read data from sensor %s" % self._mac)
            self._cache[P_CONDUCTIVITY] = unpack("<H", conductivity)[0]

            _LOGGER.debug(
                'Cache content: %s', "; ".join([
                    "%s=%s" % (key, ('%(' + key + ')s') % self._cache)
                    for key in self._cache
                ]))
            if self.cache_available():
                self._last_read = datetime.now()
            else:
                # If a sensor doesn't work, wait 5 minutes before retrying
                self._last_read = datetime.now() - self._cache_timeout + \
                    timedelta(seconds=300)

    def parameter_value(self, parameter, read_cached=True):
        """Return a value of one of the monitored paramaters.

        This method will try to retrieve the data from cache and only
        request it by bluetooth if no cached value is stored or the cache is
        expired.
        This behaviour can be overwritten by the "read_cached" parameter.
        """
        # Use the lock to make sure the cache isn't updated multiple times
        with self.lock:
            if (read_cached is False) or \
                    (self._last_read is None) or \
                    (datetime.now() - self._cache_timeout > self._last_read):
                self.fill_cache()
            else:
                _LOGGER.debug("Using cache (%s < %s)",
                              datetime.now() - self._last_read,
                              self._cache_timeout)

        if self.cache_available():
            return self._cache[parameter]
        else:
            raise BluetoothBackendException(
                "Could not read data from sensor %s" % self._mac)

    def clear_cache(self):
        """Manually force the cache to be cleared."""
        self._cache = None
        self._last_read = None

    def cache_available(self):
        """Check if there is data in the cache."""
        return self._cache is not None
Ejemplo n.º 7
0
class MiTempBtPoller:
    """"
    A class to read data from Mi Temp plant sensors.
    """
    def __init__(self,
                 mac,
                 backend,
                 cache_timeout=600,
                 retries=3,
                 adapter='hci0'):
        """
        Initialize a Mi Temp Poller for the given MAC address.
        """

        self._mac = mac
        self._bt_interface = BluetoothInterface(backend, adapter=adapter)
        self._cache = None
        self._cache_timeout = timedelta(seconds=cache_timeout)
        self._last_read = None
        self._fw_last_read = None
        self.retries = retries
        self.ble_timeout = 10
        self.lock = Lock()
        self._firmware_version = None
        self.battery = None

    def name(self):
        """Return the name of the sensor."""
        with self._bt_interface.connect(self._mac) as connection:
            name = connection.read_handle(_HANDLE_READ_NAME)  # pylint: disable=no-member

        if not name:
            raise BluetoothBackendException(
                "Could not read NAME using handle %s"
                " from Mi Temp sensor %s" %
                (hex(_HANDLE_READ_NAME), self._mac))
        return ''.join(chr(n) for n in name)

    def fill_cache(self):
        """Fill the cache with new data from the sensor."""
        _LOGGER.debug('Filling cache with new sensor data.')
        try:
            self.firmware_version()
        except BluetoothBackendException:
            # If a sensor doesn't work, wait 5 minutes before retrying
            self._last_read = datetime.now() - self._cache_timeout + \
                timedelta(seconds=300)
            raise

        with self._bt_interface.connect(self._mac) as connection:
            try:
                connection.wait_for_notification(
                    _HANDLE_READ_WRITE_SENSOR_DATA, self, self.ble_timeout)  # pylint: disable=no-member
                # If a sensor doesn't work, wait 5 minutes before retrying
            except BluetoothBackendException:
                self._last_read = datetime.now() - self._cache_timeout + \
                    timedelta(seconds=300)
                return

    def battery_level(self):
        """Return the battery level.

        The battery level is updated when reading the firmware version. This
        is done only once every 24h
        """
        self.firmware_version()
        return self.battery

    def firmware_version(self):
        """Return the firmware version."""
        if (self._firmware_version is None) or \
                (datetime.now() - timedelta(hours=24) > self._fw_last_read):
            self._fw_last_read = datetime.now()
            with self._bt_interface.connect(self._mac) as connection:
                res_firmware = connection.read_handle(
                    _HANDLE_READ_FIRMWARE_VERSION)  # pylint: disable=no-member
                _LOGGER.debug('Received result for handle %s: %s',
                              _HANDLE_READ_FIRMWARE_VERSION, res_firmware)
                res_battery = connection.read_handle(
                    _HANDLE_READ_BATTERY_LEVEL)  # pylint: disable=no-member
                _LOGGER.debug('Received result for handle %s: %d',
                              _HANDLE_READ_BATTERY_LEVEL, res_battery)

            if res_firmware is None:
                self._firmware_version = None
            else:
                self._firmware_version = res_firmware.decode("utf-8")

            if res_battery is None:
                self.battery = 0
            else:
                self.battery = int(ord(res_battery))
        return self._firmware_version

    def parameter_value(self, parameter, read_cached=True):
        """Return a value of one of the monitored paramaters.

        This method will try to retrieve the data from cache and only
        request it by bluetooth if no cached value is stored or the cache is
        expired.
        This behaviour can be overwritten by the "read_cached" parameter.
        """
        # Special handling for battery attribute
        if parameter == MI_BATTERY:
            return self.battery_level()

        # Use the lock to make sure the cache isn't updated multiple times
        with self.lock:
            if (read_cached is False) or \
                    (self._last_read is None) or \
                    (datetime.now() - self._cache_timeout > self._last_read):
                self.fill_cache()
            else:
                _LOGGER.debug("Using cache (%s < %s)",
                              datetime.now() - self._last_read,
                              self._cache_timeout)

        if self.cache_available():
            return self._parse_data()[parameter]
        raise BluetoothBackendException(
            "Could not read data from Mi Temp sensor %s" % self._mac)

    def _check_data(self):
        """Ensure that the data in the cache is valid.

        If it's invalid, the cache is wiped.
        """
        if not self.cache_available():
            return

        parsed = self._parse_data()
        _LOGGER.debug(
            'Received new data from sensor: Temp=%.1f, Humidity=%.1f',
            parsed[MI_TEMPERATURE], parsed[MI_HUMIDITY])

        if parsed[MI_HUMIDITY] > 100:  # humidity over 100 procent
            self.clear_cache()
            return

    def clear_cache(self):
        """Manually force the cache to be cleared."""
        self._cache = None
        self._last_read = None

    def cache_available(self):
        """Check if there is data in the cache."""
        return self._cache is not None

    def _parse_data(self):
        """Parses the byte array returned by the sensor.

        The sensor returns 12 - 15 bytes in total, a readable text with the
        temperature and humidity. e.g.:

        54 3d 32 35 2e 36 20 48 3d 32 33 2e 36 00 -> T=25.6 H=23.6

        Fix for single digit values thank to @rmiddlet:
        https://github.com/ratcashdev/mitemp/issues/2#issuecomment-406263635
        """
        data = self._cache
        # Sanitizing the input sometimes has spurious binary data
        data = data.strip('\0')
        data = ''.join(filter(lambda i: i.isprintable(), data))

        res = dict()
        for dataitem in data.split(' '):
            dataparts = dataitem.split('=')
            if dataparts[0] == 'T':
                res[MI_TEMPERATURE] = float(dataparts[1])
            elif dataparts[0] == 'H':
                res[MI_HUMIDITY] = float(dataparts[1])
        return res

    @staticmethod
    def _format_bytes(raw_data):
        """Prettyprint a byte array."""
        if raw_data is None:
            return 'None'
        return ' '.join([format(c, "02x") for c in raw_data]).upper()

    def handleNotification(self, handle, raw_data):  # pylint: disable=unused-argument,invalid-name
        """ gets called by the bluepy backend when using wait_for_notification
        """
        if raw_data is None:
            return
        data = raw_data.decode("utf-8").strip(' \n\t')
        self._cache = data
        self._check_data()
        if self.cache_available():
            self._last_read = datetime.now()
        else:
            # If a sensor doesn't work, wait 5 minutes before retrying
            self._last_read = datetime.now() - self._cache_timeout + \
                timedelta(seconds=300)
Ejemplo n.º 8
0
class MiFloraPoller(object):
    """"
    A class to read data from Mi Flora plant sensors.
    """
    def __init__(self,
                 mac,
                 backend,
                 cache_timeout=600,
                 retries=3,
                 adapter='hci0'):
        """
        Initialize a Mi Flora Poller for the given MAC address.
        """

        self._mac = mac
        self._bt_interface = BluetoothInterface(backend, adapter=adapter)
        self._cache = None
        self._cache_timeout = timedelta(seconds=cache_timeout)
        self._last_read = None
        self._fw_last_read = None
        self.retries = retries
        self.ble_timeout = 10
        self.lock = Lock()
        self._firmware_version = None
        self.battery = None

    def name(self):
        """Return the name of the sensor."""
        with self._bt_interface.connect(self._mac) as connection:
            name = connection.read_handle(_HANDLE_READ_NAME)  # pylint: disable=no-member

        if not name:
            raise BluetoothBackendException(
                "Could not read data from Mi Flora sensor %s" % self._mac)
        return ''.join(chr(n) for n in name)

    def fill_cache(self):
        """Fill the cache with new data from the sensor."""
        _LOGGER.debug('Filling cache with new sensor data.')
        try:
            firmware_version = self.firmware_version()
        except BluetoothBackendException:
            # If a sensor doesn't work, wait 5 minutes before retrying
            self._last_read = datetime.now() - self._cache_timeout + \
                timedelta(seconds=300)
            raise

        with self._bt_interface.connect(self._mac) as connection:
            if firmware_version >= "2.6.6":
                # for the newer models a magic number must be written before we can read the current data
                try:
                    connection.write_handle(_HANDLE_WRITE_MODE_CHANGE,
                                            _DATA_MODE_CHANGE)  # pylint: disable=no-member
                    # If a sensor doesn't work, wait 5 minutes before retrying
                except BluetoothBackendException:
                    self._last_read = datetime.now() - self._cache_timeout + \
                        timedelta(seconds=300)
                    return
            self._cache = connection.read_handle(_HANDLE_READ_SENSOR_DATA)  # pylint: disable=no-member
            _LOGGER.debug('Received result for handle %s: %s',
                          _HANDLE_READ_SENSOR_DATA,
                          self._format_bytes(self._cache))
            self._check_data()
            if self.cache_available():
                self._last_read = datetime.now()
            else:
                # If a sensor doesn't work, wait 5 minutes before retrying
                self._last_read = datetime.now() - self._cache_timeout + \
                    timedelta(seconds=300)

    def battery_level(self):
        """Return the battery level.

        The battery level is updated when reading the firmware version. This
        is done only once every 24h
        """
        self.firmware_version()
        return self.battery

    def firmware_version(self):
        """Return the firmware version."""
        if (self._firmware_version is None) or \
                (datetime.now() - timedelta(hours=24) > self._fw_last_read):
            self._fw_last_read = datetime.now()
            with self._bt_interface.connect(self._mac) as connection:
                res = connection.read_handle(_HANDLE_READ_VERSION_BATTERY)  # pylint: disable=no-member
                _LOGGER.debug('Received result for handle %s: %s',
                              _HANDLE_READ_VERSION_BATTERY,
                              self._format_bytes(res))
            if res is None:
                self.battery = 0
                self._firmware_version = None
            else:
                self.battery = res[0]
                self._firmware_version = "".join(map(chr, res[2:]))
        return self._firmware_version

    def parameter_value(self, parameter, read_cached=True):
        """Return a value of one of the monitored paramaters.

        This method will try to retrieve the data from cache and only
        request it by bluetooth if no cached value is stored or the cache is
        expired.
        This behaviour can be overwritten by the "read_cached" parameter.
        """
        # Special handling for battery attribute
        if parameter == MI_BATTERY:
            return self.battery_level()

        # Use the lock to make sure the cache isn't updated multiple times
        with self.lock:
            if (read_cached is False) or \
                    (self._last_read is None) or \
                    (datetime.now() - self._cache_timeout > self._last_read):
                self.fill_cache()
            else:
                _LOGGER.debug("Using cache (%s < %s)",
                              datetime.now() - self._last_read,
                              self._cache_timeout)

        if self.cache_available() and (len(self._cache) == 16):
            return self._parse_data()[parameter]
        else:
            raise BluetoothBackendException(
                "Could not read data from Mi Flora sensor %s" % self._mac)

    def _check_data(self):
        """Ensure that the data in the cache is valid.

        If it's invalid, the cache is wiped.
        """
        if not self.cache_available():
            return
        if self._cache[7] > 100:  # moisture over 100 procent
            self.clear_cache()
            return
        if self._firmware_version >= "2.6.6":
            if sum(self._cache[10:]) == 0:
                self.clear_cache()
                return
        if sum(self._cache) == 0:
            self.clear_cache()
            return

    def clear_cache(self):
        """Manually force the cache to be cleared."""
        self._cache = None
        self._last_read = None

    def cache_available(self):
        """Check if there is data in the cache."""
        return self._cache is not None

    def _parse_data(self):
        """Parses the byte array returned by the sensor.

        The sensor returns 16 bytes in total. It's unclear what the meaning of these bytes
        is beyond what is decoded in this method.

        semantics of the data (in little endian encoding):
        bytes 0-1: temperature in 0.1 °C
        byte 2: unknown
        bytes 3-4: brightness in Lux
        bytes 5-6: unknown
        byte 7: conductivity in µS/cm
        byte 8-9: brightness in Lux
        bytes 10-15: unknown
        """
        data = self._cache
        res = dict()
        temp, res[MI_LIGHT], res[MI_MOISTURE], res[MI_CONDUCTIVITY] = \
            unpack('<hxIBhxxxxxx', data)
        res[MI_TEMPERATURE] = temp / 10.0
        return res

    @staticmethod
    def _format_bytes(raw_data):
        """Prettyprint a byte array."""
        if raw_data is None:
            return 'None'
        return ' '.join([format(c, "02x") for c in raw_data]).upper()
Ejemplo n.º 9
0
class MiTempBtPoller(object):
    """"
    A class to read data from Mi Temp plant sensors.
    """
    def __init__(self,
                 mac,
                 backend,
                 cache_timeout=600,
                 retries=3,
                 adapter='hci0',
                 ble_timeout=10):
        """
        Initialize a Mi Temp Poller for the given MAC address.
        """

        self._mac = mac
        self._bt_interface = BluetoothInterface(backend, adapter=adapter)
        self._cache = None
        self._cache_timeout = timedelta(seconds=cache_timeout)
        self._last_read = None
        self._fw_last_read = None
        self.retries = retries
        self.ble_timeout = ble_timeout
        self.lock = Lock()
        self.battery = None
        _LOGGER.debug('INIT++')

    def fill_cache(self):
        """Fill the cache with new data from the sensor."""
        _LOGGER.debug('Filling cache with new sensor data.')

        with self._bt_interface.connect(self._mac) as connection:
            _LOGGER.debug('Send Start.')
            connection._DATA_MODE_LISTEN = b'\xf4\x01\x00'
            connection.write_handle(
                0x0038, b'\x01\00'
            )  #enable notifications of Temperature, Humidity and Battery voltage
            _LOGGER.debug('Wait condition1.')
            connection.wait_for_notification(0x0046, self, self.ble_timeout)
            _LOGGER.debug('Wait condition2.')

    def parameter_value(self, parameter, read_cached=True):
        """Return a value of one of the monitored paramaters.

        This method will try to retrieve the data from cache and only
        request it by bluetooth if no cached value is stored or the cache is
        expired.
        This behaviour can be overwritten by the "read_cached" parameter.
        """
        _LOGGER.debug('parameter_value:' + parameter)
        # Use the lock to make sure the cache isn't updated multiple times
        with self.lock:
            if (read_cached is False) or \
                    (self._last_read is None) or \
                    (datetime.now() - self._cache_timeout > self._last_read):
                _LOGGER.debug('self.fill_cache().')
                self.fill_cache()
            else:
                _LOGGER.debug("Using cache (%s < %s)",
                              datetime.now() - self._last_read,
                              self._cache_timeout)

        if self.cache_available():
            return self._parse_data()[parameter]
        else:
            raise BluetoothBackendException(
                "Could not read data from Mi Temp sensor %s" % self._mac)

    def _check_data(self):
        """Ensure that the data in the cache is valid.

        If it's invalid, the cache is wiped.
        """
        if not self.cache_available():
            return

        parsed = self._parse_data()
        _LOGGER.debug(
            'Received new data from sensor: Temp=%.1f, Humidity=%.1f, Battery = %.1f',
            parsed[MI_TEMPERATURE], parsed[MI_HUMIDITY], parsed[MI_BATTERY])

        if parsed[MI_HUMIDITY] > 100:  # humidity over 100 procent
            self.clear_cache()
            return

        if parsed[MI_TEMPERATURE] == 0:  # humidity over 100 procent
            self.clear_cache()
            return

    def clear_cache(self):
        """Manually force the cache to be cleared."""
        self._cache = None
        self._last_read = None

    def cache_available(self):
        """Check if there is data in the cache."""
        return self._cache is not None

    def _parse_data(self):
        """Parses the byte array returned by the sensor.

        """
        data = self._cache

        res = dict()
        _LOGGER.debug('_parse_data')
        res[MI_TEMPERATURE] = int.from_bytes(
            data[0:2], byteorder='little', signed=True) / 100
        res[MI_HUMIDITY] = int.from_bytes(data[2:3], byteorder='little')
        voltage = int.from_bytes(data[3:5], byteorder='little') / 1000.
        res[MI_BATTERY] = min(int(round((voltage - 2.1), 2) * 100), 100)
        _LOGGER.debug('/_parse_data')
        return res

    @staticmethod
    def _format_bytes(raw_data):
        """Prettyprint a byte array."""
        if raw_data is None:
            return 'None'
        return ' '.join([format(c, "02x") for c in raw_data]).upper()

    def handleNotification(self, handle, raw_data):  # pylint: disable=unused-argument,invalid-name
        """ gets called by the bluepy backend when using wait_for_notification
        """
        _LOGGER.debug('handleNotification')
        if raw_data is None:
            return
        data = raw_data
        self._cache = data
        self._check_data()
        if self.cache_available():
            _LOGGER.debug('self.cache_available()')
            self._last_read = datetime.now()
        else:
            _LOGGER.debug('NO self.cache_available()')
            # If a sensor doesn't work, wait 5 minutes before retrying
            self._last_read = datetime.now() - self._cache_timeout + \
                timedelta(seconds=300)
Ejemplo n.º 10
0
class MiFloraPoller:
    """"
    A class to read data from Mi Flora plant sensors.
    """

    def __init__(self, mac, backend, cache_timeout=600, retries=3, adapter='hci0'):
        """
        Initialize a Mi Flora Poller for the given MAC address.
        """

        self._mac = mac
        self._bt_interface = BluetoothInterface(backend, adapter=adapter)
        self._cache = None
        self._cache_timeout = timedelta(seconds=cache_timeout)
        self._last_read = None
        self._fw_last_read = None
        self.retries = retries
        self.ble_timeout = 10
        self.lock = Lock()
        self._firmware_version = None
        self.battery = None

    def name(self):
        """Return the name of the sensor."""
        with self._bt_interface.connect(self._mac) as connection:
            name = connection.read_handle(_HANDLE_READ_NAME)  # pylint: disable=no-member

        if not name:
            raise BluetoothBackendException("Could not read data from Mi Flora sensor %s" % self._mac)
        return ''.join(chr(n) for n in name)

    def fill_cache(self):
        """Fill the cache with new data from the sensor."""
        _LOGGER.debug('Filling cache with new sensor data.')
        try:
            firmware_version = self.firmware_version()
        except BluetoothBackendException:
            # If a sensor doesn't work, wait 5 minutes before retrying
            self._last_read = datetime.now() - self._cache_timeout + \
                timedelta(seconds=300)
            raise

        with self._bt_interface.connect(self._mac) as connection:
            if firmware_version >= "2.6.6":
                # for the newer models a magic number must be written before we can read the current data
                try:
                    connection.write_handle(_HANDLE_WRITE_MODE_CHANGE, _DATA_MODE_CHANGE)   # pylint: disable=no-member
                    # If a sensor doesn't work, wait 5 minutes before retrying
                except BluetoothBackendException:
                    self._last_read = datetime.now() - self._cache_timeout + \
                        timedelta(seconds=300)
                    return
            self._cache = connection.read_handle(_HANDLE_READ_SENSOR_DATA)  # pylint: disable=no-member
            _LOGGER.debug('Received result for handle %s: %s',
                          _HANDLE_READ_SENSOR_DATA, format_bytes(self._cache))
            self._check_data()
            if self.cache_available():
                self._last_read = datetime.now()
            else:
                # If a sensor doesn't work, wait 5 minutes before retrying
                self._last_read = datetime.now() - self._cache_timeout + \
                    timedelta(seconds=300)

    def battery_level(self):
        """Return the battery level.

        The battery level is updated when reading the firmware version. This
        is done only once every 24h
        """
        self.firmware_version()
        return self.battery

    def firmware_version(self):
        """Return the firmware version."""
        if (self._firmware_version is None) or \
                (datetime.now() - timedelta(hours=24) > self._fw_last_read):
            self._fw_last_read = datetime.now()
            with self._bt_interface.connect(self._mac) as connection:
                res = connection.read_handle(_HANDLE_READ_VERSION_BATTERY)  # pylint: disable=no-member
                _LOGGER.debug('Received result for handle %s: %s',
                              _HANDLE_READ_VERSION_BATTERY, format_bytes(res))
            if res is None:
                self.battery = 0
                self._firmware_version = None
            else:
                self.battery = res[0]
                self._firmware_version = "".join(map(chr, res[2:]))
        return self._firmware_version

    def parameter_value(self, parameter, read_cached=True):
        """Return a value of one of the monitored paramaters.

        This method will try to retrieve the data from cache and only
        request it by bluetooth if no cached value is stored or the cache is
        expired.
        This behaviour can be overwritten by the "read_cached" parameter.
        """
        # Special handling for battery attribute
        if parameter == MI_BATTERY:
            return self.battery_level()

        # Use the lock to make sure the cache isn't updated multiple times
        with self.lock:
            if (read_cached is False) or \
                    (self._last_read is None) or \
                    (datetime.now() - self._cache_timeout > self._last_read):
                self.fill_cache()
            else:
                _LOGGER.debug("Using cache (%s < %s)",
                              datetime.now() - self._last_read,
                              self._cache_timeout)

        if self.cache_available() and (len(self._cache) == 16):
            return self._parse_data()[parameter]
        if self.cache_available() and (self.is_ropot()):
            if parameter == MI_LIGHT:
                return False
            return self._parse_data()[parameter]
        raise BluetoothBackendException("Could not read data from Mi Flora sensor %s" % self._mac)

    def _check_data(self):
        """Ensure that the data in the cache is valid.

        If it's invalid, the cache is wiped.
        """
        if not self.cache_available():
            return
        if self._cache[7] > 100:  # moisture over 100 procent
            self.clear_cache()
            return
        if self._firmware_version >= "2.6.6":
            if sum(self._cache[10:]) == 0:
                self.clear_cache()
                return
        if sum(self._cache) == 0:
            self.clear_cache()
            return

    def clear_cache(self):
        """Manually force the cache to be cleared."""
        self._cache = None
        self._last_read = None

    def cache_available(self):
        """Check if there is data in the cache."""
        return self._cache is not None

    def is_ropot(self):
        """Check if the sensor is a ropot."""
        return len(self._cache) == 24

    def _parse_data(self):
        """Parses the byte array returned by the sensor.

        The sensor returns 16 bytes in total. It's unclear what the meaning of these bytes
        is beyond what is decoded in this method.

        semantics of the data (in little endian encoding):
        bytes   0-1: temperature in 0.1 °C
        byte      2: unknown
        bytes   3-6: brightness in Lux (MiFlora only)
        byte      7: moisture in %
        byted   8-9: conductivity in µS/cm
        bytes 10-15: unknown
        """
        data = self._cache
        res = dict()
        if self.is_ropot():
            temp, res[MI_MOISTURE], res[MI_CONDUCTIVITY] = \
                unpack('<hxxxxxBhxxxxxxxxxxxxxx', data)
        else:
            temp, res[MI_LIGHT], res[MI_MOISTURE], res[MI_CONDUCTIVITY] = \
                unpack('<hxIBhxxxxxx', data)
        res[MI_TEMPERATURE] = temp/10.0
        return res

    def fetch_history(self):
        """Fetch the historical measurements from the sensor.

        History is updated by the sensor every hour.
        """
        data = []
        with self._bt_interface.connect(self._mac) as connection:
            connection.write_handle(_HANDLE_HISTORY_CONTROL, _CMD_HISTORY_READ_INIT)  # pylint: disable=no-member
            history_info = connection.read_handle(_HANDLE_HISTORY_READ)  # pylint: disable=no-member
            _LOGGER.debug('history info raw: %s', format_bytes(history_info))

            history_length = int.from_bytes(history_info[0:2], BYTEORDER)
            _LOGGER.info("Getting %d measurements", history_length)
            if history_length > 0:
                for i in range(history_length):
                    payload = self._cmd_history_address(i)
                    try:
                        connection.write_handle(_HANDLE_HISTORY_CONTROL, payload)  # pylint: disable=no-member
                        response = connection.read_handle(_HANDLE_HISTORY_READ)  # pylint: disable=no-member
                        if response in _INVALID_HISTORY_DATA:
                            msg = 'Got invalid history data: {}'.format(response)
                            _LOGGER.error(msg)
                        else:
                            data.append(HistoryEntry(response))
                    except Exception:  # pylint: disable=broad-except
                        # find a more narrow exception here
                        # when reading fails, we're probably at the end of the history
                        # even when the history_length might suggest something else
                        _LOGGER.error("Could only retrieve %d of %d entries from the history. "
                                      "The rest is not readable", i, history_length)
                        # connection.write_handle(_HANDLE_HISTORY_CONTROL, _CMD_HISTORY_READ_FAILED)
                        break
                    _LOGGER.info("Progress: reading entry %d of %d", i+1, history_length)

        (device_time, wall_time) = self._fetch_device_time()
        time_diff = wall_time - device_time
        for entry in data:
            entry.compute_wall_time(time_diff)

        return data

    def clear_history(self):
        """Clear the device history.

        On the next fetch_history, you will only get new data.
        Note: The data is deleted from the device. There is no way to recover it!
        """
        with self._bt_interface.connect(self._mac) as connection:
            connection.write_handle(_HANDLE_HISTORY_CONTROL, _CMD_HISTORY_READ_INIT)  # pylint: disable=no-member
            connection.write_handle(_HANDLE_HISTORY_CONTROL, _CMD_HISTORY_READ_SUCCESS)  # pylint: disable=no-member

    @staticmethod
    def _cmd_history_address(addr):
        """Calculate this history address"""
        return b'\xa1' + addr.to_bytes(2, BYTEORDER)

    def _fetch_device_time(self):
        """Fetch device time.

        The device time is in seconds.
        """
        start = time.time()
        with self._bt_interface.connect(self._mac) as connection:
            response = connection.read_handle(_HANDLE_DEVICE_TIME)  # pylint: disable=no-member
        _LOGGER.debug("device time raw: %s", response)
        wall_time = (time.time() + start) / 2
        device_time = int.from_bytes(response, BYTEORDER)
        _LOGGER.info('device time: %s local time: %s', device_time, wall_time)

        return device_time, wall_time
Ejemplo n.º 11
0
class ParrotPotPoller(object):
    """"
    A class to read data from Parrot Pot plant sensors.
    """
    def __init__(self,
                 mac,
                 backend,
                 cache_timeout=600,
                 retries=3,
                 adapter='hci0'):
        """
        Initialize a Parrot Pot Poller for the given MAC address.
        """

        self._mac = mac
        self._bt_interface = BluetoothInterface(backend, adapter)
        self._cache = None
        self._cache_timeout = timedelta(seconds=cache_timeout)
        self._last_read = None
        self._fw_last_read = None
        self.retries = retries
        self.ble_timeout = 10
        self.lock = Lock()

    def name(self):
        """Return the name of the sensor."""
        with self._bt_interface.connect(self._mac) as connection:
            name = connection.read_handle(_HANDLE_READ_NAME)  # pylint: disable=no-member

        if not name:
            raise BluetoothBackendException(
                "Could not read data from Parrot Pot sensor %s" % self._mac)
        return ''.join(chr(n) for n in name)

    def battery_level(self):
        """Return the battery level.

        The battery level is updated when reading the firmware version. This
        is done only once every 24h
        """

        with self._bt_interface.connect(self._mac) as connection:
            data = connection.read_handle(_HANDLE_READ_VERSION_BATTERY)
            _LOGGER.debug('Received result for handle %s: %s',
                          _HANDLE_READ_VERSION_BATTERY,
                          self._format_bytes(data))
            rawValue = int.from_bytes(data, byteorder='little')
            battery = rawValue * 1.0

        return battery

    def fill_cache(self):
        """Fill the cache with new data from the sensor."""
        self._cache = dict()
        _LOGGER.info('Filling cache with new sensor data for device %s.',
                     self._mac)
        try:
            with self._bt_interface.connect(self._mac) as connection:

                for handle in HANDLES:
                    data2read = handle[0]
                    data = connection.read_handle(handle[1])
                    _LOGGER.debug('Received result for %s(%x): %s', data2read,
                                  handle[1], self._format_bytes(data))

                    if len(data) <= 2:
                        rawValue = int.from_bytes(data, byteorder='little')
                    elif len(data) == 4:
                        rawValue = unpack('<f', data)[0]
                    else:
                        rawValue = data
                    _LOGGER.debug('Rawdata for %s: %s', data2read, rawValue)

                    # if data2read == "light":
                    #     if (rawValue == 0):
                    #         value2report = "Not a number"
                    #     else:
                    #         value2report = 642.2 * (0.08640000000000001 * (192773.17000000001 * math.pow(rawValue, -1.0606619)))
                    #     # value2report = (0.08640000000000001 * (192773.17000000001 * math.pow(rawValue, -1.0606619)))
                    if data2read in ["soil_temperature", "air_temperature"]:
                        value2report = 0.00000003044 * math.pow(
                            rawValue, 3.0) - 0.00008038 * math.pow(
                                rawValue,
                                2.0) + rawValue * 0.1149 - 30.449999999999999
                    elif data2read in ["moisture", "moisture_rj"]:
                        soilMoisture = 11.4293 + (
                            0.0000000010698 * math.pow(rawValue, 4.0) -
                            0.00000152538 * math.pow(rawValue, 3.0) +
                            0.000866976 * math.pow(rawValue, 2.0) -
                            0.169422 * rawValue)
                        value2report = 100.0 * (
                            0.0000045 * math.pow(soilMoisture, 3.0) -
                            0.00055 * math.pow(soilMoisture, 2.0) +
                            0.0292 * soilMoisture - 0.053)
                    else:
                        value2report = rawValue

                    if isinstance(value2report, int):
                        value2report = value2report * 1.0
                    elif isinstance(value2report, float):
                        value2report = round(value2report, 1)
                    elif isinstance(value2report, str):
                        value2report = value2report
                    else:
                        value2report = ''.join(chr(n) for n in value2report)

                    _LOGGER.info('Decoded result for %s: %s', data2read,
                                 value2report)
                    self._cache[data2read] = value2report
        except:
            self._cache = None
            self._last_read = datetime.now() - self._cache_timeout + timedelta(
                seconds=300)
            raise

        if self.cache_available():
            self._last_read = datetime.now()
        else:
            # If a sensor doesn't work, wait 5 minutes before retrying
            self._last_read = datetime.now() - self._cache_timeout + timedelta(
                seconds=300)

    def parameter_values(self, read_cached=True):
        """Return a value of one of the monitored paramaters.

        This method will try to retrieve the data from cache and only
        request it by bluetooth if no cached value is stored or the cache is
        expired.
        This behaviour can be overwritten by the "read_cached" parameter.
        """
        # Special handling for battery attribute

        # Use the lock to make sure the cache isn't updated multiple times
        with self.lock:
            if (read_cached is False) or \
                    (self._last_read is None) or \
                    (datetime.now() - self._cache_timeout > self._last_read):
                self.fill_cache()
            else:
                _LOGGER.debug("Using cache (%s < %s)",
                              datetime.now() - self._last_read,
                              self._cache_timeout)

        if self.cache_available():
            return self._cache
        else:
            raise BluetoothBackendException(
                "Could not read data from Parrot Pot sensor %s" % self._mac)

    def parameter_value(self, parameter, read_cached=True):
        """Return a value of one of the monitored paramaters.

        This method will try to retrieve the data from cache and only
        request it by bluetooth if no cached value is stored or the cache is
        expired.
        This behaviour can be overwritten by the "read_cached" parameter.
        """
        # Special handling for battery attribute
        # if parameter == MI_BATTERY:
        #     return self.battery_level()

        return self.parameter_values(read_cached)[parameter]

    def clear_cache(self):
        """Manually force the cache to be cleared."""
        self._cache = None
        self._last_read = None

    def cache_available(self):
        """Check if there is data in the cache."""
        return ((self._cache is not None) and (self._cache))

    @staticmethod
    def _format_bytes(raw_data):
        """Prettyprint a byte array."""
        if raw_data is None:
            return 'None'
        return ' '.join([format(c, "02x") for c in raw_data]).upper()
Ejemplo n.º 12
0
#!/usr/bin/env python3

# aptitude install python3-pip libglib2.0-dev
# pip3 install btlewrap

import sys
from btlewrap.base import BluetoothInterface
from btlewrap.gatttool import GatttoolBackend

HANDLE_READ_BATTERY_LEVEL = 0x0018
HANDLE_READ_WRITE_SENSOR_DATA = 0x0010

class TempHumDelegate():
  def handleNotification(self, handle, data):
    print('Notification: handle=%s data=%s' % (handle, data))

mac = sys.argv[1]
print('MAC: %s' % mac)

bt_interface = BluetoothInterface(GatttoolBackend, adapter='hci0')
with bt_interface.connect(mac) as connection:
    res_battery = connection.read_handle(HANDLE_READ_BATTERY_LEVEL)
    battery = int(ord(res_battery))
    print('Battery: %s' % battery)

    connection.wait_for_notification(
            HANDLE_READ_WRITE_SENSOR_DATA,
            TempHumDelegate(),
            10)
Ejemplo n.º 13
0
class MiThermometerPoller:
    """"
    A class to read data from Mi thermometer sensors.
    """
    def __init__(self,
                 mac,
                 backend,
                 cache_timeout=600,
                 retries=3,
                 adapter='hci0'):
        """
        Initialize a Mi Thermometer Poller for the given MAC address.
        """

        self._mac = mac
        self._bt_interface = BluetoothInterface(backend, adapter=adapter)
        self._cache = None
        self._cache_timeout = timedelta(seconds=cache_timeout)
        self._last_read = None
        self._fw_last_read = None
        self._batt_last_read = None
        self.retries = retries
        self.ble_timeout = 10
        self.lock = Lock()
        self._firmware_version = None
        self.battery = None

    def name(self):
        """Return the name of the sensor."""
        with self._bt_interface.connect(self._mac) as connection:
            name = connection.read_handle(_HANDLE_READ_NAME)  # pylint: disable=no-member

        if not name:
            raise BluetoothBackendException(
                "Could not read data from Mi Flora sensor %s" % self._mac)
        return ''.join(chr(n) for n in name)

    def fill_cache(self):
        """Fill the cache with new data from the sensor."""
        _LOGGER.debug('Filling cache with new sensor data.')
        try:
            self.firmware_version()
        except BluetoothBackendException:
            # If a sensor doesn't work, wait 5 minutes before retrying
            self._last_read = datetime.now() - self._cache_timeout + \
                timedelta(seconds=300)
            raise

        with self._bt_interface.connect(self._mac) as connection:

            class Cache:  # pylint: disable=too-few-public-methods
                """
                Cache class for wait for notification callback
                """
                value = None

                @staticmethod
                def handleNotification(_, data):  # pylint: disable=invalid-name,missing-docstring
                    Cache.value = data

            # pylint: disable=no-member
            connection.wait_for_notification(_HANDLE_READ_SENSOR_DATA, Cache,
                                             self.ble_timeout)
            _LOGGER.debug('Received result for handle %s: %s',
                          _HANDLE_READ_SENSOR_DATA,
                          self._format_bytes(Cache.value))
            self._cache = Cache.value
            self._check_data()
            if self.cache_available():
                self._last_read = datetime.now()
            else:
                # If a sensor doesn't work, wait 5 minutes before retrying
                self._last_read = datetime.now() - self._cache_timeout + \
                    timedelta(seconds=300)

    def battery_level(self):
        """Return the battery level."""
        if (self.battery is None) or \
                (datetime.now() - timedelta(hours=1) > self._batt_last_read):
            self._batt_last_read = datetime.now()
            with self._bt_interface.connect(self._mac) as connection:
                res = connection.read_handle(_HANDLE_READ_BATTERY)  # pylint: disable=no-member
                _LOGGER.debug('Received result for handle %s: %s',
                              _HANDLE_READ_BATTERY, self._format_bytes(res))
            if res is None:
                self.battery = 0
            else:
                self.battery = res[0]
        return self.battery

    def firmware_version(self):
        """Return the firmware version."""
        if (self._firmware_version is None) or (
                datetime.now() - timedelta(hours=24) > self._fw_last_read):
            self._fw_last_read = datetime.now()
            with self._bt_interface.connect(self._mac) as connection:
                res = connection.read_handle(_HANDLE_READ_VERSION)  # pylint: disable=no-member
                _LOGGER.debug('Received result for handle %s: %s',
                              _HANDLE_READ_VERSION, self._format_bytes(res))
            if res is None:
                self._firmware_version = None
            else:
                self._firmware_version = "".join(map(chr, res))
        return self._firmware_version

    def parameter_value(self, parameter, read_cached=True):
        """Return a value of one of the monitored paramaters.

        This method will try to retrieve the data from cache and only
        request it by bluetooth if no cached value is stored or the cache is
        expired.
        This behaviour can be overwritten by the "read_cached" parameter.
        """
        # Special handling for battery attribute
        if parameter == MI_BATTERY:
            return self.battery_level()

        # Use the lock to make sure the cache isn't updated multiple times
        with self.lock:
            if (read_cached is False) or \
                    (self._last_read is None) or \
                    (datetime.now() - self._cache_timeout > self._last_read):
                self.fill_cache()
            else:
                _LOGGER.debug("Using cache (%s < %s)",
                              datetime.now() - self._last_read,
                              self._cache_timeout)

        if self.cache_available() and (len(self._cache) >= 12) and (len(
                self._cache) <= 14):
            return self._parse_data()[parameter]
        raise BluetoothBackendException(
            "Could not read data from Mi Flora sensor %s" % self._mac)

    def _check_data(self):
        """Ensure that the data in the cache is valid.

        If it's invalid, the cache is wiped.
        """
        if not self.cache_available():
            return
        if sum(self._cache) == 0:
            self.clear_cache()
            return

    def clear_cache(self):
        """Manually force the cache to be cleared."""
        self._cache = None
        self._last_read = None

    def cache_available(self):
        """Check if there is data in the cache."""
        return self._cache is not None

    def _parse_data(self):
        """Parses the byte array returned by the sensor.

        The sensor returns a string with 14 bytes. Example: "T=26.2 H=45.4\x00"
        """
        data = self._cache
        res = dict()
        res[MI_TEMPERATURE], res[MI_HUMIDITY] = re.sub(
            "[TH]=", '', data[:-1].decode()).split(' ')
        res[MI_TEMPERATURE] = float(res[MI_TEMPERATURE])
        res[MI_HUMIDITY] = float(res[MI_HUMIDITY])
        return res

    @staticmethod
    def _format_bytes(raw_data):
        """Prettyprint a byte array."""
        if raw_data is None:
            return 'None'
        return ' '.join([format(c, "02x") for c in raw_data]).upper()