Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vacuum: Implement TUI for the manual mode #845

Merged
merged 4 commits into from
Oct 31, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/vacuum.rst
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,43 @@ and updating from an URL requires you to pass the md5 hash of the file.

mirobo update-firmware v11_003094.pkg

Manual control
~~~~~~~~~~~~~~

To start the manual mode:

::

mirobo manual start

To move forward with velocity 0.3 for default amount of time:

::

mirobo manual forward 0.3

To turn 90 degrees to the right for default amount of time:

::

mirobo manual right 90

To stop the manual mode:

::

mirobo manual stop

To run the manual control TUI:

.. NOTE::

Make sure you have got `curses` library installed on your system.

::

mirobo manual tui


DND functionality
~~~~~~~~~~~~~~~~~
Expand Down
1 change: 1 addition & 0 deletions miio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from miio.pwzn_relay import PwznRelay
from miio.toiletlid import Toiletlid
from miio.vacuum import Vacuum, VacuumException
from miio.vacuum_tui import VacuumTUI
rytilahti marked this conversation as resolved.
Show resolved Hide resolved
from miio.vacuumcontainers import (
CleaningDetails,
CleaningSummary,
Expand Down
33 changes: 24 additions & 9 deletions miio/vacuum.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,22 @@ def manual_stop(self):
self.manual_seqnum = 0
return self.send("app_rc_end")

MANUAL_ROTATION_MAX = 180
MANUAL_ROTATION_MIN = -MANUAL_ROTATION_MAX
MANUAL_VELOCITY_MAX = 0.3
MANUAL_VELOCITY_MIN = -MANUAL_VELOCITY_MAX
MANUAL_DURATION_DEFAULT = 1500

@command(
click.argument("rotation", type=int),
click.argument("velocity", type=float),
click.argument("duration", type=int, required=False, default=1500),
click.argument(
"duration", type=int, required=False, default=MANUAL_DURATION_DEFAULT
),
)
def manual_control_once(self, rotation: int, velocity: float, duration: int = 1500):
def manual_control_once(
self, rotation: int, velocity: float, duration: int = MANUAL_DURATION_DEFAULT
):
"""Starts the remote control mode and executes
the action once before deactivating the mode."""
number_of_tries = 3
Expand All @@ -191,18 +201,23 @@ def manual_control_once(self, rotation: int, velocity: float, duration: int = 15
@command(
click.argument("rotation", type=int),
click.argument("velocity", type=float),
click.argument("duration", type=int, required=False, default=1500),
click.argument(
"duration", type=int, required=False, default=MANUAL_DURATION_DEFAULT
),
)
def manual_control(self, rotation: int, velocity: float, duration: int = 1500):
def manual_control(
self, rotation: int, velocity: float, duration: int = MANUAL_DURATION_DEFAULT
):
"""Give a command over manual control interface."""
if rotation < -180 or rotation > 180:
if rotation < self.MANUAL_ROTATION_MIN or rotation > self.MANUAL_ROTATION_MAX:
raise DeviceException(
"Given rotation is invalid, should " "be ]-180, 180[, was %s" % rotation
"Given rotation is invalid, should be ]%s, %s[, was %s"
% (self.MANUAL_ROTATION_MIN, self.MANUAL_ROTATION_MAX, rotation)
)
if velocity < -0.3 or velocity > 0.3:
if velocity < self.MANUAL_VELOCITY_MIN or velocity > self.MANUAL_VELOCITY_MAX:
raise DeviceException(
"Given velocity is invalid, should "
"be ]-0.3, 0.3[, was: %s" % velocity
"Given velocity is invalid, should be ]%s, %s[, was: %s"
% (self.MANUAL_VELOCITY_MIN, self.MANUAL_VELOCITY_MAX, velocity)
)

self.manual_seqnum += 1
Expand Down
7 changes: 7 additions & 0 deletions miio/vacuum_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,13 @@ def manual(vac: miio.Vacuum):
# if not vac.manual_mode and command :


@manual.command()
@pass_dev
def tui(vac: miio.Vacuum):
"""TUI for the manual mode."""
miio.VacuumTUI(vac).run()
rytilahti marked this conversation as resolved.
Show resolved Hide resolved


@manual.command()
@pass_dev
def start(vac: miio.Vacuum): # noqa: F811 # redef of start
Expand Down
101 changes: 101 additions & 0 deletions miio/vacuum_tui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
try:
import curses
except ImportError: # curses unavailable
pass
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When can this happen?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What we import here as curses is actually an extension module that must be backed by one of few C implementations of a curses library. ImportError occurs when none of the implementations are available on the system.

I do not think there is much we can do about it except for mentioning in docs/vacuum.rst, that curses lib is a requirement for TUI.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see. I thought it is part of the standard library and always available.

How about letting it bubble up and doing the import inside the tui() command? It could be caught there to display a user-friendly error message.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I prefer all the imports to be grouped in one place - at the top of a module. They are easier to manage that way.

If all we want is a prettier error message, I would rather delay the exception until VacuumTUI is instantiated:

try:
    import curses
except ImportError:
    curses = None

...

class VacuumTUI:
    def __new__(cls, *args, **kwargs):
        if curses is None:
            raise ImportError("curses library is not available")
        return super().__new__(cls)

What do you think?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what I want is it not to simply crash silently in any case, so your suggested solution works fine, too! Maybe check for the Noneness inside the __init__ though?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the check to __init__ in a new commit.


import enum
from typing import Tuple

from .vacuum import Vacuum


class Control(enum.Enum):

Quit = "q"
Forward = "w"
ForwardFast = "W"
Backward = "s"
BackwardFast = "S"
Left = "a"
LeftFast = "A"
Right = "d"
RightFast = "D"


class VacuumTUI:
def __init__(self, vac: Vacuum):
self.vac = vac
self.rot = 0
self.rot_delta = 30
self.rot_min = Vacuum.MANUAL_ROTATION_MIN
self.rot_max = Vacuum.MANUAL_ROTATION_MAX
self.vel = 0.0
self.vel_delta = 0.1
self.vel_min = Vacuum.MANUAL_VELOCITY_MIN
self.vel_max = Vacuum.MANUAL_VELOCITY_MAX
self.dur = 10 * 1000

def run(self) -> None:
self.vac.manual_start()
try:
curses.wrapper(self.main)
finally:
self.vac.manual_stop()

def main(self, screen) -> None:
rytilahti marked this conversation as resolved.
Show resolved Hide resolved
screen.addstr("Use wasd to control the device.\n")
screen.addstr("Hold shift to enable fast mode.\n")
screen.addstr("Press q to quit.\n")
screen.refresh()
self.loop(screen)

def loop(self, win) -> None:
done = False
while not done:
key = win.getkey()
text, done = self.handle_key(key)
win.clear()
win.addstr(text)
win.refresh()

def handle_key(self, key: str) -> Tuple[str, bool]:
try:
ctl = Control(key)
except ValueError as e:
return "Ignoring %s: %s.\n" % (key, e), False

done = self.dispatch_control(ctl)
return self.info(), done

def dispatch_control(self, ctl: Control) -> bool:
if ctl == Control.Quit:
return True

if ctl == Control.Forward:
self.vel = min(self.vel + self.vel_delta, self.vel_max)
elif ctl == Control.ForwardFast:
self.vel = 0 if self.vel < 0 else self.vel_max

elif ctl == Control.Backward:
self.vel = max(self.vel - self.vel_delta, self.vel_min)
elif ctl == Control.BackwardFast:
self.vel = 0 if self.vel > 0 else self.vel_min

elif ctl == Control.Left:
self.rot = min(self.rot + self.rot_delta, self.rot_max)
elif ctl == Control.LeftFast:
self.rot = 0 if self.rot < 0 else self.rot_max

elif ctl == Control.Right:
self.rot = max(self.rot - self.rot_delta, self.rot_min)
elif ctl == Control.RightFast:
self.rot = 0 if self.rot > 0 else self.rot_min

else:
raise RuntimeError("unreachable")

self.vac.manual_control(rotation=self.rot, velocity=self.vel, duration=self.dur)
return False

def info(self) -> str:
return "Rotation=%s\nVelocity=%s\n" % (self.rot, self.vel)