From 78dbaa1525867da0c9af2d0e1bd74bc68fe495e8 Mon Sep 17 00:00:00 2001 From: William Pietri Date: Sat, 4 Nov 2017 10:26:07 -0700 Subject: [PATCH] Adding probabilistic options and improving docs. --- README.md | 76 +++++++++++++++++++++++++++++++++++--------- sucks.py | 87 +++++++++++++++++++++++++++++++++++++++++---------- test_sucks.py | 31 ++++++++++++++++-- 3 files changed, 162 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index b9dee92..18e0ab3 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ Right now this code offered more as inspiration than something for other people to just download and use. But if you'd like to help flesh it out, send email to my first name at williampietri.com. +If you're curious about the protocol, I have [a very rough +doc](protocol.md) started. I'll happily accept pull requests for it. + +Why the project name? Well, a) it's ridiculous that I needed to MITM +my own vacuum. This is not the future I signed up for. There should +be a nice, tidy RESTful API. That would be easy enough to make. And b), +it's a vacuum. + +## Usage + If you do try to use it, you'll need to create ~/.config/sucks.conf. It should look something like this: @@ -20,23 +30,61 @@ vacuum=[robot id]@126.ecorobot.net ``` I got these values by using -[xmppeek](https://www.beneaththewaves.net/Software/XMPPPeek.html) -to do a man-in-the-middle attack on -the android app. You can use the included log_clean.py script to generate -a config from a captured session. (I suspect that the Android app re-keys -the connection on a regular basis, as the secret was changing regularly -up until I cleared the Android app's data from my phone.) +[xmppeek](https://www.beneaththewaves.net/Software/XMPPPeek.html) to do +a man-in-the-middle attack on the android app. You can use the included +log_clean.py script to generate a config from a captured session. (I +suspect that the Android app re-keys the connection on a regular basis, +as the secret was changing regularly up until I cleared the Android +app's data from my phone.) -If you're curious about the protocol, I have [a very rough -doc](protocol.md) started. I'll happily accept pull requests for it. +With that set up, you could have it clean in auto mode for 10 minutes +and return to its charger: + +``` + % sucks clean 10 +``` + +You could have it clean for 15 minutes and then do an extra 10 minutes +of edging: + +``` + % sucks clean 15 edge 10 +``` + +If you wanted it to clean for 5 minutes and then stop where it was, +either of these would work: + +``` + % sucks clean 5 stop + % sucks --no-charge clean 5 +``` + +If it's running amok and you'd just like it to stop where it is: + +``` + % sucks stop +``` + +To tell it to go find plug in: + +``` + % sucks charge +``` + +I run mine from my crontab, but I didn't want it to clean every day, +so it also has a mode where it randomly decide to run or not based on +a frequency you give it. My crontab entry looks like this: + +``` +0 10 * * * /home/william/projects/sucks/sucks clean -f 4/7 15 edge -f 1/14 10 +``` + +This means that every day at 10 am, it might do something. 4 days out +of 7, it will do 15 minutes of automatic cleaning. 1 day out of 14, +it will do another 10 minutes of edging. And afterward it will always +go back to charge. -Why the project name? Well, a) it's ridiculous that I needed to MITM -my own vacuum. This is not the future I signed up for. There should -be a nice, tidy RESTful API. That would be easy enough to make. And b), -it's a vacuum. ## To Do -* add probabilistic cleaning options -* implement more commands * add a status commmand diff --git a/sucks.py b/sucks.py index 0471d08..7b74306 100644 --- a/sucks.py +++ b/sucks.py @@ -2,6 +2,8 @@ import configparser import itertools import logging import os +import random +import re import time from threading import Event @@ -74,7 +76,6 @@ class VacBot(ClientXMPP): self.wait_until_ready() def run(self, action): - click.echo("performing " + str(action)) self.send_command(action.to_xml()) action.wait_for_completion(self) @@ -88,7 +89,7 @@ class VacBotCommand(): def wait_for_completion(self, bot): if self.wait: - click.echo("waiting in " + self.name + " for " + str(self.wait) + "s") + click.echo("waiting in " + self.command_name() + " for " + str(self.wait) + "s") time.sleep(self.wait) def to_xml(self): @@ -98,13 +99,17 @@ class VacBotCommand(): return ctl def __str__(self, *args, **kwargs): - return self.name + " command" + return self.command_name() + " command" + + def command_name(self): + return self.__class__.__name__.lower() class Clean(VacBotCommand): def __init__(self, wait): super().__init__('clean', {'type': 'auto', 'speed': 'standard'}, wait) + class Edge(VacBotCommand): def __init__(self, wait): super().__init__('clean', {'type': 'border', 'speed': 'strong'}, wait) @@ -133,6 +138,35 @@ class Stop(VacBotCommand): logging.debug("done waiting in " + self.name) +class FrequencyParamType(click.ParamType): + name = 'frequency' + RATIONAL_PATTERN = re.compile(r'([.0-9])/([.0-9])') + + def convert(self, value, param, ctx): + result = None + try: + search = self.RATIONAL_PATTERN.search(value) + if search: + result = float(search.group(1)) / float(search.group(2)) + else: + try: + result = float(value) + except ValueError: + pass + except ValueError: + pass + + if result is None: + self.fail('%s is not a valid frequency' % value, param, ctx) + if 0 <= result <= 1: + return result + + self.fail('%s is not between 0 and 1' % value, param, ctx) + + +FREQUENCY = FrequencyParamType() + + def read_config(filename): parser = configparser.ConfigParser() with open(filename) as fp: @@ -140,6 +174,15 @@ def read_config(filename): return parser['global'] +def should_run(frequency): + if frequency is None: + return + n = random.random() + result = n <= frequency + logging.debug("tossing coin: {:0.3f} <= {:0.3f}: {}".format( n, frequency, result)) + return result + + @click.group(chain=True) @click.option('--charge/--no-charge', default=True, help='Return to charge after running. Defaults to yes.') @click.option('--debug/--no-debug', default=False) @@ -149,14 +192,19 @@ def cli(charge, debug): @cli.command(help='auto-cleans for the specified number of minutes') +@click.option('--frequency', '-f', type=FREQUENCY, help='frequency with which to run; e.g. 0.5 or 3/7') @click.argument('minutes', type=click.FLOAT) -def clean(minutes): - return Clean(minutes * 60) +def clean(frequency, minutes): + if should_run(frequency): + return Clean(minutes * 60) + @cli.command(help='cleans room edges for the specified number of minutes') +@click.option('--frequency', '-f', type=FREQUENCY, help='frequency with which to run; e.g. 0.5 or 3/7') @click.argument('minutes', type=click.FLOAT) -def edge(minutes): - return Edge(minutes * 60) +def edge(frequency, minutes): + if should_run(frequency): + return Edge(minutes * 60) @cli.command(help='returns to charger') @@ -171,15 +219,22 @@ def stop(): @cli.resultcallback() def run(actions, charge, debug): - config = read_config(os.path.expanduser('~/.config/sucks.conf')) - vacbot = VacBot(config['user'], config['domain'], config['resource'], config['secret'], - config['vacuum']) - vacbot.connect_and_wait_until_ready() - for action in actions: - vacbot.run(action) - if charge and not actions[-1].terminal: - vacbot.run(Charge()) - vacbot.disconnect(wait=True) + actions = list(filter(None.__ne__, actions)) + if actions and charge and not actions[-1].terminal: + actions.append(Charge()) + + if actions: + config = read_config(os.path.expanduser('~/.config/sucks.conf')) + vacbot = VacBot(config['user'], config['domain'], config['resource'], config['secret'], + config['vacuum']) + vacbot.connect_and_wait_until_ready() + + for action in actions: + click.echo("performing " + str(action)) + vacbot.run(action) + + vacbot.disconnect(wait=True) + click.echo("done") diff --git a/test_sucks.py b/test_sucks.py index 36e4543..3b645d0 100644 --- a/test_sucks.py +++ b/test_sucks.py @@ -1,9 +1,10 @@ from xml.etree import ElementTree -from nose.tools import assert_equals +from nose.tools import * from sucks import * + # There are no tests for the XMPP stuff here because a) it's relatively complicated to test given # the library's design and its multithreaded nature, and b) I'm manually testing every change anyhow, # as it's not clear how the robot really behaves. @@ -16,7 +17,6 @@ def test_clean_command(): b'') # protocol has attribs in other order - def test_edge_command(): # called Edge because that's what the UI uses, even though the protocol is different c = Edge(10) @@ -39,3 +39,30 @@ def test_stop_command(): assert_equals(ElementTree.tostring(c.to_xml()), b'') + +def test_frequency_param_type(): + t = FREQUENCY + assert_equals(t.convert('0', None, None), 0) + assert_equals(t.convert('1', None, None), 1.0) + assert_equals(t.convert('1/2', None, None), 0.5) + assert_equals(t.convert('1/7', None, None), 1.0 / 7.0) + with assert_raises(click.exceptions.BadParameter): + t.convert('bob', None, None) + with assert_raises(click.exceptions.BadParameter): + t.convert('2', None, None) + with assert_raises(click.exceptions.BadParameter): + t.convert('7/5', None, None) + +def test_should_run(): + count = 0 + for _ in range(10000): + if should_run(0.1): + count += 1 + assert_almost_equal(count, 1000, delta=200) + + count = 0 + for _ in range(10000): + if should_run(0.9): + count += 1 + assert_almost_equal(count, 9000, delta=200) +