"""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 {path}")
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("
".join(f"{i.level.upper()}: {i.text}"
for i in issues) or "Settings OK.")
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: "The connected box runs the factory v1.0 firmware, which ignores these settings "
"(it only uses Matter). 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"{out.message}
Insert the next card and press Refresh cards to "
"provision another one.", "ok")
else:
self._show_result(f"{out.message}", "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)