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+
121import struct
222import time
323from collections import namedtuple
424
525import logging
626from datetime import datetime
727
8- import bluepy .btle as btle
28+ from bleak import BleakClient
29+ from bleak import BleakScanner
30+ import asyncio
931
1032from 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
1637CHAR_UUID_MANUFACTURER_NAME = UUID ('00002a29-0000-1000-8000-00805f9b34fb' )
1738CHAR_UUID_SERIAL_NUMBER_STRING = UUID ('00002a25-0000-1000-8000-00805f9b34fb' )
1839CHAR_UUID_MODEL_NUMBER_STRING = UUID ('00002a24-0000-1000-8000-00805f9b34fb' )
1940CHAR_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+
2044CHAR_UUID_DATETIME = UUID ('00002a08-0000-1000-8000-00805f9b34fb' )
2145CHAR_UUID_TEMPERATURE = UUID ('00002a6e-0000-1000-8000-00805f9b34fb' )
2246CHAR_UUID_HUMIDITY = UUID ('00002a6f-0000-1000-8000-00805f9b34fb' )
3458device_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
3965class 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
5179sensors_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-
174188sensor_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 ())
0 commit comments