Store all text files with LF line endings
Some files had been saved with CRLF on Windows, so a one-line change showed as a whole-file diff. .gitattributes now stores text as LF (.bat stays CRLF for cmd, .command/.sh LF for macOS). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3fe45fa4d5
commit
0d21c5c205
19 files changed
+8290
-8288
No files matched your search
+364
-364
@@ -1,364 +1,364 @@
|
||||
"""Flash firmware tab: only the .bin files in the tool's firmware/ folder,
|
||||
chosen by their compile date; App-only by default."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..core.session import SESSIONS_DIR
|
||||
from ..diag.firmware import local_build, register_local_build, same_build
|
||||
from ..flash.image import FIRMWARE_DIR, FirmwareImage, folder_images
|
||||
from ..flash.plan import Mode, build_plan
|
||||
from ..flash.runner import FlashRunner, Result
|
||||
from ..serialio.ports import PortInfo, list_serial_ports
|
||||
|
||||
BACKUP_DIR = SESSIONS_DIR.parent / "backups"
|
||||
VERIFY_TIMEOUT_MS = 45_000
|
||||
|
||||
HELP = (
|
||||
"<b>If flashing cannot connect:</b> use a data-capable USB cable directly on the laptop, close "
|
||||
"other programs using the port, then retry. Last resort (case must be opened): hold <b>BOOT</b>, "
|
||||
"press and release <b>RESET</b>, release <b>BOOT</b>, and press Start again."
|
||||
)
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
step = Signal(int, str)
|
||||
progress = Signal(int)
|
||||
output = Signal(str)
|
||||
done = Signal(object)
|
||||
|
||||
|
||||
class FlashTab(QWidget):
|
||||
def __init__(self, serial_tab, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.serial_tab = serial_tab
|
||||
self._images: list[FirmwareImage] = []
|
||||
self._ports: list[PortInfo] = [] # before any signal can call _update()
|
||||
self._runner: FlashRunner | None = None
|
||||
self._port: PortInfo | None = None
|
||||
self._image: FirmwareImage | None = None
|
||||
|
||||
# 1. box
|
||||
box = QGroupBox("1. Sensorbox")
|
||||
self.port_combo = QComboBox(minimumWidth=360)
|
||||
refresh = QPushButton("Refresh")
|
||||
refresh.clicked.connect(self.refresh_ports)
|
||||
self.box_fw = QLabel(wordWrap=True)
|
||||
bl = QVBoxLayout(box)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.port_combo)
|
||||
row.addWidget(refresh)
|
||||
row.addStretch(1)
|
||||
bl.addLayout(row)
|
||||
bl.addWidget(self.box_fw)
|
||||
|
||||
# 2. firmware - only files in the tool's firmware/ folder
|
||||
fwbox = QGroupBox("2. Firmware (by compile date)")
|
||||
self.image_combo = QComboBox(minimumWidth=360)
|
||||
self.image_combo.setToolTip(f"Firmware files in {FIRMWARE_DIR}")
|
||||
self.reload_btn = QPushButton("Refresh list")
|
||||
self.reload_btn.setToolTip("Read the firmware folder again (after the firmware team added a file)")
|
||||
self.reload_btn.clicked.connect(lambda: self._load_images(self._selected_sha()))
|
||||
self.folder_btn = QPushButton("Open firmware folder")
|
||||
self.folder_btn.clicked.connect(
|
||||
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(FIRMWARE_DIR))))
|
||||
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.reload_btn)
|
||||
brow.addWidget(self.folder_btn)
|
||||
brow.addStretch(1)
|
||||
fl.addLayout(brow)
|
||||
fl.addWidget(self.image_info)
|
||||
self._problems: list[str] = []
|
||||
|
||||
# 3. mode
|
||||
modebox = QGroupBox("3. What to write")
|
||||
self.rb_app = QRadioButton("App only (recommended for repairs)")
|
||||
self.rb_full = QRadioButton("Full image (new or completely wiped boards)")
|
||||
self.mode_group = QButtonGroup(self)
|
||||
self.mode_group.addButton(self.rb_app)
|
||||
self.mode_group.addButton(self.rb_full)
|
||||
self.rb_app.setChecked(True)
|
||||
app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the "
|
||||
"settings stored on the box.", wordWrap=True)
|
||||
full_note = QLabel("Writes everything like the factory. <b>Erases the Matter pairing and the OTA "
|
||||
"registration</b> - the box must be paired again afterwards.", wordWrap=True)
|
||||
for n in (app_note, full_note):
|
||||
n.setContentsMargins(24, 0, 0, 6)
|
||||
n.setStyleSheet("color:#555")
|
||||
self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased")
|
||||
self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)")
|
||||
ml = QVBoxLayout(modebox)
|
||||
for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup):
|
||||
ml.addWidget(w)
|
||||
|
||||
# 4. run
|
||||
runbox = QGroupBox("4. Flash")
|
||||
self.plan_label = QLabel(wordWrap=True)
|
||||
self.start_btn = QPushButton("Start flashing")
|
||||
self.start_btn.setMinimumHeight(36)
|
||||
self.start_btn.clicked.connect(self._start)
|
||||
self.step_label = QLabel()
|
||||
self.bar = QProgressBar()
|
||||
self.result = QLabel(wordWrap=True)
|
||||
self.result.setMargin(8)
|
||||
self.result.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.details_btn = QPushButton("Show esptool output")
|
||||
self.details_btn.setCheckable(True)
|
||||
self.output = QPlainTextEdit(readOnly=True)
|
||||
self.output.setVisible(False)
|
||||
self.details_btn.toggled.connect(self.output.setVisible)
|
||||
rl = QVBoxLayout(runbox)
|
||||
for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output):
|
||||
rl.addWidget(w)
|
||||
help_lbl = QLabel(HELP, wordWrap=True)
|
||||
help_lbl.setStyleSheet("color:#555")
|
||||
rl.addWidget(help_lbl)
|
||||
rl.addStretch(1) # keep the run column packed at the top
|
||||
|
||||
left = QVBoxLayout()
|
||||
for w in (box, fwbox, modebox):
|
||||
left.addWidget(w)
|
||||
left.addStretch(1)
|
||||
lay = QHBoxLayout(self)
|
||||
lay.addLayout(left, 1)
|
||||
lay.addWidget(runbox, 1)
|
||||
|
||||
self._bridge = _Bridge()
|
||||
self._bridge.step.connect(self._on_step)
|
||||
self._bridge.progress.connect(self.bar.setValue)
|
||||
self._bridge.output.connect(self.output.appendPlainText)
|
||||
self._bridge.done.connect(self._on_done)
|
||||
self._verify_timer = QTimer(self, interval=500)
|
||||
self._verify_timer.timeout.connect(self._check_verify)
|
||||
|
||||
for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled):
|
||||
sig.connect(self._update)
|
||||
self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full
|
||||
self.image_combo.currentIndexChanged.connect(self._update)
|
||||
self.port_combo.currentIndexChanged.connect(self._update)
|
||||
|
||||
self._load_images()
|
||||
self.refresh_ports()
|
||||
self._show_result("", None)
|
||||
|
||||
# ------------------------------------------------------------ setup
|
||||
def _load_images(self, select_sha: str | None = None) -> None:
|
||||
self._images, self._problems = folder_images()
|
||||
for img in self._images: # lets the Serial monitor show a box build's compile date
|
||||
register_local_build(img.app.elf_sha, img.app.version, img.app.built, img.name)
|
||||
self.image_combo.blockSignals(True)
|
||||
self.image_combo.clear()
|
||||
for img in self._images:
|
||||
self.image_combo.addItem(img.label())
|
||||
if not self._images:
|
||||
self.image_combo.addItem("No firmware file in the firmware folder")
|
||||
idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), 0)
|
||||
self.image_combo.setCurrentIndex(idx) # default: newest compile date
|
||||
self.image_combo.blockSignals(False)
|
||||
self._update()
|
||||
|
||||
def _selected_sha(self) -> str | None:
|
||||
img = self._selected_image()
|
||||
return img.sha256 if img else None
|
||||
|
||||
def refresh_ports(self) -> None:
|
||||
current = self.port_combo.currentData()
|
||||
self._ports = list_serial_ports()
|
||||
self.port_combo.clear()
|
||||
for p in self._ports:
|
||||
self.port_combo.addItem(p.label, p.device)
|
||||
if not self._ports:
|
||||
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
|
||||
self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0))
|
||||
self._update()
|
||||
|
||||
def showEvent(self, event): # noqa: N802 - refresh when the tab is opened
|
||||
self.refresh_ports()
|
||||
super().showEvent(event)
|
||||
|
||||
# ------------------------------------------------------------ state
|
||||
def _selected_image(self) -> FirmwareImage | None:
|
||||
i = self.image_combo.currentIndex()
|
||||
return self._images[i] if 0 <= i < len(self._images) else None
|
||||
|
||||
def _selected_port(self) -> PortInfo | None:
|
||||
dev = self.port_combo.currentData()
|
||||
return next((p for p in self._ports if p.device == dev), None)
|
||||
|
||||
def _update(self) -> None:
|
||||
img = self._selected_image()
|
||||
info = ""
|
||||
if img:
|
||||
a = img.app
|
||||
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
|
||||
info = (f"<b>Compiled {a.built}</b> · {a.project} v{a.version}, ESP-IDF {a.idf}, {kind}<br>"
|
||||
f"<small>file firmware/{img.name} · build {a.elf_sha[:8]} · "
|
||||
f"SHA-256 {img.sha256[:16]}... · checksum OK</small>")
|
||||
else:
|
||||
info = (f"Put the firmware .bin file from the firmware team into<br><code>{FIRMWARE_DIR}</code><br>"
|
||||
"and press <b>Refresh list</b>. Only files in this folder can be flashed.")
|
||||
if self._problems:
|
||||
info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> "
|
||||
+ "; ".join(self._problems) + "</span>")
|
||||
self.image_info.setText(info)
|
||||
# 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
|
||||
and (self.rb_app.isChecked() or self.confirm_full.isChecked()))
|
||||
self.start_btn.setEnabled(ok)
|
||||
if img and self._selected_port():
|
||||
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
|
||||
steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else []
|
||||
if mode == Mode.APP_ONLY:
|
||||
steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"]
|
||||
else:
|
||||
steps += ["Write the complete image (~25 s)"]
|
||||
steps += ["Restart the box and verify the new firmware boots"]
|
||||
self.plan_label.setText("<b>Plan:</b><ol>" + "".join(f"<li>{s}</li>" for s in steps) + "</ol>")
|
||||
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()}"
|
||||
if not img:
|
||||
return msg
|
||||
if same_build(fw.elf_sha, img.app.elf_sha):
|
||||
return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
|
||||
local = local_build(fw.elf_sha)
|
||||
box_built, img_built = _parse_built(fw.built or (local[1] if local else None)), 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()
|
||||
if not img or not port:
|
||||
return
|
||||
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
|
||||
what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY
|
||||
else "the COMPLETE image (pairing and OTA registration are erased)")
|
||||
if QMessageBox.question(self, "Flash firmware",
|
||||
f"Write {what}\n\nFirmware: v{img.app.version}, compiled {img.app.built}\n"
|
||||
f"File: firmware/{img.name}\nBox: {port.label}\n\n"
|
||||
"Keep the box powered and the USB cable connected until it finishes.",
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
|
||||
) != QMessageBox.StandardButton.Ok:
|
||||
return
|
||||
backup_to = None
|
||||
if self.backup.isChecked():
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin"
|
||||
plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to)
|
||||
|
||||
self._port, self._image = port, img
|
||||
self.serial_tab.release_port() # esptool needs the port exclusively
|
||||
self.output.clear()
|
||||
self.output.appendPlainText("\n".join(plan.summary()))
|
||||
self._show_result("", None)
|
||||
self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit,
|
||||
self._bridge.output.emit, self._bridge.done.emit)
|
||||
self._set_busy(True)
|
||||
self._runner.start()
|
||||
|
||||
def _set_busy(self, busy: bool) -> None:
|
||||
for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full,
|
||||
self.reload_btn):
|
||||
w.setEnabled(not busy)
|
||||
self._update()
|
||||
|
||||
def _on_step(self, i: int, title: str) -> None:
|
||||
n = len(self._runner.plan.steps) if self._runner else 0
|
||||
self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}")
|
||||
|
||||
def _on_done(self, res: Result) -> None:
|
||||
self._runner = None
|
||||
if not res.ok:
|
||||
self._set_busy(False)
|
||||
self.step_label.setText("Stopped.")
|
||||
hint = f"<p>{res.hint}</p>" if res.hint else ""
|
||||
self._show_result(f"<b>Flashing failed.</b> {res.message}{hint}", "error")
|
||||
self.details_btn.setChecked(True)
|
||||
return
|
||||
self.step_label.setText("Final step: restarting the box and checking which firmware it boots...")
|
||||
self.bar.setValue(0)
|
||||
self._show_result("Firmware written. Waiting for the box to start...", "info")
|
||||
self.serial_tab.connect_port(self._port, capture=True)
|
||||
self._verify_ms = 0
|
||||
self._verify_timer.start()
|
||||
|
||||
def _check_verify(self) -> None:
|
||||
self._verify_ms += self._verify_timer.interval()
|
||||
self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS)))
|
||||
fw = self.serial_tab.engine.firmware
|
||||
want = self._image.app.elf_sha[:8]
|
||||
done = same_build(fw.elf_sha, self._image.app.elf_sha)
|
||||
if done or self._verify_ms >= VERIFY_TIMEOUT_MS:
|
||||
self._verify_timer.stop()
|
||||
self._set_busy(False)
|
||||
self.bar.setValue(100)
|
||||
if done:
|
||||
self.step_label.setText("Done.")
|
||||
self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). "
|
||||
"Check the Diagnosis panel in the Serial monitor.", "ok")
|
||||
else:
|
||||
seen = fw.elf_sha[:8] if fw.elf_sha else "no boot seen"
|
||||
self._show_result(f"<b>Written, but not verified.</b> Expected build {want}, saw: {seen}. "
|
||||
"Open the Serial monitor and press Reset & capture.", "warning")
|
||||
|
||||
def _show_result(self, html: str, kind: str | None) -> None:
|
||||
colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"),
|
||||
"warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")}
|
||||
self.result.setVisible(bool(html))
|
||||
if html:
|
||||
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
|
||||
"""Flash firmware tab: only the .bin files in the tool's firmware/ folder,
|
||||
chosen by their compile date; App-only by default."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..core.session import SESSIONS_DIR
|
||||
from ..diag.firmware import local_build, register_local_build, same_build
|
||||
from ..flash.image import FIRMWARE_DIR, FirmwareImage, folder_images
|
||||
from ..flash.plan import Mode, build_plan
|
||||
from ..flash.runner import FlashRunner, Result
|
||||
from ..serialio.ports import PortInfo, list_serial_ports
|
||||
|
||||
BACKUP_DIR = SESSIONS_DIR.parent / "backups"
|
||||
VERIFY_TIMEOUT_MS = 45_000
|
||||
|
||||
HELP = (
|
||||
"<b>If flashing cannot connect:</b> use a data-capable USB cable directly on the laptop, close "
|
||||
"other programs using the port, then retry. Last resort (case must be opened): hold <b>BOOT</b>, "
|
||||
"press and release <b>RESET</b>, release <b>BOOT</b>, and press Start again."
|
||||
)
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
step = Signal(int, str)
|
||||
progress = Signal(int)
|
||||
output = Signal(str)
|
||||
done = Signal(object)
|
||||
|
||||
|
||||
class FlashTab(QWidget):
|
||||
def __init__(self, serial_tab, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.serial_tab = serial_tab
|
||||
self._images: list[FirmwareImage] = []
|
||||
self._ports: list[PortInfo] = [] # before any signal can call _update()
|
||||
self._runner: FlashRunner | None = None
|
||||
self._port: PortInfo | None = None
|
||||
self._image: FirmwareImage | None = None
|
||||
|
||||
# 1. box
|
||||
box = QGroupBox("1. Sensorbox")
|
||||
self.port_combo = QComboBox(minimumWidth=360)
|
||||
refresh = QPushButton("Refresh")
|
||||
refresh.clicked.connect(self.refresh_ports)
|
||||
self.box_fw = QLabel(wordWrap=True)
|
||||
bl = QVBoxLayout(box)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(self.port_combo)
|
||||
row.addWidget(refresh)
|
||||
row.addStretch(1)
|
||||
bl.addLayout(row)
|
||||
bl.addWidget(self.box_fw)
|
||||
|
||||
# 2. firmware - only files in the tool's firmware/ folder
|
||||
fwbox = QGroupBox("2. Firmware (by compile date)")
|
||||
self.image_combo = QComboBox(minimumWidth=360)
|
||||
self.image_combo.setToolTip(f"Firmware files in {FIRMWARE_DIR}")
|
||||
self.reload_btn = QPushButton("Refresh list")
|
||||
self.reload_btn.setToolTip("Read the firmware folder again (after the firmware team added a file)")
|
||||
self.reload_btn.clicked.connect(lambda: self._load_images(self._selected_sha()))
|
||||
self.folder_btn = QPushButton("Open firmware folder")
|
||||
self.folder_btn.clicked.connect(
|
||||
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(FIRMWARE_DIR))))
|
||||
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.reload_btn)
|
||||
brow.addWidget(self.folder_btn)
|
||||
brow.addStretch(1)
|
||||
fl.addLayout(brow)
|
||||
fl.addWidget(self.image_info)
|
||||
self._problems: list[str] = []
|
||||
|
||||
# 3. mode
|
||||
modebox = QGroupBox("3. What to write")
|
||||
self.rb_app = QRadioButton("App only (recommended for repairs)")
|
||||
self.rb_full = QRadioButton("Full image (new or completely wiped boards)")
|
||||
self.mode_group = QButtonGroup(self)
|
||||
self.mode_group.addButton(self.rb_app)
|
||||
self.mode_group.addButton(self.rb_full)
|
||||
self.rb_app.setChecked(True)
|
||||
app_note = QLabel("Replaces the firmware only. Keeps the Matter pairing, the OTA registration and the "
|
||||
"settings stored on the box.", wordWrap=True)
|
||||
full_note = QLabel("Writes everything like the factory. <b>Erases the Matter pairing and the OTA "
|
||||
"registration</b> - the box must be paired again afterwards.", wordWrap=True)
|
||||
for n in (app_note, full_note):
|
||||
n.setContentsMargins(24, 0, 0, 6)
|
||||
n.setStyleSheet("color:#555")
|
||||
self.confirm_full = QCheckBox("I understand the pairing and OTA registration will be erased")
|
||||
self.backup = QCheckBox("Back up the box's complete flash first (8 MB, about 3 minutes)")
|
||||
ml = QVBoxLayout(modebox)
|
||||
for w in (self.rb_app, app_note, self.rb_full, full_note, self.confirm_full, self.backup):
|
||||
ml.addWidget(w)
|
||||
|
||||
# 4. run
|
||||
runbox = QGroupBox("4. Flash")
|
||||
self.plan_label = QLabel(wordWrap=True)
|
||||
self.start_btn = QPushButton("Start flashing")
|
||||
self.start_btn.setMinimumHeight(36)
|
||||
self.start_btn.clicked.connect(self._start)
|
||||
self.step_label = QLabel()
|
||||
self.bar = QProgressBar()
|
||||
self.result = QLabel(wordWrap=True)
|
||||
self.result.setMargin(8)
|
||||
self.result.setTextFormat(Qt.TextFormat.RichText)
|
||||
self.details_btn = QPushButton("Show esptool output")
|
||||
self.details_btn.setCheckable(True)
|
||||
self.output = QPlainTextEdit(readOnly=True)
|
||||
self.output.setVisible(False)
|
||||
self.details_btn.toggled.connect(self.output.setVisible)
|
||||
rl = QVBoxLayout(runbox)
|
||||
for w in (self.plan_label, self.start_btn, self.step_label, self.bar, self.result, self.details_btn, self.output):
|
||||
rl.addWidget(w)
|
||||
help_lbl = QLabel(HELP, wordWrap=True)
|
||||
help_lbl.setStyleSheet("color:#555")
|
||||
rl.addWidget(help_lbl)
|
||||
rl.addStretch(1) # keep the run column packed at the top
|
||||
|
||||
left = QVBoxLayout()
|
||||
for w in (box, fwbox, modebox):
|
||||
left.addWidget(w)
|
||||
left.addStretch(1)
|
||||
lay = QHBoxLayout(self)
|
||||
lay.addLayout(left, 1)
|
||||
lay.addWidget(runbox, 1)
|
||||
|
||||
self._bridge = _Bridge()
|
||||
self._bridge.step.connect(self._on_step)
|
||||
self._bridge.progress.connect(self.bar.setValue)
|
||||
self._bridge.output.connect(self.output.appendPlainText)
|
||||
self._bridge.done.connect(self._on_done)
|
||||
self._verify_timer = QTimer(self, interval=500)
|
||||
self._verify_timer.timeout.connect(self._check_verify)
|
||||
|
||||
for sig in (self.rb_app.toggled, self.confirm_full.toggled, self.backup.toggled):
|
||||
sig.connect(self._update)
|
||||
self.rb_full.toggled.connect(lambda on: self.backup.setChecked(on)) # backup default on for Full
|
||||
self.image_combo.currentIndexChanged.connect(self._update)
|
||||
self.port_combo.currentIndexChanged.connect(self._update)
|
||||
|
||||
self._load_images()
|
||||
self.refresh_ports()
|
||||
self._show_result("", None)
|
||||
|
||||
# ------------------------------------------------------------ setup
|
||||
def _load_images(self, select_sha: str | None = None) -> None:
|
||||
self._images, self._problems = folder_images()
|
||||
for img in self._images: # lets the Serial monitor show a box build's compile date
|
||||
register_local_build(img.app.elf_sha, img.app.version, img.app.built, img.name)
|
||||
self.image_combo.blockSignals(True)
|
||||
self.image_combo.clear()
|
||||
for img in self._images:
|
||||
self.image_combo.addItem(img.label())
|
||||
if not self._images:
|
||||
self.image_combo.addItem("No firmware file in the firmware folder")
|
||||
idx = next((i for i, im in enumerate(self._images) if im.sha256 == select_sha), 0)
|
||||
self.image_combo.setCurrentIndex(idx) # default: newest compile date
|
||||
self.image_combo.blockSignals(False)
|
||||
self._update()
|
||||
|
||||
def _selected_sha(self) -> str | None:
|
||||
img = self._selected_image()
|
||||
return img.sha256 if img else None
|
||||
|
||||
def refresh_ports(self) -> None:
|
||||
current = self.port_combo.currentData()
|
||||
self._ports = list_serial_ports()
|
||||
self.port_combo.clear()
|
||||
for p in self._ports:
|
||||
self.port_combo.addItem(p.label, p.device)
|
||||
if not self._ports:
|
||||
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
|
||||
self.port_combo.setCurrentIndex(max(self.port_combo.findData(current), 0))
|
||||
self._update()
|
||||
|
||||
def showEvent(self, event): # noqa: N802 - refresh when the tab is opened
|
||||
self.refresh_ports()
|
||||
super().showEvent(event)
|
||||
|
||||
# ------------------------------------------------------------ state
|
||||
def _selected_image(self) -> FirmwareImage | None:
|
||||
i = self.image_combo.currentIndex()
|
||||
return self._images[i] if 0 <= i < len(self._images) else None
|
||||
|
||||
def _selected_port(self) -> PortInfo | None:
|
||||
dev = self.port_combo.currentData()
|
||||
return next((p for p in self._ports if p.device == dev), None)
|
||||
|
||||
def _update(self) -> None:
|
||||
img = self._selected_image()
|
||||
info = ""
|
||||
if img:
|
||||
a = img.app
|
||||
kind = "complete image (bootloader + layout + app)" if img.kind == "merged" else "app only"
|
||||
info = (f"<b>Compiled {a.built}</b> · {a.project} v{a.version}, ESP-IDF {a.idf}, {kind}<br>"
|
||||
f"<small>file firmware/{img.name} · build {a.elf_sha[:8]} · "
|
||||
f"SHA-256 {img.sha256[:16]}... · checksum OK</small>")
|
||||
else:
|
||||
info = (f"Put the firmware .bin file from the firmware team into<br><code>{FIRMWARE_DIR}</code><br>"
|
||||
"and press <b>Refresh list</b>. Only files in this folder can be flashed.")
|
||||
if self._problems:
|
||||
info += ("<br><span style='color:#b03a2e'><b>Not offered:</b> "
|
||||
+ "; ".join(self._problems) + "</span>")
|
||||
self.image_info.setText(info)
|
||||
# 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
|
||||
and (self.rb_app.isChecked() or self.confirm_full.isChecked()))
|
||||
self.start_btn.setEnabled(ok)
|
||||
if img and self._selected_port():
|
||||
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
|
||||
steps = ["Back up the complete flash (~3 min)"] if self.backup.isChecked() else []
|
||||
if mode == Mode.APP_ONLY:
|
||||
steps += ["Check the box has the same partition layout", "Write the firmware app (~20 s)"]
|
||||
else:
|
||||
steps += ["Write the complete image (~25 s)"]
|
||||
steps += ["Restart the box and verify the new firmware boots"]
|
||||
self.plan_label.setText("<b>Plan:</b><ol>" + "".join(f"<li>{s}</li>" for s in steps) + "</ol>")
|
||||
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()}"
|
||||
if not img:
|
||||
return msg
|
||||
if same_build(fw.elf_sha, img.app.elf_sha):
|
||||
return msg + "<br><i>It already has this firmware. Re-flash only if you suspect the firmware is damaged.</i>"
|
||||
local = local_build(fw.elf_sha)
|
||||
box_built, img_built = _parse_built(fw.built or (local[1] if local else None)), 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()
|
||||
if not img or not port:
|
||||
return
|
||||
mode = Mode.APP_ONLY if self.rb_app.isChecked() else Mode.FULL
|
||||
what = ("the firmware app (pairing and settings are kept)" if mode == Mode.APP_ONLY
|
||||
else "the COMPLETE image (pairing and OTA registration are erased)")
|
||||
if QMessageBox.question(self, "Flash firmware",
|
||||
f"Write {what}\n\nFirmware: v{img.app.version}, compiled {img.app.built}\n"
|
||||
f"File: firmware/{img.name}\nBox: {port.label}\n\n"
|
||||
"Keep the box powered and the USB cable connected until it finishes.",
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
|
||||
) != QMessageBox.StandardButton.Ok:
|
||||
return
|
||||
backup_to = None
|
||||
if self.backup.isChecked():
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_to = BACKUP_DIR / f"{(port.mac or port.device).replace(':', '')}_{stamp}.bin"
|
||||
plan = build_plan(img, mode, port.device, Path(tempfile.mkdtemp(prefix="kota_flash_")), backup_to)
|
||||
|
||||
self._port, self._image = port, img
|
||||
self.serial_tab.release_port() # esptool needs the port exclusively
|
||||
self.output.clear()
|
||||
self.output.appendPlainText("\n".join(plan.summary()))
|
||||
self._show_result("", None)
|
||||
self._runner = FlashRunner(plan, self._bridge.step.emit, self._bridge.progress.emit,
|
||||
self._bridge.output.emit, self._bridge.done.emit)
|
||||
self._set_busy(True)
|
||||
self._runner.start()
|
||||
|
||||
def _set_busy(self, busy: bool) -> None:
|
||||
for w in (self.port_combo, self.image_combo, self.rb_app, self.backup, self.confirm_full,
|
||||
self.reload_btn):
|
||||
w.setEnabled(not busy)
|
||||
self._update()
|
||||
|
||||
def _on_step(self, i: int, title: str) -> None:
|
||||
n = len(self._runner.plan.steps) if self._runner else 0
|
||||
self.step_label.setText(f"Step {i + 1} of {n + 1}: {title}")
|
||||
|
||||
def _on_done(self, res: Result) -> None:
|
||||
self._runner = None
|
||||
if not res.ok:
|
||||
self._set_busy(False)
|
||||
self.step_label.setText("Stopped.")
|
||||
hint = f"<p>{res.hint}</p>" if res.hint else ""
|
||||
self._show_result(f"<b>Flashing failed.</b> {res.message}{hint}", "error")
|
||||
self.details_btn.setChecked(True)
|
||||
return
|
||||
self.step_label.setText("Final step: restarting the box and checking which firmware it boots...")
|
||||
self.bar.setValue(0)
|
||||
self._show_result("Firmware written. Waiting for the box to start...", "info")
|
||||
self.serial_tab.connect_port(self._port, capture=True)
|
||||
self._verify_ms = 0
|
||||
self._verify_timer.start()
|
||||
|
||||
def _check_verify(self) -> None:
|
||||
self._verify_ms += self._verify_timer.interval()
|
||||
self.bar.setValue(min(100, int(100 * self._verify_ms / VERIFY_TIMEOUT_MS)))
|
||||
fw = self.serial_tab.engine.firmware
|
||||
want = self._image.app.elf_sha[:8]
|
||||
done = same_build(fw.elf_sha, self._image.app.elf_sha)
|
||||
if done or self._verify_ms >= VERIFY_TIMEOUT_MS:
|
||||
self._verify_timer.stop()
|
||||
self._set_busy(False)
|
||||
self.bar.setValue(100)
|
||||
if done:
|
||||
self.step_label.setText("Done.")
|
||||
self._show_result(f"<b>Success.</b> The box boots the new firmware (build {want}). "
|
||||
"Check the Diagnosis panel in the Serial monitor.", "ok")
|
||||
else:
|
||||
seen = fw.elf_sha[:8] if fw.elf_sha else "no boot seen"
|
||||
self._show_result(f"<b>Written, but not verified.</b> Expected build {want}, saw: {seen}. "
|
||||
"Open the Serial monitor and press Reset & capture.", "warning")
|
||||
|
||||
def _show_result(self, html: str, kind: str | None) -> None:
|
||||
colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"),
|
||||
"warning": ("#c77c02", "#fdf0d6"), "error": ("#d9534f", "#f9e0df")}
|
||||
self.result.setVisible(bool(html))
|
||||
if html:
|
||||
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
|
||||
Reference in new issue
Block a user