Skip to content

Commit ef4d7ab

Browse files
Merge pull request #87 from B1ob/convert_to_bleak
Converted from the bluepy library to bleak library for bluetooth access. Solves incompatibility with HA 2022.7 + home-assistant/core#73830
2 parents 6fcef04 + 304cfee commit ef4d7ab

File tree

3 files changed

+135
-114
lines changed

3 files changed

+135
-114
lines changed

custom_components/airthings_wave/airthings.py

Lines changed: 127 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,46 @@
1+
# Copyright (c) 2021 Martin Tremblay, Mark McCans
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a copy
4+
# of this software and associated documentation files (the "Software"), to deal
5+
# in the Software without restriction, including without limitation the rights
6+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
# copies of the Software, and to permit persons to whom the Software is
8+
# furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in all
11+
# copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
# SOFTWARE.
20+
121
import struct
222
import time
323
from collections import namedtuple
424

525
import logging
626
from datetime import datetime
727

8-
import bluepy.btle as btle
28+
from bleak import BleakClient
29+
from bleak import BleakScanner
30+
import asyncio
931

1032
from uuid import UUID
1133

1234
_LOGGER = logging.getLogger(__name__)
1335

14-
# Use full UUID since we do not use UUID from bluepy.btle
15-
CHAR_UUID_CCCD = btle.UUID('2902') # Client Characteristic Configuration Descriptor (CCCD)
36+
# Use full UUID since we do not use UUID from bluetooth library
1637
CHAR_UUID_MANUFACTURER_NAME = UUID('00002a29-0000-1000-8000-00805f9b34fb')
1738
CHAR_UUID_SERIAL_NUMBER_STRING = UUID('00002a25-0000-1000-8000-00805f9b34fb')
1839
CHAR_UUID_MODEL_NUMBER_STRING = UUID('00002a24-0000-1000-8000-00805f9b34fb')
1940
CHAR_UUID_DEVICE_NAME = UUID('00002a00-0000-1000-8000-00805f9b34fb')
41+
CHAR_UUID_FIRMWARE_REV = UUID('00002a26-0000-1000-8000-00805f9b34fb')
42+
CHAR_UUID_HARDWARE_REV = UUID('00002a27-0000-1000-8000-00805f9b34fb')
43+
2044
CHAR_UUID_DATETIME = UUID('00002a08-0000-1000-8000-00805f9b34fb')
2145
CHAR_UUID_TEMPERATURE = UUID('00002a6e-0000-1000-8000-00805f9b34fb')
2246
CHAR_UUID_HUMIDITY = UUID('00002a6f-0000-1000-8000-00805f9b34fb')
@@ -34,18 +58,22 @@
3458
device_info_characteristics = [manufacturer_characteristics,
3559
Characteristic(CHAR_UUID_SERIAL_NUMBER_STRING, 'serial_nr', "utf-8"),
3660
Characteristic(CHAR_UUID_MODEL_NUMBER_STRING, 'model_nr', "utf-8"),
37-
Characteristic(CHAR_UUID_DEVICE_NAME, 'device_name', "utf-8")]
61+
Characteristic(CHAR_UUID_DEVICE_NAME, 'device_name', "utf-8"),
62+
Characteristic(CHAR_UUID_FIRMWARE_REV, 'firmware_rev', "utf-8"),
63+
Characteristic(CHAR_UUID_HARDWARE_REV, 'hardware_rev', "utf-8")]
3864

3965
class AirthingsDeviceInfo:
40-
def __init__(self, manufacturer='', serial_nr='', model_nr='', device_name=''):
66+
def __init__(self, manufacturer='', serial_nr='', model_nr='', device_name='', firmware_rev='', hardware_rev=''):
4167
self.manufacturer = manufacturer
4268
self.serial_nr = serial_nr
4369
self.model_nr = model_nr
4470
self.device_name = device_name
71+
self.firmware_rev = firmware_rev
72+
self.hardware_rev = hardware_rev
4573

4674
def __str__(self):
47-
return "Manufacturer: {} Model: {} Serial: {} Device:{}".format(
48-
self.manufacturer, self.model_nr, self.serial_nr, self.device_name)
75+
return "Manufacturer: {} Model: {} Serial: {} Device: {} Firmware: {} Hardware Rev.: {}".format(
76+
self.manufacturer, self.model_nr, self.serial_nr, self.device_name, self.firmware_rev, self.hardware_rev)
4977

