322 lines
13 KiB
Python
322 lines
13 KiB
Python
"""SD card tab: format a card for the sensorbox and write / check ENV.TXT."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import QObject, QSettings, Qt, Signal
|
|
from PySide6.QtWidgets import (
|
|
QCheckBox,
|
|
QFileDialog,
|
|
QFormLayout,
|
|
QGroupBox,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QLineEdit,
|
|
QListWidget,
|
|
QListWidgetItem,
|
|
QMessageBox,
|
|
QPlainTextEdit,
|
|
QPushButton,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from ..sd.disks import Disk, list_cards
|
|
from ..sd.envfile import EnvConfig, load_template
|
|
from ..sd.provision import Outcome, check_card, format_and_write, write_env
|
|
|
|
# The shared settings file the factory script uses, next to the tool folders.
|
|
DEFAULT_TEMPLATE = Path(__file__).resolve().parents[4] / "002-SD_card_format" / "ENV.txt"
|
|
|
|
|
|
class _Bridge(QObject):
|
|
done = Signal(object)
|
|
listed = Signal(object)
|
|
|
|
|
|
def _secret_field() -> tuple[QLineEdit, QCheckBox]:
|
|
edit = QLineEdit()
|
|
edit.setEchoMode(QLineEdit.EchoMode.Password)
|
|
show = QCheckBox("show")
|
|
show.toggled.connect(lambda on: edit.setEchoMode(
|
|
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password))
|
|
return edit, show
|
|
|
|
|
|
class SdTab(QWidget):
|
|
def __init__(self, serial_tab, parent: QWidget | None = None):
|
|
super().__init__(parent)
|
|
self.serial_tab = serial_tab
|
|
self._settings = QSettings("KOTA", "TechnicianTool")
|
|
self._cards: list[Disk] = []
|
|
self._busy = False
|
|
self._listing = False
|
|
|
|
# --- 1. settings
|
|
sbox = QGroupBox("1. Settings written to the card (ENV.TXT)")
|
|
self.template_label = QLabel(wordWrap=True)
|
|
load_btn = QPushButton("Load settings from ENV file...")
|
|
load_btn.clicked.connect(self._choose_template)
|
|
self.url = QLineEdit()
|
|
self.token, show_token = _secret_field()
|
|
form = QFormLayout()
|
|
form.addRow("INFLUX_URL", self.url)
|
|
trow = QHBoxLayout()
|
|
trow.addWidget(self.token, 1)
|
|
trow.addWidget(show_token)
|
|
form.addRow("INFLUX_TOKEN", trow)
|
|
self.wifi_box = QGroupBox("Local Wi-Fi instead of Matter (optional)")
|
|
self.wifi_box.setCheckable(True)
|
|
self.wifi_box.setChecked(False)
|
|
self.ssid = QLineEdit()
|
|
self.password, show_pw = _secret_field()
|
|
wform = QFormLayout(self.wifi_box)
|
|
wform.addRow("SSID", self.ssid)
|
|
prow = QHBoxLayout()
|
|
prow.addWidget(self.password, 1)
|
|
prow.addWidget(show_pw)
|
|
wform.addRow("PASSWORD", prow)
|
|
self.wifi_note = QLabel(wordWrap=True)
|
|
self.wifi_note.setStyleSheet("color:#555")
|
|
wform.addRow(self.wifi_note)
|
|
self.issues = QLabel(wordWrap=True)
|
|
sl = QVBoxLayout(sbox)
|
|
sl.addWidget(self.template_label)
|
|
sl.addWidget(load_btn, 0, Qt.AlignmentFlag.AlignLeft)
|
|
sl.addLayout(form)
|
|
sl.addWidget(self.wifi_box)
|
|
sl.addWidget(self.issues)
|
|
|
|
# --- 2. card
|
|
cbox = QGroupBox("2. SD card (inside the sensorbox case)")
|
|
self.card_list = QListWidget()
|
|
self.card_list.setMinimumHeight(80)
|
|
self.others = QLabel(wordWrap=True)
|
|
self.others.setStyleSheet("color:#666")
|
|
refresh = QPushButton("Refresh cards")
|
|
refresh.clicked.connect(self.refresh_cards)
|
|
cl = QVBoxLayout(cbox)
|
|
cl.addWidget(QLabel("Insert the card into the laptop's reader. Only removable cards are listed."))
|
|
cl.addWidget(self.card_list)
|
|
cl.addWidget(self.others)
|
|
cl.addWidget(refresh, 0, Qt.AlignmentFlag.AlignLeft)
|
|
|
|
# --- 3. actions
|
|
abox = QGroupBox("3. Action")
|
|
self.format_btn = QPushButton("Format card and write ENV.TXT")
|
|
self.format_btn.setMinimumHeight(34)
|
|
self.format_btn.setToolTip("Erases the card: FAT32 (MBR), label KOTA, then writes and verifies ENV.TXT")
|
|
self.write_btn = QPushButton("Write ENV.TXT only (keep card contents)")
|
|
self.write_btn.setToolTip("Keeps buffered measurements (BUFFER.TXT) on the card")
|
|
self.check_btn = QPushButton("Check card")
|
|
self.check_btn.setToolTip("Show what the box would read from this card")
|
|
self.format_btn.clicked.connect(self._format)
|
|
self.write_btn.clicked.connect(self._write_only)
|
|
self.check_btn.clicked.connect(self._check)
|
|
self.result = QLabel(wordWrap=True)
|
|
self.result.setTextFormat(Qt.TextFormat.RichText)
|
|
self.result.setMargin(8)
|
|
self.log = QPlainTextEdit(readOnly=True)
|
|
self.log.setMaximumBlockCount(500)
|
|
al = QVBoxLayout(abox)
|
|
for w in (self.format_btn, self.write_btn, self.check_btn, self.result, self.log):
|
|
al.addWidget(w)
|
|
|
|
left = QVBoxLayout()
|
|
left.addWidget(sbox)
|
|
left.addStretch(1)
|
|
right = QVBoxLayout()
|
|
right.addWidget(cbox)
|
|
right.addWidget(abox, 1)
|
|
lay = QHBoxLayout(self)
|
|
lay.addLayout(left, 1)
|
|
lay.addLayout(right, 1)
|
|
|
|
self._bridge = _Bridge()
|
|
self._bridge.done.connect(self._on_done)
|
|
self._bridge.listed.connect(self._on_listed)
|
|
for w in (self.url, self.token, self.ssid, self.password):
|
|
w.textChanged.connect(self._update)
|
|
self.wifi_box.toggled.connect(self._update)
|
|
self.card_list.currentRowChanged.connect(self._update)
|
|
|
|
path = Path(self._settings.value("sd/template", str(DEFAULT_TEMPLATE)))
|
|
self._load_template(path, quiet=True)
|
|
self._show_result("", "")
|
|
self._update()
|
|
|
|
# ---------------------------------------------------------- settings
|
|
def _choose_template(self) -> None:
|
|
path, _ = QFileDialog.getOpenFileName(self, "ENV file with the InfluxDB settings",
|
|
str(DEFAULT_TEMPLATE.parent), "Text (*.txt *.env);;All files (*)")
|
|
if path:
|
|
self._load_template(Path(path))
|
|
|
|
def _load_template(self, path: Path, quiet: bool = False) -> None:
|
|
try:
|
|
env = load_template(path)
|
|
except OSError as exc:
|
|
self.template_label.setText("No settings file loaded - load the ENV file the firmware team gave you.")
|
|
if not quiet:
|
|
QMessageBox.warning(self, "Cannot read file", str(exc))
|
|
return
|
|
self._settings.setValue("sd/template", str(path))
|
|
self.template_label.setText(f"Loaded from <code>{path}</code>")
|
|
self.url.setText(env.influx_url)
|
|
self.token.setText(env.influx_token)
|
|
# Wi-Fi is decided per box by the technician, never copied silently.
|
|
self._update()
|
|
|
|
def _env(self) -> EnvConfig:
|
|
wifi = self.wifi_box.isChecked()
|
|
return EnvConfig(self.url.text(), self.token.text(),
|
|
self.ssid.text() if wifi else "", self.password.text() if wifi else "")
|
|
|
|
def _update(self) -> None:
|
|
env = self._env()
|
|
issues = env.problems()
|
|
colour = {"error": "#b03a2e", "warning": "#c77c02", "info": "#2e6da4"}
|
|
self.issues.setText("<br>".join(f"<span style='color:{colour[i.level]}'>{i.level.upper()}: {i.text}</span>"
|
|
for i in issues) or "<span style='color:#2e9e44'>Settings OK.</span>")
|
|
fw = self.serial_tab.engine.firmware
|
|
support = fw.supports_sd_wifi
|
|
self.wifi_note.setText(
|
|
"Only for boxes that should NOT use Matter. The box then joins this Wi-Fi directly. "
|
|
+ {True: "The connected box's firmware supports this.",
|
|
False: "<b>The connected box runs the factory v1.0 firmware, which ignores these settings "
|
|
"(it only uses Matter).</b> Newer firmware (after an OTA update) uses them.",
|
|
None: "Supported by newer firmware only; the factory v1.0 firmware ignores it."}[support])
|
|
errors = any(i.level == "error" for i in issues)
|
|
card = self._selected_card()
|
|
mounted = bool(card and card.volumes and card.volumes[0].mount)
|
|
self.format_btn.setEnabled(not self._busy and not errors and card is not None)
|
|
self.write_btn.setEnabled(not self._busy and not errors and mounted)
|
|
self.check_btn.setEnabled(not self._busy and mounted)
|
|
|
|
# -------------------------------------------------------------- cards
|
|
def showEvent(self, event): # noqa: N802
|
|
self.refresh_cards()
|
|
super().showEvent(event)
|
|
|
|
def refresh_cards(self) -> None:
|
|
"""List cards in the background: the UI never waits for a slow card reader."""
|
|
if self._busy or self._listing:
|
|
return
|
|
self._listing = True
|
|
self.others.setText("Looking for SD cards...")
|
|
|
|
def worker():
|
|
try:
|
|
result = list_cards()
|
|
except Exception as exc: # show it, never leave the tab stuck
|
|
result = ([], [], str(exc))
|
|
self._bridge.listed.emit(result)
|
|
|
|
threading.Thread(target=worker, daemon=True, name="sd-list").start()
|
|
|
|
def _on_listed(self, result) -> None:
|
|
self._listing = False
|
|
self._cards, others = result[0], result[1]
|
|
if len(result) > 2:
|
|
self.others.setText(f"Could not list cards: {result[2]}")
|
|
self._update()
|
|
return
|
|
self.card_list.clear()
|
|
for d in self._cards:
|
|
self.card_list.addItem(QListWidgetItem(d.label))
|
|
if self._cards:
|
|
self.card_list.setCurrentRow(0)
|
|
empty = [d for d in others if not d.has_media]
|
|
big = [d for d in others if d.has_media]
|
|
notes = []
|
|
if empty:
|
|
notes.append("Card reader found but no card inserted: " + ", ".join(d.name for d in empty) + ".")
|
|
if big:
|
|
notes.append("Not offered (too large to be an SD card): " + ", ".join(d.label for d in big) + ".")
|
|
if not self._cards and not empty:
|
|
notes.append("No SD card or card reader found.")
|
|
self.others.setText(" ".join(notes))
|
|
self._update()
|
|
|
|
def _selected_card(self) -> Disk | None:
|
|
i = self.card_list.currentRow()
|
|
return self._cards[i] if 0 <= i < len(self._cards) else None
|
|
|
|
# ------------------------------------------------------------ actions
|
|
def _format(self) -> None:
|
|
card = self._selected_card()
|
|
if card is None:
|
|
return
|
|
box = QMessageBox(QMessageBox.Icon.Warning, "Format SD card",
|
|
f"ALL DATA ON THIS CARD WILL BE ERASED:\n\n{card.label}\n\n"
|
|
"Buffered measurements on the card are lost too. Continue?",
|
|
QMessageBox.StandardButton.Cancel, self)
|
|
erase = box.addButton("Erase and format", QMessageBox.ButtonRole.DestructiveRole)
|
|
box.exec()
|
|
if box.clickedButton() is not erase:
|
|
return
|
|
env = self._env()
|
|
self._run(lambda: format_and_write(card, env), f"Formatting {card.name}...")
|
|
|
|
def _write_only(self) -> None:
|
|
card = self._selected_card()
|
|
if card and card.volumes:
|
|
env, mount = self._env(), card.volumes[0].mount
|
|
self._run(lambda: write_env(mount, env), f"Writing ENV.TXT to {mount}...")
|
|
|
|
def _check(self) -> None:
|
|
card = self._selected_card()
|
|
if not (card and card.volumes):
|
|
return
|
|
vol = card.volumes[0]
|
|
rep = check_card(vol.mount, vol.filesystem)
|
|
lines = [f"Card {vol.mount} ({vol.filesystem or '?'})"]
|
|
if rep.env:
|
|
lines += [f" {k}={v}" for k, v in rep.env.masked().items()]
|
|
lines += [f" {i.level.upper()}: {i.text}" for i in rep.env.issues]
|
|
lines += [f" PROBLEM: {p}" for p in rep.problems]
|
|
if rep.buffered_lines is not None:
|
|
lines.append(f" {rep.buffered_lines} measurement lines buffered on the card, waiting for upload.")
|
|
self.log.appendPlainText("\n".join(lines))
|
|
ok = not rep.problems and rep.env is not None and rep.env.ok
|
|
self._show_result("Card is OK for the sensorbox." if ok else "Card has problems - see below. "
|
|
"Use 'Write ENV.TXT only' or 'Format card'.", "ok" if ok else "error")
|
|
|
|
def _run(self, job, what: str) -> None:
|
|
self._busy = True
|
|
self._update()
|
|
self._show_result(what, "info")
|
|
|
|
def worker():
|
|
try:
|
|
out = job()
|
|
except Exception as exc: # report, never leave the tab busy
|
|
out = Outcome(False, f"Unexpected error: {exc}")
|
|
self._bridge.done.emit(out)
|
|
|
|
threading.Thread(target=worker, daemon=True).start()
|
|
|
|
def _on_done(self, out: Outcome) -> None:
|
|
self._busy = False
|
|
for line in out.log:
|
|
self.log.appendPlainText(line)
|
|
self.log.appendPlainText(out.message)
|
|
if out.ok:
|
|
self._show_result(f"<b>{out.message}</b><br>Insert the next card and press Refresh cards to "
|
|
"provision another one.", "ok")
|
|
else:
|
|
self._show_result(f"<b>{out.message}</b>", "error")
|
|
self.refresh_cards()
|
|
|
|
def _show_result(self, html: str, kind: str) -> None:
|
|
colours = {"ok": ("#2e9e44", "#e3f4e7"), "info": ("#2e6da4", "#e3edf7"), "error": ("#d9534f", "#f9e0df")}
|
|
self.result.setVisible(bool(html))
|
|
if html:
|
|
border, bg = colours[kind]
|
|
self.result.setStyleSheet(f"QLabel {{ border:2px solid {border}; background:{bg}; border-radius:6px; }}")
|
|
self.result.setText(html)
|
|
|
|
|