diff --git a/README.md b/README.md index a6967c4..14d7733 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,7 @@ print(thermostat) ## Fetching schedule diff --git a/eq3bt/connection.py b/eq3bt/connection.py index 0330a7c..d7d69b6 100644 --- a/eq3bt/connection.py +++ b/eq3bt/connection.py @@ -15,7 +15,7 @@ class BTLEConnection(btle.DefaultDelegate): """Representation of a BTLE Connection.""" - def __init__(self, mac, iface): + def __init__(self, mac, iface, retries): """Initialize the connection.""" btle.DefaultDelegate.__init__(self) @@ -23,6 +23,7 @@ def __init__(self, mac, iface): self._mac = mac self._iface = iface self._callbacks = {} + self._max_conn_attempts = retries+1 def __enter__(self): """ @@ -33,15 +34,15 @@ def __enter__(self): self._conn = btle.Peripheral() self._conn.withDelegate(self) _LOGGER.debug("Trying to connect to %s", self._mac) - try: - self._conn.connect(self._mac, iface=self._iface) - except btle.BTLEException as ex: - _LOGGER.debug("Unable to connect to the device %s, retrying: %s", self._mac, ex) + + for conn_attempt in range(1, self._max_conn_attempts+1): try: self._conn.connect(self._mac, iface=self._iface) - except Exception as ex2: - _LOGGER.debug("Second connection try to %s failed: %s", self._mac, ex2) - raise + break + except btle.BTLEException as ex: + _LOGGER.warning("%s: Connection attempt #%s(%s) failed", self._mac, conn_attempt, self._max_conn_attempts) + if conn_attempt == self._max_conn_attempts: + raise _LOGGER.debug("Connected to %s", self._mac) return self @@ -78,4 +79,3 @@ def make_request(self, handle, value, timeout=DEFAULT_TIMEOUT, with_response=Tru except btle.BTLEException as ex: _LOGGER.debug("Got exception from bluepy while making a request: %s", ex) raise - diff --git a/eq3bt/eq3btsmart.py b/eq3bt/eq3btsmart.py index 62e024f..ab36e36 100644 --- a/eq3bt/eq3btsmart.py +++ b/eq3bt/eq3btsmart.py @@ -71,7 +71,7 @@ class TemperatureException(Exception): class Thermostat: """Representation of a EQ3 Bluetooth Smart thermostat.""" - def __init__(self, _mac, _iface=None, connection_cls=BTLEConnection): + def __init__(self, _mac, _iface=None, connection_cls=BTLEConnection, retries=1): """Initialize the thermostat.""" self._target_temperature = Mode.Unknown @@ -94,10 +94,15 @@ def __init__(self, _mac, _iface=None, connection_cls=BTLEConnection): self._firmware_version = None self._device_serial = None - self._conn = connection_cls(_mac, _iface) + self._conn = connection_cls(_mac, _iface, retries) self._conn.set_callback(PROP_NTFY_HANDLE, self.handle_notification) + self._conn_error = False + def __str__(self): + if self._conn_error: + return "[%s] Connection error" % (self._conn.mac) + away_end = "no" if self.away_end: away_end = "end: %s" % self._away_end @@ -192,7 +197,12 @@ def query_id(self): """Query device identification information, e.g. the serial number.""" _LOGGER.debug("Querying id..") value = struct.pack('B', PROP_ID_QUERY) - self._conn.make_request(PROP_WRITE_HANDLE, value) + try: + self._conn.make_request(PROP_WRITE_HANDLE, value) + except Exception as ex: + _LOGGER.warning(ex) + self._conn_error = True + return def update(self): """Update the data from the thermostat. Always sets the current time.""" @@ -201,8 +211,12 @@ def update(self): value = struct.pack('BBBBBBB', PROP_INFO_QUERY, time.year % 100, time.month, time.day, time.hour, time.minute, time.second) - - self._conn.make_request(PROP_WRITE_HANDLE, value) + try: + self._conn.make_request(PROP_WRITE_HANDLE, value) + except Exception as ex: + _LOGGER.warning(ex) + self._conn_error = True + return def query_schedule(self, day): _LOGGER.debug("Querying schedule..") @@ -211,8 +225,12 @@ def query_schedule(self, day): _LOGGER.error("Invalid day: %s", day) value = struct.pack('BB', PROP_SCHEDULE_QUERY, day) - - self._conn.make_request(PROP_WRITE_HANDLE, value) + try: + self._conn.make_request(PROP_WRITE_HANDLE, value) + except Exception as ex: + _LOGGER.warning(ex) + self._conn_error = True + return @property def schedule(self): @@ -224,7 +242,12 @@ def schedule(self): def set_schedule(self, data): """Sets the schedule for the given day. """ value = Schedule.build(data) - self._conn.make_request(PROP_WRITE_HANDLE, value) + try: + self._conn.make_request(PROP_WRITE_HANDLE, value) + except Exception as ex: + _LOGGER.warning(ex) + self._conn_error = True + return @property def target_temperature(self): @@ -241,8 +264,12 @@ def target_temperature(self, temperature): else: self._verify_temperature(temperature) value = struct.pack('BB', PROP_TEMPERATURE_WRITE, dev_temp) - - self._conn.make_request(PROP_WRITE_HANDLE, value) + try: + self._conn.make_request(PROP_WRITE_HANDLE, value) + except Exception as ex: + _LOGGER.warning(ex) + self._conn_error = True + return @property def mode(self): @@ -296,7 +323,12 @@ def set_mode(self, mode, payload=None): value = struct.pack('BB', PROP_MODE_WRITE, mode) if payload: value += payload - self._conn.make_request(PROP_WRITE_HANDLE, value) + try: + self._conn.make_request(PROP_WRITE_HANDLE, value) + except Exception as ex: + _LOGGER.warning(ex) + self._conn_error = True + return @property def mode_readable(self): @@ -340,7 +372,12 @@ def boost(self, boost): """Sets boost mode.""" _LOGGER.debug("Setting boost mode: %s", boost) value = struct.pack('BB', PROP_BOOST, bool(boost)) - self._conn.make_request(PROP_WRITE_HANDLE, value) + try: + self._conn.make_request(PROP_WRITE_HANDLE, value) + except Exception as ex: + _LOGGER.warning(ex) + self._conn_error = True + return @property def valve_state(self): @@ -363,7 +400,12 @@ def window_open_config(self, temperature, duration): value = struct.pack('BBB', PROP_WINDOW_OPEN_CONFIG, int(temperature * 2), int(duration.seconds / 300)) - self._conn.make_request(PROP_WRITE_HANDLE, value) + try: + self._conn.make_request(PROP_WRITE_HANDLE, value) + except Exception as ex: + _LOGGER.warning(ex) + self._conn_error = True + return @property def window_open_temperature(self): @@ -385,7 +427,12 @@ def locked(self, lock): """Locks or unlocks the thermostat.""" _LOGGER.debug("Setting the lock: %s", lock) value = struct.pack('BB', PROP_LOCK, bool(lock)) - self._conn.make_request(PROP_WRITE_HANDLE, value) + try: + self._conn.make_request(PROP_WRITE_HANDLE, value) + except Exception as ex: + _LOGGER.warning(ex) + self._conn_error = True + return @property def low_battery(self): @@ -400,7 +447,12 @@ def temperature_presets(self, comfort, eco): self._verify_temperature(eco) value = struct.pack('BBB', PROP_COMFORT_ECO_CONFIG, int(comfort * 2), int(eco * 2)) - self._conn.make_request(PROP_WRITE_HANDLE, value) + try: + self._conn.make_request(PROP_WRITE_HANDLE, value) + except Exception as ex: + _LOGGER.warning(ex) + self._conn_error = True + return @property def comfort_temperature(self): @@ -433,12 +485,22 @@ def temperature_offset(self, offset): current += 0.5 value = struct.pack('BB', PROP_OFFSET, values[offset]) - self._conn.make_request(PROP_WRITE_HANDLE, value) + try: + self._conn.make_request(PROP_WRITE_HANDLE, value) + except Exception as ex: + _LOGGER.warning(ex) + self._conn_error = True + return def activate_comfort(self): """Activates the comfort temperature.""" value = struct.pack('B', PROP_COMFORT) - self._conn.make_request(PROP_WRITE_HANDLE, value) + try: + self._conn.make_request(PROP_WRITE_HANDLE, value) + except Exception as ex: + _LOGGER.warning(ex) + self._conn_error = True + return def activate_eco(self): """Activates the comfort temperature.""" @@ -465,3 +527,7 @@ def device_serial(self): """Return the device serial number.""" return self._device_serial + @property + def connection_error(self): + """Return the connection error state""" + return self._conn_error diff --git a/eq3bt/eq3cli.py b/eq3bt/eq3cli.py index ce895f5..f5afb71 100644 --- a/eq3bt/eq3cli.py +++ b/eq3bt/eq3cli.py @@ -22,16 +22,17 @@ def validate_mac(ctx, param, mac): @click.group(invoke_without_command=True) @click.option('--mac', envvar="EQ3_MAC", required=True, callback=validate_mac) @click.option('--interface', default=None) +@click.option('--retries', default=1) @click.option('--debug/--normal', default=False) @click.pass_context -def cli(ctx, mac, interface, debug): +def cli(ctx, mac, interface, retries, debug): """ Tool to query and modify the state of EQ3 BT smart thermostat. """ if debug: logging.basicConfig(level=logging.DEBUG) else: logging.basicConfig(level=logging.INFO) - thermostat = Thermostat(mac, interface) + thermostat = Thermostat(mac, interface, retries=retries) thermostat.update() ctx.obj = thermostat @@ -44,10 +45,13 @@ def cli(ctx, mac, interface, debug): @pass_dev def temp(dev, target): """ Gets or sets the target temperature.""" - click.echo("Current target temp: %s" % dev.target_temperature) - if target: - click.echo("Setting target temp: %s" % target) - dev.target_temperature = target + if not dev.connection_error: + click.echo("Current target temp: %s" % dev.target_temperature) + if target: + click.echo("Setting target temp: %s" % target) + dev.target_temperature = target + else: + click.echo("Connection error") @cli.command() @@ -55,10 +59,13 @@ def temp(dev, target): @pass_dev def mode(dev, target): """ Gets or sets the active mode. """ - click.echo("Current mode: %s" % dev.mode_readable) - if target: - click.echo("Setting mode: %s" % target) - dev.mode = target + if not dev.connection_error: + click.echo("Current mode: %s" % dev.mode_readable) + if target: + click.echo("Setting mode: %s" % target) + dev.mode = target + else: + click.echo("Connection error") @cli.command() @@ -66,17 +73,23 @@ def mode(dev, target): @pass_dev def boost(dev, target): """ Gets or sets the boost mode. """ - click.echo("Boost: %s" % dev.boost) - if target is not None: - click.echo("Setting boost: %s" % target) - dev.boost = target + if not dev.connection_error: + click.echo("Boost: %s" % dev.boost) + if target is not None: + click.echo("Setting boost: %s" % target) + dev.boost = target + else: + click.echo("Connection error") @cli.command() @pass_dev def valve_state(dev): """ Gets the state of the valve. """ - click.echo("Valve: %s" % dev.valve_state) + if not dev.connection_error: + click.echo("Valve: %s" % dev.valve_state) + else: + click.echo("Connection error") @cli.command() @@ -84,17 +97,23 @@ def valve_state(dev): @pass_dev def locked(dev, target): """ Gets or sets the lock. """ - click.echo("Locked: %s" % dev.locked) - if target is not None: - click.echo("Setting lock: %s" % target) - dev.locked = target + if not dev.connection_error: + click.echo("Locked: %s" % dev.locked) + if target is not None: + click.echo("Setting lock: %s" % target) + dev.locked = target + else: + click.echo("Connection error") @cli.command() @pass_dev def low_battery(dev): """ Gets the low battery status. """ - click.echo("Batter low: %s" % dev.low_battery) + if not dev.connection_error: + click.echo("Batter low: %s" % dev.low_battery) + else: + click.echo("Connection error") @cli.command() @@ -103,14 +122,17 @@ def low_battery(dev): @pass_dev def window_open(dev, temp, duration): """ Gets and sets the window open settings. """ - click.echo("Window open: %s" % dev.window_open) - if dev.window_open_temperature is not None: - click.echo("Window open temp: %s" % dev.window_open_temperature) - if dev.window_open_time is not None: - click.echo("Window open time: %s" % dev.window_open_time) - if temp and duration: - click.echo("Setting window open conf, temp: %s duration: %s" % (temp, duration)) - dev.window_open_config(temp, duration) + if not dev.connection_error: + click.echo("Window open: %s" % dev.window_open) + if dev.window_open_temperature is not None: + click.echo("Window open temp: %s" % dev.window_open_temperature) + if dev.window_open_time is not None: + click.echo("Window open time: %s" % dev.window_open_time) + if temp and duration: + click.echo("Setting window open conf, temp: %s duration: %s" % (temp, duration)) + dev.window_open_config(temp, duration) + else: + click.echo("Connection error") @cli.command() @@ -119,29 +141,35 @@ def window_open(dev, temp, duration): @pass_dev def presets(dev, comfort, eco): """ Sets the preset temperatures for auto mode. """ - if dev.comfort_temperature is not None: - click.echo("Current comfort temp: %s" % dev.comfort_temperature) - if dev.eco_temperature is not None: - click.echo("Current eco temp: %s" % dev.eco_temperature) - if comfort and eco: - click.echo("Setting presets: comfort %s, eco %s" % (comfort, eco)) - dev.temperature_presets(comfort, eco) + if not dev.connection_error: + if dev.comfort_temperature is not None: + click.echo("Current comfort temp: %s" % dev.comfort_temperature) + if dev.eco_temperature is not None: + click.echo("Current eco temp: %s" % dev.eco_temperature) + if comfort and eco: + click.echo("Setting presets: comfort %s, eco %s" % (comfort, eco)) + dev.temperature_presets(comfort, eco) + else: + click.echo("Connection error") @cli.command() @pass_dev def schedule(dev): """ Gets the schedule from the thermostat. """ - # TODO: expose setting the schedule somehow? - for d in range(7): - dev.query_schedule(d) - for day in dev.schedule.values(): - click.echo("Day %s, base temp: %s" % (day.day, day.base_temp)) - current_hour = day.next_change_at - for hour in day.hours: - if current_hour == 0: continue - click.echo("\t[%s-%s] %s" % (current_hour, hour.next_change_at, hour.target_temp)) - current_hour = hour.next_change_at + if not dev.connection_error: + # TODO: expose setting the schedule somehow? + for d in range(7): + dev.query_schedule(d) + for day in dev.schedule.values(): + click.echo("Day %s, base temp: %s" % (day.day, day.base_temp)) + current_hour = day.next_change_at + for hour in day.hours: + if current_hour == 0: continue + click.echo("\t[%s-%s] %s" % (current_hour, hour.next_change_at, hour.target_temp)) + current_hour = hour.next_change_at + else: + click.echo("Connection error") @cli.command() @@ -149,12 +177,14 @@ def schedule(dev): @pass_dev def offset(dev, offset): """ Sets the temperature offset [-3,5 3,5] """ - if dev.temperature_offset is not None: - click.echo("Current temp offset: %s" % dev.temperature_offset) - if offset is not None: - click.echo("Setting the offset to %s" % offset) - dev.temperature_offset = offset - + if not dev.connection_error: + if dev.temperature_offset is not None: + click.echo("Current temp offset: %s" % dev.temperature_offset) + if offset is not None: + click.echo("Setting the offset to %s" % offset) + dev.temperature_offset = offset + else: + click.echo("Connection error") @cli.command() @click.argument('away_end', type=Datetime(format='%Y-%m-%d %H:%M'), default=None, required=False) @@ -162,38 +192,44 @@ def offset(dev, offset): @pass_dev def away(dev, away_end, temperature): """ Enables or disables the away mode. """ - if away_end: - click.echo("Setting away until %s, temperature: %s" % (away_end, temperature)) + if not dev.connection_error: + if away_end: + click.echo("Setting away until %s, temperature: %s" % (away_end, temperature)) + else: + click.echo("Disabling away mode") + dev.set_away(away_end, temperature) else: - click.echo("Disabling away mode") - dev.set_away(away_end, temperature) - + click.echo("Connection error") @cli.command() @pass_dev def device(dev): """ Displays basic device information. """ - dev.query_id() - click.echo("Firmware version: %s" % dev.firmware_version) - click.echo("Device serial: %s" % dev.device_serial) - + if not dev.connection_error: + dev.query_id() + click.echo("Firmware version: %s" % dev.firmware_version) + click.echo("Device serial: %s" % dev.device_serial) + else: + click.echo("Connection error") @cli.command() @click.pass_context def state(ctx): """ Prints out all available information. """ dev = ctx.obj - click.echo(dev) - ctx.forward(locked) - ctx.forward(low_battery) - ctx.forward(window_open) - ctx.forward(boost) - ctx.forward(temp) - ctx.forward(presets) - ctx.forward(offset) - ctx.forward(mode) - ctx.forward(valve_state) - + if not dev.connection_error: + click.echo(dev) + ctx.forward(locked) + ctx.forward(low_battery) + ctx.forward(window_open) + ctx.forward(boost) + ctx.forward(temp) + ctx.forward(presets) + ctx.forward(offset) + ctx.forward(mode) + ctx.forward(valve_state) + else: + click.echo("Connection error") if __name__ == "__main__": cli()