From 2c5e3edaec3a6cbf049e8e666de6ce39de330472 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Sat, 30 Dec 2017 23:29:00 +0100 Subject: [PATCH 01/22] Add vim modelines --- cometblue/cli.py | 1 + cometblue/device.py | 1 + cometblue/discovery.py | 1 + 3 files changed, 3 insertions(+) diff --git a/cometblue/cli.py b/cometblue/cli.py index 6d6c2b2..7f0ea37 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# vim: tabstop=4 shiftwidth=4 expandtab from __future__ import absolute_import import datetime diff --git a/cometblue/device.py b/cometblue/device.py index 4132f2f..a063931 100644 --- a/cometblue/device.py +++ b/cometblue/device.py @@ -1,3 +1,4 @@ +# vim: tabstop=4 shiftwidth=4 expandtab from __future__ import absolute_import import datetime diff --git a/cometblue/discovery.py b/cometblue/discovery.py index 8da7675..4827510 100644 --- a/cometblue/discovery.py +++ b/cometblue/discovery.py @@ -1,3 +1,4 @@ +# vim: tabstop=4 shiftwidth=4 expandtab from __future__ import absolute_import import logging From aa4bab258f8a3fca6904a4342b8abf8b30ec2ec9 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Sat, 30 Dec 2017 23:36:57 +0100 Subject: [PATCH 02/22] Rename packing formats, use tabs for temperature output --- README.md | 14 ++++----- cometblue/cli.py | 41 +++++++++++++++++++-------- cometblue/device.py | 69 ++++++++++++++++++++++++++++++--------------- 3 files changed, 82 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 4a6b2ae..55c184d 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,13 @@ Usage examples: 2016-03-27 18:32:00 # cometblue device -p 0 E0:E5:CF:D6:98:53 get temperatures -Current temperature: 23.0 °C -Temperature for manual mode: 16.0 °C -Target temperature low: 16.0 °C -Target temperature high: 21.0 °C -Offset temperature: 0.0 °C -Window open detection: 4 -Window open minutes: 10 +Current temperature: 23.0 °C +Temperature for manual mode: 16.0 °C +Target temperature low: 16.0 °C +Target temperature high: 21.0 °C +Offset temperature: 0.0 °C +Window open detection: 4 +Window open minutes: 10 # cometblue device E0:E5:CF:D6:98:53 get device_name # no PIN required Comet Blue diff --git a/cometblue/cli.py b/cometblue/cli.py index 7f0ea37..0933ab7 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -94,13 +94,13 @@ def print_battery(self, value): def print_temperatures(self, value): text = '' - text += 'Current temperature: %.01f °C\n' % value['current_temp'] - text += 'Temperature for manual mode: %.01f °C\n' % value['manual_temp'] - text += 'Target temperature low: %.01f °C\n' % value['target_temp_l'] - text += 'Target temperature high: %.01f °C\n' % value['target_temp_h'] - text += 'Offset temperature: %.01f °C\n' % value['offset_temp'] - text += 'Window open detection: %u\n' % value['window_open_detection'] - text += 'Window open minutes: %u\n' % value['window_open_minutes'] + text += 'Current temperature:\t%.01f °C\n' % value['current_temp'] + text += 'Temperature for manual mode:\t%.01f °C\n' % value['manual_temp'] + text += 'Target temperature low:\t%.01f °C\n' % value['target_temp_l'] + text += 'Target temperature high:\t%.01f °C\n' % value['target_temp_h'] + text += 'Offset temperature:\t%.01f °C\n' % value['offset_temp'] + text += 'Window open detection:\t%u\n' % value['window_open_detection'] + text += 'Window open minutes:\t%u\n' % value['window_open_minutes'] self._stream.write(text) self._stream.flush() @@ -247,7 +247,8 @@ def _parse_datetime(datetime_str): @click.command( 'discover', - help='Discover "Comet Blue" Bluetooth LE devices') + help='Discover "Comet Blue" Bluetooth LE devices (might take a while)', + short_help='Scan for devices (might take a while)') @click.option( '--timeout', '-t', type=int, @@ -611,7 +612,7 @@ def set_lcd_timer(ctx, value): return set_lcd_timer -def _add_values(): +def _enroll_subcommands(): for val_name, val_conf in six.iteritems( cometblue.device.CometBlue.SUPPORTED_VALUES): if 'decode' in val_conf: @@ -661,10 +662,10 @@ def real_set_fn(ctx, value): _device_set.add_command(set_fn) -def main(): +def _init_command_processing(): _configure_logger() - _add_values() + _enroll_subcommands() _main.add_command(_discover) _main.add_command(_device) @@ -680,8 +681,24 @@ def main(): _device_set.add_command(_device_set_day) _device_set.add_command(_device_set_holiday) - return _main(obj=_ContextObj()) + context = _ContextObj() + return context + +def cli_main(argv): + context = _init_command_processing() + rv = 0 + try: + rv = _main(obj=context, args=argv) + except RuntimeError as err: + print(str(err), file=sys.stderr) + rv = -1 + except SystemExit: + pass + return rv + +def main(): + return cli_main(sys.argv[1:]) if __name__ == '__main__': exit(main()) diff --git a/cometblue/device.py b/cometblue/device.py index a063931..a95f125 100644 --- a/cometblue/device.py +++ b/cometblue/device.py @@ -12,23 +12,24 @@ import six -_PIN_STRUCT = ' 23) or (ho_end > 23) \ or (da_start > 31) or (da_end > 31) \ @@ -231,14 +254,14 @@ def _decode_holiday(value): def _encode_holiday(holiday): if any(map(lambda v: v is None, six.itervalues(holiday))): - return struct.pack(_HOLIDAY_STRUCT, + return struct.pack(_HOLIDAY_STRUCT_PACKING, 128, 128, 128, 128, 128, 128, 128, 128, -128) if (holiday['start'].year < 2000) or (holiday['end'].year < 2000): raise RuntimeError('Invalid year') return struct.pack( - _HOLIDAY_STRUCT, + _HOLIDAY_STRUCT_PACKING, holiday['start'].hour, holiday['start'].day, holiday['start'].month, From 5436778edc581e4b2ea6bddb838309296a4f5fb1 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Sat, 30 Dec 2017 23:47:27 +0100 Subject: [PATCH 03/22] Status decoding (instead of flags) --- README.md | 2 +- cometblue/cli.py | 16 ++++++++++++++++ cometblue/device.py | 6 +++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 55c184d..fcc94a0 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ Usage example: - http://torsten-traenkner.de/wissen/smarthome/heizung.php ## TODO -- Support flags +- Support status write - Support timer - Write tests - Python3 diff --git a/cometblue/cli.py b/cometblue/cli.py index 0933ab7..782c9fe 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -104,6 +104,22 @@ def print_temperatures(self, value): self._stream.write(text) self._stream.flush() + def print_status(self, value): + text = '' + text += 'Temperature satisfied:\t%r\n' % value['satisfied'] + text += 'Child-lock:\t%r\n' % value['childlock'] + text += 'Manual mode is:\t%r\n' % value['manual_mode'] + text += 'Adapting:\t%r\n' % value['adapting'] + text += 'Not ready:\t%r\n' % value['not_ready'] + text += 'Motor moving:\t%r\n' % value['motor_moving'] + text += 'Install procedure running:\t%r\n' % value['installing'] + text += 'Antifrost active:\t%r\n' % value['antifrost_activated'] + text += 'Low battery alert:\t%r\n' % value['low_battery'] + text += 'State dword:\t0x%08X\n' % value['state_as_dword'] + text += 'Unknown state:\t0x%08X\n' % value['unused_bits'] + self._stream.write(text) + self._stream.flush() + def print_lcd_timer(self, value): self._print_simple('%02u:%02u' % (value['preload'], value['current'])) diff --git a/cometblue/device.py b/cometblue/device.py index a95f125..b0a8ee1 100644 --- a/cometblue/device.py +++ b/cometblue/device.py @@ -320,11 +320,11 @@ class CometBlue(object): 'encode': _encode_datetime, }, - 'flags': { - 'description': 'flags', + 'status': { + 'description': 'status', 'uuid': '47e9ee2a-47e9-11e4-8939-164230d1df67', 'read_requires_pin': True, - 'decode': _decode_flags, + 'decode': _decode_status, }, 'temperatures': { From 3ab28867804198a46de5f82c9311bff245ef5cda Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Sat, 30 Dec 2017 23:53:41 +0100 Subject: [PATCH 04/22] Target python3 --- README.md | 10 ++++---- cometblue/cli.py | 1 + cometblue/device.py | 56 +++++++++++++++++++++++++++++++-------------- setup.cfg | 9 ++++---- setup.py | 3 +-- 5 files changed, 50 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index fcc94a0..5037958 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,14 @@ This project provides python library and command line tool which may be used to From sources: ``` # Install dependencies -pip install -r requirements.txt +pip3 install -r requirements.txt # Install cometblue -python setup.py install +python3 setup.py install ``` -Using *pip*: +Using *pip* (unsupported): +(currently only original project, this fork is unsupported) ``` -pip install cometblue +pip3 install cometblue ``` ## Command line tool @@ -217,7 +218,6 @@ Usage example: - Support status write - Support timer - Write tests -- Python3 ## Notes Tool and library may not work as expected because it is not well tested. Patches and bugreports are always welcome. diff --git a/cometblue/cli.py b/cometblue/cli.py index 782c9fe..7a7e647 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -1,3 +1,4 @@ +#!/usr/bin/python3 # -*- coding: utf-8 -*- # vim: tabstop=4 shiftwidth=4 expandtab from __future__ import absolute_import diff --git a/cometblue/device.py b/cometblue/device.py index b0a8ee1..4faa88d 100644 --- a/cometblue/device.py +++ b/cometblue/device.py @@ -118,6 +118,10 @@ def _encode_temperatures(temps): _temp_int_to_int(temps, 'window_open_minutes')) +def _decode_str(value): + return value.decode() + + def _decode_battery(value): value = struct.unpack(_BATTERY_STRUCT_PACKING, value)[0] if value == 255: @@ -140,13 +144,31 @@ def _encode_lcd_timer(lcd_timer): 0) -def _day_period_cmp(p1, p2): - if p1['start'] is None: - return 1 - if p2['start'] is None: - return -1 - return cmp(p1['start'], p2['start']) +class _day_period_cmp(object): + def __init__(self, period): + self.period = period + + def __lt__(self, other): + if self.period['start'] is None: + return False + if other.period['start'] is None: + return True + return self.period['start'] < other.period['start'] + + def __gt__(self, other): + return other < self + + def __eq__(self, other): + return self.period['start'] == other.period['start'] + + def __le__(self, other): + return self == other or self < other + + def __ge__(self, other): + return self == toher or self > other + def __ne__(self, other): + return not self == other def _decode_day(value): max_raw_time = ((23 * 60) + 59) / 10 @@ -165,14 +187,14 @@ def _decode_day(value): start = datetime.time() else: raw_start *= 10 - start = datetime.time(hour=raw_start / 60, + start = datetime.time(hour=raw_start // 60, minute=raw_start % 60) if raw_end > max_raw_time: end = datetime.time(23, 59, 59) else: raw_end *= 10 - end = datetime.time(hour=raw_end / 60, + end = datetime.time(hour=raw_end // 60, minute=raw_end % 60) if start == end: @@ -186,7 +208,7 @@ def _decode_day(value): 'end': end, }) - day.sort(_day_period_cmp) + day.sort(key=_day_period_cmp) return day @@ -203,8 +225,8 @@ def _encode_day(periods): start = 255 end = 255 else: - start = (period['start'].hour * 60 + period['start'].minute) / 10 - end = (period['end'].hour * 60 + period['end'].minute) / 10 + start = (period['start'].hour * 60 + period['start'].minute) // 10 + end = (period['end'].hour * 60 + period['end'].minute) // 10 if start == 0: start = 255 @@ -285,31 +307,31 @@ class CometBlue(object): 'device_name': { 'description': 'device name', 'uuid': '00002a00-0000-1000-8000-00805f9b34fb', - 'decode': str, + 'decode': _decode_str, }, 'model_number': { 'description': 'model number', 'uuid': '00002a24-0000-1000-8000-00805f9b34fb', - 'decode': str, + 'decode': _decode_str, }, 'firmware_revision': { 'description': 'firmware revision', 'uuid': '00002a26-0000-1000-8000-00805f9b34fb', - 'decode': str, + 'decode': _decode_str, }, 'software_revision': { 'description': 'software revision', 'uuid': '00002a28-0000-1000-8000-00805f9b34fb', - 'decode': str, + 'decode': _decode_str, }, 'manufacturer_name': { 'description': 'manufacturer name', 'uuid': '00002a29-0000-1000-8000-00805f9b34fb', - 'decode': str, + 'decode': _decode_str, }, 'datetime': { @@ -346,7 +368,7 @@ class CometBlue(object): 'description': 'firmware revision #2', 'uuid': '47e9ee2d-47e9-11e4-8939-164230d1df67', 'read_requires_pin': True, - 'decode': str, + 'decode': _decode_str, }, 'lcd_timer': { diff --git a/setup.cfg b/setup.cfg index 1be9718..75797d1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,9 @@ [metadata] name = cometblue summary = Command line tool and python library for "Comet Blue" radiator thermostat -author = Ivan Mironov -author-email = mironov.ivan@gmail.com -home-page = https://github.com/im-0/cometblue +author = Ivan Mironov (original), Lukas Rucka (forked) +author-email = xrucka@fi.muni.cz +home-page = https://github.com/xrucka/cometblue classifier = Development Status :: 4 - Beta Environment :: Console @@ -11,8 +11,7 @@ classifier = Intended Audience :: End Users/Desktop License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) Operating System :: POSIX :: Linux - Programming Language :: Python :: 2 - Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 keywords = cometblue bluetooth diff --git a/setup.py b/setup.py index b89487e..194e9d2 100755 --- a/setup.py +++ b/setup.py @@ -1,9 +1,8 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 # -*- coding: utf-8 -*- import setuptools - setuptools.setup( setup_requires=['pbr'], pbr=True) From 274ea65ffd646083aa23b73b789c9e43e101c321 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Sun, 31 Dec 2017 00:14:22 +0100 Subject: [PATCH 05/22] Rewrote gatt calls to utilize gatt-python instead of gattlib RESULT: superuser privileges for python no-longer needed gatt-python: https://github.com/getsenic/gatt-python --- cometblue/cli.py | 1 + cometblue/device.py | 107 +++++++++++++++++++++++++++++------------ cometblue/discovery.py | 34 +++++++------ requirements.txt | 2 +- 4 files changed, 97 insertions(+), 47 deletions(-) diff --git a/cometblue/cli.py b/cometblue/cli.py index 7a7e647..f0b4c8e 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -10,6 +10,7 @@ import logging import os import sys +import gatt import click import shellescape diff --git a/cometblue/device.py b/cometblue/device.py index 4faa88d..58e3f2d 100644 --- a/cometblue/device.py +++ b/cometblue/device.py @@ -8,7 +8,8 @@ import struct import uuid as uuid_module -import gattlib +import gatt +import time import six @@ -411,13 +412,24 @@ def _read_value(self, uuid, decode, pin_required): raise RuntimeError('PIN required') _log.debug('Reading value "%s" from "%s"...', - uuid, self._device_address) - value = self._device.read_by_uuid(uuid) + uuid, self._device.mac_address) + + characteristics_handle = self._chars.get(uuid, None) + if characteristics_handle is None: + raise RuntimeError('Handle for uuid "%s" not found, perhaps sync issue?' % (uuid)) + + value = characteristics_handle.read_value() + _log.debug('Read value "%s" from "%s": %r', - uuid, self._device_address, value) - if len(value) != 1: + uuid, self._device.mac_address, value) + if len(value.signature) != 1: raise RuntimeError('Got more than one value') - return decode(value[0]) + + value = bytes(int(byte) for byte in value) + value = decode(value) + _log.debug('Decoded value "%s" from "%s": %r', + uuid, self._device.mac_address, value) + return value def _read_value_n(self, uuid, decode, pin_required, max_n, n): if (n < 0) or (n >= max_n): @@ -431,22 +443,27 @@ def _write_value(self, uuid, encode, value): raise RuntimeError('PIN required') _log.debug('Writing value "%s" to "%s": %r...', - uuid, self._device_address, value) - self._device.write_by_handle(self._chars[uuid], encode(value)) + uuid, self._device.mac_address, value) + + characteristics_handle = self._chars.get(uuid, None) + if characteristics_handle is None: + if self._chars: + raise NotImplementedError('Device does not offer characteristics with uuid "%s", required to fulfill the request' % (uuid)) + else: + raise RuntimeError('Handle for characteristics uuid "%s" not found, perhaps sync issue?' % (uuid)) + + value = encode(value) + characteristics_handle.write_value(value) _log.debug('Wrote value "%s" to "%s": %r', - uuid, self._device_address, value) + uuid, self._device.mac_address, value) def _write_value_n(self, uuid, encode, max_n, n, value): if (n < 0) or (n >= max_n): raise RuntimeError('Invalid table row number') return self._write_value(_increase_uuid(uuid, n), encode, value) - def __init__(self, address, adapter='hci0', channel_type='public', - security_level='low', pin=None): - self._device_address = address - self._device = gattlib.GATTRequester(str(address), False, str(adapter)) - self._channel_type = channel_type - self._security_level = security_level + def __init__(self, address, adapter='hci0', pin=None): + self._device = gatt.Device(address, gatt.DeviceManager(adapter)) self._chars = None self._pin = pin @@ -490,19 +507,40 @@ def __init__(self, address, adapter='hci0', channel_type='public', val_conf['encode'], val_conf['num'])) + def __str__(self): + return \ + "device_" + self._device.alias() \ + + "@" + self._device.mac_address + "_[" \ + + ("connected" if self._device.is_connected() else "disconnected") \ + + ", " \ + + ("services resolved" if self._device.is_services_resolved() else "pending service resolution") + "]" + def __enter__(self): - _log.info('Connecting to device "%s"...', self._device_address) - self._device.connect(wait=True, channel_type=self._channel_type, - security_level=self._security_level) + _log.info('Connecting to device "%s"...', self._device.mac_address) + self._device.connect() + + if not self._device.is_connected(): + raise RuntimeError('Failed to connect the device') _log.debug('Discovering characteristics for "%s"...', - self._device_address) - chars = self._device.discover_characteristics(0x0001, 0xffff, '') - _log.debug('Discovered characteristics for "%s": %r', - self._device_address, chars) + self._device.mac_address) + + while not self._device.services and self._device.is_connected() and not self._device.is_services_resolved(): + time.sleep(0.020) + if not self._device.is_connected() or not self._device.is_services_resolved(): + raise RuntimeError('Failed to resolve device services') + + # BUG: gatt does not always correctly update service list + self._device.services_resolved() + _log.debug('Characteristics resolved for "%s": %r', self._device.mac_address, self._device.services) + + services = self._device.services self._chars = dict( - (char_data['uuid'], char_data['value_handle']) - for char_data in chars) + (str(characteristics_handle.uuid), characteristics_handle) + for service_handle in services + for characteristics_handle in service_handle.characteristics ) + _log.debug('Discovered characteristics for "%s": %r', + self._device.mac_address, self._chars.keys()) if self._pin is not None: try: @@ -510,14 +548,19 @@ def __enter__(self): except RuntimeError as exc: raise RuntimeError('Invalid PIN', exc) - _log.info('Connected to device "%s"', self._device_address) + _log.info('Connected to device "%s"', self._device.mac_address) return self def __exit__(self, exc_type, exc_val, exc_tb): - if self._device.is_connected(): - _log.info('Disconnecting from device "%s"...', self._device_address) + if not self._device.is_connected(): + return + + _log.info('Disconnecting from device "%s"...', self._device.mac_address) + try: self._device.disconnect() - _log.info('Disconnected from device "%s"', self._device_address) + _log.info('Disconnected from device "%s"', self._device.mac_address) + except: + _log.error('Failed disconnect from device "%s", considering disconnected anyway', self._device.mac_address) def get_days(self): return list(map(self.get_day, range(7))) @@ -527,7 +570,7 @@ def get_holidays(self): def backup(self): _log.info('Saving all supported values from "%s"...', - self._device_address) + self._device.mac_address) data = {} @@ -544,7 +587,7 @@ def backup(self): for val_name in 'days', 'holidays': data[val_name] = getattr(self, 'get_' + val_name)() - _log.info('All supported values from "%s" saved', self._device_address) + _log.info('All supported values from "%s" saved', self._device.mac_address) return data @@ -558,7 +601,7 @@ def set_holidays(self, value): def restore(self, data): _log.info('Restoring values from backup for "%s"...', - self._device_address) + self._device.mac_address) _log.debug('Backup data: %r', data) for val_name, val_data in six.iteritems(data): @@ -568,4 +611,4 @@ def restore(self, data): self.set_datetime(datetime.datetime.now()) _log.info('Values from backup for "%s" successfully restored', - self._device_address) + self._device.mac_address) diff --git a/cometblue/discovery.py b/cometblue/discovery.py index 4827510..888a627 100644 --- a/cometblue/discovery.py +++ b/cometblue/discovery.py @@ -3,7 +3,8 @@ import logging -import gattlib +import gatt +import time import six import cometblue.device @@ -16,31 +17,36 @@ _log = logging.getLogger(__name__) -def discover(adapter='hci0', timeout=10, channel_type='public', - security_level='low'): +def discover(adapter='hci0', timeout=10): _log.info('Starting discovery on adapter "%s" with %u seconds timeout...', adapter, timeout) - # TODO: Python3 - service = gattlib.DiscoveryService(str(adapter)) - devices = service.discover(timeout) - _log.debug('All discovered devices: %r', devices) + manager = gatt.DeviceManager(adapter) + + manager.start_discovery() + time.sleep(timeout) + manager.stop_discovery() + + devices = manager.devices() + _log.debug('All discovered devices: %r', [(device.mac_address, str(device.alias())) for device in devices]) filtered_devices = {} - for address, name in six.iteritems(devices): + + for _device in devices: + name = _device.alias() + address = _device.mac_address try: with cometblue.device.CometBlue( address, - adapter=adapter, - channel_type=channel_type, - security_level=security_level) as device: + adapter=adapter) as device: manufacturer_name = device.get_manufacturer_name().lower() model_number = device.get_model_number().lower() + if (manufacturer_name, model_number) in _SUPPORTED_DEVICES: - filtered_devices[address] = name + filtered_devices[device._device.mac_address] = name + except RuntimeError as exc: _log.debug('Skipping device "%s" ("%s") because of ' - 'exception: %r', - name, address, exc) + 'exception: %r' % (name, address, exc)) _log.info('Discovery finished') return filtered_devices diff --git a/requirements.txt b/requirements.txt index 06d3fc4..0fe6742 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ click -gattlib +gatt pbr shellescape six From 039e00fa89e4eb7e52f079c07679a739ff9b1962 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Sun, 31 Dec 2017 00:22:21 +0100 Subject: [PATCH 06/22] Reuse allready established device and adapter instances --- cometblue/cli.py | 73 +++++++++++++++++++++--------------------- cometblue/device.py | 37 ++++++++++++++++++--- cometblue/discovery.py | 9 ++---- 3 files changed, 73 insertions(+), 46 deletions(-) diff --git a/cometblue/cli.py b/cometblue/cli.py index f0b4c8e..6220017 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -275,8 +275,7 @@ def _parse_datetime(datetime_str): help='Device discovery timeout in seconds') @click.pass_context def _discover(ctx, timeout): - devices = cometblue.discovery.discover( - ctx.obj.adapter, timeout) + devices = cometblue.discovery.discover(ctx.obj.manager, timeout) devices = [dict(name=name, address=address) for address, name in six.iteritems(devices)] ctx.obj.formatter.print_discovered_devices(devices) @@ -287,10 +286,7 @@ def _discover(ctx, timeout): help='Get configured periods per days of the week (requires PIN)') @click.pass_context def _device_get_days(ctx): - with cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) as device: + with ctx.obj.device as device: days = device.get_days() ctx.obj.formatter.print_days(days) @@ -301,10 +297,7 @@ def _device_get_days(ctx): help='Get configured holidays (requires PIN)') @click.pass_context def _device_get_holidays(ctx): - with cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) as device: + with ctx.obj.device as device: holidays = device.get_holidays() ctx.obj.formatter.print_holidays(holidays) @@ -356,10 +349,7 @@ def _device_set_day(ctx, day, period): periods.append(dict(start=start, end=end)) - with cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) as device: + with ctx.obj.device as device: device.set_day(day_index, periods) @@ -397,10 +387,7 @@ def _device_set_holiday(ctx, holiday, start, end, temperature): 'temp': temperature, } - with cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) as device: + with ctx.obj.device as device: device.set_holiday(holiday_index, holiday_data) @@ -421,10 +408,7 @@ def _device_set(): required=False) @click.pass_context def _device_backup(ctx, file_name): - with cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) as device: + with ctx.obj.device as device: backup = device.backup() if file_name is None: @@ -467,10 +451,7 @@ def _device_restore(ctx, file_name): for holiday in backup['holidays'] ] - with cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) as device: + with ctx.obj.device as device: device.restore(backup) @@ -490,7 +471,25 @@ def _device_restore(ctx, file_name): required=True) @click.pass_context def _device(ctx, address, pin, pin_file): - ctx.obj.device_address = address + ''' + Get or set values. + + You may use address 00:00:00:00:00:00 to access subcommand help without a real device. + ''' + + class connection_manager(object): + def __init__(self, device): + self._device = device + if self._device is None: + return + + device.manual_connect() + + def __call__(self): + if self._device is None: + return + + self._device.manual_disconnect() if pin_file is not None: with open(pin_file, 'r') as pin_file: @@ -500,6 +499,13 @@ def _device(ctx, address, pin, pin_file): else: ctx.obj.pin = None + ctx.obj.device_address = address + ctx.obj.device = None + if address != "00:00:00:00:00:00": + gattdevice = gatt.Device(ctx.obj.device_address, ctx.obj.manager) + ctx.obj.device = cometblue.device.CometBlue(gattdevice, ctx.obj.pin) + + ctx.call_on_close(connection_manager(ctx.obj.device)) @click.group( context_settings={'help_option_names': ['-h', '--help']}, @@ -523,7 +529,8 @@ def _device(ctx, address, pin, pin_file): def _main(ctx, adapter, formatter, log_level): _configure_logger(_get_log_level(log_level)) - ctx.obj.adapter = adapter + manager = gatt.DeviceManager(adapter_name = str(adapter)) + ctx.obj.manager = manager if formatter == 'json': ctx.obj.formatter = _JSONFormatter() @@ -636,10 +643,7 @@ def _enroll_subcommands(): if 'decode' in val_conf: def get_fn_with_name(get_fn_name, print_fn_name): def real_get_fn(ctx): - with cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) as device: + with ctx.obj.device as device: value = getattr(device, get_fn_name)() print_fn = getattr(ctx.obj.formatter, print_fn_name) @@ -662,10 +666,7 @@ def real_get_fn(ctx): if 'encode' in val_conf: def set_fn_with_name(set_fn_name): def real_set_fn(ctx, value): - with cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) as device: + with ctx.obj.device as device: getattr(device, set_fn_name)(value) return real_set_fn diff --git a/cometblue/device.py b/cometblue/device.py index 58e3f2d..ee9555c 100644 --- a/cometblue/device.py +++ b/cometblue/device.py @@ -462,10 +462,13 @@ def _write_value_n(self, uuid, encode, max_n, n, value): raise RuntimeError('Invalid table row number') return self._write_value(_increase_uuid(uuid, n), encode, value) - def __init__(self, address, adapter='hci0', pin=None): - self._device = gatt.Device(address, gatt.DeviceManager(adapter)) + def __init__(self, gattDevice, pin=None): + self._device = gattDevice self._chars = None self._pin = pin + # for manual connect + disconnect vs. __enter__ vs. __exit__ + self._entered = False + self._locked = False for val_name, val_conf in six.iteritems(self.SUPPORTED_VALUES): if 'decode' in val_conf: @@ -515,7 +518,7 @@ def __str__(self): + ", " \ + ("services resolved" if self._device.is_services_resolved() else "pending service resolution") + "]" - def __enter__(self): + def _connect(self): _log.info('Connecting to device "%s"...', self._device.mac_address) self._device.connect() @@ -549,9 +552,26 @@ def __enter__(self): raise RuntimeError('Invalid PIN', exc) _log.info('Connected to device "%s"', self._device.mac_address) + self._entered = True return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __enter__(self): + if not self._entered: + self._connect() + + return self + + def manual_connect(self): + if not self._entered: + self._connect() + self._locked = True + + def _disconnect(self): + if not self._entered: + return + + self._entered = False + if not self._device.is_connected(): return @@ -562,6 +582,15 @@ def __exit__(self, exc_type, exc_val, exc_tb): except: _log.error('Failed disconnect from device "%s", considering disconnected anyway', self._device.mac_address) + def manual_disconnect(self): + if self._locked: + self._disconnect() + self._locked = False + + def __exit__(self, exc_type, exc_val, exc_tb): + if not self._locked: + self._disconnect() + def get_days(self): return list(map(self.get_day, range(7))) diff --git a/cometblue/discovery.py b/cometblue/discovery.py index 888a627..c8051a5 100644 --- a/cometblue/discovery.py +++ b/cometblue/discovery.py @@ -17,10 +17,9 @@ _log = logging.getLogger(__name__) -def discover(adapter='hci0', timeout=10): +def discover(manager, timeout=10): _log.info('Starting discovery on adapter "%s" with %u seconds timeout...', - adapter, timeout) - manager = gatt.DeviceManager(adapter) + manager.adapter_name, timeout) manager.start_discovery() time.sleep(timeout) @@ -35,9 +34,7 @@ def discover(adapter='hci0', timeout=10): name = _device.alias() address = _device.mac_address try: - with cometblue.device.CometBlue( - address, - adapter=adapter) as device: + with cometblue.device.CometBlue(_device, None) as device: manufacturer_name = device.get_manufacturer_name().lower() model_number = device.get_model_number().lower() From c04ec24ea98bcdc05d1de18170926e4206fa08bd Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Sun, 31 Dec 2017 00:25:18 +0100 Subject: [PATCH 07/22] Support get/set subcommand chaining --- cometblue/cli.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cometblue/cli.py b/cometblue/cli.py index 6220017..4c80b36 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -305,7 +305,8 @@ def _device_get_holidays(ctx): @click.group( 'get', - help='Get value') + help='Get value', + chain=True) def _device_get(): pass @@ -393,7 +394,8 @@ def _device_set_holiday(ctx, holiday, start, end, temperature): @click.group( 'set', - help='Set value (always requires PIN)') + help='Set value (always requires PIN)', + chain=True) def _device_set(): pass @@ -457,7 +459,9 @@ def _device_restore(ctx, file_name): @click.group( 'device', - help='Get or set values') + short_help='Get or set values' + #, chain=True + ) @click.option( '--pin', '-p', default=None, From d8453516c8faf0894e7ec04fa9aaab5f0efea676 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Sun, 31 Dec 2017 00:27:56 +0100 Subject: [PATCH 08/22] Support setting relevant status controls --- cometblue/cli.py | 32 ++++++++++++++++++++++++++++++++ cometblue/device.py | 18 ++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/cometblue/cli.py b/cometblue/cli.py index 4c80b36..1033af1 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -575,6 +575,38 @@ def set_datetime(ctx, dt): return set_datetime + @staticmethod + def status(real_setter): + + @click.option('+c/-c', '--childlock/--no-childlock', 'childlock', is_flag=True, default=None, help='Enable/disable childlock') + @click.option('+m/-m', '--manual-mode', '--auto-mode', 'manual_mode', is_flag=True, default=None, help='Enable/disable manual mode') + @click.option('+a', '--adapt', 'adapting', is_flag=True, default=None, help='Re-adapt (make sure device is mounted)') + @click.pass_context + def set_status(ctx, childlock, manual_mode, adapting): + keys = ['childlock', 'manual_mode', 'adapting'] + vals = [childlock, manual_mode, adapting] + + status = {} + for i in range(len(keys)): + if vals[i] is None: + continue + status[keys[i]] = vals[i] + + if not status: + raise RuntimeError( + 'No status flags to update, try "status -h"') + + if ctx.obj.device._device.is_connected(): + current = ctx.obj.device.get_status() + current = dict((k, v) for k, v in current.items() if k in keys) + for k, v in status.items(): + current[k] = v + status = current + + real_setter(ctx, status) + + return set_status + @staticmethod def temperatures(real_setter): @click.option( diff --git a/cometblue/device.py b/cometblue/device.py index ee9555c..2f6d9f8 100644 --- a/cometblue/device.py +++ b/cometblue/device.py @@ -78,6 +78,23 @@ def _decode_status(value): return report +def _encode_status(value): + status_dword = 0 + for key, state in value.items(): + if not state: + continue + + if not key in _STATUS_BITMASKS: + _log.error('Unknown flag ' + key) + continue + + status_dword |= _STATUS_BITMASKS[key] + + value = struct.pack(' Date: Sun, 31 Dec 2017 00:31:24 +0100 Subject: [PATCH 09/22] Allow to turn on otherwise offline adapter --- cometblue/cli.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/cometblue/cli.py b/cometblue/cli.py index 1033af1..e53027a 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -519,6 +519,12 @@ def __call__(self): show_default=True, default='hci0', help='Bluetooth adapter interface') +@click.option( + '--poweron', '-p', + show_default=True, + default=False, + is_flag=True, + help='Power ON/OFF adapter if needed') @click.option( '--formatter', '-f', type=click.Choice(('json', 'human-readable', 'shell-var')), @@ -530,11 +536,30 @@ def __call__(self): show_default=True, default='error') @click.pass_context -def _main(ctx, adapter, formatter, log_level): +def _main(ctx, adapter, poweron, formatter, log_level): _configure_logger(_get_log_level(log_level)) manager = gatt.DeviceManager(adapter_name = str(adapter)) + + class power_manager(object): + def __init__(self, manager, poweron_mgmt): + self._manager = manager + self._poweron_mgmt = poweron_mgmt and not manager.is_adapter_powered + if not self._poweron_mgmt: + return + + _log.debug('Powering on bluetooth adapter %s' % (self._manager.adapter_name)) + self._manager.is_adapter_powered = True + + def __call__(self): + if not self._poweron_mgmt: + return + + _log.debug('Shutting down bluetooth adapter %s' % (self._manager.adapter_name)) + self._manager.is_adapter_powered = False + ctx.obj.manager = manager + ctx.call_on_close(power_manager(ctx.obj.manager, poweron)) if formatter == 'json': ctx.obj.formatter = _JSONFormatter() From efe3aa542709c9d20ea86dbe070ac143cc256306 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Sun, 31 Dec 2017 00:42:47 +0100 Subject: [PATCH 10/22] Update readme to reflect changes --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5037958..cb26bdf 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ From the software point of view, "Comet Blue" is an BLE (Bluetooth Low Energy) d This project provides python library and command line tool which may be used to control "Comet Blue" from any linux system equipped with Bluetooth adapter (USB Bluetooth 4.0 dongle, for example). +This project is fork of https://github.com/im-0/cometblue rewrited with library https://github.com/getsenic/gatt-python + ## Installation From sources: ``` @@ -29,6 +31,8 @@ Usage: cometblue [OPTIONS] COMMAND [ARGS]... Options: -a, --adapter TEXT Bluetooth adapter interface [default: hci0] + -p, --poweron Power ON/OFF adapter if needed [default: + False] -f, --formatter [json|human-readable|shell-var] Output formatter [default: human-readable] -L, --log-level TEXT [default: error] @@ -87,12 +91,12 @@ Commands: device_name Get device name firmware_revision Get firmware revision firmware_revision2 Get firmware revision #2 (requires PIN) - flags Get flags (requires PIN) holidays Get configured holidays (requires PIN) lcd_timer Get LCD timer (requires PIN) manufacturer_name Get manufacturer name model_number Get model number software_revision Get software revision + status Get status (requires PIN) temperatures Get temperatures (requires PIN) ``` Usage examples: @@ -137,6 +141,7 @@ Commands: holiday Set period and temperature for holiday... lcd_timer Set LCD timer (requires PIN) pin Set PIN (requires PIN) + status Set status (requires PIN) temperatures Set temperatures (requires PIN) ``` Usage examples: @@ -215,7 +220,6 @@ Usage example: - http://torsten-traenkner.de/wissen/smarthome/heizung.php ## TODO -- Support status write - Support timer - Write tests From 91c32eef794e7f47c570c3d0067eae1989139689 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Sun, 31 Dec 2017 00:53:06 +0100 Subject: [PATCH 11/22] Explain window open sensitivity --- README.md | 2 +- cometblue/cli.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cb26bdf..48bb4af 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Temperature for manual mode: 16.0 °C Target temperature low: 16.0 °C Target temperature high: 21.0 °C Offset temperature: 0.0 °C -Window open detection: 4 +Window open sensitivity: 4 (1 = low, 4 = high, 8 = mid) Window open minutes: 10 # cometblue device E0:E5:CF:D6:98:53 get device_name # no PIN required diff --git a/cometblue/cli.py b/cometblue/cli.py index e53027a..5ee024f 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -101,7 +101,7 @@ def print_temperatures(self, value): text += 'Target temperature low:\t%.01f °C\n' % value['target_temp_l'] text += 'Target temperature high:\t%.01f °C\n' % value['target_temp_h'] text += 'Offset temperature:\t%.01f °C\n' % value['offset_temp'] - text += 'Window open detection:\t%u\n' % value['window_open_detection'] + text += 'Window open sensitivity:\t%u (1 = low, 4 = high, 8 = mid)\n' % value['window_open_detection'] text += 'Window open minutes:\t%u\n' % value['window_open_minutes'] self._stream.write(text) self._stream.flush() @@ -658,7 +658,7 @@ def temperatures(real_setter): '--window-open-detect', '-w', type=int, default=None, - help='Window open detection') + help='Window open sensitivity (1 = low, 4 = high, 8 = mid)') @click.option( '--window-open-minutes', '-W', type=int, From a62921c9373b9aa37c6e05750b6420887e4ab380 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Fri, 5 Jan 2018 19:34:35 +0100 Subject: [PATCH 12/22] CometBlue device as subclass of gatt --- cometblue/cli.py | 25 +++--- cometblue/device.py | 170 ++++++++++++++++++++--------------------- cometblue/discovery.py | 4 +- 3 files changed, 100 insertions(+), 99 deletions(-) diff --git a/cometblue/cli.py b/cometblue/cli.py index 5ee024f..9a9438c 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -487,27 +487,28 @@ def __init__(self, device): if self._device is None: return - device.manual_connect() + device.connect() def __call__(self): if self._device is None: return - self._device.manual_disconnect() + self._device.disconnect() + def _setup_pin(ctx, pin, pin_file): + if pin_file is not None: + with open(pin_file, 'r') as pin_file: + ctx.obj.pin = int(pin_file.read()) + elif pin is not None: + ctx.obj.pin = int(pin) + else: + ctx.obj.pin = None - if pin_file is not None: - with open(pin_file, 'r') as pin_file: - ctx.obj.pin = int(pin_file.read()) - elif pin is not None: - ctx.obj.pin = int(pin) - else: - ctx.obj.pin = None + _setup_pin(ctx, pin, pin_file) ctx.obj.device_address = address ctx.obj.device = None if address != "00:00:00:00:00:00": - gattdevice = gatt.Device(ctx.obj.device_address, ctx.obj.manager) - ctx.obj.device = cometblue.device.CometBlue(gattdevice, ctx.obj.pin) + ctx.obj.device = cometblue.device.CometBlue(ctx.obj.device_address, ctx.obj.manager, ctx.obj.pin) ctx.call_on_close(connection_manager(ctx.obj.device)) @@ -539,7 +540,7 @@ def __call__(self): def _main(ctx, adapter, poweron, formatter, log_level): _configure_logger(_get_log_level(log_level)) - manager = gatt.DeviceManager(adapter_name = str(adapter)) + manager = cometblue.device.CometBlueManager(adapter_name = str(adapter)) class power_manager(object): def __init__(self, manager, poweron_mgmt): diff --git a/cometblue/device.py b/cometblue/device.py index 2f6d9f8..84b7ce5 100644 --- a/cometblue/device.py +++ b/cometblue/device.py @@ -319,8 +319,14 @@ def _increase_uuid(uuid_str, n): uuid_fields[0] += n return str(uuid_module.UUID(fields=uuid_fields)) +class CometBlueManager(gatt.DeviceManager): + def __init__(self, adapter_name): + super().__init__(adapter_name) -class CometBlue(object): + def make_device(self, mac_address): + return CometBlue(mac_address = mac_address, manager = self) + +class CometBlue(gatt.Device): SUPPORTED_VALUES = { 'device_name': { 'description': 'device name', @@ -423,49 +429,51 @@ class CometBlue(object): }, } - def _read_value(self, uuid, decode, pin_required): - if not self._device.is_connected(): + def _cb_read_value(self, uuid, decode, pin_required): + if not self.is_connected(): raise RuntimeError('Not connected') + if pin_required and (self._pin is None): raise RuntimeError('PIN required') _log.debug('Reading value "%s" from "%s"...', - uuid, self._device.mac_address) + uuid, self.mac_address) - characteristics_handle = self._chars.get(uuid, None) + characteristics_handle = self._cb_chars.get(uuid, None) if characteristics_handle is None: raise RuntimeError('Handle for uuid "%s" not found, perhaps sync issue?' % (uuid)) value = characteristics_handle.read_value() _log.debug('Read value "%s" from "%s": %r', - uuid, self._device.mac_address, value) + uuid, self.mac_address, value) if len(value.signature) != 1: raise RuntimeError('Got more than one value') value = bytes(int(byte) for byte in value) value = decode(value) _log.debug('Decoded value "%s" from "%s": %r', - uuid, self._device.mac_address, value) + uuid, self.mac_address, value) return value - def _read_value_n(self, uuid, decode, pin_required, max_n, n): + def _cb_read_value_n(self, uuid, decode, pin_required, max_n, n): if (n < 0) or (n >= max_n): raise RuntimeError('Invalid table row number') - return self._read_value(_increase_uuid(uuid, n), decode, pin_required) + return self._cb_read_value(_increase_uuid(uuid, n), decode, pin_required) - def _write_value(self, uuid, encode, value): - if not self._device.is_connected(): + def _cb_write_value(self, uuid, encode, value): + if not self.is_connected(): raise RuntimeError('Not connected') + if self._pin is None: raise RuntimeError('PIN required') _log.debug('Writing value "%s" to "%s": %r...', - uuid, self._device.mac_address, value) + uuid, self.mac_address, value) - characteristics_handle = self._chars.get(uuid, None) + characteristics_handle = self._cb_chars.get(uuid, None) if characteristics_handle is None: - if self._chars: + if self._cb_chars: raise NotImplementedError('Device does not offer characteristics with uuid "%s", required to fulfill the request' % (uuid)) else: raise RuntimeError('Handle for characteristics uuid "%s" not found, perhaps sync issue?' % (uuid)) @@ -473,28 +481,40 @@ def _write_value(self, uuid, encode, value): value = encode(value) characteristics_handle.write_value(value) _log.debug('Wrote value "%s" to "%s": %r', - uuid, self._device.mac_address, value) + uuid, self.mac_address, value) - def _write_value_n(self, uuid, encode, max_n, n, value): + def _cb_write_value_n(self, uuid, encode, max_n, n, value): if (n < 0) or (n >= max_n): raise RuntimeError('Invalid table row number') - return self._write_value(_increase_uuid(uuid, n), encode, value) + return self._cb_write_value(_increase_uuid(uuid, n), encode, value) + + @property + def pin(self): + return self._pin + + @pin.setter + def pin(self, _pin): + self._pin = _pin + return self._pin + + def __init__(self, mac_address, manager, pin=None): + super().__init__(mac_address, manager) - def __init__(self, gattDevice, pin=None): - self._device = gattDevice - self._chars = None + self._cb_chars = None self._pin = pin # for manual connect + disconnect vs. __enter__ vs. __exit__ - self._entered = False - self._locked = False + self._enter_nesting = 0 + self._cb_enter_managed_connection = True + self._cb_setup_methods() + def _cb_setup_methods(self): for val_name, val_conf in six.iteritems(self.SUPPORTED_VALUES): if 'decode' in val_conf: setattr( self, 'get_' + val_name, functools.partial( - self._read_value, + self._cb_read_value, str(val_conf['uuid']), val_conf['decode'], val_conf.get('read_requires_pin', False))) @@ -503,7 +523,7 @@ def __init__(self, gattDevice, pin=None): self, 'set_' + val_name, functools.partial( - self._write_value, + self._cb_write_value, str(val_conf['uuid']), val_conf['encode'])) @@ -513,7 +533,7 @@ def __init__(self, gattDevice, pin=None): self, 'get_' + val_name, functools.partial( - self._read_value_n, + self._cb_read_value_n, str(val_conf['uuid']), val_conf['decode'], val_conf.get('read_requires_pin', False), @@ -523,45 +543,28 @@ def __init__(self, gattDevice, pin=None): self, 'set_' + val_name, functools.partial( - self._write_value_n, + self._cb_write_value_n, str(val_conf['uuid']), val_conf['encode'], val_conf['num'])) def __str__(self): return \ - "device_" + self._device.alias() \ - + "@" + self._device.mac_address + "_[" \ - + ("connected" if self._device.is_connected() else "disconnected") \ + "device_" + self.alias() \ + + "@" + self.mac_address + "_[" \ + + ("connected" if self.is_connected() else "disconnected") \ + ", " \ - + ("services resolved" if self._device.is_services_resolved() else "pending service resolution") + "]" - - def _connect(self): - _log.info('Connecting to device "%s"...', self._device.mac_address) - self._device.connect() - - if not self._device.is_connected(): - raise RuntimeError('Failed to connect the device') - - _log.debug('Discovering characteristics for "%s"...', - self._device.mac_address) - - while not self._device.services and self._device.is_connected() and not self._device.is_services_resolved(): - time.sleep(0.020) - if not self._device.is_connected() or not self._device.is_services_resolved(): - raise RuntimeError('Failed to resolve device services') - - # BUG: gatt does not always correctly update service list - self._device.services_resolved() - _log.debug('Characteristics resolved for "%s": %r', self._device.mac_address, self._device.services) + + ("services resolved" if self.is_services_resolved() else "pending service resolution") + "]" - services = self._device.services - self._chars = dict( + def services_resolved(self): + super().services_resolved() + self._cb_chars = dict( (str(characteristics_handle.uuid), characteristics_handle) - for service_handle in services + for service_handle in self.services for characteristics_handle in service_handle.characteristics ) + _log.debug('Discovered characteristics for "%s": %r', - self._device.mac_address, self._chars.keys()) + self.mac_address, self._cb_chars.keys()) if self._pin is not None: try: @@ -569,45 +572,42 @@ def _connect(self): except RuntimeError as exc: raise RuntimeError('Invalid PIN', exc) - _log.info('Connected to device "%s"', self._device.mac_address) - self._entered = True + def __enter__(self): + self._enter_nesting += 1 + if not self.is_connected(): + self.connect() return self - def __enter__(self): - if not self._entered: - self._connect() + def connect(self): + # if connect() is called before __enter__, make it not managed + if self._enter_nesting == 0: + self._cb_enter_managed_connection = False - return self + _log.info('Connecting to device "%s"...', self.mac_address) + super().connect() - def manual_connect(self): - if not self._entered: - self._connect() - self._locked = True + if not self.is_connected(): + raise RuntimeError('Failed to connect the device') - def _disconnect(self): - if not self._entered: - return + def ready(self): + return self.is_connected() and self.is_services_resolved() and self._cb_chars - self._entered = False + def __exit__(self, exc_type, exc_val, exc_tb): + self._enter_nesting -= 1 + + if self._enter_nesting == 0 and self._cb_enter_managed_connection: + self.disconnect() - if not self._device.is_connected(): + def disconnect(self): + if not self.is_connected(): return - _log.info('Disconnecting from device "%s"...', self._device.mac_address) + _log.info('Disconnecting from device "%s"...', self.mac_address) try: - self._device.disconnect() - _log.info('Disconnected from device "%s"', self._device.mac_address) + super().disconnect() + _log.info('Disconnected from device "%s"', self.mac_address) except: - _log.error('Failed disconnect from device "%s", considering disconnected anyway', self._device.mac_address) - - def manual_disconnect(self): - if self._locked: - self._disconnect() - self._locked = False - - def __exit__(self, exc_type, exc_val, exc_tb): - if not self._locked: - self._disconnect() + _log.error('Failed disconnect from device "%s", considering disconnected anyway', self.mac_address) def get_days(self): return list(map(self.get_day, range(7))) @@ -617,7 +617,7 @@ def get_holidays(self): def backup(self): _log.info('Saving all supported values from "%s"...', - self._device.mac_address) + self.mac_address) data = {} @@ -634,7 +634,7 @@ def backup(self): for val_name in 'days', 'holidays': data[val_name] = getattr(self, 'get_' + val_name)() - _log.info('All supported values from "%s" saved', self._device.mac_address) + _log.info('All supported values from "%s" saved', self.mac_address) return data @@ -648,7 +648,7 @@ def set_holidays(self, value): def restore(self, data): _log.info('Restoring values from backup for "%s"...', - self._device.mac_address) + self.mac_address) _log.debug('Backup data: %r', data) for val_name, val_data in six.iteritems(data): @@ -658,4 +658,4 @@ def restore(self, data): self.set_datetime(datetime.datetime.now()) _log.info('Values from backup for "%s" successfully restored', - self._device.mac_address) + self.mac_address) diff --git a/cometblue/discovery.py b/cometblue/discovery.py index c8051a5..7f7c84f 100644 --- a/cometblue/discovery.py +++ b/cometblue/discovery.py @@ -34,12 +34,12 @@ def discover(manager, timeout=10): name = _device.alias() address = _device.mac_address try: - with cometblue.device.CometBlue(_device, None) as device: + with _device as device: manufacturer_name = device.get_manufacturer_name().lower() model_number = device.get_model_number().lower() if (manufacturer_name, model_number) in _SUPPORTED_DEVICES: - filtered_devices[device._device.mac_address] = name + filtered_devices[device.mac_address] = name except RuntimeError as exc: _log.debug('Skipping device "%s" ("%s") because of ' From 48dce9eea348aaaf53c2ac5986f78fa7d2427c4c Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Fri, 5 Jan 2018 19:46:33 +0100 Subject: [PATCH 13/22] Device operations should wait for completion of previous one --- cometblue/device.py | 122 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 118 insertions(+), 4 deletions(-) diff --git a/cometblue/device.py b/cometblue/device.py index 84b7ce5..353eda0 100644 --- a/cometblue/device.py +++ b/cometblue/device.py @@ -436,6 +436,12 @@ def _cb_read_value(self, uuid, decode, pin_required): if pin_required and (self._pin is None): raise RuntimeError('PIN required') + if pin_required: + self._cb_wait_pinok() + + if self.aborter(): + raise StopIteration('Operation aborted due to external request') + _log.debug('Reading value "%s" from "%s"...', uuid, self.mac_address) @@ -461,6 +467,38 @@ def _cb_read_value_n(self, uuid, decode, pin_required, max_n, n): raise RuntimeError('Invalid table row number') return self._cb_read_value(_increase_uuid(uuid, n), decode, pin_required) + def characteristic_write_value_succeeded(self, characteristic): + _log.debug("write for " + characteristic.uuid + " succeeded") + self._cb_writes[characteristic.uuid] = True + + def characteristic_write_value_failed(self, characteristic, error): + self._cb_writes[characteristic.uuid] = False + _log.error('Value write failed for characteristic "%s" with error "%s"' % (characteristic.uuid, error)) + + def _cb_wait_write_result(self, uuid): + iterations_limit = self._cb_complete_timeout / self._cb_complete_sleep + i = 0 + while i < iterations_limit and not self.aborter(): + i += 1 + if not self.is_connected(): + raise StopIteration('Device disconnected while waiting for reply') + + if not self._cb_writes.get(uuid, None) is None: + return self._cb_writes[uuid] + time.sleep(self._cb_complete_sleep) + + if self.aborter(): + raise StopIteration('Operation aborted due to external request') + + raise StopIteration('Operation has not been completed within timeout') + + + def _cb_wait_pinok(self): + uuid = self.SUPPORTED_VALUES['pin']['uuid'] + if not self._cb_wait_write_result(uuid): + _log.debug('Failed to write pin characteristic') + raise StopIteration('Failed to write pin to device') + def _cb_write_value(self, uuid, encode, value): if not self.is_connected(): raise RuntimeError('Not connected') @@ -468,6 +506,13 @@ def _cb_write_value(self, uuid, encode, value): if self._pin is None: raise RuntimeError('PIN required') + # precaution - glib main loop runs in the same thread as services_discovered, + # therefore waiting for pin confirmation inside write would cause livelock, as there + # would be no main loop available for dbus call + pin_uuid = self.SUPPORTED_VALUES['pin']['uuid'] + if pin_uuid != uuid: + self._cb_wait_pinok() + _log.debug('Writing value "%s" to "%s": %r...', uuid, self.mac_address, value) @@ -478,16 +523,44 @@ def _cb_write_value(self, uuid, encode, value): else: raise RuntimeError('Handle for characteristics uuid "%s" not found, perhaps sync issue?' % (uuid)) + self._cb_writes[uuid] = None value = encode(value) characteristics_handle.write_value(value) - _log.debug('Wrote value "%s" to "%s": %r', - uuid, self.mac_address, value) + + if not self.blocking: + _log.debug('Assuming successfull write "%s" to "%s": %r', uuid, self.mac_address, value) + return + + if self._cb_wait_write_result(uuid): + _log.debug('Confirmed write value "%s" to "%s": %r', uuid, self.mac_address, value) + return + + _log.debug('Write failed for "%s" to "%s": %r', uuid, self.mac_address, value) + def _cb_write_value_n(self, uuid, encode, max_n, n, value): if (n < 0) or (n >= max_n): raise RuntimeError('Invalid table row number') return self._cb_write_value(_increase_uuid(uuid, n), encode, value) + @property + def blocking(self): + return self._blocking + + @blocking.setter + def blocking(self, blocking): + self._blocking = blocking + + @property + def aborter(self): + return self._aborter + + @aborter.setter + def aborter(self, aborter): + if aborter is None: + aborter = lambda: False + self._aborter = aborter + @property def pin(self): return self._pin @@ -497,15 +570,20 @@ def pin(self, _pin): self._pin = _pin return self._pin - def __init__(self, mac_address, manager, pin=None): + def __init__(self, mac_address, manager, pin=None, aborter=None): super().__init__(mac_address, manager) self._cb_chars = None + self._cb_writes = {} self._pin = pin # for manual connect + disconnect vs. __enter__ vs. __exit__ self._enter_nesting = 0 self._cb_enter_managed_connection = True self._cb_setup_methods() + self._blocking = True + self.aborter = aborter + self._cb_complete_timeout = 60 + self._cb_complete_sleep = 0.050 def _cb_setup_methods(self): for val_name, val_conf in six.iteritems(self.SUPPORTED_VALUES): @@ -556,6 +634,20 @@ def __str__(self): + ", " \ + ("services resolved" if self.is_services_resolved() else "pending service resolution") + "]" + def enumerate_unhandled_characteristics(self): + handled = [] + for _, simple in self.SUPPORTED_VALUES.items(): + handled.append(simple['uuid']) + for _, tabbed in self.SUPPORTED_TABLE_VALUES.items(): + for i in range(tabbed['num']): + handled.append(_increase_uuid(tabbed['uuid'], i)) + + unhandled_characteristics = [] + for characteristics in self._cb_chars.keys(): + if characteristics not in handled: + unhandled_characteristics.append(characteristics) + return unhandled_characteristics + def services_resolved(self): super().services_resolved() self._cb_chars = dict( @@ -568,14 +660,28 @@ def services_resolved(self): if self._pin is not None: try: + self.blocking = False self.set_pin(self._pin) except RuntimeError as exc: raise RuntimeError('Invalid PIN', exc) + finally: + self.blocking = True + + unhandled_characteristics = self.enumerate_unhandled_characteristics() + if unhandled_characteristics: + _log.info('Unknown characteristics discovered on "%s": %r', + self.mac_address, unhandled_characteristics) + def __enter__(self): self._enter_nesting += 1 if not self.is_connected(): self.connect() + + self.attempt_to_get_ready() + if not self.ready(): + raise RuntimeError("Unable to connect & resolve the device") + return self def connect(self): @@ -589,8 +695,16 @@ def connect(self): if not self.is_connected(): raise RuntimeError('Failed to connect the device') + def attempt_to_get_ready(self): + iterations_limit = self._cb_complete_timeout / self._cb_complete_sleep + i = 0 + while not self.ready() and i < iterations_limit: + i += 1 + time.sleep(self._cb_complete_sleep) + return self.ready() + def ready(self): - return self.is_connected() and self.is_services_resolved() and self._cb_chars + return self.is_connected() and self.is_services_resolved() and bool(self._cb_chars) def __exit__(self, exc_type, exc_val, exc_tb): self._enter_nesting -= 1 From c675d02bbc245348847ef92ad3e4a3af67045cc1 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Fri, 5 Jan 2018 19:47:50 +0100 Subject: [PATCH 14/22] Split device discovery to candidate probing + candidate discovery --- cometblue/discovery.py | 46 +++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/cometblue/discovery.py b/cometblue/discovery.py index 7f7c84f..311e640 100644 --- a/cometblue/discovery.py +++ b/cometblue/discovery.py @@ -16,10 +16,24 @@ _log = logging.getLogger(__name__) +def probe_candidate(_device): + name = _device.alias() + address = _device.mac_address + try: + with _device as device: + manufacturer_name = device.get_manufacturer_name().lower() + model_number = device.get_model_number().lower() -def discover(manager, timeout=10): - _log.info('Starting discovery on adapter "%s" with %u seconds timeout...', - manager.adapter_name, timeout) + if (manufacturer_name, model_number) in _SUPPORTED_DEVICES: + return (device.mac_address, str(name)) + + except RuntimeError as exc: + _log.debug('Skipping device "%s" ("%s"), reason: %r' % (name, address, str(exc))) + return None + + +def discover_candidates(manager, timeout=10): + _log.info('Probing for candidate devices...') manager.start_discovery() time.sleep(timeout) @@ -27,23 +41,17 @@ def discover(manager, timeout=10): devices = manager.devices() _log.debug('All discovered devices: %r', [(device.mac_address, str(device.alias())) for device in devices]) + return devices - filtered_devices = {} - - for _device in devices: - name = _device.alias() - address = _device.mac_address - try: - with _device as device: - manufacturer_name = device.get_manufacturer_name().lower() - model_number = device.get_model_number().lower() - - if (manufacturer_name, model_number) in _SUPPORTED_DEVICES: - filtered_devices[device.mac_address] = name - - except RuntimeError as exc: - _log.debug('Skipping device "%s" ("%s") because of ' - 'exception: %r' % (name, address, exc)) +def discover(manager, timeout=10): + _log.info('Starting discovery on adapter "%s" with %u seconds timeout...', + manager.adapter_name, timeout) + devices = discover_candidates(manager, timeout) + filtered_devices = {} + for device in devices: + device_entry = cometblue.discovery.probe_candidate(device) + if not device_entry is None: + filtered_devices.update(dict([device_entry])) _log.info('Discovery finished') return filtered_devices From b99861b538b780f1cd081dcceb6cb988ac52ee8e Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Fri, 5 Jan 2018 20:02:23 +0100 Subject: [PATCH 15/22] Rewrite CLI to counteract BUG in gatt https://github.com/getsenic/gatt-python/issues/5 --- cometblue/cli.py | 374 ++++++++++++++++++++++++++++++----------------- 1 file changed, 239 insertions(+), 135 deletions(-) diff --git a/cometblue/cli.py b/cometblue/cli.py index 9a9438c..3a0f985 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -20,6 +20,9 @@ import cometblue.device import cometblue.discovery +import threading +from collections import deque + _SHELL_VAR_PREFIX = 'COMETBLUE_' _WEEK_DAYS = ('mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun') @@ -263,6 +266,23 @@ def _parse_datetime(datetime_str): datetime_str, '%Y-%m-%dT%H:%M:%S') +class Command(object): + def __init__(self, routine, *args): + self._routine = routine + self._args = args + + def __call__(self): + return self._routine(*self._args) + + +def _queue_command(ctx, *cmdargs): + ctx.obj.commands.append(Command(*cmdargs)) +def _inject_command(ctx, *cmdargs): + ctx.obj.commands.appendleft(Command(*cmdargs)) +def _queue_cleanup(ctx, *cmdargs): + ctx.obj.cleanup.append(Command(*cmdargs)) + + @click.command( 'discover', help='Discover "Comet Blue" Bluetooth LE devices (might take a while)', @@ -275,10 +295,15 @@ def _parse_datetime(datetime_str): help='Device discovery timeout in seconds') @click.pass_context def _discover(ctx, timeout): - devices = cometblue.discovery.discover(ctx.obj.manager, timeout) - devices = [dict(name=name, address=address) + def _discover_command(manager, timeout, formatter): + devices = cometblue.discovery.discover(manager, timeout) + devices = [dict(name=name, address=address) for address, name in six.iteritems(devices)] - ctx.obj.formatter.print_discovered_devices(devices) + formatter.print_discovered_devices(devices) + _log.info('Starting discovery on adapter "%s" with %u seconds timeout...', + manager.adapter_name, timeout) + return 0 + _queue_command(ctx, _discover_command, ctx.obj.manager, timeout, ctx.obj.formatter) @click.command( @@ -286,10 +311,12 @@ def _discover(ctx, timeout): help='Get configured periods per days of the week (requires PIN)') @click.pass_context def _device_get_days(ctx): - with ctx.obj.device as device: + def _device_get_days_command(device): days = device.get_days() - ctx.obj.formatter.print_days(days) + ctx.obj.formatter.print_days(days) + return 0 + _queue_command(ctx, _device_get_days_command, ctx.obj.device) @click.command( @@ -297,10 +324,12 @@ def _device_get_days(ctx): help='Get configured holidays (requires PIN)') @click.pass_context def _device_get_holidays(ctx): - with ctx.obj.device as device: + def _device_get_holidays_command(device): holidays = device.get_holidays() - ctx.obj.formatter.print_holidays(holidays) + ctx.obj.formatter.print_holidays(holidays) + return 0 + _queue_command(ctx, _device_get_holidays_command, ctx.obj.device) @click.group( @@ -322,37 +351,40 @@ def _device_get(): nargs=-1) @click.pass_context def _device_set_day(ctx, day, period): - try: - day_index = int(day) - 1 - except ValueError: - day_index = None - for day_n, day_name in zip(itertools.count(), _WEEK_DAYS): - if day.lower().startswith(day_name): - day_index = day_n - break - if day_index is None: - raise RuntimeError('Unknown day: "%s"' % day) - - periods = [] - for one_period in period: - str_start, str_end = tuple(map(lambda s: s.strip(), - one_period.split('-'))) - - if str_start: - start = _parse_time(str_start) - else: - start = datetime.time() + def _device_set_day_command(device, day, period): + try: + day_index = int(day) - 1 + except ValueError: + day_index = None + for day_n, day_name in zip(itertools.count(), _WEEK_DAYS): + if day.lower().startswith(day_name): + day_index = day_n + break + if day_index is None: + raise RuntimeError('Unknown day: "%s"' % day) + + periods = [] + for one_period in period: + str_start, str_end = tuple(map(lambda s: s.strip(), + one_period.split('-'))) + + if str_start: + start = _parse_time(str_start) + else: + start = datetime.time() - if str_end: - end = _parse_time(str_end) - else: - end = datetime.time(23, 59, 59) + if str_end: + end = _parse_time(str_end) + else: + end = datetime.time(23, 59, 59) - periods.append(dict(start=start, end=end)) + periods.append(dict(start=start, end=end)) - with ctx.obj.device as device: device.set_day(day_index, periods) + return 0 + _queue_command(ctx, _device_set_day_command, ctx.obj.device, day, period) + @click.command( 'holiday', @@ -375,21 +407,23 @@ def _device_set_day(ctx, day, period): default=None) @click.pass_context def _device_set_holiday(ctx, holiday, start, end, temperature): - holiday_index = int(holiday) - 1 + def _device_set_holiday_command(device, holiday, start, end, temperature): + holiday_index = int(holiday) - 1 - if any(map(lambda v: v is None, (start, end, temperature))): - start = None - end = None - temperature = None + if any(map(lambda v: v is None, (start, end, temperature))): + start = None + end = None + temperature = None - holiday_data = { - 'start': _parse_datetime(start), - 'end': _parse_datetime(end), - 'temp': temperature, - } + holiday_data = { + 'start': _parse_datetime(start), + 'end': _parse_datetime(end), + 'temp': temperature, + } - with ctx.obj.device as device: device.set_holiday(holiday_index, holiday_data) + return 0 + _queue_command(ctx, __device_set_holiday_command, ctx.obj.device, holiday, start, end, temperature) @click.group( @@ -410,15 +444,17 @@ def _device_set(): required=False) @click.pass_context def _device_backup(ctx, file_name): - with ctx.obj.device as device: + def _device_backup_command(device, file_name): backup = device.backup() - if file_name is None: - json.dump(backup, sys.stdout, default=_json_default_serializer) - sys.stdout.flush() - else: - with open(file_name, 'w') as backup_file: - json.dump(backup, backup_file, default=_json_default_serializer) + if file_name is None: + json.dump(backup, sys.stdout, default=_json_default_serializer) + sys.stdout.flush() + else: + with open(file_name, 'w') as backup_file: + json.dump(backup, backup_file, default=_json_default_serializer) + return 0 + _queue_command(ctx, _device_backup_command, ctx.obj.device, file_name) @click.command( @@ -431,30 +467,33 @@ def _device_backup(ctx, file_name): required=False) @click.pass_context def _device_restore(ctx, file_name): - if file_name is None: - backup = json.load(sys.stdin) - else: - with open(file_name, 'r') as backup_file: - backup = json.load(backup_file) - - if 'days' in backup: - backup['days'] = [ - [dict(start=_parse_time(period['start']), - end=_parse_time(period['end'])) - for period in day] - for day in backup['days'] - ] - - if 'holidays' in backup: - backup['holidays'] = [ - dict(start=_parse_datetime(holiday['start']), - end=_parse_datetime(holiday['end']), - temp=holiday['temp']) - for holiday in backup['holidays'] - ] - - with ctx.obj.device as device: + def _device_restore_command(device, file_name): + if file_name is None: + backup = json.load(sys.stdin) + else: + with open(file_name, 'r') as backup_file: + backup = json.load(backup_file) + + if 'days' in backup: + backup['days'] = [ + [dict(start=_parse_time(period['start']), + end=_parse_time(period['end'])) + for period in day] + for day in backup['days'] + ] + + if 'holidays' in backup: + backup['holidays'] = [ + dict(start=_parse_datetime(holiday['start']), + end=_parse_datetime(holiday['end']), + temp=holiday['temp']) + for holiday in backup['holidays'] + ] + device.restore(backup) + return 0 + + _queue_command(ctx, _device_restore_command, ctx.obj.device, file_name) @click.group( @@ -477,40 +516,45 @@ def _device_restore(ctx, file_name): def _device(ctx, address, pin, pin_file): ''' Get or set values. - - You may use address 00:00:00:00:00:00 to access subcommand help without a real device. ''' - - class connection_manager(object): - def __init__(self, device): - self._device = device - if self._device is None: - return - - device.connect() - - def __call__(self): - if self._device is None: - return - - self._device.disconnect() - def _setup_pin(ctx, pin, pin_file): + def _setup_pin(pin, pin_file): if pin_file is not None: with open(pin_file, 'r') as pin_file: - ctx.obj.pin = int(pin_file.read()) + return int(pin_file.read()) elif pin is not None: - ctx.obj.pin = int(pin) + return int(pin) else: - ctx.obj.pin = None + return None + + pin = _setup_pin(pin, pin_file) + device = cometblue.device.CometBlue(address, ctx.obj.manager, pin) + ctx.obj.device = device + + def _device_connect_command(device): + device.connect() + return 0 if device.is_connected() else 1 + def _device_disconnect_command(device): + device.disconnect() + return 0 - _setup_pin(ctx, pin, pin_file) + _queue_command(ctx, _device_connect_command, device) + _queue_cleanup(ctx, _device_disconnect_command, device) - ctx.obj.device_address = address - ctx.obj.device = None - if address != "00:00:00:00:00:00": - ctx.obj.device = cometblue.device.CometBlue(ctx.obj.device_address, ctx.obj.manager, ctx.obj.pin) + def _wait_for_device_ready_command(device): + _log.info('Waiting for device handler to become ready...') + + if device is None: + return + + device.attempt_to_get_ready() + if not device.ready(): + raise RuntimeError("Waited for device to become ready for too long, aborting") + _log.debug("Device reports ready") + + return 0 + + _queue_command(ctx, _wait_for_device_ready_command, device) - ctx.call_on_close(connection_manager(ctx.obj.device)) @click.group( context_settings={'help_option_names': ['-h', '--help']}, @@ -539,29 +583,7 @@ def _setup_pin(ctx, pin, pin_file): @click.pass_context def _main(ctx, adapter, poweron, formatter, log_level): _configure_logger(_get_log_level(log_level)) - manager = cometblue.device.CometBlueManager(adapter_name = str(adapter)) - - class power_manager(object): - def __init__(self, manager, poweron_mgmt): - self._manager = manager - self._poweron_mgmt = poweron_mgmt and not manager.is_adapter_powered - if not self._poweron_mgmt: - return - - _log.debug('Powering on bluetooth adapter %s' % (self._manager.adapter_name)) - self._manager.is_adapter_powered = True - - def __call__(self): - if not self._poweron_mgmt: - return - - _log.debug('Shutting down bluetooth adapter %s' % (self._manager.adapter_name)) - self._manager.is_adapter_powered = False - - ctx.obj.manager = manager - ctx.call_on_close(power_manager(ctx.obj.manager, poweron)) - if formatter == 'json': ctx.obj.formatter = _JSONFormatter() elif formatter == 'human-readable': @@ -569,7 +591,21 @@ def __call__(self): else: ctx.obj.formatter = _ShellVarFormatter() - return os.EX_OK + def _main_command(ctx, manager, poweron): + def _powerdown_adapter_command(manager): + _log.debug('Shutting down bluetooth adapter %s' % (manager.adapter_name)) + manager.is_adapter_powered = False + + poweron_mgmt = poweron and not manager.is_adapter_powered + if poweron_mgmt: + _log.debug('Powering on bluetooth adapter %s' % (manager.adapter_name)) + manager.is_adapter_powered = True + _queue_cleanup(ctx, _powerdown_adapter_command, manager) + + return 0 + + ctx.obj.manager = manager + _queue_command(ctx, _main_command, ctx, manager, poweron) class _SetterFunctions(object): @@ -705,11 +741,13 @@ def _enroll_subcommands(): if 'decode' in val_conf: def get_fn_with_name(get_fn_name, print_fn_name): def real_get_fn(ctx): - with ctx.obj.device as device: + def _get_command(device): value = getattr(device, get_fn_name)() - print_fn = getattr(ctx.obj.formatter, print_fn_name) - print_fn(value) + print_fn = getattr(ctx.obj.formatter, print_fn_name) + print_fn(value) + return 0 + _queue_command(ctx, _get_command, ctx.obj.device) return real_get_fn @@ -728,8 +766,9 @@ def real_get_fn(ctx): if 'encode' in val_conf: def set_fn_with_name(set_fn_name): def real_set_fn(ctx, value): - with ctx.obj.device as device: + def _set_command(device): getattr(device, set_fn_name)(value) + _queue_command(ctx, _set_command, ctx.obj.device) return real_set_fn @@ -743,9 +782,7 @@ def real_set_fn(ctx, value): _device_set.add_command(set_fn) -def _init_command_processing(): - _configure_logger() - +def _init_command_parsing(): _enroll_subcommands() _main.add_command(_discover) @@ -765,18 +802,85 @@ def _init_command_processing(): context = _ContextObj() return context + +# Bug in gatt-python, see https://github.com/getsenic/gatt-python/issues/5 +# efectively, this means that glib main loop has to run somewhere... +class ManagerThread(threading.Thread): + def __init__(self, manager, kill_event): + super().__init__() + self._manager = manager + self._kill_event = kill_event + + def run(self): + _log.debug("Manager thread running") + while True: + try: + self._manager.run() + break + except KeyboardInterrupt as ex: + self._kill_event.set() + _log.debug("Manager thread received kill, dropping out to notify main thread") + continue + + _log.debug("Manager thread done") + + def join(self): + self._manager.stop() + _log.debug("Manager thread done (join)") + super().join() + +class CliThread(threading.Thread): + def __init__(self, commands, kill_event): + super().__init__() + self._commands = commands + self._kill_event = kill_event + + def run(self): + try: + # command parsing done, start manager thread + while self._commands and not self._kill_event.is_set(): + command = self._commands.popleft() + rv = command() + if rv != 0: + break + except Exception as ex: + _log.error('Command processing returned exception: ' + str(ex)) + self._kill_event.set() + def cli_main(argv): - context = _init_command_processing() + _configure_logger() + context = _init_command_parsing() + context.commands = deque() + context.cleanup = deque() + # click is from now on used only for command parsing rv = 0 try: rv = _main(obj=context, args=argv) - except RuntimeError as err: - print(str(err), file=sys.stderr) - rv = -1 except SystemExit: pass - return rv + except (RuntimeError, Exception) as err: + _log.error(str(err)) + return -1 + + somebody_killed = threading.Event() + manager_thread = ManagerThread(context.manager, somebody_killed) + cli_thread = CliThread(context.commands, somebody_killed) + + context.device.aborter = lambda: somebody_killed.is_set() + + manager_thread.start() + cli_thread.start() + + # wait for either thread to be done + somebody_killed.wait() + cli_thread.join() + + while context.cleanup: + command = context.cleanup.popleft() + rv = command() + + manager_thread.join() def main(): return cli_main(sys.argv[1:]) From d15d6dede0aaf9b0cda975d39706f627ca536d70 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Fri, 5 Jan 2018 20:03:36 +0100 Subject: [PATCH 16/22] Implement discovery as cli command set in order to support aborting scan --- cometblue/cli.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/cometblue/cli.py b/cometblue/cli.py index 3a0f985..b5d71e8 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -295,15 +295,32 @@ def _queue_cleanup(ctx, *cmdargs): help='Device discovery timeout in seconds') @click.pass_context def _discover(ctx, timeout): - def _discover_command(manager, timeout, formatter): - devices = cometblue.discovery.discover(manager, timeout) - devices = [dict(name=name, address=address) - for address, name in six.iteritems(devices)] - formatter.print_discovered_devices(devices) + # this duplicates functionality of discovery.discover(), yet for convinience implemented as series of commands + + filtered_devices = {} + + def _probe_command(device, filtered_devices): + device_entry = cometblue.discovery.probe_candidate(device) + if not device_entry is None: + filtered_devices.update(dict([device_entry])) + return 0 + + def _discovery_results_command(filtered_devices, formatter): + devices = [dict(name=name, address=address) for address, name in six.iteritems(filtered_devices)] + formatter.print_discovered_devices(devices) + return 0 + + def _discover_command(manager, timeout, filtered_devices): _log.info('Starting discovery on adapter "%s" with %u seconds timeout...', manager.adapter_name, timeout) + + devices = cometblue.discovery.discover_candidates(manager, timeout) + for device in devices: + _inject_command(ctx, _probe_command, device, filtered_devices) return 0 - _queue_command(ctx, _discover_command, ctx.obj.manager, timeout, ctx.obj.formatter) + + _queue_command(ctx, _discover_command, ctx.obj.manager, timeout, filtered_devices) + _queue_command(ctx, _discovery_results_command, filtered_devices, ctx.obj.formatter) @click.command( From c32e9725e6b3b56a38f1211702374a75da98d39a Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Sat, 6 Jan 2018 21:03:32 +0100 Subject: [PATCH 17/22] FIX: fix typo in status setting (leftover from initial gatt-python implementation) --- cometblue/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cometblue/cli.py b/cometblue/cli.py index b5d71e8..8a12c94 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -675,7 +675,7 @@ def set_status(ctx, childlock, manual_mode, adapting): raise RuntimeError( 'No status flags to update, try "status -h"') - if ctx.obj.device._device.is_connected(): + if ctx.obj.device.is_connected(): current = ctx.obj.device.get_status() current = dict((k, v) for k, v in current.items() if k in keys) for k, v in status.items(): From 67c2e31b0dbd1245a461fac0bf847107ba376e57 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Tue, 9 Jan 2018 23:11:13 +0100 Subject: [PATCH 18/22] Do not die on bluez device error upon discovery --- cometblue/cli.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/cometblue/cli.py b/cometblue/cli.py index 8a12c94..223f3f4 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -300,9 +300,13 @@ def _discover(ctx, timeout): filtered_devices = {} def _probe_command(device, filtered_devices): - device_entry = cometblue.discovery.probe_candidate(device) - if not device_entry is None: - filtered_devices.update(dict([device_entry])) + try: + device_entry = cometblue.discovery.probe_candidate(device) + if not device_entry is None: + filtered_devices.update(dict([device_entry])) + except RuntimeError as e: + _log.debug("Probe failed for " + device + " with error: %s", e) + pass return 0 def _discovery_results_command(filtered_devices, formatter): @@ -884,7 +888,10 @@ def cli_main(argv): manager_thread = ManagerThread(context.manager, somebody_killed) cli_thread = CliThread(context.commands, somebody_killed) - context.device.aborter = lambda: somebody_killed.is_set() + try: + context.device.aborter = lambda: somebody_killed.is_set() + except AttributeError: + pass manager_thread.start() cli_thread.start() From 17915800319e5096bfad3829249eb835b7ccea36 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Fri, 19 Jan 2018 19:30:06 +0100 Subject: [PATCH 19/22] Improve error handling --- cometblue/cli.py | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/cometblue/cli.py b/cometblue/cli.py index 223f3f4..5b0dcfc 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -827,14 +827,14 @@ def _init_command_parsing(): # Bug in gatt-python, see https://github.com/getsenic/gatt-python/issues/5 # efectively, this means that glib main loop has to run somewhere... class ManagerThread(threading.Thread): - def __init__(self, manager, kill_event): + def __init__(self, context, kill_event): super().__init__() - self._manager = manager + self._manager = context.manager self._kill_event = kill_event def run(self): _log.debug("Manager thread running") - while True: + while self._manager: try: self._manager.run() break @@ -846,15 +846,18 @@ def run(self): _log.debug("Manager thread done") def join(self): - self._manager.stop() + if self._manager: + self._manager.stop() _log.debug("Manager thread done (join)") super().join() class CliThread(threading.Thread): - def __init__(self, commands, kill_event): + def __init__(self, context, kill_event): super().__init__() - self._commands = commands + self._commands = context.commands self._kill_event = kill_event + if context.device: + context.device.aborter = lambda: kill_event.is_set() def run(self): try: @@ -873,9 +876,14 @@ def cli_main(argv): context = _init_command_parsing() context.commands = deque() context.cleanup = deque() + context.manager = None + context.device = None # click is from now on used only for command parsing rv = 0 + somebody_killed = threading.Event() + manager_thread = None + cli_thread = None try: rv = _main(obj=context, args=argv) except SystemExit: @@ -883,15 +891,9 @@ def cli_main(argv): except (RuntimeError, Exception) as err: _log.error(str(err)) return -1 - - somebody_killed = threading.Event() - manager_thread = ManagerThread(context.manager, somebody_killed) - cli_thread = CliThread(context.commands, somebody_killed) - - try: - context.device.aborter = lambda: somebody_killed.is_set() - except AttributeError: - pass + + manager_thread = ManagerThread(context, somebody_killed) + cli_thread = CliThread(context, somebody_killed) manager_thread.start() cli_thread.start() From 5a717b818e83f49787259de9ea879569ca7d9c56 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Wed, 21 Mar 2018 12:02:29 +0100 Subject: [PATCH 20/22] FIX: Reset internal connection state after disconnect (broken connect()->disconnect()->connect()) --- cometblue/device.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cometblue/device.py b/cometblue/device.py index 353eda0..51bdde5 100644 --- a/cometblue/device.py +++ b/cometblue/device.py @@ -720,6 +720,8 @@ def disconnect(self): try: super().disconnect() _log.info('Disconnected from device "%s"', self.mac_address) + self._cb_chars = None + self._cb_writes = {} except: _log.error('Failed disconnect from device "%s", considering disconnected anyway', self.mac_address) From 7f21215553637d7abf0550991e65fae0d6b75135 Mon Sep 17 00:00:00 2001 From: Lukas Rucka <359687@mail.muni.cz> Date: Sun, 10 Jun 2018 10:01:43 +0200 Subject: [PATCH 21/22] Reformulated description on lcd timer (has nothing to do with timer, it's display blank timeout) --- README.md | 4 ++-- cometblue/cli.py | 23 ++++++++++++----------- cometblue/device.py | 18 +++++++++--------- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 48bb4af..39e7869 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Commands: firmware_revision Get firmware revision firmware_revision2 Get firmware revision #2 (requires PIN) holidays Get configured holidays (requires PIN) - lcd_timer Get LCD timer (requires PIN) + lcd_timeout Get LCD timeout settings (requires PIN) manufacturer_name Get manufacturer name model_number Get model number software_revision Get software revision @@ -139,7 +139,7 @@ Commands: datetime Set time and date (requires PIN) day Set periods per days of the week (requires... holiday Set period and temperature for holiday... - lcd_timer Set LCD timer (requires PIN) + lcd_timeout Set LCD blank timeout (requires PIN) pin Set PIN (requires PIN) status Set status (requires PIN) temperatures Set temperatures (requires PIN) diff --git a/cometblue/cli.py b/cometblue/cli.py index 5b0dcfc..448de80 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -125,8 +125,9 @@ def print_status(self, value): self._stream.write(text) self._stream.flush() - def print_lcd_timer(self, value): - self._print_simple('%02u:%02u' % (value['preload'], value['current'])) + def print_lcd_timeout(self, value): + self._print_simple('Default timeout:\t%02u' % (value['default']) + self._print_simple('Timeout in progress:\t%02u' % (value['current'])) def print_days(self, value): table = zip( @@ -211,9 +212,9 @@ def print_temperatures(self, value): var_name.upper(), shellescape.quote(val_str))) self._stream.flush() - def print_lcd_timer(self, value): - self._print_simple('lcd_timer_preload', '%u' % value['preload']) - self._print_simple('lcd_timer_current', '%u' % value['current']) + def print_lcd_timeout(self, value): + self._print_simple('lcd_timeout_default', '%u' % value['default']) + self._print_simple('lcd_timeout_current', '%u' % value['current']) def print_days(self, value): for day_n, day in zip(itertools.count(), value): @@ -742,18 +743,18 @@ def set_temperatures(ctx, temp_manual, temp_target_low, return set_temperatures @staticmethod - def lcd_timer(real_setter): + def lcd_timeout(real_setter): @click.argument( 'value', required=True) @click.pass_context - def set_lcd_timer(ctx, value): - lcd_timer = { - 'preload': int(value), + def set_lcd_timeout(ctx, value): + lcd_timeout = { + 'default': int(value), } - real_setter(ctx, lcd_timer) + real_setter(ctx, lcd_timeout) - return set_lcd_timer + return set_lcd_timeout def _enroll_subcommands(): diff --git a/cometblue/device.py b/cometblue/device.py index 51bdde5..c1bac50 100644 --- a/cometblue/device.py +++ b/cometblue/device.py @@ -147,18 +147,18 @@ def _decode_battery(value): return value -def _decode_lcd_timer(value): - preload, current = struct.unpack(_LCD_TIMER_STRUCT_PACKING, value) +def _decode_lcd_timeout(value): + default, current = struct.unpack(_LCD_TIMER_STRUCT_PACKING, value) return { - 'preload': preload, + 'default': default, 'current': current, } -def _encode_lcd_timer(lcd_timer): +def _encode_lcd_timeout(lcd_timeout): return struct.pack( _LCD_TIMER_STRUCT_PACKING, - lcd_timer['preload'], + lcd_timeout['default'], 0) @@ -396,12 +396,12 @@ class CometBlue(gatt.Device): 'decode': _decode_str, }, - 'lcd_timer': { - 'description': 'LCD timer', + 'lcd_timeout': { + 'description': 'LCD timeout', 'uuid': '47e9ee2e-47e9-11e4-8939-164230d1df67', 'read_requires_pin': True, - 'decode': _decode_lcd_timer, - 'encode': _encode_lcd_timer, + 'decode': _decode_lcd_timeout, + 'encode': _encode_lcd_timeout, }, 'pin': { From e405d73e0654279cec09999adf9c33c80ee50c6e Mon Sep 17 00:00:00 2001 From: lukeIam Date: Sat, 30 Jun 2018 17:59:17 +0200 Subject: [PATCH 22/22] Fix: closing bracket missing in cli.py ``` File "/usr/local/lib/python3.5/dist-packages/cometblue/cli.py", line 130 self._print_simple('Timeout in progress:\t%02u' % (value['current'])) ^ SyntaxError: invalid syntax ``` --- cometblue/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cometblue/cli.py b/cometblue/cli.py index 448de80..27cf9c1 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -126,7 +126,7 @@ def print_status(self, value): self._stream.flush() def print_lcd_timeout(self, value): - self._print_simple('Default timeout:\t%02u' % (value['default']) + self._print_simple('Default timeout:\t%02u' % (value['default'])) self._print_simple('Timeout in progress:\t%02u' % (value['current'])) def print_days(self, value):