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

Implement firmware update functionality #153

Merged
merged 8 commits into from
Feb 19, 2018
27 changes: 27 additions & 0 deletions miio/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
import construct
from typing import Any, List, Optional # noqa: F401
from enum import Enum

from .protocol import Message

Expand All @@ -20,6 +21,13 @@ class DeviceError(DeviceException):
pass


class UpdateState(Enum):
Downloading = "downloading"
Installing = "installing"
Failed = "failed"
Idle = "idle"


class DeviceInfo:
"""Container of miIO device information.
Hardware properties such as device model, MAC address, memory information,
Expand Down Expand Up @@ -282,6 +290,25 @@ def info(self) -> DeviceInfo:
and harware and software versions."""
return DeviceInfo(self.send("miIO.info", []))

def update(self, url: str, md5: str):
"""Start an OTA update."""
payload = {
"mode": "normal",
"install": "1",
"app_url": url,
"file_md5": md5,
"proc": "dnld install"
}
return self.send("miIO.ota", payload)[0] == "ok"

def update_progress(self) -> int:
"""Return current update progress [0-100]."""
return self.send("miIO.get_ota_progress", [])[0]

def update_state(self):
"""Return current update state."""
return UpdateState(self.send("miIO.get_ota_state", [])[0])

@property
def _id(self) -> int:
"""Increment and return the sequence id."""
Expand Down
91 changes: 91 additions & 0 deletions miio/updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from http.server import HTTPServer, BaseHTTPRequestHandler
import hashlib
import logging
import netifaces
from os.path import basename

_LOGGER = logging.getLogger(__name__)


class SingleFileHandler(BaseHTTPRequestHandler):
"""A simplified handler just returning the contents of a buffer."""
def __init__(self, request, client_address, server):
self.payload = server.payload
self.server = server

super().__init__(request, client_address, server)

def handle_one_request(self):
self.server.got_request = True
self.raw_requestline = self.rfile.readline()

if not self.parse_request():
_LOGGER.error("unable to parse request: %s" % self.raw_requestline)
return

self.send_response(200)
self.send_header('Content-type', 'application/octet-stream')
self.send_header('Content-Length', len(self.payload))
self.end_headers()
self.wfile.write(self.payload)


class OneShotServer:
"""A simple HTTP server for serving an update file.

The server will be started in an emphemeral port, and will only accept
a single request to keep it simple."""
def __init__(self, file, interface=None):
addr = ('', 0)
self.server = HTTPServer(addr, SingleFileHandler)
setattr(self.server, "got_request", False)

self.addr, self.port = self.server.server_address
self.server.timeout = 10

_LOGGER.info("Serving on %s:%s, timeout %s" % (self.addr, self.port,
self.server.timeout))

self.file = basename(file)
with open(file, 'rb') as f:
self.payload = f.read()
Copy link

Choose a reason for hiding this comment

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

Should be memory inefficient

Copy link
Owner Author

Choose a reason for hiding this comment

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

I think it doesn't matter here, as the file needs to be read at some point for sending it to the client. The code is however incomplete, there is currently no way to tell interface/IP to use for the url (e.g. when one has multiple network interfaces as I do). I will have to look more into it in a week when I'll be able to test it on the device.

Copy link

Choose a reason for hiding this comment

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

The point i mean is you should read file by chunks and feed data to hash, reading hundreds megabytes to memory at once is a bad idea.

Copy link
Owner Author

Choose a reason for hiding this comment

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

I understand what you mean, but at some the file has to be read (again) for sending over the wire to the device. I'll look into that though, thanks :-)

self.server.payload = self.payload
self.md5 = hashlib.md5(self.payload).hexdigest()
_LOGGER.info("Using local %s (md5: %s)" % (file, self.md5))

@staticmethod
def find_local_ip():
ifaces_without_lo = [x for x in netifaces.interfaces()
if not x.startswith("lo")]
_LOGGER.debug("available interfaces: %s" % ifaces_without_lo)

for iface in ifaces_without_lo:
addresses = netifaces.ifaddresses(iface)
if netifaces.AF_INET not in addresses:
_LOGGER.debug("%s has no ipv4 addresses, skipping" % iface)
continue
for entry in addresses[netifaces.AF_INET]:
_LOGGER.debug("Got addr: %s" % entry['addr'])
return entry['addr']

def url(self, ip=None):
if ip is None:
ip = OneShotServer.find_local_ip()

url = "http://%s:%s/%s" % (ip, self.port, self.file)
return url

def serve_once(self):
self.server.handle_request()
if getattr(self.server, "got_request"):
_LOGGER.info("Got a request, shold be downloading now.")
return True
else:
_LOGGER.error("No request was made..")
return False


