"""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)