Technician tool v0.5

Imported from the Google Drive folder 004-technician_tool/v0.5.
This commit is contained in:
kkallas committed 2026-09-26 18:30:51 +02:00
1 parent 72adfe1cec
commit f5b32e1554
21 files changed
+2036 -442

No files matched your search

+108 -26
View File
@@ -1,4 +1,4 @@
"""Flash firmware tab: official image only, App-only by default."""
"""Flash firmware tab: factory image or imported developer builds; App-only by default."""
from __future__ import annotations
@@ -10,6 +10,7 @@ from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QFileDialog,
QComboBox,
QGroupBox,
QHBoxLayout,
@@ -24,7 +25,7 @@ from PySide6.QtWidgets import (
)
from ..core.session import SESSIONS_DIR
from ..flash.image import FirmwareImage, ImageError, official_images
from ..flash.image import FirmwareImage, FirmwareLibrary, ImageError, factory_images
from ..flash.plan import Mode, build_plan
from ..flash.runner import FlashRunner, Result
from ..serialio.ports import PortInfo, list_serial_ports
@@ -71,13 +72,26 @@ class FlashTab(QWidget):
bl.addWidget(self.box_fw)
# 2. firmware
fwbox = QGroupBox("2. Firmware (official images only)")
fwbox = QGroupBox("2. Firmware")
self.image_combo = QComboBox(minimumWidth=360)
self.import_btn = QPushButton("Import firmware file...")
self.import_btn.setToolTip("Add a newer build the firmware team sent you (.bin)")
self.import_btn.clicked.connect(self._import)
self.remove_btn = QPushButton("Remove from list")
self.remove_btn.clicked.connect(self._remove)
self.image_info = QLabel(wordWrap=True)
self.image_info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
fl = QVBoxLayout(fwbox)
fl.addWidget(self.image_combo)
brow = QHBoxLayout()
brow.addWidget(self.import_btn)
brow.addWidget(self.remove_btn)
brow.addStretch(1)
fl.addLayout(brow)
fl.addWidget(self.image_info)
self._library = FirmwareLibrary()
self._reference: FirmwareImage | None = None
self._problems: list[str] = []
# 3. mode
modebox = QGroupBox("3. What to write")
@@ -151,17 +165,55 @@ class FlashTab(QWidget):
self._show_result("", None)
# ------------------------------------------------------------ setup
def _load_images(self) -> None:
def _load_images(self, select_sha: str | None = None) -> None:
self._problems = []
try:
self._images = official_images()
factory = factory_images()
except ImageError as exc:
self._images = []
self.image_info.setText(f"<span style='color:#b03a2e'><b>Firmware file problem:</b> {exc}</span>")
factory = []
self._problems.append(f"factory image: {exc}")
self._reference = factory[0] if factory else None
dev, problems = self._library.images(self._reference)
self._problems += problems
# Newest developer builds first, then the factory image.
dev.sort(key=lambda i: i.app.built_at or dt.datetime.min, reverse=True)
self._images = dev + factory
self.image_combo.blockSignals(True)
self.image_combo.clear()
for img in self._images:
self.image_combo.addItem(img.name)
self.image_combo.addItem(img.label())
if not self._images:
self.image_combo.addItem("No official firmware image found in the firmware folder")
self.image_combo.addItem("No firmware image available - import one")
idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), None)
if idx is None:
idx = len(dev) if factory else 0 # default: the factory image
self.image_combo.setCurrentIndex(idx)
self.image_combo.blockSignals(False)
self._update()
def _import(self) -> None:
path, _ = QFileDialog.getOpenFileName(self, "Import firmware from the firmware team", str(Path.home()),
"Firmware (*.bin);;All files (*)")
if not path:
return
try:
img = self._library.import_file(Path(path), self._reference)
except (ImageError, OSError) as exc:
QMessageBox.warning(self, "Cannot use this file", f"This file cannot be flashed:\n\n{exc}")
return
self._load_images(select_sha=img.sha256)
kind = "complete image" if img.kind == "merged" else "app only"
QMessageBox.information(
self, "Firmware imported",
f"{img.name}\nbuild {img.app.elf_sha[:9]}, {kind}\n\nFile fingerprint (SHA-256):\n{img.sha256}\n\n"
"Compare it with the fingerprint the firmware team sent you before flashing.")
def _remove(self) -> None:
img = self._selected_image()
if img and img.is_developer and QMessageBox.question(
self, "Remove firmware", f"Remove {img.name} from this computer?") == QMessageBox.StandardButton.Yes:
self._library.remove(img.sha256)
self._load_images()
def refresh_ports(self) -> None:
current = self.port_combo.currentData()
@@ -189,24 +241,27 @@ class FlashTab(QWidget):
def _update(self) -> None:
img = self._selected_image()
info = ""
if img:
a = img.app
self.image_info.setText(
f"{a.project} {a.version}, built {a.built}, ESP-IDF {a.idf}<br>"
f"<small>build {a.elf_sha[:9]} &middot; file SHA-256 {img.sha256[:12]}... verified</small><br>"
f"<small>{img.notes}</small>")
fw = self.serial_tab.engine.firmware
if fw.elf_sha and img:
same = fw.elf_sha.startswith(img.app.elf_sha[:9]) or img.app.elf_sha.startswith(fw.elf_sha)
msg = f"Box currently runs: {fw.describe()}"
if same:
msg += "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
else:
msg += ("<br><b style='color:#c77c02'>The box runs a different build (probably updated over OTA). "
"Flashing puts the older factory image back; the box updates itself again once online.</b>")
else:
msg = "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
self.box_fw.setText(msg)
origin = ("<b>Factory image</b> (bundled with the tool)" if not img.is_developer else
"<b style='color:#c77c02'>Developer build</b> - only flash builds you received "
"from the firmware team")
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
info = (f"{origin}<br>{a.project} {a.version}, built {a.built}, ESP-IDF {a.idf}, {kind}<br>"
f"<small>build {a.elf_sha[:9]} &middot; file SHA-256 {img.sha256[:16]}... "
f"&middot; checksum OK</small>" + (f"<br><small>{img.notes}</small>" if img.notes else ""))
if self._problems:
info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> "
+ "; ".join(self._problems) + "</span>")
self.image_info.setText(info)
self.remove_btn.setEnabled(bool(img and img.is_developer) and self._runner is None)
# An app-only file has no bootloader/layout: Full mode is impossible.
full_ok = bool(img and img.kind == "merged")
self.rb_full.setEnabled(full_ok and self._runner is None)
if not full_ok and self.rb_full.isChecked():
self.rb_app.setChecked(True)
self.box_fw.setText(self._box_text(img))
self.confirm_full.setVisible(self.rb_full.isChecked())
ok = bool(img and self._selected_port() and self._runner is None
@@ -224,6 +279,23 @@ class FlashTab(QWidget):
else:
self.plan_label.setText("Select a sensorbox and a firmware image.")
def _box_text(self, img: FirmwareImage | None) -> str:
fw = self.serial_tab.engine.firmware
if not fw.elf_sha:
return "Firmware on the box: unknown - connect in the Serial monitor and press Reset & capture to see it."
msg = f"Box currently runs: {fw.describe()}" + (f", built {fw.built}" if fw.built else "")
if not img:
return msg
if fw.elf_sha.startswith(img.app.elf_sha[:9]) or img.app.elf_sha.startswith(fw.elf_sha):
return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
box_built, img_built = _parse_built(fw.built), img.app.built_at
if box_built and img_built and img_built > box_built:
return msg + f"<br><b style='color:#2e6da4'>The selected firmware is newer (built {img.app.built}).</b>"
if box_built and img_built and img_built < box_built:
return msg + ("<br><b style='color:#c77c02'>The selected firmware is OLDER than the box's (the box was "
"probably updated over OTA). The box updates itself again once online.</b>")
return msg + "<br><b style='color:#c77c02'>The box runs a different build.</b>"
# ------------------------------------------------------------ flash
def _start(self) -> None:
img, port = self._selected_image(), self._selected_port()
@@ -256,7 +328,7 @@ class FlashTab(QWidget):
self._runner.start()
def _set_busy(self, busy: bool) -> None:
for w in (self.port_combo, self.image_combo, self.rb_app, self.rb_full, self.backup, self.confirm_full):
for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full, self.import_btn):
w.setEnabled(not busy)
self._update()
@@ -307,3 +379,13 @@ class FlashTab(QWidget):
border, bg = colours[kind or "info"]
self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
self.result.setText(html)
def _parse_built(s: str | None) -> dt.datetime | None:
"""'Jun 29 2026 11:03:32' (app_init "Compile time") -> datetime."""
if not s:
return None
try:
return dt.datetime.strptime(" ".join(s.split()), "%b %d %Y %H:%M:%S")
except ValueError:
return None