if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG)
upd = OneShotServer("/tmp/test")
upd.serve_once()
101 changes: 93 additions & 8 deletions miio/vacuum_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@
import json
import time
import pathlib
import threading
from tqdm import tqdm
from appdirs import user_cache_dir
from pprint import pformat as pf
from typing import Any # noqa: F401
from miio.click_common import (ExceptionHandlerGroup, validate_ip,
validate_token)
from .device import UpdateState
from .updater import OneShotServer
import miio # noqa: E402

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -425,26 +429,46 @@ def sound(vac: miio.Vacuum, volume: int, test_mode: bool):

@cli.command()
@click.argument('url')
@click.argument('md5sum')
@click.argument('sid', type=int)
@click.argument('md5sum', required=False, default=None)
@click.argument('sid', type=int, required=False, default=10000)
@pass_dev
def install_sound(vac: miio.Vacuum, url: str, md5sum: str, sid: int):
"""Install a sound."""
click.echo("Installing from %s (md5: %s) for id %s" % (url, md5sum, sid))
click.echo(vac.install_sound(url, md5sum, sid))

local_url = None
server = None
if url.startswith("http"):
if md5sum is None:
click.echo("You need to pass md5 when using URL for updating.")
return
local_url = url
else:
server = OneShotServer(url)
local_url = server.url()
md5sum = server.md5

t = threading.Thread(target=server.serve_once)
t.start()
click.echo("Hosting file at %s" % local_url)

click.echo(vac.install_sound(local_url, md5sum, sid))

progress = vac.sound_install_progress()
while progress.is_installing:
print(progress)
progress = vac.sound_install_progress()
time.sleep(0.1)
print("%s (%s %%)" % (progress.state.name, progress.progress))
time.sleep(1)

progress = vac.sound_install_progress()

if progress.progress == 100 and progress.error == 0:
click.echo("Installation of sid '%s' complete!" % progress.sid)
else:
if progress.is_errored:
click.echo("Error during installation: %s" % progress.error)
else:
click.echo("Installation of sid '%s' complete!" % sid)

if server is not None:
t.join()

@cli.command()
@pass_dev
Expand Down Expand Up @@ -480,6 +504,67 @@ def configure_wifi(vac: miio.Vacuum, ssid: str, password: str,
click.echo("Configuring wifi to SSID: %s" % ssid)
click.echo(vac.configure_wifi(ssid, password, uid, timezone))

@cli.command()

Choose a reason for hiding this comment

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

expected 2 blank lines, found 1

Choose a reason for hiding this comment

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

expected 2 blank lines, found 1

@pass_dev
def update_status(vac: miio.Vacuum):
"""Return update state and progress."""
update_state = vac.update_state()
click.echo("Update state: %s" % update_state)

if update_state == UpdateState.Downloading:
click.echo("Update progress: %s" % vac.update_progress())

@cli.command()

Choose a reason for hiding this comment

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

expected 2 blank lines, found 1

Choose a reason for hiding this comment

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

expected 2 blank lines, found 1

@click.argument('url', required=True)
@click.argument('md5', required=False, default=None)
@pass_dev
def update_firmware(vac: miio.Vacuum, url: str, md5: str):
"""Update device firmware.

If `file` starts with http* it is expected to be an URL.
In that case md5sum of the file has to be given."""

# TODO Check that the device is in updateable state.

click.echo("Going to update from %s" % url)
if url.lower().startswith("http"):
if md5 is None:
click.echo("You need to pass md5 when using URL for updating.")
return

click.echo("Using %s (md5: %s)" % (url, md5))
else:
server = OneShotServer(url)
url = server.url()

t = threading.Thread(target=server.serve_once)
t.start()
click.echo("Hosting file at %s" % url)
md5 = server.md5

update_res = vac.update(url, md5)
if update_res:
click.echo("Update started!")
else:
click.echo("Starting the update failed: %s" % update_res)

with tqdm(total=100) as t:
state = vac.update_state()
while state == UpdateState.Downloading:
try:
state = vac.update_state()
progress = vac.update_progress()
except: # we may not get our messages through during upload

Choose a reason for hiding this comment

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

at least two spaces before inline comment

Choose a reason for hiding this comment

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

at least two spaces before inline comment

continue

if state == UpdateState.Installing:
click.echo("Installation started, please wait until the vacuum reboots")
break

t.update(progress - t.n)
t.set_description("%s" % state.name)
time.sleep(1)


@cli.command()
@click.argument('cmd', required=True)
Expand Down
8 changes: 7 additions & 1 deletion miio/vacuumcontainers.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,13 @@ def error(self) -> int:
@property
def is_installing(self) -> bool:
"""True if install is in progress."""
return self.sid != 0 and self.progress < 100 and self.error == 0
return self.state == SoundInstallState.Downloading or \
self.state == SoundInstallState.Installing

Choose a reason for hiding this comment

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

continuation line over-indented for visual indent

Choose a reason for hiding this comment

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

continuation line over-indented for visual indent


@property
def is_errored(self) -> bool:
"""True if the state has an error, use `error`to access it."""
return self.state == SoundInstallState.Error

def __repr__(self) -> str:
return "<SoundInstallStatus sid: %s (state: %s, error: %s)" \
Expand Down