From 269749e0ea4a0d7a8cdc3fe3fc8176989422f215 Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Fri, 13 Jul 2018 22:10:24 -0700 Subject: [PATCH 1/3] Add event emitters, error handling, vacuum state and lifespan tracking Tons of cleanup alongside a lot of new functionality. BotVac now provides an overall vacuum state as a single state, and can emit events to subscribers every time the state changes, as well as any lifespan or battery changes. The BotVac can also now be created in a `monitor` mode, meaning the BotVac object will handle fetching initial state, refetching state after the BotVac goes offline then online, and regularly checking in on component lifespans. All statuses are also now normalized so that the BotVac's reported statuses always match the names defined by the sucks library. --- sucks/__init__.py | 261 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 219 insertions(+), 42 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index bcc764b..9bbd5f7 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,189 @@ 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) + logging.debug("*** clean_status = " + self.clean_status + " fan_speed = " + self.fan_speed) 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' + status = event['type'] + try: + status = CHARGE_MODE_FROM_ECOVACS[status] + except KeyError: + logging.warning("Unknown charging status '" + status + "'") else: - logging.warning("Unknown charging status '" + report + "'") - logging.debug("*** charge_status = " + self.charge_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() + + 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 +407,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 +459,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 +492,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 +512,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 +520,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 +542,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): From df44def7c5c5db1c689b8b51decd91a9d17d923c Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Sun, 15 Jul 2018 15:19:40 -0700 Subject: [PATCH 2/3] Add tests, and fix some edge cases revealed by tests (yay!) --- sucks/__init__.py | 30 ++++--- tests/test_commands.py | 12 +++ tests/test_vacbot.py | 181 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 210 insertions(+), 13 deletions(-) diff --git a/sucks/__init__.py b/sucks/__init__.py index 9bbd5f7..c2cbde3 100644 --- a/sucks/__init__.py +++ b/sucks/__init__.py @@ -293,7 +293,10 @@ class VacBot(): logging.warning("Unknown fan speed: '" + fan + "'") self.fan_speed = fan self.statusEvents.notify(self.vacuum_status) - logging.debug("*** clean_status = " + self.clean_status + " fan_speed = " + self.fan_speed) + 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: @@ -310,15 +313,15 @@ class VacBot(): status = CHARGE_MODE_FROM_ECOVACS[status] except KeyError: logging.warning("Unknown charging status '" + status + "'") - else: - 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) + + 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' @@ -337,9 +340,14 @@ class VacBot(): 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 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: 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) From 741295079d0f6ddd72c3af9f2f3f8dc24d8773ae Mon Sep 17 00:00:00 2001 From: Greg Laabs Date: Sun, 15 Jul 2018 15:20:02 -0700 Subject: [PATCH 3/3] gitignore nosetest output files --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) 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