5078

5179
sensors_characteristics_uuid = [CHAR_UUID_DATETIME, CHAR_UUID_TEMPERATURE, CHAR_UUID_HUMIDITY, CHAR_UUID_RADON_1DAYAVG,
@@ -157,20 +185,6 @@ def decode_data(self, raw_data):
157185

158186
return res
159187

160-
161-
class MyDelegate(btle.DefaultDelegate):
162-
def __init__(self):
163-
btle.DefaultDelegate.__init__(self)
164-
# ... initialise here
165-
self.data = None
166-
167-
def handleNotification(self, cHandle, data):
168-
if self.data is None:
169-
self.data = data
170-
else:
171-
self.data = self.data + data
172-
173-
174188
sensor_decoders = {str(CHAR_UUID_WAVE_PLUS_DATA):WavePlussDecode(name="Pluss", format_type='BBBBHHHHHHHH', scale=0),
175189
str(CHAR_UUID_DATETIME):WaveDecodeDate(name="date_time", format_type='HBBBBB', scale=0),
176190
str(CHAR_UUID_HUMIDITY):BaseDecode(name="humidity", format_type='H', scale=1.0/100.0),
@@ -192,152 +206,158 @@ def __init__(self, scan_interval, mac=None):
192206
self.scan_interval = scan_interval
193207
self.last_scan = -1
194208
self._dev = None
195-
196-
def _parse_serial_number(self, manufacturer_data):
197-
try:
198-
(ID, SN, _) = struct.unpack("<HLH", manufacturer_data)
199-
except Exception as e: # Return None for non-Airthings devices
200-
return None
201-
else: # Executes only if try-block succeeds
202-
if ID == 0x0334:
203-
return SN
204-
205-
def find_devices(self, scans=50, timeout=0.1):
209+
self._command_data = None
210+
211+
def notification_handler(self, sender, data):
212+
_LOGGER.debug("Notification handler: {0}: {1}".format(sender, data))
213+
self._command_data = data
214+
self._event.set()
215+
216+
async def find_devices(self, scans=2, timeout=5):
206217
# Search for devices, scan for BLE devices scans times for timeout seconds
207218
# Get manufacturer data and try to match it to airthings ID.
208-
scanner = btle.Scanner()
219+
220+
_LOGGER.debug("Scanning for airthings devices")
209221
for _count in range(scans):
210-
advertisements = scanner.scan(timeout)
222+
advertisements = await BleakScanner.discover(timeout)
211223
for adv in advertisements:
212-
sn = self._parse_serial_number(adv.getValue(btle.ScanEntry.MANUFACTURER))
213-
if sn is not None:
214-
if adv.addr not in self.airthing_devices:
215-
self.airthing_devices.append(adv.addr)
224+
if 820 in adv.metadata["manufacturer_data"]: # TODO: Not sure if this is the best way to identify Airthings devices
225+
if adv.address not in self.airthing_devices:
226+
self.airthing_devices.append(adv.address)
216227

217228
_LOGGER.debug("Found {} airthings devices".format(len(self.airthing_devices)))
218229
return len(self.airthing_devices)
219230

220-
def connect(self, mac, retries=10):
231+
async def connect(self, mac, retries=10):
232+
_LOGGER.debug("Connecting to {}".format(mac))
233+
await self.disconnect()
221234
tries = 0
222-
self.disconnect()
223235
while (tries < retries):
224236
tries += 1
225237
try:
226-
self._dev = btle.Peripheral(mac.lower())
227-
self.delgate = MyDelegate()
228-
self._dev.withDelegate( self.delgate )
229-
break
238+
self._dev = BleakClient(mac.lower())
239+
ret = await self._dev.connect()
240+
if ret:
241+
_LOGGER.debug("Connected to {}".format(mac))
242+
break
230243
except Exception as e:
231-
print(e)
232244
if tries == retries:
245+
_LOGGER.info("Not able to connect to {}".format(mac))
233246
pass
234247
else:
235248
_LOGGER.debug("Retrying {}".format(mac))
236249

237-
def disconnect(self):
250+
async def disconnect(self):
238251
if self._dev is not None:
239-
self._dev.disconnect()
252+
await self._dev.disconnect()
240253
self._dev = None
241254

242-
def get_info(self):
255+
async def get_info(self):
243256
# Try to get some info from the discovered airthings devices
244257
self.devices = {}
245258
for mac in self.airthing_devices:
246-
self.connect(mac)
247-
if self._dev is not None:
248-
device = AirthingsDeviceInfo(serial_nr=mac)
249-
for characteristic in device_info_characteristics:
250-
try:
251-
char = self._dev.getCharacteristics(uuid=characteristic.uuid)[0]
252-
data = char.read()
253-
setattr(device, characteristic.name, data.decode(characteristic.format))
254-
except btle.BTLEDisconnectError:
255-
_LOGGER.exception("Disconnected")
256-
self._dev = None
257-
258-
self.devices[mac] = device
259-
self.disconnect()
259+
await self.connect(mac)
260+
if self._dev is not None and self._dev.is_connected:
261+
try:
262+
if self._dev is not None and self._dev.is_connected:
263+
device = AirthingsDeviceInfo(serial_nr=mac)
264+
for characteristic in device_info_characteristics:
265+
try:
266+
data = await self._dev.read_gatt_char(characteristic.uuid)
267+
setattr(device, characteristic.name, data.decode(characteristic.format))
268+
except:
269+
_LOGGER.exception("Error getting info")
270+
self._dev = None
271+
self.devices[mac] = device
272+
except:
273+
_LOGGER.exception("Error getting device info.")
274+
await self.disconnect()
275+
else:
276+
_LOGGER.error("Not getting device info because failed to connect to device.")
260277
return self.devices
261278

262-
def get_sensors(self):
279+
async def get_sensors(self):
263280
self.sensors = {}
264281
for mac in self.airthing_devices:
265-
self.connect(mac)
266-
if self._dev is not None:
267-
try:
268-
characteristics = self._dev.getCharacteristics()
269-
sensor_characteristics = []
270-
for characteristic in characteristics:
282+
await self.connect(mac)
283+
if self._dev is not None and self._dev.is_connected:
284+
sensor_characteristics = []
285+
svcs = await self._dev.get_services()
286+
for service in svcs:
287+
for characteristic in service.characteristics:
271288
_LOGGER.debug(characteristic)
272289
if characteristic.uuid in sensors_characteristics_uuid_str:
273290
sensor_characteristics.append(characteristic)
274-
self.sensors[mac] = sensor_characteristics
275-
except btle.BTLEDisconnectError:
276-
_LOGGER.exception("Disconnected")
277-
self._dev = None
278-
self.disconnect()
291+
self.sensors[mac] = sensor_characteristics
292+
await self.disconnect()
279293
return self.sensors
280294

281-
def get_sensor_data(self):
295+
async def get_sensor_data(self):
282296
if time.monotonic() - self.last_scan > self.scan_interval or self.last_scan == -1:
283297
self.last_scan = time.monotonic()
284298
for mac, characteristics in self.sensors.items():
285-
self.connect(mac)
286-
if self._dev is not None:
299+
await self.connect(mac)
300+
if self._dev is not None and self._dev.is_connected:
287301
try:
288302
for characteristic in characteristics:
289303
sensor_data = None
290304
if str(characteristic.uuid) in sensor_decoders:
291-
char = self._dev.getCharacteristics(uuid=characteristic.uuid)[0]
292-
data = char.read()
305+
data = await self._dev.read_gatt_char(characteristic.uuid)
293306
sensor_data = sensor_decoders[str(characteristic.uuid)].decode_data(data)
294307
_LOGGER.debug("{} Got sensordata {}".format(mac, sensor_data))
295-
308+
296309
if str(characteristic.uuid) in command_decoders:
297-
self.delgate.data = None # Clear the delegate so it is ready for new data.
298-
char = self._dev.getCharacteristics(uuid=characteristic.uuid)[0]
299-
# Do these steps to get notification to work, I do not know how it works, this link should explain it
300-
# https://devzone.nordicsemi.com/guides/short-range-guides/b/bluetooth-low-energy/posts/ble-characteristics-a-beginners-tutorial
301-
desc, = char.getDescriptors(forUUID=CHAR_UUID_CCCD)
302-
desc.write(struct.pack('<H', 1), True)
303-
char.write(command_decoders[str(characteristic.uuid)].cmd)
304-
for i in range(3):
305-
if self._dev.waitForNotifications(0.1):
306-
_LOGGER.debug("Received notification, total data received len {}".format(len(self.delgate.data)))
307-
308-
sensor_data = command_decoders[str(characteristic.uuid)].decode_data(self.delgate.data)
309-
_LOGGER.debug("{} Got cmddata {}".format(mac, sensor_data))
310+
_LOGGER.debug("command characteristic: {}".format(characteristic.uuid))
311+
# Create an Event object.
312+
self._event = asyncio.Event()
313+
# Set up the notification handlers
314+
await self._dev.start_notify(characteristic.uuid, self.notification_handler)
315+
# send command to this 'indicate' characteristic
316+
await self._dev.write_gatt_char(characteristic.uuid, command_decoders[str(characteristic.uuid)].cmd)
317+
# Wait for up to one second to see if a callblack comes in.
318+
try:
319+
await asyncio.wait_for(self._event.wait(), 1)
320+
except asyncio.TimeoutError:
321+
_LOGGER.warn("Timeout getting command data.")
322+
if self._command_data is not None:
323+
sensor_data = command_decoders[str(characteristic.uuid)].decode_data(self._command_data)
324+
self._command_data = None
325+
# Stop notification handler
326+
await self._dev.stop_notify(characteristic.uuid)
310327

311328
if sensor_data is not None:
312329
if self.sensordata.get(mac) is None:
313330
self.sensordata[mac] = sensor_data
314331
else:
315-
self.sensordata[mac].update(sensor_data)
316-
317-
except btle.BTLEDisconnectError:
318-
_LOGGER.exception("Disconnected")
332+
self.sensordata[mac].update(sensor_data)
333+
except:
334+
_LOGGER.exception("Error getting sensor data.")
319335
self._dev = None
320-
self.disconnect()
321336

322-
return self.sensordata
337+
await self.disconnect()
323338

339+
return self.sensordata
324340

325-
if __name__ == "__main__":
341+
async def main():
326342
logging.basicConfig()
327343
_LOGGER.setLevel(logging.DEBUG)
328344
ad = AirthingsWaveDetect(0)
329-
num_dev_found = ad.find_devices()
345+
num_dev_found = await ad.find_devices()
330346
if num_dev_found > 0:
331-
devices = ad.get_info()
347+
devices = await ad.get_info()
332348
for mac, dev in devices.items():
333-
_LOGGER.info("{}: {}".format(mac, dev))
349+
_LOGGER.info("Device: {}: {}".format(mac, dev))
334350

335-
devices_sensors = ad.get_sensors()
351+
devices_sensors = await ad.get_sensors()
336352
for mac, sensors in devices_sensors.items():
337353
for sensor in sensors:
338-
_LOGGER.info("{}: {}".format(mac, sensor))
354+
_LOGGER.info("Sensor: {}: {}".format(mac, sensor))
339355

340-
sensordata = ad.get_sensor_data()
356+
sensordata = await ad.get_sensor_data()
341357
for mac, data in sensordata.items():
342358
for name, val in data.items():
343-
_LOGGER.info("{}: {}: {}".format(mac, name, val))
359+
_LOGGER.info("Sensor data: {}: {}: {}".format(mac, name, val))
360+
361+
362+
if __name__ == "__main__":
363+
asyncio.run(main())
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
{
22
"domain": "airthings_wave",
33
"name": "Airthings Wave",
4-
"version": "3.1.0",
4+
"version": "4.0.0",
55
"documentation": "https://github.com/custom-components/sensor.airthings_wave/",
66
"issue_tracker": "https://github.com/custom-components/sensor.airthings_wave/issues",
77
"dependencies": [],
88
"codeowners": ["@MartyTremblay","@sverrham"],
99
"requirements": [
10-
"bluepy==1.3.0"
10+
"bleak==0.14.3"
1111
]
1212
}

0 commit comments

Comments
 (0)