diff --git a/README.md b/README.md index 4a6b2ae..39e7869 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,20 @@ 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: ``` # 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 @@ -28,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] @@ -86,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) + lcd_timeout Get LCD timeout settings (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: @@ -103,13 +108,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 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 Comet Blue @@ -134,8 +139,9 @@ 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) ``` Usage examples: @@ -214,10 +220,8 @@ Usage example: - http://torsten-traenkner.de/wissen/smarthome/heizung.php ## TODO -- Support flags - 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 6d6c2b2..27cf9c1 100644 --- a/cometblue/cli.py +++ b/cometblue/cli.py @@ -1,4 +1,6 @@ +#!/usr/bin/python3 # -*- coding: utf-8 -*- +# vim: tabstop=4 shiftwidth=4 expandtab from __future__ import absolute_import import datetime @@ -8,6 +10,7 @@ import logging import os import sys +import gatt import click import shellescape @@ -17,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') @@ -93,18 +99,35 @@ 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 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() + + 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'])) + 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( @@ -189,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): @@ -244,9 +267,27 @@ 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') + 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, @@ -255,11 +296,36 @@ 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 = [dict(name=name, address=address) - for address, name in six.iteritems(devices)] - ctx.obj.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): + 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): + 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, filtered_devices) + _queue_command(ctx, _discovery_results_command, filtered_devices, ctx.obj.formatter) @click.command( @@ -267,13 +333,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 cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) 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( @@ -281,18 +346,18 @@ 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: + 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( 'get', - help='Get value') + help='Get value', + chain=True) def _device_get(): pass @@ -308,40 +373,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 cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) 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', @@ -364,29 +429,29 @@ 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 - - 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, - } - - with cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) as device: + 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 + + holiday_data = { + 'start': _parse_datetime(start), + 'end': _parse_datetime(end), + 'temp': temperature, + } + 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( 'set', - help='Set value (always requires PIN)') + help='Set value (always requires PIN)', + chain=True) def _device_set(): pass @@ -401,18 +466,17 @@ 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: + 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( @@ -425,38 +489,40 @@ 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 cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) 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( 'device', - help='Get or set values') + short_help='Get or set values' + #, chain=True + ) @click.option( '--pin', '-p', default=None, @@ -470,15 +536,46 @@ 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. + ''' + def _setup_pin(pin, pin_file): + if pin_file is not None: + with open(pin_file, 'r') as pin_file: + return int(pin_file.read()) + elif pin is not None: + return int(pin) + else: + return 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 + 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 + + _queue_command(ctx, _device_connect_command, device) + _queue_cleanup(ctx, _device_disconnect_command, device) + + 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) @click.group( @@ -489,6 +586,12 @@ def _device(ctx, address, pin, pin_file): 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')), @@ -500,11 +603,9 @@ def _device(ctx, address, pin, pin_file): 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)) - - ctx.obj.adapter = adapter - + manager = cometblue.device.CometBlueManager(adapter_name = str(adapter)) if formatter == 'json': ctx.obj.formatter = _JSONFormatter() elif formatter == 'human-readable': @@ -512,7 +613,21 @@ def _main(ctx, adapter, formatter, log_level): 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): @@ -544,6 +659,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.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( @@ -570,7 +717,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, @@ -596,34 +743,33 @@ 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 _add_values(): +def _enroll_subcommands(): for val_name, val_conf in six.iteritems( cometblue.device.CometBlue.SUPPORTED_VALUES): 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: + 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 @@ -642,11 +788,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 cometblue.device.CometBlue( - ctx.obj.device_address, - adapter=ctx.obj.adapter, - pin=ctx.obj.pin) as device: + def _set_command(device): getattr(device, set_fn_name)(value) + _queue_command(ctx, _set_command, ctx.obj.device) return real_set_fn @@ -660,10 +804,8 @@ def real_set_fn(ctx, value): _device_set.add_command(set_fn) -def main(): - _configure_logger() - - _add_values() +def _init_command_parsing(): + _enroll_subcommands() _main.add_command(_discover) _main.add_command(_device) @@ -679,8 +821,96 @@ def main(): _device_set.add_command(_device_set_day) _device_set.add_command(_device_set_holiday) - return _main(obj=_ContextObj()) + 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, context, kill_event): + super().__init__() + self._manager = context.manager + self._kill_event = kill_event + + def run(self): + _log.debug("Manager thread running") + while self._manager: + 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): + if self._manager: + self._manager.stop() + _log.debug("Manager thread done (join)") + super().join() + +class CliThread(threading.Thread): + def __init__(self, context, kill_event): + super().__init__() + self._commands = context.commands + self._kill_event = kill_event + if context.device: + context.device.aborter = lambda: kill_event.is_set() + + 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): + _configure_logger() + 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: + pass + except (RuntimeError, Exception) as err: + _log.error(str(err)) + return -1 + + manager_thread = ManagerThread(context, somebody_killed) + cli_thread = CliThread(context, somebody_killed) + + 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:]) if __name__ == '__main__': exit(main()) diff --git a/cometblue/device.py b/cometblue/device.py index 4132f2f..c1bac50 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 @@ -7,27 +8,29 @@ import struct import uuid as uuid_module -import gattlib +import gatt +import time import six -_PIN_STRUCT = ' other + + def __ne__(self, other): + return not self == other def _decode_day(value): max_raw_time = ((23 * 60) + 59) / 10 - raw_time_values = list(struct.unpack(_DAY_STRUCT, value)) + raw_time_values = list(struct.unpack(_DAY_STRUCT_PACKING, value)) day = [] while raw_time_values: raw_start = raw_time_values.pop(0) @@ -141,14 +205,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: @@ -162,7 +226,7 @@ def _decode_day(value): 'end': end, }) - day.sort(_day_period_cmp) + day.sort(key=_day_period_cmp) return day @@ -179,8 +243,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 @@ -190,13 +254,13 @@ def _encode_day(periods): values.append(start) values.append(end) - return struct.pack(_DAY_STRUCT, *values) + return struct.pack(_DAY_STRUCT_PACKING, *values) def _decode_holiday(value): ho_start, da_start, mo_start, ye_start, \ ho_end, da_end, mo_end, ye_end, \ - temp = struct.unpack(_HOLIDAY_STRUCT, value) + temp = struct.unpack(_HOLIDAY_STRUCT_PACKING, value) if (ho_start > 23) or (ho_end > 23) \ or (da_start > 31) or (da_end > 31) \ @@ -230,14 +294,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, @@ -255,37 +319,43 @@ 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) + + def make_device(self, mac_address): + return CometBlue(mac_address = mac_address, manager = self) -class CometBlue(object): +class CometBlue(gatt.Device): SUPPORTED_VALUES = { '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': { @@ -296,11 +366,12 @@ 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, + 'encode': _encode_status, }, 'temperatures': { @@ -322,15 +393,15 @@ class CometBlue(object): 'description': 'firmware revision #2', 'uuid': '47e9ee2d-47e9-11e4-8939-164230d1df67', 'read_requires_pin': True, - 'decode': str, + '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': { @@ -358,59 +429,170 @@ 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') + 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._device_address) - value = self._device.read_by_uuid(uuid) + uuid, self.mac_address) + + 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_address, value) - if len(value) != 1: + uuid, self.mac_address, value) + if len(value.signature) != 1: raise RuntimeError('Got more than one value') - return decode(value[0]) - def _read_value_n(self, uuid, decode, pin_required, max_n, n): + value = bytes(int(byte) for byte in value) + value = decode(value) + _log.debug('Decoded value "%s" from "%s": %r', + uuid, self.mac_address, value) + return value + + 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 characteristic_write_value_succeeded(self, characteristic): + _log.debug("write for " + characteristic.uuid + " succeeded") + self._cb_writes[characteristic.uuid] = True - def _write_value(self, uuid, encode, value): - if not self._device.is_connected(): + 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') + 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._device_address, value) - self._device.write_by_handle(self._chars[uuid], encode(value)) - _log.debug('Wrote value "%s" to "%s": %r', - uuid, self._device_address, value) + uuid, self.mac_address, value) + + characteristics_handle = self._cb_chars.get(uuid, None) + if characteristics_handle is None: + 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)) + + self._cb_writes[uuid] = None + value = encode(value) + characteristics_handle.write_value(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 _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) - - 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 - self._chars = None - self._pin = pin + 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 + + @pin.setter + def pin(self, _pin): + self._pin = _pin + return self._pin + + 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): 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))) @@ -419,7 +601,7 @@ def __init__(self, address, adapter='hci0', channel_type='public', self, 'set_' + val_name, functools.partial( - self._write_value, + self._cb_write_value, str(val_conf['uuid']), val_conf['encode'])) @@ -429,7 +611,7 @@ def __init__(self, address, adapter='hci0', channel_type='public', 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), @@ -439,39 +621,109 @@ def __init__(self, address, adapter='hci0', channel_type='public', 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 __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) + def __str__(self): + return \ + "device_" + self.alias() \ + + "@" + self.mac_address + "_[" \ + + ("connected" if self.is_connected() else "disconnected") \ + + ", " \ + + ("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( + (str(characteristics_handle.uuid), characteristics_handle) + for service_handle in self.services + for characteristics_handle in service_handle.characteristics ) - _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._chars = dict( - (char_data['uuid'], char_data['value_handle']) - for char_data in chars) + self.mac_address, self._cb_chars.keys()) 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") - _log.info('Connected to device "%s"', self._device_address) return self + def connect(self): + # if connect() is called before __enter__, make it not managed + if self._enter_nesting == 0: + self._cb_enter_managed_connection = False + + _log.info('Connecting to device "%s"...', self.mac_address) + super().connect() + + 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 bool(self._cb_chars) + def __exit__(self, exc_type, exc_val, exc_tb): - if self._device.is_connected(): - _log.info('Disconnecting from device "%s"...', self._device_address) - self._device.disconnect() - _log.info('Disconnected from device "%s"', self._device_address) + self._enter_nesting -= 1 + + if self._enter_nesting == 0 and self._cb_enter_managed_connection: + self.disconnect() + + def disconnect(self): + if not self.is_connected(): + return + + _log.info('Disconnecting from device "%s"...', self.mac_address) + 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) def get_days(self): return list(map(self.get_day, range(7))) @@ -481,7 +733,7 @@ def get_holidays(self): def backup(self): _log.info('Saving all supported values from "%s"...', - self._device_address) + self.mac_address) data = {} @@ -498,7 +750,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.mac_address) return data @@ -512,7 +764,7 @@ def set_holidays(self, value): def restore(self, data): _log.info('Restoring values from backup for "%s"...', - self._device_address) + self.mac_address) _log.debug('Backup data: %r', data) for val_name, val_data in six.iteritems(data): @@ -522,4 +774,4 @@ def restore(self, data): self.set_datetime(datetime.datetime.now()) _log.info('Values from backup for "%s" successfully restored', - self._device_address) + self.mac_address) diff --git a/cometblue/discovery.py b/cometblue/discovery.py index 8da7675..311e640 100644 --- a/cometblue/discovery.py +++ b/cometblue/discovery.py @@ -1,8 +1,10 @@ +# vim: tabstop=4 shiftwidth=4 expandtab from __future__ import absolute_import import logging -import gattlib +import gatt +import time import six import cometblue.device @@ -14,32 +16,42 @@ _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(adapter='hci0', timeout=10, channel_type='public', - security_level='low'): + 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) + manager.stop_discovery() + + devices = manager.devices() + _log.debug('All discovered devices: %r', [(device.mac_address, str(device.alias())) for device in devices]) + return devices + +def discover(manager, 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.adapter_name, timeout) + devices = discover_candidates(manager, timeout) filtered_devices = {} - for address, name in six.iteritems(devices): - try: - with cometblue.device.CometBlue( - address, - adapter=adapter, - channel_type=channel_type, - security_level=security_level) 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 - except RuntimeError as exc: - _log.debug('Skipping device "%s" ("%s") because of ' - 'exception: %r', - name, address, exc) - + 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 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 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)