Merge pull request #41 from OverloadUT/subscribe
Add event emitters, error handling, vacuum state and lifespan tracking
This commit is contained in:
@@ -5,3 +5,7 @@ scratch*
|
|||||||
.idea
|
.idea
|
||||||
build
|
build
|
||||||
dist
|
dist
|
||||||
|
|
||||||
|
# Nosetests files
|
||||||
|
cover/
|
||||||
|
.coverage
|
||||||
|
|||||||
+227
-42
@@ -9,7 +9,61 @@ import requests
|
|||||||
import stringcase
|
import stringcase
|
||||||
from sleekxmpp import ClientXMPP, Callback, MatchXPath
|
from sleekxmpp import ClientXMPP, Callback, MatchXPath
|
||||||
from sleekxmpp.xmlstream import ET
|
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:
|
class EcoVacsAPI:
|
||||||
CLIENT_KEY = "eJUWrzRv34qFSaYk"
|
CLIENT_KEY = "eJUWrzRv34qFSaYk"
|
||||||
@@ -126,54 +180,197 @@ class EcoVacsAPI:
|
|||||||
return str(b64encode(result), 'utf8')
|
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():
|
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
|
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.clean_status = None
|
||||||
self.charge_status = None
|
self.charge_status = None
|
||||||
self.battery_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)
|
self.xmpp.subscribe_to_ctls(self._handle_ctl)
|
||||||
|
|
||||||
def connect_and_wait_until_ready(self):
|
def connect_and_wait_until_ready(self):
|
||||||
self.xmpp.connect_and_wait_until_ready()
|
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):
|
def _handle_ctl(self, ctl):
|
||||||
method = '_handle_' + ctl['event']
|
method = '_handle_' + ctl['event']
|
||||||
if hasattr(self, method):
|
if hasattr(self, method):
|
||||||
getattr(self, method)(ctl)
|
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):
|
def _handle_clean_report(self, event):
|
||||||
self.clean_status = event['type']
|
type = event['type']
|
||||||
logging.debug("*** clean_status = " + self.clean_status)
|
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):
|
def _handle_battery_info(self, iq):
|
||||||
try:
|
try:
|
||||||
self.battery_status = float(iq['power']) / 100
|
self.battery_status = float(iq['power']) / 100
|
||||||
logging.debug("*** battery_status = {:.0%}".format(self.battery_status))
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logging.warning("couldn't parse battery status " + ET.tostring(iq))
|
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):
|
def _handle_charge_state(self, event):
|
||||||
report = event['type']
|
status = event['type']
|
||||||
if report == 'going':
|
try:
|
||||||
self.charge_status = 'returning'
|
status = CHARGE_MODE_FROM_ECOVACS[status]
|
||||||
elif report == 'slot_charging':
|
except KeyError:
|
||||||
self.charge_status = 'charging'
|
logging.warning("Unknown charging status '" + status + "'")
|
||||||
elif report == 'idle':
|
|
||||||
self.charge_status = 'idle'
|
self.charge_status = status
|
||||||
else:
|
if status != 'idle' or self.vacuum_status == 'charging':
|
||||||
logging.warning("Unknown charging status '" + report + "'")
|
# 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)
|
logging.debug("*** charge_status = " + self.charge_status)
|
||||||
|
|
||||||
def _vacuum_address(self):
|
def _vacuum_address(self):
|
||||||
return self.vacuum['did'] + '@' + self.vacuum['class'] + '.ecorobot.net/atom'
|
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):
|
def send_command(self, xml):
|
||||||
self.xmpp.send_command(xml, self._vacuum_address())
|
self.xmpp.send_command(xml, self._vacuum_address())
|
||||||
|
|
||||||
@@ -218,11 +415,16 @@ class EcoVacsXMPP(ClientXMPP):
|
|||||||
def _handle_ctl(self, message):
|
def _handle_ctl(self, message):
|
||||||
the_good_part = message.get_payload()[0][0]
|
the_good_part = message.get_payload()[0][0]
|
||||||
as_dict = self._ctl_to_dict(the_good_part)
|
as_dict = self._ctl_to_dict(the_good_part)
|
||||||
for s in self.ctl_subscribers:
|
if as_dict is not None:
|
||||||
s(as_dict)
|
for s in self.ctl_subscribers:
|
||||||
|
s(as_dict)
|
||||||
|
|
||||||
def _ctl_to_dict(self, xml):
|
def _ctl_to_dict(self, xml):
|
||||||
result = xml.attrib.copy()
|
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')
|
result['event'] = result.pop('td')
|
||||||
if xml:
|
if xml:
|
||||||
result.update(xml[0].attrib)
|
result.update(xml[0].attrib)
|
||||||
@@ -265,28 +467,6 @@ class EcoVacsXMPP(ClientXMPP):
|
|||||||
|
|
||||||
|
|
||||||
class VacBotCommand:
|
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 = {
|
ACTION = {
|
||||||
'forward': 'forward',
|
'forward': 'forward',
|
||||||
'left': 'SpinLeft',
|
'left': 'SpinLeft',
|
||||||
@@ -320,7 +500,7 @@ class VacBotCommand:
|
|||||||
|
|
||||||
class Clean(VacBotCommand):
|
class Clean(VacBotCommand):
|
||||||
def __init__(self, mode='auto', speed='normal', terminal=False):
|
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):
|
class Edge(Clean):
|
||||||
@@ -340,7 +520,7 @@ class Stop(Clean):
|
|||||||
|
|
||||||
class Charge(VacBotCommand):
|
class Charge(VacBotCommand):
|
||||||
def __init__(self):
|
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):
|
class Move(VacBotCommand):
|
||||||
@@ -348,6 +528,11 @@ class Move(VacBotCommand):
|
|||||||
super().__init__('Move', {'move': {'action': self.ACTION[action]}})
|
super().__init__('Move', {'move': {'action': self.ACTION[action]}})
|
||||||
|
|
||||||
|
|
||||||
|
class PlaySound(VacBotCommand):
|
||||||
|
def __init__(self, sid="0"):
|
||||||
|
super().__init__('PlaySound', {'sid': sid})
|
||||||
|
|
||||||
|
|
||||||
class GetCleanState(VacBotCommand):
|
class GetCleanState(VacBotCommand):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__('GetCleanState')
|
super().__init__('GetCleanState')
|
||||||
@@ -365,7 +550,7 @@ class GetBatteryState(VacBotCommand):
|
|||||||
|
|
||||||
class GetLifeSpan(VacBotCommand):
|
class GetLifeSpan(VacBotCommand):
|
||||||
def __init__(self, component):
|
def __init__(self, component):
|
||||||
super().__init__('GetLifeSpan', {'type': self.COMPONENT[component]})
|
super().__init__('GetLifeSpan', {'type': COMPONENT_TO_ECOVACS[component]})
|
||||||
|
|
||||||
|
|
||||||
class SetTime(VacBotCommand):
|
class SetTime(VacBotCommand):
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ def test_stop_command():
|
|||||||
b'<ctl td="Clean"><clean speed="standard" type="stop" /></ctl>')
|
b'<ctl td="Clean"><clean speed="standard" type="stop" /></ctl>')
|
||||||
|
|
||||||
|
|
||||||
|
def test_play_sound_command():
|
||||||
|
c = PlaySound()
|
||||||
|
assert_equals(ElementTree.tostring(c.to_xml()),
|
||||||
|
b'<ctl sid="0" td="PlaySound" />')
|
||||||
|
|
||||||
|
|
||||||
|
def test_play_sound_command_with_sid():
|
||||||
|
c = PlaySound(sid="1")
|
||||||
|
assert_equals(ElementTree.tostring(c.to_xml()),
|
||||||
|
b'<ctl sid="1" td="PlaySound" />')
|
||||||
|
|
||||||
|
|
||||||
def test_get_clean_state_command():
|
def test_get_clean_state_command():
|
||||||
c = GetCleanState()
|
c = GetCleanState()
|
||||||
assert_equals(ElementTree.tostring(c.to_xml()),
|
assert_equals(ElementTree.tostring(c.to_xml()),
|
||||||
|
|||||||
+179
-2
@@ -2,6 +2,8 @@ from nose.tools import *
|
|||||||
|
|
||||||
from sucks import *
|
from sucks import *
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
from sleekxmpp.exceptions import XMPPError
|
||||||
|
|
||||||
|
|
||||||
def test_handle_clean_report():
|
def test_handle_clean_report():
|
||||||
@@ -10,6 +12,22 @@ def test_handle_clean_report():
|
|||||||
|
|
||||||
v._handle_ctl({'event': 'clean_report', 'type': 'auto', 'speed': 'strong'})
|
v._handle_ctl({'event': 'clean_report', 'type': 'auto', 'speed': 'strong'})
|
||||||
assert_equals('auto', v.clean_status)
|
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():
|
def test_handle_charge_state():
|
||||||
@@ -25,6 +43,28 @@ def test_handle_charge_state():
|
|||||||
v._handle_ctl({'event': 'charge_state', 'type': 'idle'})
|
v._handle_ctl({'event': 'charge_state', 'type': 'idle'})
|
||||||
assert_equals('idle', v.charge_status)
|
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():
|
def test_handle_battery_info():
|
||||||
v = a_vacbot()
|
v = a_vacbot()
|
||||||
@@ -39,6 +79,143 @@ def test_handle_battery_info():
|
|||||||
v._handle_ctl({'event': 'battery_info', 'power': '000'})
|
v._handle_ctl({'event': 'battery_info', 'power': '000'})
|
||||||
assert_equals(0.0, v.battery_status)
|
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():
|
def test_handle_unknown_ctl():
|
||||||
v = a_vacbot()
|
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:
|
if bot is None:
|
||||||
bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob"}
|
bot = {"did": "E0000000001234567890", "class": "126", "nick": "bob"}
|
||||||
return VacBot('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12',
|
return VacBot('20170101abcdefabcdefa', 'ecouser.net', 'abcdef12', 'A1b2C3d4efghijklmNOPQrstuvwxyz12',
|
||||||
bot, 'na')
|
bot, 'na', monitor=monitor)
|
||||||
|
|||||||
Reference in New Issue
Block a user