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) self._cache = None self._cache_timeout = timedelta(seconds=cache_timeout) self._last_read = None self._fw_last_read = datetime.now() self.retries = retries self.ble_timeout = 10 self.lock = Lock() self._firmware_version = None self.battery = None
def test_context_manager_locking(self): bt = BluetoothInterface(MockBackend) self.assertFalse(bt.is_connected()) with bt.connect('abc'): # as connection: self.assertTrue(bt.is_connected()) self.assertFalse(bt.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())
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) 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: brightness in Lux > 0xffff byte 7: MOISTURE byte 8-9: conductivity in µS/cm bytes 10-15: unknown """ data = self._cache res = dict() if self._firmware_version >= "3.1.8": temp, brne0,brne1,brne2,brne3, res[MI_MOISTURE], res[MI_CONDUCTIVITY] = \ unpack('<hxBBBBBhxxxxxx', data) res[MI_TEMPERATURE] = temp / 10.0 res[MI_LIGHT] = brne0 + brne1 * 0x100 + brne2 * 0x10000 else: temp, res[MI_LIGHT], res[MI_MOISTURE], res[MI_CONDUCTIVITY] = \ unpack('<hxhxxBhxxxxxx', 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()
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) self._cache = None self._cache_timeout = timedelta(seconds=cache_timeout) self._last_read = None self._fw_last_read = datetime.now() 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("0x03") if not name: raise IOError("Could not read data from Mi Flora sensor %s" % (self._mac)) return ''.join(chr(n) for n in name) def fill_cache(self): firmware_version = self.firmware_version() with self._bt_interface.connect(self._mac) as connection: if not firmware_version: # If a sensor doesn't work, wait 5 minutes before retrying self._last_read = datetime.now() - self._cache_timeout + \ timedelta(seconds=300) return if firmware_version >= "2.6.6": if not connection.write_handle("0x33", "A01F"): # If a sensor doesn't work, wait 5 minutes before retrying self._last_read = datetime.now() - self._cache_timeout + \ timedelta(seconds=300) return self._cache = connection.read_handle("0x35") self._check_data() if self._cache is not None: 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('0x038') 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 and (len(self._cache) == 16): return self._parse_data()[parameter] else: raise IOError("Could not read data from Mi Flora sensor %s" % (self._mac)) def _check_data(self): if self._cache is None: return if self._cache[7] > 100: # moisture over 100 procent self._cache = None return if self._firmware_version >= "2.6.6": if sum(self._cache[10:]) == 0: self._cache = None return if sum(self._cache) == 0: self._cache = None return None def _parse_data(self): data = self._cache res = {} res[MI_TEMPERATURE] = float(data[1] * 256 + data[0]) / 10 res[MI_MOISTURE] = data[7] res[MI_LIGHT] = data[4] * 256 + data[3] res[MI_CONDUCTIVITY] = data[9] * 256 + data[8] return res
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) self._cache = None self._cache_timeout = timedelta(seconds=cache_timeout) self._last_read = None self._fw_last_read = datetime.now() 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) if not name: raise IOError("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.') firmware_version = self.firmware_version() with self._bt_interface.connect(self._mac) as connection: if not firmware_version: # If a sensor doesn't work, wait 5 minutes before retrying self._last_read = datetime.now() - self._cache_timeout + \ timedelta(seconds=300) return if firmware_version >= "2.6.6": if not connection.write_handle(_HANDLE_WRITE_MODE_CHANGE, _DATA_MODE_CHANGE): # If a sensor doesn't work, wait 5 minutes before retrying self._last_read = datetime.now() - self._cache_timeout + \ timedelta(seconds=300) return self._cache = connection.read_handle(_HANDLE_READ_SENSOR_DATA) _LOGGER.debug('Received result for handle %s: %s', _HANDLE_READ_SENSOR_DATA, self._format_bytes(self._cache)) self._check_data() if self._cache is not None: 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) _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 and (len(self._cache) == 16): return self._parse_data()[parameter] else: raise IOError("Could not read data from Mi Flora sensor %s" % self._mac) def _check_data(self): if self._cache is None: return if self._cache[7] > 100: # moisture over 100 procent self._cache = None return if self._firmware_version >= "2.6.6": if sum(self._cache[10:]) == 0: self._cache = None return if sum(self._cache) == 0: self._cache = None return 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. """ data = self._cache res = dict() res[MI_TEMPERATURE] = float(data[1] * 256 + data[0]) / 10 res[MI_MOISTURE] = data[7] res[MI_LIGHT] = data[4] * 256 + data[3] res[MI_CONDUCTIVITY] = data[9] * 256 + data[8] return res @staticmethod def _format_bytes(raw_data): """Prettyprint a byte array.""" return ' '.join([format(c, "02x") for c in raw_data]).upper()
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 percent 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