diff --git a/.gitignore b/.gitignore index 4984b16..e0d16c7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ scratch* .idea build dist + +# Nosetests files +cover/ +.coverage diff --git a/sucks/__init__.py b/sucks/__init__.py index bcc764b..c2cbde3 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -9,7 +9,61 @@ import requests import stringcase from sleekxmpp import ClientXMPP, Callback, MatchXPath from sleekxmpp.xmlstream import ET +from sleekxmpp.exceptions import XMPPError +# These consts convert to and from Sucks's consts (which closely match what the UI and manuals use) +# to and from what the Ecovacs API uses (which are sometimes very oddly named and have random capitalization.) +CLEAN_MODE_TO_ECOVACS = { + 'auto': 'auto', + 'edge': 'border', + 'spot': 'spot', + 'single_room': 'singleroom', + 'stop': 'stop' +} + +CLEAN_MODE_FROM_ECOVACS = { + 'auto': 'auto', + 'border': 'edge', + 'spot': 'spot', + 'singleroom': 'single_room', + 'stop': 'stop', + 'going': 'returning' +} + +FAN_SPEED_TO_ECOVACS = { + 'normal': 'standard', + 'high': 'strong' +} + +FAN_SPEED_FROM_ECOVACS = { + 'standard': 'normal', + 'strong': 'high' +} + +CHARGE_MODE_TO_ECOVACS = { + 'return': 'go', + 'returning': 'Going', + 'charging': 'SlotCharging', + 'idle': 'Idle' +} + +CHARGE_MODE_FROM_ECOVACS = { + 'going': 'returning', + 'slot_charging': 'charging', + 'idle': 'idle' +} + +COMPONENT_TO_ECOVACS = { + 'main_brush': 'Brush', + 'side_brush': 'SideBrush', + 'filter': 'DustCaseHeap' +} + +COMPONENT_FROM_ECOVACS = { + 'brush': 'main_brush', + 'side_brush': 'side_brush', + 'dust_case_heap': 'filter' +} class EcoVacsAPI: CLIENT_KEY = "eJUWrzRv34qFSaYk" @@ -126,54 +180,197 @@ class EcoVacsAPI: return str(b64encode(result), 'utf8') +class EventEmitter(object): + """A very simple event emitting system.""" + def __init__(self): + self._subscribers = [] + + def subscribe(self, callback): + listener = EventListener(self, callback) + self._subscribers.append(listener) + return listener + + def unsubscribe(self, listener): + self._subscribers.remove(listener) + + def notify(self, event): + for subscriber in self._subscribers: + subscriber.callback(event) + + +class EventListener(object): + """Object that allows event consumers to easily unsubscribe from events.""" + def __init__(self, emitter, callback): + self._emitter = emitter + self.callback = callback + + def unsubscribe(self): + self._emitter.unsubscribe(self) + + class VacBot(): - def __init__(self, user, domain, resource, secret, vacuum, continent, server_address=None): + def __init__(self, user, domain, resource, secret, vacuum, continent, server_address=None, monitor=False): self.vacuum = vacuum + + # If True, the VacBot object will handle keeping track of all statuses, + # including the initial request for statuses, and new requests after the + # VacBot returns from being offline. It will also cause it to regularly + # request component lifespans + self._monitor = monitor + + self._failed_pings = 0 + + # These three are representations of the vacuum state as reported by the API self.clean_status = None self.charge_status = None self.battery_status = None - self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, server_address) + # This is an aggregate state managed by the sucks library, combining the clean and charge events to a single state + self.vacuum_status = None + self.fan_speed = None + # Populated by component Lifespan reports + self.components = {} + + self.statusEvents = EventEmitter() + self.batteryEvents = EventEmitter() + self.lifespanEvents = EventEmitter() + self.errorEvents = EventEmitter() + + self.xmpp = EcoVacsXMPP(user, domain, resource, secret, continent, server_address) self.xmpp.subscribe_to_ctls(self._handle_ctl) def connect_and_wait_until_ready(self): self.xmpp.connect_and_wait_until_ready() - self.xmpp.schedule('Ping', 30, lambda: self.xmpp.send_ping(self._vacuum_address()), repeat=True) + self.xmpp.schedule('Ping', 30, lambda: self.send_ping(), repeat=True) + + if self._monitor: + # Do a first ping, which will also fetch initial statuses if the ping succeeds + self.send_ping() + self.xmpp.schedule('Components', 3600, lambda: self.refresh_components(), repeat=True) def _handle_ctl(self, ctl): method = '_handle_' + ctl['event'] if hasattr(self, method): getattr(self, method)(ctl) + def _handle_error(self, event): + error = event['error'] + self.errorEvents.notify(error) + logging.debug("*** error = " + error) + + def _handle_life_span(self, event): + type = event['type'] + try: + type = COMPONENT_FROM_ECOVACS[type] + except KeyError: + logging.warning("Unknown component type: '" + type + "'") + + total = float(event['total']) + val = float(event['val']) + lifespan = val / total + self.components[type] = lifespan + + lifespan_event = {'type': type, 'lifespan': lifespan} + self.lifespanEvents.notify(lifespan_event) + logging.debug("*** life_span " + type + " = " + str(lifespan)) + def _handle_clean_report(self, event): - self.clean_status = event['type'] - logging.debug("*** clean_status = " + self.clean_status) + type = event['type'] + try: + type = CLEAN_MODE_FROM_ECOVACS[type] + except KeyError: + logging.warning("Unknown cleaning status '" + type + "'") + self.clean_status = type + self.vacuum_status = type + fan = event.get('speed', None) + if fan is not None: + try: + fan = FAN_SPEED_FROM_ECOVACS[fan] + except KeyError: + logging.warning("Unknown fan speed: '" + fan + "'") + self.fan_speed = fan + self.statusEvents.notify(self.vacuum_status) + if self.fan_speed: + logging.debug("*** clean_status = " + self.clean_status + " fan_speed = " + self.fan_speed) + else: + logging.debug("*** clean_status = " + self.clean_status + " fan_speed = None") def _handle_battery_info(self, iq): try: self.battery_status = float(iq['power']) / 100 - logging.debug("*** battery_status = {:.0%}".format(self.battery_status)) except ValueError: logging.warning("couldn't parse battery status " + ET.tostring(iq)) + else: + self.batteryEvents.notify(self.battery_status) + logging.debug("*** battery_status = {:.0%}".format(self.battery_status)) def _handle_charge_state(self, event): - report = event['type'] - if report == 'going': - self.charge_status = 'returning' - elif report == 'slot_charging': - self.charge_status = 'charging' - elif report == 'idle': - self.charge_status = 'idle' - else: - logging.warning("Unknown charging status '" + report + "'") + status = event['type'] + try: + status = CHARGE_MODE_FROM_ECOVACS[status] + except KeyError: + logging.warning("Unknown charging status '" + status + "'") + + self.charge_status = status + if status != 'idle' or self.vacuum_status == 'charging': + # We have to ignore the idle messages, because all it means is that it's not + # currently charging, in which case the clean_status is a better indicator + # of what the vacuum is currently up to. + self.vacuum_status = status + self.statusEvents.notify(self.vacuum_status) logging.debug("*** charge_status = " + self.charge_status) def _vacuum_address(self): return self.vacuum['did'] + '@' + self.vacuum['class'] + '.ecorobot.net/atom' + def send_ping(self): + try: + self.xmpp.send_ping(self._vacuum_address()) + except XMPPError as err: + logging.warning("Ping did not reach VacBot. Will retry.") + logging.debug("*** Error type: " + err.etype) + logging.debug("*** Error condition: " + err.condition) + self._failed_pings += 1 + if self._failed_pings >= 4: + self.vacuum_status = 'offline' + self.statusEvents.notify(self.vacuum_status) + else: + self._failed_pings = 0 + if self._monitor: + # If we don't yet have a vacuum status, request initial statuses again now that the ping succeeded + if self.vacuum_status == 'offline' or self.vacuum_status is None: + self.request_all_statuses() + else: + # If we're not auto-monitoring the status, then just reset the status to None, which indicates unknown + if self.vacuum_status == 'offline': + self.vacuum_status = None + self.statusEvents.notify(self.vacuum_status) + + def refresh_components(self): + try: + self.run(GetLifeSpan('main_brush')) + self.run(GetLifeSpan('side_brush')) + self.run(GetLifeSpan('filter')) + except XMPPError as err: + logging.warning("Component refresh requests failed to reach VacBot. Will try again later.") + logging.debug("*** Error type: " + err.etype) + logging.debug("*** Error condition: " + err.condition) + + def request_all_statuses(self): + try: + self.run(GetCleanState()) + self.run(GetChargeState()) + self.run(GetBatteryState()) + except XMPPError as err: + logging.warning("Initial status requests failed to reach VacBot. Will try again on next ping.") + logging.debug("*** Error type: " + err.etype) + logging.debug("*** Error condition: " + err.condition) + else: + self.refresh_components() + def send_command(self, xml): self.xmpp.send_command(xml, self._vacuum_address()) @@ -218,11 +415,16 @@ class EcoVacsXMPP(ClientXMPP): def _handle_ctl(self, message): the_good_part = message.get_payload()[0][0] as_dict = self._ctl_to_dict(the_good_part) - for s in self.ctl_subscribers: - s(as_dict) + if as_dict is not None: + for s in self.ctl_subscribers: + s(as_dict) def _ctl_to_dict(self, xml): result = xml.attrib.copy() + if 'td' not in result: + # This happens for commands with no response data, such as PlaySound + return + result['event'] = result.pop('td') if xml: result.update(xml[0].attrib) @@ -265,28 +467,6 @@ class EcoVacsXMPP(ClientXMPP): class VacBotCommand: - CLEAN_MODE = { - 'auto': 'auto', - 'edge': 'border', - 'spot': 'spot', - 'single_room': 'singleroom', - 'stop': 'stop' - } - FAN_SPEED = { - 'normal': 'standard', - 'high': 'strong' - } - CHARGE_MODE = { - 'return': 'go', - 'returning': 'Going', - 'charging': 'SlotCharging', - 'idle': 'Idle' - } - COMPONENT = { - 'main_brush': 'Brush', - 'side_brush': 'SideBrush', - 'filter': 'DustCaseHeap' - } ACTION = { 'forward': 'forward', 'left': 'SpinLeft', @@ -320,7 +500,7 @@ class VacBotCommand: class Clean(VacBotCommand): def __init__(self, mode='auto', speed='normal', terminal=False): - super().__init__('Clean', {'clean': {'type': self.CLEAN_MODE[mode], 'speed': self.FAN_SPEED[speed]}}) + super().__init__('Clean', {'clean': {'type': CLEAN_MODE_TO_ECOVACS[mode], 'speed': FAN_SPEED_TO_ECOVACS[speed]}}) class Edge(Clean): @@ -340,7 +520,7 @@ class Stop(Clean): class Charge(VacBotCommand): def __init__(self): - super().__init__('Charge', {'charge': {'type': self.CHARGE_MODE['return']}}) + super().__init__('Charge', {'charge': {'type': CHARGE_MODE_TO_ECOVACS['return']}}) class Move(VacBotCommand): @@ -348,6 +528,11 @@ class Move(VacBotCommand): super().__init__('Move', {'move': {'action': self.ACTION[action]}}) +class PlaySound(VacBotCommand): + def __init__(self, sid="0"): + super().__init__('PlaySound', {'sid': sid}) + + class GetCleanState(VacBotCommand): def __init__(self): super().__init__('GetCleanState') @@ -365,7 +550,7 @@ class GetBatteryState(VacBotCommand): class GetLifeSpan(VacBotCommand): def __init__(self, component): - super().__init__('GetLifeSpan', {'type': self.COMPONENT[component]}) + super().__init__('GetLifeSpan', {'type': COMPONENT_TO_ECOVACS[component]}) class SetTime(VacBotCommand): diff --git a/tests/test_commands.py b/tests/test_commands.py index d3fe743..a3039b3 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -59,6 +59,18 @@ def test_stop_command(): b'') +def test_play_sound_command(): + c = PlaySound() + assert_equals(ElementTree.tostring(c.to_xml()), + b'') + + +def test_play_sound_command_with_sid(): + c = PlaySound(sid="1") + assert_equals(ElementTree.tostring(c.to_xml()), + b'') + + def test_get_clean_state_command(): c = GetCleanState() assert_equals(ElementTree.tostring(c.to_xml()), diff --git a/tests/test_vacbot.py b/tests/test_vacbot.py index 8da9ab3..6114e9e 100644 --- a/tests/test_vacbot.py +++ b/tests/test_vacbot.py @@ -2,6 +2,8 @@ from nose.tools import * from sucks import * +from unittest.mock import Mock +from sleekxmpp.exceptions import XMPPError def test_handle_clean_report(): @@ -10,6 +12,22 @@ def test_handle_clean_report(): v._handle_ctl({'event': 'clean_report', 'type': 'auto', 'speed': 'strong'}) assert_equals('auto', v.clean_status) + assert_equals('high', v.fan_speed) + + v._handle_ctl({'event': 'clean_report', 'type': 'border', 'speed': 'standard'}) + assert_equals('edge', v.clean_status) + assert_equals('normal', v.fan_speed) + + # Missing fan_speed + v = a_vacbot() + v._handle_ctl({'event': 'clean_report', 'type': 'border'}) + assert_equals('edge', v.clean_status) + assert_is_none(v.fan_speed) + + # For states not handled by sucks constants, fall back to just using whatever the vacuum said + v._handle_ctl({'event': 'clean_report', 'type': 'a_type_not_supported_by_sucks', 'speed': 'a_weird_speed'}) + assert_equals('a_type_not_supported_by_sucks', v.clean_status) + assert_equals('a_weird_speed', v.fan_speed) def test_handle_charge_state(): @@ -25,6 +43,28 @@ def test_handle_charge_state(): v._handle_ctl({'event': 'charge_state', 'type': 'idle'}) assert_equals('idle', v.charge_status) + v._handle_ctl({'event': 'charge_state', 'type': 'a_type_not_supported_by_sucks'}) + assert_equals('a_type_not_supported_by_sucks', v.charge_status) + + +def test_vacuum_states(): + # Vacuum state usually mirrors the latest charge or clean report, but there are some edge cases where it doesn't + # work that way. This test ensures the edge cases are handled correctly. + v = a_vacbot() + assert_equals(None, v.vacuum_status) + + v._handle_ctl({'event': 'clean_report', 'type': 'auto', 'speed': 'strong'}) + assert_equals('auto', v.vacuum_status) + + # Ignore the "idle" charge state in most cases, as it can be reported during a cleaning (such as during initialization) + v._handle_ctl({'event': 'clean_report', 'type': 'auto'}) + v._handle_ctl({'event': 'charge_state', 'type': 'idle'}) + assert_equals('auto', v.vacuum_status) + + # However, we do honor the idle state when our current state is charging, as that can happen in some certain combination of events + v._handle_ctl({'event': 'charge_state', 'type': 'slot_charging'}) + v._handle_ctl({'event': 'charge_state', 'type': 'idle'}) + assert_equals('idle', v.vacuum_status) def test_handle_battery_info(): v = a_vacbot() @@ -39,6 +79,143 @@ def test_handle_battery_info(): v._handle_ctl({'event': 'battery_info', 'power': '000'}) assert_equals(0.0, v.battery_status) +def test_lifespan_reports(): + v = a_vacbot() + assert_equals({}, v.components) + + v._handle_ctl({'event': 'life_span', 'type': 'side_brush', 'total': '100', 'val': '50'}) + assert_equals({'side_brush': 0.5}, v.components) + + v._handle_ctl({'event': 'life_span', 'type': 'brush', 'total': '200', 'val': '1'}) + assert_equals({'side_brush': 0.5, 'main_brush': 0.005}, v.components) + + v._handle_ctl({'event': 'life_span', 'type': 'side_brush', 'total': '100', 'val': '0'}) + assert_equals({'side_brush': 0, 'main_brush': 0.005}, v.components) + + v._handle_ctl({'event': 'life_span', 'type': 'a_weird_component', 'total': '100', 'val': '87'}) + assert_equals({'side_brush': 0, 'main_brush': 0.005, 'a_weird_component': 0.87}, v.components) + +def test_send_ping_no_monitor(): + v = a_vacbot() + + mock = v.xmpp.send_ping = Mock() + v.send_ping() + + # On four failed pings, vacuum state gets set to 'offline' + mock.side_effect = XMPPError() + v.send_ping() + v.send_ping() + v.send_ping() + assert_equals(None, v.vacuum_status) + v.send_ping() + assert_equals('offline', v.vacuum_status) + + # On a successful ping after the offline state, state gets reset to None, indicating that it is unknown + mock.side_effect = None + v.send_ping() + assert_equals(None, v.vacuum_status) + + +def test_send_ping_with_monitor(): + v = a_vacbot(monitor=True) + + ping_mock = v.xmpp.send_ping = Mock() + request_statuses_mock = v.request_all_statuses = Mock() + + # First ping should try to fetch statuses + v.send_ping() + assert_equals(1, request_statuses_mock.call_count) + + # Nothing blowing up is success + + # On four failed pings, vacuum state gets set to 'offline' + ping_mock.side_effect = XMPPError() + v.send_ping() + v.send_ping() + v.send_ping() + assert_equals(None, v.vacuum_status) + v.send_ping() + assert_equals('offline', v.vacuum_status) + + # On a successful ping after the offline state, a request for initial statuses is made + ping_mock.side_effect = None + request_statuses_mock.reset_mock() + v.send_ping() + assert_equals(1, request_statuses_mock.call_count) + + +def test_status_event_subscription(): + v = a_vacbot() + + mock = Mock() + v.statusEvents.subscribe(mock) + v._handle_ctl({'event': 'clean_report', 'type': 'auto', 'speed': 'strong'}) + mock.assert_called_once_with('auto') + + mock = Mock() + v.statusEvents.subscribe(mock) + v._handle_ctl({'event': 'charge_state', 'type': 'going'}) + mock.assert_called_once_with('returning') + + # Test unsubscribe + mock = Mock() + subscription = v.statusEvents.subscribe(mock) + v._handle_ctl({'event': 'charge_state', 'type': 'going'}) + assert_equals(1, mock.call_count) + subscription.unsubscribe() + v._handle_ctl({'event': 'charge_state', 'type': 'slot_charging'}) + assert_equals(1, mock.call_count) + +def test_battery_event_subscription(): + v = a_vacbot() + + mock = Mock() + v.batteryEvents.subscribe(mock) + v._handle_ctl({'event': 'battery_info', 'power': '095'}) + mock.assert_called_once_with(0.95) + + # Test unsubscribe + mock = Mock() + subscription = v.batteryEvents.subscribe(mock) + v._handle_ctl({'event': 'battery_info', 'power': '095'}) + assert_equals(1, mock.call_count) + subscription.unsubscribe() + v._handle_ctl({'event': 'battery_info', 'power': '090'}) + assert_equals(1, mock.call_count) + +def test_lifespan_event_subscription(): + v = a_vacbot() + + mock = Mock() + v.lifespanEvents.subscribe(mock) + v._handle_ctl({'event': 'life_span', 'type': 'side_brush', 'total': '100', 'val': '50'}) + mock.assert_called_once_with({'type': 'side_brush', 'lifespan': 0.5}) + + # Test unsubscribe + mock = Mock() + subscription = v.lifespanEvents.subscribe(mock) + v._handle_ctl({'event': 'life_span', 'type': 'side_brush', 'total': '100', 'val': '50'}) + assert_equals(1, mock.call_count) + subscription.unsubscribe() + v._handle_ctl({'event': 'life_span', 'type': 'side_brush', 'total': '100', 'val': '25'}) + assert_equals(1, mock.call_count) + +def test_error_event_subscription(): + v = a_vacbot() + + mock = Mock() + v.errorEvents.subscribe(mock) + v._handle_ctl({'event': 'error', 'error': 'an_error_name'}) + mock.assert_called_once_with('an_error_name') + + # Test unsubscribe + mock = Mock() + subscription = v.errorEvents.subscribe(mock) + v._handle_ctl({'event': 'error', 'error': 'an_error_name'}) + assert_equals(1, mock.call_count) + subscription.unsubscribe() + v._handle_ctl({'event': 'error', 'error': 'an_error_name'}) + assert_equals(1, mock.call_count) def test_handle_unknown_ctl(): v = a_vacbot() @@ -67,8 +244,8 @@ def test_model_variation(): -def a_vacbot(bot=None): +def a_vacbot(bot=None, monitor=False): if bot is None: bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob"} return VacBot('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12', - bot, 'na') + bot, 'na', monitor=monitor)