diff --git a/gui/mainwindow.ui b/gui/mainwindow.ui
index 096f345..f0fc336 100644
--- a/gui/mainwindow.ui
+++ b/gui/mainwindow.ui
@@ -93,6 +93,14 @@
0
+
+
+ 11
+
+
+
+ padding:10px
+
Upload
diff --git a/i18n/Polish.ts b/i18n/Polish.ts
index 7437ff5..dd877fb 100644
--- a/i18n/Polish.ts
+++ b/i18n/Polish.ts
@@ -1,61 +1,97 @@
-
+
+
MainWindow
-
+
No boards found
Nie znaleziono płytki
-
+
Others...
Inne...
-
+
No device selected.
Nie wybrano urządzenia.
-
+
No version selected.
Nie wybrano wersji.
-
+
Luftdaten.info Flashing Tool
-
+
Board:
Płytka:
-
+
Firmware version:
Wersja oprogramowania:
-
+
master
master
-
+
Upload
Wgraj
-
+
Expert mode
Tryb eksperta
-
+
Baudrate:
Prędkość portu:
+
+
+ Invalid version / file does not exist
+ Błędna wersja / plik nie istnieje
+
+
+
+ Work in progess...
+ Praca w toku...
+
+
+
+ Downloading...
+ Pobieranie...
+
+
+
+ Connecting...
+ Łączenie...
+
+
+
+ Done. Chip type: %s
+ Zrobione. Typ układu: %s
+
+
+
+ Writing at 0x%08x...
+ Zapisywanie pod adresem 0x%08x...
+
+
+
+ Finished in %.2f seconds
+ Zrobione w %.2f sekundy
+
diff --git a/luftdaten-tool.py b/luftdaten-tool.py
index 83f31fa..3c62699 100644
--- a/luftdaten-tool.py
+++ b/luftdaten-tool.py
@@ -1,14 +1,27 @@
# -* encoding: utf-8 *-
import sys
import os.path
+import re
+import time
+import tempfile
+import hashlib
+import zlib
import serial
import serial.tools.list_ports
+import requests
+from esptool import ESPLoader
from PyQt5 import QtGui, QtCore, QtWidgets
from gui import mainwindow
+# Firmware update repository
+UPDATE_REPOSITORY = 'https://www.madavi.de/sensor/update/data/'
+# URI prefixes (protocol parts, essentially) to be downloaded using requests
+ALLOWED_PROTO = ('http://', 'https://')
+
+# vid/pid pairs of known NodeMCU/ESP8266 development boards
PREFERED_PORTS = [
# CH341
(0x1A86, 0x7523),
@@ -19,8 +32,44 @@ PREFERED_PORTS = [
ROLE_DEVICE = QtCore.Qt.UserRole + 1
+# FIXME move this into something like qtvariant.py
+QtCore.Signal = QtCore.pyqtSignal
+QtCore.Slot = QtCore.pyqtSlot
+
+file_index_re = re.compile(r'([^<]*)')
+
+
+def indexof(path):
+ """Returns list of filenames parsed off "Index of" page"""
+ resp = requests.get(path)
+ return [a for a, b in file_index_re.findall(resp.text) if a == b]
+
+
+class QThread(QtCore.QThread):
+ """Provides similar API to threading.Thread but with additional error
+ reporting based on Qt Signals"""
+ def __init__(self, parent=None, target=None, args=None, kwargs=None,
+ error=None):
+ super(QThread, self).__init__(parent)
+ self.target = target
+ self.args = args or []
+ self.kwargs = kwargs or {}
+ self.error = error
+
+ def run(self):
+ try:
+ self.target(*self.args, **self.kwargs)
+ except Exception as exc:
+ if self.error:
+ self.error.emit(str(exc))
+ raise
+
class MainWindow(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow):
+ signal = QtCore.Signal([str, int])
+ errorSignal = QtCore.Signal([str])
+ uploadThread = None
+
def __init__(self, parent=None, app=None):
super(MainWindow, self).__init__(parent)
@@ -34,18 +83,50 @@ class MainWindow(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow):
self.translator = QtCore.QTranslator()
self.i18n_init(QtCore.QLocale.system())
+
+ # TODO: extract this to separate thread
+ self.populate_versions()
self.populate_boards(serial.tools.list_ports.comports())
self.on_expertModeBox_clicked()
+ self.signal.connect(self.on_work_update)
+ self.errorSignal.connect(self.on_work_error)
+
+ self.cachedir = tempfile.TemporaryDirectory()
+
+ def on_work_update(self, status, progress):
+ self.statusbar.showMessage(status)
+ self.progressBar.setValue(progress)
+
+ def on_work_error(self, message):
+ self.statusbar.showMessage(message)
+
def i18n_init(self, locale):
+ """Initializes i18n to specified QLocale"""
+
self.app.removeTranslator(self.translator)
- self.translator.load(os.path.join('i18n', QtCore.QLocale.languageToString(locale.language())+ '.qm'))
+ lang = QtCore.QLocale.languageToString(locale.language())
+ self.translator.load(os.path.join('i18n', lang + '.qm'))
self.app.installTranslator(self.translator)
self.retranslateUi(self)
+ def populate_versions(self):
+ """Loads available firmware versions into versionbox widget"""
+
+ for fname in indexof(UPDATE_REPOSITORY):
+ if not fname.endswith('.bin'):
+ continue
+
+ item = QtGui.QStandardItem(fname)
+ item.setData(UPDATE_REPOSITORY + fname, ROLE_DEVICE)
+ self.versionBox.model().appendRow(item)
+
def populate_boards(self, ports):
- prefered, others = self.group_ports(serial.tools.list_ports.comports())
+ """Populates board selection combobox from list of pyserial
+ ListPortInfo objects"""
+
+ prefered, others = self.group_ports(ports)
for b in prefered:
item = QtGui.QStandardItem(
@@ -80,10 +161,7 @@ class MainWindow(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow):
others.append(p)
return prefered, others
- def on_actionExit_triggered(self):
- """This handles activation of "Exit" menu action"""
- self.app.exit()
-
+ @QtCore.Slot()
def on_uploadButton_clicked(self):
self.statusbar.clearMessage()
@@ -98,6 +176,98 @@ class MainWindow(QtWidgets.QMainWindow, mainwindow.Ui_MainWindow):
self.statusbar.showMessage(self.tr("No version selected."))
return
+ orig_version = self.versionBox.model().item(
+ self.versionBox.currentIndex()).text()
+
+ if version == orig_version:
+ # Editable combobox has been unchanged
+ binary_uri = self.versionBox.currentData(ROLE_DEVICE)
+ elif version.startswith(ALLOWED_PROTO):
+ # User has provided a download URL
+ binary_uri = version
+ elif os.path.exists(version):
+ binary_uri = version
+ else:
+ self.statusbar.showMessage(self.tr(
+ "Invalid version / file does not exist"))
+ return
+
+ if self.uploadThread and self.uploadThread.isRunning():
+ self.statusbar.showMessage(self.tr("Work in progess..."))
+ return
+
+ self.uploadThread = QThread(self, self.flash_board, [
+ self.signal, device, binary_uri], error=self.errorSignal)
+ self.uploadThread.start()
+
+ def cache_download(self, progress, binary_uri):
+ """Downloads and caches file with status reports via Qt Signals"""
+ cache_fname = os.path.join(
+ self.cachedir.name,
+ hashlib.sha256(binary_uri.encode('utf-8')).hexdigest())
+
+ if os.path.exists(cache_fname):
+ return cache_fname
+
+ with open(cache_fname, 'wb') as fd:
+ progress.emit(self.tr('Downloading...'), 0)
+ response = requests.get(binary_uri, stream=True)
+ total_length = response.headers.get('content-length')
+
+ dl = 0
+ total_length = int(total_length or 0)
+ for data in response.iter_content(chunk_size=4096):
+ dl += len(data)
+ fd.write(data)
+
+ if total_length:
+ progress.emit(self.tr('Downloading...'),
+ (100*dl) // total_length)
+
+ return cache_fname
+
+ def flash_board(self, progress, device, binary_uri, baudrate=460800):
+ if binary_uri.startswith(ALLOWED_PROTO):
+ binary_uri = self.cache_download(progress, binary_uri)
+
+ print(binary_uri)
+
+ progress.emit(self.tr('Connecting...'), 0)
+
+ init_baud = min(ESPLoader.ESP_ROM_BAUD, baudrate)
+ esp = ESPLoader.detect_chip(device, init_baud, 'default_reset', False)
+
+ progress.emit(self.tr('Done. Chip type: %s') %
+ esp.get_chip_description(), 0)
+ esp = esp.run_stub()
+ esp.change_baud(baudrate)
+
+ with open(binary_uri, 'rb') as fd:
+ uncimage = fd.read()
+
+ image = zlib.compress(uncimage, 9)
+
+ address = 0x0
+ blocks = esp.flash_defl_begin(len(uncimage), len(image), address)
+
+ seq = 0
+ written = 0
+ t = time.time()
+ while len(image) > 0:
+ current_addr = address + seq * esp.FLASH_WRITE_SIZE
+ progress.emit(self.tr('Writing at 0x%08x...') % (current_addr,),
+ 100 * (seq + 1) // blocks)
+
+ block = image[0:esp.FLASH_WRITE_SIZE]
+ esp.flash_defl_block(block, seq, timeout=3.0)
+ image = image[esp.FLASH_WRITE_SIZE:]
+ seq += 1
+ written += len(block)
+ t = time.time() - t
+
+ progress.emit(self.tr('Finished in %.2f seconds') % (t,), 100)
+
+ @QtCore.Slot()
def on_expertModeBox_clicked(self):
self.expertForm.setVisible(self.expertModeBox.checkState())