-
-
Notifications
You must be signed in to change notification settings - Fork 572
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
Changes from 7 commits
bc7252e
a8e5f5a
b268cd3
585b04b
78d88d9
e582b21
40d65bf
791768f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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() | ||
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() |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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__) | ||
|
@@ -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 | ||
|
@@ -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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. expected 2 blank lines, found 1 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. expected 2 blank lines, found 1 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. at least two spaces before inline comment There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. continuation line over-indented for visual indent There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)" \ | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be memory inefficient
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 :-)