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 __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 __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 __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 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
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 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' )
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
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)
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()
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)
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
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()
#!/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)
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()