From b64ecfe5a65d97df78c5da45ec6baae6008b235d Mon Sep 17 00:00:00 2001 From: boringethan <48163023+boringethan@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:40:04 -0700 Subject: [PATCH 1/6] feat: FPGA local .jed upload and per-fan toggle controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 1 – FPGA local .jed upload - _ConsoleFpgaUpdateThread accepts optional local_jed_path; when set it skips GitHub and programs directly from the supplied file - New beginFpgaFirmwareFromLocal(target, path) slot validates the file and starts the update thread - Settings.qml: adds fpgaFwUploadTarget property, _startFpgaFromLocal() helper, fpgaJedUploadDialog FileDialog, and an "Upload..." button alongside "Update" in all four FPGA panels (TA, Seed, Safety EE, Safety OPT) Feature 2 – Three individual fan toggles - fanStatusReceived(list) signal added to MOTIONConnector - queryFanStatus() fetches speed for fans 1-3 and emits the list - setFanEnabled(fan_index, enabled) targets a single fan at 100/0% - Console.qml replaces the single fan slider with three Switch rows (Fan 1/2/3) that independently toggle each console fan; box height updated to 155 to accommodate the extra row Co-Authored-By: Claude Sonnet 4.6 --- motion_connector.py | 295 ++++++++++++++++++++++++++------------------ pages/Console.qml | 90 ++++++++------ pages/Settings.qml | 208 +++++++++++++++++++++++-------- 3 files changed, 388 insertions(+), 205 deletions(-) diff --git a/motion_connector.py b/motion_connector.py index a46d2be..fe54b63 100644 --- a/motion_connector.py +++ b/motion_connector.py @@ -484,151 +484,140 @@ class _ConsoleFpgaUpdateThread(QThread): finished_ok = pyqtSignal(str) def __init__( - self, connector: "MOTIONConnector", target: str, tag: str, verify: bool = False + self, connector: "MOTIONConnector", target: str, tag: str, verify: bool = False, + local_jed_path: str | None = None, ): super().__init__() self._connector = connector self._target = (target or "").upper() self._tag = (tag or "").strip() self._verify = bool(verify) + self._local_jed_path = local_jed_path def run(self): try: - logger.info( - f"[FPGA-UPD] thread start target={self._target} tag={self._tag}" - ) - if self._connector._github_disabled: - logger.info("[FPGA-UPD] GitHub disabled (--no-github flag)") - self.failed.emit( - "GitHub access is disabled (--no-github). Cannot download FPGA firmware." - ) - return - if GitHubReleases is None: - logger.info("[FPGA-UPD] GitHubReleases unavailable in environment") - self.failed.emit( - "GitHubReleases is unavailable (omotion SDK not found in environment)." - ) - return + logger.info(f"[FPGA-UPD] thread start target={self._target} tag={self._tag} local={bool(self._local_jed_path)}") + + # FpgaPageProgrammer is always required if FpgaPageProgrammer is None or MuxChannel is None: - logger.info( - "[FPGA-UPD] FPGA programmer components unavailable in environment" - ) - self.failed.emit( - "FPGA programmer is unavailable (omotion SDK FPGA components missing)." - ) + logger.info("[FPGA-UPD] FPGA programmer components unavailable in environment") + self.failed.emit("FPGA programmer is unavailable (omotion SDK FPGA components missing).") return - repo = _FPGA_FW_REPO_MAP.get(self._target) channels = _FPGA_PROGRAM_CHANNELS.get(self._target) - if not repo or not channels: - logger.info( - f"[FPGA-UPD] invalid target mapping target={self._target} repo={repo} channels={channels}" - ) + if not channels: + logger.info(f"[FPGA-UPD] invalid target mapping target={self._target}") self.failed.emit(f"Invalid FPGA update target: {self._target}") return - logger.info(f"[FPGA-UPD] using repo={repo} channels={channels}") + if self._local_jed_path: + # --- Local file path: skip GitHub entirely --- + jed_path = Path(self._local_jed_path).resolve() + if not jed_path.exists(): + self.failed.emit(f"Local .jed file not found: {self._local_jed_path}") + return + self.progress.emit(35, f"Using local file {jed_path.name}…") + logger.info(f"[FPGA-UPD] using local jed path={jed_path}") + else: + # --- GitHub download path --- + if self._connector._github_disabled: + logger.info("[FPGA-UPD] GitHub disabled (--no-github flag)") + self.failed.emit("GitHub access is disabled (--no-github). Cannot download FPGA firmware.") + return + if GitHubReleases is None: + logger.info("[FPGA-UPD] GitHubReleases unavailable in environment") + self.failed.emit("GitHubReleases is unavailable (omotion SDK not found in environment).") + return - self.progress.emit(5, f"Fetching {self._target} release {self._tag}…") - gh = GitHubReleases(_CONSOLE_FW_REPO_OWNER, repo, timeout=30) + repo = _FPGA_FW_REPO_MAP.get(self._target) + if not repo: + logger.info(f"[FPGA-UPD] invalid target repo mapping target={self._target}") + self.failed.emit(f"Invalid FPGA update target: {self._target}") + return - release = None - last_exc: Exception | None = None - for candidate_tag in _candidate_console_fw_tags(self._tag): - try: - logger.info( - f"[FPGA-UPD] try get_release_by_tag tag={candidate_tag}" - ) - release = gh.get_release_by_tag(candidate_tag) - logger.info(f"[FPGA-UPD] release resolved tag={candidate_tag}") - break - except Exception as exc: - last_exc = exc - logger.info( - f"[FPGA-UPD] tag lookup failed tag={candidate_tag} err={exc}" - ) + logger.info(f"[FPGA-UPD] using repo={repo} channels={channels}") - if release is None: - msg = f"Release '{self._tag}' not found for {self._target}." - if last_exc is not None: - msg += f" ({last_exc})" - logger.info(f"[FPGA-UPD] release resolution failed msg={msg}") - self.failed.emit(msg) - return + self.progress.emit(5, f"Fetching {self._target} release {self._tag}…") + gh = GitHubReleases(_CONSOLE_FW_REPO_OWNER, repo, timeout=30) - self.progress.emit(15, "Resolving .jed asset…") - assets = gh.get_asset_list(release=release) - if not isinstance(assets, list): - assets = [] - logger.info(f"[FPGA-UPD] assets discovered count={len(assets)}") + release = None + last_exc: Exception | None = None + for candidate_tag in _candidate_console_fw_tags(self._tag): + try: + logger.info(f"[FPGA-UPD] try get_release_by_tag tag={candidate_tag}") + release = gh.get_release_by_tag(candidate_tag) + logger.info(f"[FPGA-UPD] release resolved tag={candidate_tag}") + break + except Exception as exc: + last_exc = exc + logger.info(f"[FPGA-UPD] tag lookup failed tag={candidate_tag} err={exc}") + + if release is None: + msg = f"Release '{self._tag}' not found for {self._target}." + if last_exc is not None: + msg += f" ({last_exc})" + logger.info(f"[FPGA-UPD] release resolution failed msg={msg}") + self.failed.emit(msg) + return - jed_assets = [] - for asset in assets: - if not isinstance(asset, dict): - continue - name = str(asset.get("name") or "") - if name.lower().endswith(".jed"): - jed_assets.append(asset) + self.progress.emit(15, "Resolving .jed asset…") + assets = gh.get_asset_list(release=release) + if not isinstance(assets, list): + assets = [] + logger.info(f"[FPGA-UPD] assets discovered count={len(assets)}") + + jed_assets = [] + for asset in assets: + if not isinstance(asset, dict): + continue + name = str(asset.get("name") or "") + if name.lower().endswith(".jed"): + jed_assets.append(asset) - logger.info(f"[FPGA-UPD] jed assets count={len(jed_assets)}") + logger.info(f"[FPGA-UPD] jed assets count={len(jed_assets)}") - if not jed_assets: - logger.info( - f"[FPGA-UPD] no .jed assets in release target={self._target} tag={self._tag}" - ) - self.failed.emit( - f"No .jed asset found in release '{self._tag}' for {self._target}." - ) - return + if not jed_assets: + logger.info(f"[FPGA-UPD] no .jed assets in release target={self._target} tag={self._tag}") + self.failed.emit(f"No .jed asset found in release '{self._tag}' for {self._target}.") + return - jed_assets.sort(key=lambda a: str(a.get("created_at") or ""), reverse=True) - jed_name = str(jed_assets[0].get("name") or "") - if not jed_name: - logger.info("[FPGA-UPD] resolved .jed asset missing name") - self.failed.emit("Resolved .jed asset has no filename.") - return + jed_assets.sort(key=lambda a: str(a.get("created_at") or ""), reverse=True) + jed_name = str(jed_assets[0].get("name") or "") + if not jed_name: + logger.info("[FPGA-UPD] resolved .jed asset missing name") + self.failed.emit("Resolved .jed asset has no filename.") + return - logger.info(f"[FPGA-UPD] selected jed asset={jed_name}") + logger.info(f"[FPGA-UPD] selected jed asset={jed_name}") - self.progress.emit(25, f"Downloading {jed_name}…") - dl_dir = _downloads_dir() - dl_dir.mkdir(parents=True, exist_ok=True) - jed_path = Path( - gh.download_asset(release, jed_name, output_dir=dl_dir) - ).resolve() - self.progress.emit(35, f"Downloaded {jed_name}") - logger.info(f"[FPGA-UPD] downloaded jed path={jed_path}") + self.progress.emit(25, f"Downloading {jed_name}…") + dl_dir = _downloads_dir() + dl_dir.mkdir(parents=True, exist_ok=True) + jed_path = Path(gh.download_asset(release, jed_name, output_dir=dl_dir)).resolve() + self.progress.emit(35, f"Downloaded {jed_name}") + logger.info(f"[FPGA-UPD] downloaded jed path={jed_path}") + # --- Programming (shared for local and GitHub paths) --- programmer = FpgaPageProgrammer( motion_interface.console_module, verify=self._verify, erase_timeout=35.0, refresh_timeout=10.0, ) - logger.info( - f"[FPGA-UPD] FpgaPageProgrammer initialized verify={self._verify} erase_timeout=35 refresh_timeout=10" - ) + logger.info(f"[FPGA-UPD] FpgaPageProgrammer initialized verify={self._verify} erase_timeout=35 refresh_timeout=10") total = len(channels) for idx, channel in enumerate(channels): base = 35 + int((55 * idx) / total) span = max(1, int(55 / total)) - def _on_progress( - pages_done: int, total_pages: int, ch=channel, b=base, s=span - ): - local_pct = ( - 0.0 - if total_pages <= 0 - else (100.0 * float(pages_done) / float(total_pages)) - ) + def _on_progress(pages_done: int, total_pages: int, ch=channel, b=base, s=span): + local_pct = 0.0 if total_pages <= 0 else (100.0 * float(pages_done) / float(total_pages)) overall = min(95, b + int((s * local_pct) / 100.0)) self.progress.emit(overall, f"Programming channel {ch}…") self.progress.emit(base, f"Programming channel {channel}…") - logger.info( - f"[FPGA-UPD] programming start target={self._target} channel={channel} ({idx + 1}/{total})" - ) + logger.info(f"[FPGA-UPD] programming start target={self._target} channel={channel} ({idx + 1}/{total})") self._connector._console_mutex.lock() try: attempt = 0 @@ -642,34 +631,23 @@ def _on_progress( break except Exception as exc_inner: attempt += 1 - logger.warning( - f"[FPGA-UPD] programming attempt {attempt} failed target={self._target} channel={channel} err={exc_inner}" - ) + logger.warning(f"[FPGA-UPD] programming attempt {attempt} failed target={self._target} channel={channel} err={exc_inner}") if attempt >= 2: raise - # small delay to allow bus/mux/device to settle before retry time.sleep(0.5) finally: self._connector._console_mutex.unlock() - logger.info( - f"[FPGA-UPD] programming done target={self._target} channel={channel}" - ) + logger.info(f"[FPGA-UPD] programming done target={self._target} channel={channel}") self.progress.emit(100, "FPGA programming complete") - logger.info( - f"[FPGA-UPD] thread complete target={self._target} tag={self._tag}" - ) + logger.info(f"[FPGA-UPD] thread complete target={self._target} tag={self._tag}") self.finished_ok.emit(f"{self._target} FPGA updated successfully.") except (FpgaUpdateError, CommandError) as exc: - logger.error( - f"[FPGA-UPD] programmer error target={self._target} tag={self._tag}: {exc}" - ) + logger.error(f"[FPGA-UPD] programmer error target={self._target} tag={self._tag}: {exc}") self.failed.emit(str(exc)) except Exception as exc: - logger.exception( - f"[FPGA-UPD] unexpected error target={self._target} tag={self._tag}" - ) + logger.exception(f"[FPGA-UPD] unexpected error target={self._target} tag={self._tag}") self.failed.emit(str(exc)) @@ -837,6 +815,7 @@ class MOTIONConnector(QObject): stateChanged = pyqtSignal() # Notifies QML when state changes rgbStateReceived = pyqtSignal(int, str) # Emit both integer value and text fanSpeedsReceived = pyqtSignal(int) # Emit both integers + fanStatusReceived = pyqtSignal(list) # [fan1_speed, fan2_speed, fan3_speed] (0-100 each) fpgaVersionsReceived = pyqtSignal( "QVariant" ) # {"TA": str, "Seed": str, "SafetyEE": str, "SafetyOPT": str} @@ -2579,6 +2558,51 @@ def beginFpgaFirmwareUpdate(self, target: str, tag: str) -> None: f"[FPGA-UPD] thread started target={target} tag={tag} verify={verify}" ) + @pyqtSlot(str, str) + def beginFpgaFirmwareFromLocal(self, target: str, local_path: str) -> None: + """Program an FPGA from a local .jed file (no GitHub download).""" + target = (target or "").upper() + logger.info(f"beginFpgaFirmwareFromLocal target={target} path={local_path}") + + if target not in _FPGA_PROGRAM_CHANNELS: + self.fpgaFirmwareUpdateError.emit(target or "UNKNOWN", "Invalid FPGA target.") + return + if not self._consoleConnected: + self.fpgaFirmwareUpdateError.emit(target, "Console is not connected.") + return + if self.fpgaFirmwareUpdateBusy: + self.fpgaFirmwareUpdateError.emit(target, "An FPGA update is already in progress.") + return + + p = Path(local_path) + if not p.exists(): + self.fpgaFirmwareUpdateError.emit(target, f"File not found: {local_path}") + return + if p.suffix.lower() != ".jed": + self.fpgaFirmwareUpdateError.emit(target, "Selected file must be a .jed file.") + return + + verify = bool(getattr(self, "_fpga_fw_verify", False)) + self._set_fpga_fw_busy(True) + self._fpga_update_thread = _ConsoleFpgaUpdateThread( + self, target, "local", verify=verify, local_jed_path=str(p.resolve()) + ) + logger.info(f"[FPGA-UPD] local thread created target={target} path={p} verify={verify}") + self._fpga_update_thread.progress.connect( + lambda pct, msg: self.fpgaFirmwareUpdateProgress.emit(target, int(pct), str(msg)) + ) + self._fpga_update_thread.failed.connect( + lambda msg: self._on_fpga_fw_failed(target, str(msg)) + ) + self._fpga_update_thread.finished_ok.connect( + lambda msg: self._on_fpga_fw_finished(target, True, str(msg)) + ) + self._fpga_update_thread.finished.connect( + lambda: setattr(self, "_fpga_update_thread", None) + ) + self._fpga_update_thread.start() + logger.info(f"[FPGA-UPD] local thread started target={target}") + def _on_fpga_fw_failed(self, target: str, message: str) -> None: logger.info(f"[FPGA-UPD] failed target={target} message={message}") self.fpgaFirmwareUpdateError.emit(target, message) @@ -3260,6 +3284,41 @@ def setFanLevel(self, speed: int): finally: self._console_mutex.unlock() + @pyqtSlot() + def queryFanStatus(self): + """Fetch and emit per-fan speed for all 3 console fans (fan indices 1-3).""" + self._console_mutex.lock() + try: + speeds = [] + for fan_idx in range(1, 4): + speed = motion_interface.console_module.get_fan_speed(fan_index=fan_idx) + speeds.append(int(speed) if speed is not None else 0) + logger.info(f"Fan speeds: {speeds}") + self.fanStatusReceived.emit(speeds) + except Exception as e: + logger.error(f"Error querying fan status: {e}") + finally: + self._console_mutex.unlock() + + @pyqtSlot(int, bool, result=bool) + def setFanEnabled(self, fan_index: int, enabled: bool) -> bool: + """Set a specific console fan on (100%) or off (0%). fan_index: 1-3.""" + self._console_mutex.lock() + try: + speed = 100 if enabled else 0 + result = motion_interface.console_module.set_fan_speed(fan_speed=speed, fan_index=fan_index) + if result == speed: + logger.info(f"Fan {fan_index} set to {'ON' if enabled else 'OFF'}") + return True + else: + logger.error(f"Failed to set fan {fan_index}") + return False + except Exception as e: + logger.error(f"Error setting fan {fan_index}: {e}") + return False + finally: + self._console_mutex.unlock() + @pyqtProperty(int, notify=tecTripValueChanged) def tecTripValue(self): return getattr(self, "_tec_trip_value", 0) diff --git a/pages/Console.qml b/pages/Console.qml index 332ba96..73abbbe 100644 --- a/pages/Console.qml +++ b/pages/Console.qml @@ -21,7 +21,9 @@ Rectangle { property real temperature1: 0.0 property real temperature2: 0.0 property real temperature3: 0.0 - property int fan_speed: 0 + property bool fan1On: false + property bool fan2On: false + property bool fan3On: false property var fn: null property int rawValue: 0 property int tecTripValue: 0 @@ -95,7 +97,7 @@ Rectangle { // console.log("Console Updating all states...") MOTIONInterface.queryConsoleInfo() MOTIONInterface.queryRGBState() // Query Indicator state - MOTIONInterface.queryFans() // Query Indicator state + MOTIONInterface.queryFanStatus() // Query per-fan status MOTIONInterface.queryConsoleTemperature() MOTIONInterface.queryTecTripValue(); } @@ -132,7 +134,9 @@ Rectangle { deviceId = "N/A" boardRevId = "N/A" rgbState = "Off" // Indicator off - fan_speed = 0 + fan1On = false + fan2On = false + fan3On = false temperature1 = 0.0 temperature2 = 0.0 temperature3 = 0.0 @@ -167,9 +171,10 @@ Rectangle { rgbLedDropdown.currentIndex = stateValue // Sync ComboBox to received state } - function onFanSpeedsReceived(fanVal) { - fan_speed = fanVal - fanSlider.value = fanVal; + function onFanStatusReceived(speeds) { + fan1On = speeds.length > 0 && speeds[0] > 0 + fan2On = speeds.length > 1 && speeds[1] > 0 + fan3On = speeds.length > 2 && speeds[2] > 0 } function onConsoleTemperatureUpdated(temp1, temp2, temp3) { @@ -1024,7 +1029,7 @@ Rectangle { Rectangle { id: fanTestsBox Layout.preferredWidth: 320 - height: 140 + height: 155 radius: 8 color: "#1E1E20" border.color: "#3E4E6F" @@ -1040,43 +1045,58 @@ Rectangle { anchors.topMargin: 5 } - // Slider for Fan Column { anchors.top: parent.top - anchors.topMargin: 28 + anchors.topMargin: 32 anchors.horizontalCenter: parent.horizontalCenter - spacing: 5 + spacing: 8 - Rectangle { height: 10; width: 1; color: "transparent" } + // Fan 1 + RowLayout { + spacing: 10 + width: 260 + Text { text: "Fan 1:"; color: "#BDC3C7"; font.pixelSize: 14; Layout.preferredWidth: 50 } + Switch { + checked: fan1On + enabled: MOTIONInterface.consoleConnected + onToggled: { + let ok = MOTIONInterface.setFanEnabled(1, checked) + if (ok) fan1On = checked; else checked = fan1On + } + } + Text { text: fan1On ? "ON" : "OFF"; color: fan1On ? "#2ECC71" : "#BDC3C7"; font.pixelSize: 14 } + } - Text { - text: "Console Fan: " + (fanSlider.value === 0 ? "OFF" : fanSlider.value.toFixed(0) + "%") - color: "#BDC3C7" - font.pixelSize: 14 + // Fan 2 + RowLayout { + spacing: 10 + width: 260 + Text { text: "Fan 2:"; color: "#BDC3C7"; font.pixelSize: 14; Layout.preferredWidth: 50 } + Switch { + checked: fan2On + enabled: MOTIONInterface.consoleConnected + onToggled: { + let ok = MOTIONInterface.setFanEnabled(2, checked) + if (ok) fan2On = checked; else checked = fan2On + } + } + Text { text: fan2On ? "ON" : "OFF"; color: fan2On ? "#2ECC71" : "#BDC3C7"; font.pixelSize: 14 } } - Slider { - id: fanSlider - width: 280 - from: 0 - to: 100 - stepSize: 10 - value: fan_speed || 0 - enabled: MOTIONInterface.consoleConnected - - property bool userIsSliding: false - - onPressedChanged: { - if (pressed) { - userIsSliding = true - } else if (!pressed && userIsSliding) { - let snappedValue = Math.round(value / 10) * 10 - value = snappedValue - userIsSliding = false - let success = MOTIONInterface.setFanLevel(snappedValue); - if (!success) console.error("Failed to set fan speed"); + // Fan 3 + RowLayout { + spacing: 10 + width: 260 + Text { text: "Fan 3:"; color: "#BDC3C7"; font.pixelSize: 14; Layout.preferredWidth: 50 } + Switch { + checked: fan3On + enabled: MOTIONInterface.consoleConnected + onToggled: { + let ok = MOTIONInterface.setFanEnabled(3, checked) + if (ok) fan3On = checked; else checked = fan3On } } + Text { text: fan3On ? "ON" : "OFF"; color: fan3On ? "#2ECC71" : "#BDC3C7"; font.pixelSize: 14 } } } } diff --git a/pages/Settings.qml b/pages/Settings.qml index 60bbb71..52c2d69 100644 --- a/pages/Settings.qml +++ b/pages/Settings.qml @@ -75,6 +75,7 @@ Rectangle { property string fpgaFwUpdateTarget: "" property int fpgaFwPercent: -1 property string fpgaFwMessage: "" + property string fpgaFwUploadTarget: "" function _startFpgaUpdate(target, tag) { if (!MOTIONInterface.consoleConnected) { @@ -94,6 +95,16 @@ Rectangle { fpgaProgressDialog.open() } + function _startFpgaFromLocal(target) { + if (!MOTIONInterface.consoleConnected) { + fwErrorDialog.message = "Console is not connected." + fwErrorDialog.open() + return + } + fpgaFwUploadTarget = target + fpgaJedUploadDialog.open() + } + // Modal dialog styling (firmware update) property int modalMaxWidth: 520 property int modalMinWidth: 420 @@ -564,6 +575,35 @@ Rectangle { } } + FileDialog { + id: fpgaJedUploadDialog + title: "Select FPGA .jed file" + nameFilters: ["FPGA firmware files (*.jed)"] + onAccepted: { + var file = "" + if (typeof selectedFiles !== 'undefined' && selectedFiles && selectedFiles.length > 0) file = selectedFiles[0] + else if (typeof fileUrls !== 'undefined' && fileUrls && fileUrls.length > 0) file = fileUrls[0] + else if (typeof fileUrl !== 'undefined' && fileUrl) file = fileUrl + if (!file) return + + if (file && typeof file !== 'string') { + if (typeof file.toLocalFile === 'function') file = file.toLocalFile() + else if (typeof file.toString === 'function') file = file.toString() + else file = String(file) + } + + if (typeof file === 'string' && file.indexOf("file://") === 0) { + file = file.replace(/^file:\/\//, "") + if (file.length > 0 && file[0] === '/' && file[2] === ':') file = file.substring(1) + } + + fpgaFwPercent = -1 + fpgaFwMessage = "" + MOTIONInterface.beginFpgaFirmwareFromLocal(fpgaFwUploadTarget, file) + fpgaProgressDialog.open() + } + } + Dialog { id: fwResultDialog parent: contentArea @@ -1239,20 +1279,36 @@ Rectangle { Layout.fillWidth: true Text { text: "TA"; font.pixelSize: 16; color: "#BDC3C7" } Item { Layout.fillWidth: true } - Rectangle { - width: 80; height: 28; radius: 8 - color: enabled ? "#E74C3C" : "#7F8C8D" - enabled: MOTIONInterface.consoleConnected - && taFpgaLatestVersion !== "N/A" - && !MOTIONInterface.fpgaFirmwareUpdateBusy - Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } - MouseArea { - anchors.fill: parent; enabled: parent.enabled - onClicked: _startFpgaUpdate("TA", taFpgaLatestVersion) - onEntered: if (parent.enabled) parent.color = "#C0392B" - onExited: if (parent.enabled) parent.color = "#E74C3C" + RowLayout { + spacing: 4 + Rectangle { + width: 70; height: 28; radius: 8 + color: enabled ? "#E74C3C" : "#7F8C8D" + enabled: MOTIONInterface.consoleConnected + && taFpgaLatestVersion !== "N/A" + && !MOTIONInterface.fpgaFirmwareUpdateBusy + Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } + MouseArea { + anchors.fill: parent; enabled: parent.enabled + onClicked: _startFpgaUpdate("TA", taFpgaLatestVersion) + onEntered: if (parent.enabled) parent.color = "#C0392B" + onExited: if (parent.enabled) parent.color = "#E74C3C" + } + Behavior on color { ColorAnimation { duration: 200 } } + } + Rectangle { + width: 70; height: 28; radius: 8 + color: enabled ? "#2980B9" : "#7F8C8D" + enabled: MOTIONInterface.consoleConnected && !MOTIONInterface.fpgaFirmwareUpdateBusy + Text { anchors.centerIn: parent; text: "Upload…"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } + MouseArea { + anchors.fill: parent; enabled: parent.enabled + onClicked: _startFpgaFromLocal("TA") + onEntered: if (parent.enabled) parent.color = "#2471A3" + onExited: if (parent.enabled) parent.color = "#2980B9" + } + Behavior on color { ColorAnimation { duration: 200 } } } - Behavior on color { ColorAnimation { duration: 200 } } } } @@ -1291,20 +1347,36 @@ Rectangle { Layout.fillWidth: true Text { text: "Seed"; font.pixelSize: 16; color: "#BDC3C7" } Item { Layout.fillWidth: true } - Rectangle { - width: 80; height: 28; radius: 8 - color: enabled ? "#E74C3C" : "#7F8C8D" - enabled: MOTIONInterface.consoleConnected - && seedFpgaLatestVersion !== "N/A" - && !MOTIONInterface.fpgaFirmwareUpdateBusy - Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } - MouseArea { - anchors.fill: parent; enabled: parent.enabled - onClicked: _startFpgaUpdate("SEED", seedFpgaLatestVersion) - onEntered: if (parent.enabled) parent.color = "#C0392B" - onExited: if (parent.enabled) parent.color = "#E74C3C" + RowLayout { + spacing: 4 + Rectangle { + width: 70; height: 28; radius: 8 + color: enabled ? "#E74C3C" : "#7F8C8D" + enabled: MOTIONInterface.consoleConnected + && seedFpgaLatestVersion !== "N/A" + && !MOTIONInterface.fpgaFirmwareUpdateBusy + Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } + MouseArea { + anchors.fill: parent; enabled: parent.enabled + onClicked: _startFpgaUpdate("SEED", seedFpgaLatestVersion) + onEntered: if (parent.enabled) parent.color = "#C0392B" + onExited: if (parent.enabled) parent.color = "#E74C3C" + } + Behavior on color { ColorAnimation { duration: 200 } } + } + Rectangle { + width: 70; height: 28; radius: 8 + color: enabled ? "#2980B9" : "#7F8C8D" + enabled: MOTIONInterface.consoleConnected && !MOTIONInterface.fpgaFirmwareUpdateBusy + Text { anchors.centerIn: parent; text: "Upload…"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } + MouseArea { + anchors.fill: parent; enabled: parent.enabled + onClicked: _startFpgaFromLocal("SEED") + onEntered: if (parent.enabled) parent.color = "#2471A3" + onExited: if (parent.enabled) parent.color = "#2980B9" + } + Behavior on color { ColorAnimation { duration: 200 } } } - Behavior on color { ColorAnimation { duration: 200 } } } } @@ -1343,20 +1415,36 @@ Rectangle { Layout.fillWidth: true Text { text: "Safety EE"; font.pixelSize: 16; color: "#BDC3C7" } Item { Layout.fillWidth: true } - Rectangle { - width: 80; height: 28; radius: 8 - color: enabled ? "#E74C3C" : "#7F8C8D" - enabled: MOTIONInterface.consoleConnected - && safetyFpgaLatestVersion !== "N/A" - && !MOTIONInterface.fpgaFirmwareUpdateBusy - Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } - MouseArea { - anchors.fill: parent; enabled: parent.enabled - onClicked: _startFpgaUpdate("SAFETY_EE", safetyFpgaLatestVersion) - onEntered: if (parent.enabled) parent.color = "#C0392B" - onExited: if (parent.enabled) parent.color = "#E74C3C" + RowLayout { + spacing: 4 + Rectangle { + width: 70; height: 28; radius: 8 + color: enabled ? "#E74C3C" : "#7F8C8D" + enabled: MOTIONInterface.consoleConnected + && safetyFpgaLatestVersion !== "N/A" + && !MOTIONInterface.fpgaFirmwareUpdateBusy + Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } + MouseArea { + anchors.fill: parent; enabled: parent.enabled + onClicked: _startFpgaUpdate("SAFETY_EE", safetyFpgaLatestVersion) + onEntered: if (parent.enabled) parent.color = "#C0392B" + onExited: if (parent.enabled) parent.color = "#E74C3C" + } + Behavior on color { ColorAnimation { duration: 200 } } + } + Rectangle { + width: 70; height: 28; radius: 8 + color: enabled ? "#2980B9" : "#7F8C8D" + enabled: MOTIONInterface.consoleConnected && !MOTIONInterface.fpgaFirmwareUpdateBusy + Text { anchors.centerIn: parent; text: "Upload…"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } + MouseArea { + anchors.fill: parent; enabled: parent.enabled + onClicked: _startFpgaFromLocal("SAFETY_EE") + onEntered: if (parent.enabled) parent.color = "#2471A3" + onExited: if (parent.enabled) parent.color = "#2980B9" + } + Behavior on color { ColorAnimation { duration: 200 } } } - Behavior on color { ColorAnimation { duration: 200 } } } } @@ -1401,20 +1489,36 @@ Rectangle { Layout.fillWidth: true Text { text: "Safety OPT"; font.pixelSize: 16; color: "#BDC3C7" } Item { Layout.fillWidth: true } - Rectangle { - width: 80; height: 28; radius: 8 - color: enabled ? "#E74C3C" : "#7F8C8D" - enabled: MOTIONInterface.consoleConnected - && safetyFpgaLatestVersion !== "N/A" - && !MOTIONInterface.fpgaFirmwareUpdateBusy - Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } - MouseArea { - anchors.fill: parent; enabled: parent.enabled - onClicked: _startFpgaUpdate("SAFETY_OPT", safetyFpgaLatestVersion) - onEntered: if (parent.enabled) parent.color = "#C0392B" - onExited: if (parent.enabled) parent.color = "#E74C3C" + RowLayout { + spacing: 4 + Rectangle { + width: 70; height: 28; radius: 8 + color: enabled ? "#E74C3C" : "#7F8C8D" + enabled: MOTIONInterface.consoleConnected + && safetyFpgaLatestVersion !== "N/A" + && !MOTIONInterface.fpgaFirmwareUpdateBusy + Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } + MouseArea { + anchors.fill: parent; enabled: parent.enabled + onClicked: _startFpgaUpdate("SAFETY_OPT", safetyFpgaLatestVersion) + onEntered: if (parent.enabled) parent.color = "#C0392B" + onExited: if (parent.enabled) parent.color = "#E74C3C" + } + Behavior on color { ColorAnimation { duration: 200 } } + } + Rectangle { + width: 70; height: 28; radius: 8 + color: enabled ? "#2980B9" : "#7F8C8D" + enabled: MOTIONInterface.consoleConnected && !MOTIONInterface.fpgaFirmwareUpdateBusy + Text { anchors.centerIn: parent; text: "Upload…"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } + MouseArea { + anchors.fill: parent; enabled: parent.enabled + onClicked: _startFpgaFromLocal("SAFETY_OPT") + onEntered: if (parent.enabled) parent.color = "#2471A3" + onExited: if (parent.enabled) parent.color = "#2980B9" + } + Behavior on color { ColorAnimation { duration: 200 } } } - Behavior on color { ColorAnimation { duration: 200 } } } } From 53cddbd56c5584c69da0cfdd3423f0e6f73ac6be Mon Sep 17 00:00:00 2001 From: boringethan Date: Thu, 12 Mar 2026 14:49:59 -0700 Subject: [PATCH 2/6] fix get csv implementation to match sdk --- motion_connector.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/motion_connector.py b/motion_connector.py index fe54b63..5b44c4f 100644 --- a/motion_connector.py +++ b/motion_connector.py @@ -2007,13 +2007,26 @@ def captureHistogramToCSV( f"Capturing {capture_type} for {sensor_side} camera {camera_index} with SN {serial_number}" ) + sensor = self._interface.sensors.get(sensor_side) + if sensor is None: + logger.error("%s sensor not connected.", sensor_side.capitalize()) + return + # Single camera - bins, histo = self._interface.get_camera_histogram( - sensor_side=sensor_side, + hist_result = sensor.get_camera_histogram( camera_id=camera_index, test_pattern_id=4, auto_upload=True, ) + if not hist_result: + logger.error( + "Failed to get %s for camera %d", + capture_type, + camera_index + 1, + ) + return + + bins, histo = hist_result if bins: suffix = "_dark" if is_dark else "_light" filename = f"{serial_number}_histogram{suffix}.csv" @@ -3536,12 +3549,23 @@ def getCameraHistogram( self, target: str, camera_index: int, test_pattern_id: int = 4 ): logger.info(f"Getting histogram for camera {camera_index + 1}") - bins, histo = motion_interface.get_camera_histogram( - sensor_side=target, + sensor = motion_interface.sensors.get(target) + if sensor is None: + logger.error("%s sensor not connected.", target.capitalize()) + self.histogramReady.emit([]) + return + + hist_result = sensor.get_camera_histogram( camera_id=camera_index, test_pattern_id=test_pattern_id, auto_upload=True, ) + if not hist_result: + logger.error("Failed to retrieve histogram.") + self.histogramReady.emit([]) # Emit empty to clear + return + + bins, histo = hist_result if bins: self.histogramReady.emit(bins) From b27e1dfdc588190cde15cdfbbc78186939cede96 Mon Sep 17 00:00:00 2001 From: boringethan Date: Wed, 8 Apr 2026 12:16:55 -0700 Subject: [PATCH 3/6] update fan speed getter and setter --- motion_connector.py | 76 +++++++------------- pages/Console.qml | 111 ++++++++++++++-------------- pages/Settings.qml | 172 ++++++++++++++++---------------------------- 3 files changed, 144 insertions(+), 215 deletions(-) diff --git a/motion_connector.py b/motion_connector.py index 5b44c4f..70f361b 100644 --- a/motion_connector.py +++ b/motion_connector.py @@ -814,8 +814,7 @@ class MOTIONConnector(QObject): stateChanged = pyqtSignal() # Notifies QML when state changes rgbStateReceived = pyqtSignal(int, str) # Emit both integer value and text - fanSpeedsReceived = pyqtSignal(int) # Emit both integers - fanStatusReceived = pyqtSignal(list) # [fan1_speed, fan2_speed, fan3_speed] (0-100 each) + fanFeedbackUpdated = pyqtSignal(int, int, int) # Tachometer RPM for fans 1/2/3 (-1 on failure) fpgaVersionsReceived = pyqtSignal( "QVariant" ) # {"TA": str, "Seed": str, "SafetyEE": str, "SafetyOPT": str} @@ -2704,20 +2703,6 @@ def queryRGBState(self): finally: self._console_mutex.unlock() - @pyqtSlot() - def queryFans(self): - """Fetch and emit Fan Speed.""" - self._console_mutex.lock() - try: - fan_speed = motion_interface.console_module.get_fan_speed() - - logger.info(f"Fan Speed: {fan_speed}") - self.fanSpeedsReceived.emit(fan_speed) # Emit both values - except Exception as e: - logger.error(f"Error querying Fan Speeds: {e}") - finally: - self._console_mutex.unlock() - @pyqtSlot() def queryFpgaVersions(self): """Read 4-byte version registers from each FPGA and emit fpgaVersionsReceived. @@ -3280,17 +3265,15 @@ def getTecEnabled(self) -> bool: self._console_mutex.unlock() @pyqtSlot(int, result=bool) - def setFanLevel(self, speed: int): - """Set Fan Level to device.""" + def setFanLevel(self, speed: int) -> bool: + """Set console fan PWM level (0..100).""" self._console_mutex.lock() try: if motion_interface.console_module.set_fan_speed(fan_speed=speed) == speed: - logger.info("Fan set successfully") + logger.info(f"Fan set to {speed}%") return True - else: - logger.error("Failed to set Fan Speed") - return False - + logger.error("Failed to set Fan Speed") + return False except Exception as e: logger.error(f"Error setting Fan Speed: {e}") return False @@ -3298,39 +3281,32 @@ def setFanLevel(self, speed: int): self._console_mutex.unlock() @pyqtSlot() - def queryFanStatus(self): - """Fetch and emit per-fan speed for all 3 console fans (fan indices 1-3).""" + def readFanFeedback(self): + """Read tachometer RPM for all 3 console fans and emit fanFeedbackUpdated. + + The console firmware samples each fan's GPIO over ~50 ms per call, so this + takes ~150 ms total. Intended for one-shot button-press refreshes only — do + not call on a periodic timer. Per-fan failures are reported as -1. + """ self._console_mutex.lock() try: - speeds = [] + rpms = [] for fan_idx in range(1, 4): - speed = motion_interface.console_module.get_fan_speed(fan_index=fan_idx) - speeds.append(int(speed) if speed is not None else 0) - logger.info(f"Fan speeds: {speeds}") - self.fanStatusReceived.emit(speeds) - except Exception as e: - logger.error(f"Error querying fan status: {e}") + try: + rpm = motion_interface.console_module.get_fan_rpm(fan_index=fan_idx) + rpms.append(int(rpm) if rpm is not None and rpm >= 0 else -1) + except Exception as e: + logger.error(f"Error reading fan {fan_idx} RPM: {e}") + rpms.append(-1) + logger.info(f"Fan RPMs: {rpms}") + self.fanFeedbackUpdated.emit(rpms[0], rpms[1], rpms[2]) finally: self._console_mutex.unlock() - @pyqtSlot(int, bool, result=bool) - def setFanEnabled(self, fan_index: int, enabled: bool) -> bool: - """Set a specific console fan on (100%) or off (0%). fan_index: 1-3.""" - self._console_mutex.lock() - try: - speed = 100 if enabled else 0 - result = motion_interface.console_module.set_fan_speed(fan_speed=speed, fan_index=fan_index) - if result == speed: - logger.info(f"Fan {fan_index} set to {'ON' if enabled else 'OFF'}") - return True - else: - logger.error(f"Failed to set fan {fan_index}") - return False - except Exception as e: - logger.error(f"Error setting fan {fan_index}: {e}") - return False - finally: - self._console_mutex.unlock() + @pyqtSlot() + def queryFanStatus(self): + """Backwards-compatible alias — reads fan feedback via readFanFeedback().""" + self.readFanFeedback() @pyqtProperty(int, notify=tecTripValueChanged) def tecTripValue(self): diff --git a/pages/Console.qml b/pages/Console.qml index 73abbbe..09bb476 100644 --- a/pages/Console.qml +++ b/pages/Console.qml @@ -21,9 +21,10 @@ Rectangle { property real temperature1: 0.0 property real temperature2: 0.0 property real temperature3: 0.0 - property bool fan1On: false - property bool fan2On: false - property bool fan3On: false + property int fan_speed: 0 + property int fan1Rpm: -1 + property int fan2Rpm: -1 + property int fan3Rpm: -1 property var fn: null property int rawValue: 0 property int tecTripValue: 0 @@ -97,7 +98,7 @@ Rectangle { // console.log("Console Updating all states...") MOTIONInterface.queryConsoleInfo() MOTIONInterface.queryRGBState() // Query Indicator state - MOTIONInterface.queryFanStatus() // Query per-fan status + MOTIONInterface.readFanFeedback() // One-shot fan PWM feedback read MOTIONInterface.queryConsoleTemperature() MOTIONInterface.queryTecTripValue(); } @@ -134,9 +135,10 @@ Rectangle { deviceId = "N/A" boardRevId = "N/A" rgbState = "Off" // Indicator off - fan1On = false - fan2On = false - fan3On = false + fan_speed = 0 + fan1Rpm = -1 + fan2Rpm = -1 + fan3Rpm = -1 temperature1 = 0.0 temperature2 = 0.0 temperature3 = 0.0 @@ -171,10 +173,10 @@ Rectangle { rgbLedDropdown.currentIndex = stateValue // Sync ComboBox to received state } - function onFanStatusReceived(speeds) { - fan1On = speeds.length > 0 && speeds[0] > 0 - fan2On = speeds.length > 1 && speeds[1] > 0 - fan3On = speeds.length > 2 && speeds[2] > 0 + function onFanFeedbackUpdated(fan1, fan2, fan3) { + fan1Rpm = fan1 + fan2Rpm = fan2 + fan3Rpm = fan3 } function onConsoleTemperatureUpdated(temp1, temp2, temp3) { @@ -1029,7 +1031,7 @@ Rectangle { Rectangle { id: fanTestsBox Layout.preferredWidth: 320 - height: 155 + height: 200 radius: 8 color: "#1E1E20" border.color: "#3E4E6F" @@ -1049,54 +1051,57 @@ Rectangle { anchors.top: parent.top anchors.topMargin: 32 anchors.horizontalCenter: parent.horizontalCenter - spacing: 8 + spacing: 6 - // Fan 1 - RowLayout { - spacing: 10 - width: 260 - Text { text: "Fan 1:"; color: "#BDC3C7"; font.pixelSize: 14; Layout.preferredWidth: 50 } - Switch { - checked: fan1On - enabled: MOTIONInterface.consoleConnected - onToggled: { - let ok = MOTIONInterface.setFanEnabled(1, checked) - if (ok) fan1On = checked; else checked = fan1On - } - } - Text { text: fan1On ? "ON" : "OFF"; color: fan1On ? "#2ECC71" : "#BDC3C7"; font.pixelSize: 14 } + Text { + text: "Console Fan: " + (fanSlider.value === 0 ? "OFF" : fanSlider.value.toFixed(0) + "%") + color: "#BDC3C7" + font.pixelSize: 14 } - // Fan 2 - RowLayout { - spacing: 10 - width: 260 - Text { text: "Fan 2:"; color: "#BDC3C7"; font.pixelSize: 14; Layout.preferredWidth: 50 } - Switch { - checked: fan2On - enabled: MOTIONInterface.consoleConnected - onToggled: { - let ok = MOTIONInterface.setFanEnabled(2, checked) - if (ok) fan2On = checked; else checked = fan2On + Slider { + id: fanSlider + width: 280 + from: 0 + to: 100 + stepSize: 10 + value: fan_speed || 0 + enabled: MOTIONInterface.consoleConnected + + property bool userIsSliding: false + + onPressedChanged: { + if (pressed) { + userIsSliding = true + } else if (!pressed && userIsSliding) { + let snappedValue = Math.round(value / 10) * 10 + value = snappedValue + userIsSliding = false + let success = MOTIONInterface.setFanLevel(snappedValue) + if (!success) console.error("Failed to set fan speed") } } - Text { text: fan2On ? "ON" : "OFF"; color: fan2On ? "#2ECC71" : "#BDC3C7"; font.pixelSize: 14 } } - // Fan 3 - RowLayout { - spacing: 10 - width: 260 - Text { text: "Fan 3:"; color: "#BDC3C7"; font.pixelSize: 14; Layout.preferredWidth: 50 } - Switch { - checked: fan3On - enabled: MOTIONInterface.consoleConnected - onToggled: { - let ok = MOTIONInterface.setFanEnabled(3, checked) - if (ok) fan3On = checked; else checked = fan3On - } - } - Text { text: fan3On ? "ON" : "OFF"; color: fan3On ? "#2ECC71" : "#BDC3C7"; font.pixelSize: 14 } + Rectangle { width: 280; height: 1; color: "#3E4E6F" } + + // Per-fan PWM feedback readout (read-only) + Text { + width: 280 + horizontalAlignment: Text.AlignHCenter + text: "1: " + (fan1Rpm < 0 ? "--" : fan1Rpm + " RPM") + + " 2: " + (fan2Rpm < 0 ? "--" : fan2Rpm + " RPM") + + " 3: " + (fan3Rpm < 0 ? "--" : fan3Rpm + " RPM") + color: "#2ECC71" + font.pixelSize: 14 + font.weight: Font.Bold + } + + Button { + text: "Get Fan Feedback" + enabled: MOTIONInterface.consoleConnected + width: 280 + onClicked: MOTIONInterface.readFanFeedback() } } } diff --git a/pages/Settings.qml b/pages/Settings.qml index 52c2d69..74449d0 100644 --- a/pages/Settings.qml +++ b/pages/Settings.qml @@ -1279,36 +1279,23 @@ Rectangle { Layout.fillWidth: true Text { text: "TA"; font.pixelSize: 16; color: "#BDC3C7" } Item { Layout.fillWidth: true } - RowLayout { - spacing: 4 - Rectangle { - width: 70; height: 28; radius: 8 - color: enabled ? "#E74C3C" : "#7F8C8D" - enabled: MOTIONInterface.consoleConnected - && taFpgaLatestVersion !== "N/A" - && !MOTIONInterface.fpgaFirmwareUpdateBusy - Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } - MouseArea { - anchors.fill: parent; enabled: parent.enabled - onClicked: _startFpgaUpdate("TA", taFpgaLatestVersion) - onEntered: if (parent.enabled) parent.color = "#C0392B" - onExited: if (parent.enabled) parent.color = "#E74C3C" + Rectangle { + width: 70; height: 28; radius: 8 + color: enabled ? "#E74C3C" : "#7F8C8D" + enabled: MOTIONInterface.consoleConnected && !MOTIONInterface.fpgaFirmwareUpdateBusy + Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } + MouseArea { + anchors.fill: parent; enabled: parent.enabled + onClicked: { + if (taFpgaLatestVersion === "N/A") + _startFpgaFromLocal("TA") + else + _startFpgaUpdate("TA", taFpgaLatestVersion) } - Behavior on color { ColorAnimation { duration: 200 } } - } - Rectangle { - width: 70; height: 28; radius: 8 - color: enabled ? "#2980B9" : "#7F8C8D" - enabled: MOTIONInterface.consoleConnected && !MOTIONInterface.fpgaFirmwareUpdateBusy - Text { anchors.centerIn: parent; text: "Upload…"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } - MouseArea { - anchors.fill: parent; enabled: parent.enabled - onClicked: _startFpgaFromLocal("TA") - onEntered: if (parent.enabled) parent.color = "#2471A3" - onExited: if (parent.enabled) parent.color = "#2980B9" - } - Behavior on color { ColorAnimation { duration: 200 } } + onEntered: if (parent.enabled) parent.color = "#C0392B" + onExited: if (parent.enabled) parent.color = "#E74C3C" } + Behavior on color { ColorAnimation { duration: 200 } } } } @@ -1347,36 +1334,23 @@ Rectangle { Layout.fillWidth: true Text { text: "Seed"; font.pixelSize: 16; color: "#BDC3C7" } Item { Layout.fillWidth: true } - RowLayout { - spacing: 4 - Rectangle { - width: 70; height: 28; radius: 8 - color: enabled ? "#E74C3C" : "#7F8C8D" - enabled: MOTIONInterface.consoleConnected - && seedFpgaLatestVersion !== "N/A" - && !MOTIONInterface.fpgaFirmwareUpdateBusy - Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } - MouseArea { - anchors.fill: parent; enabled: parent.enabled - onClicked: _startFpgaUpdate("SEED", seedFpgaLatestVersion) - onEntered: if (parent.enabled) parent.color = "#C0392B" - onExited: if (parent.enabled) parent.color = "#E74C3C" - } - Behavior on color { ColorAnimation { duration: 200 } } - } - Rectangle { - width: 70; height: 28; radius: 8 - color: enabled ? "#2980B9" : "#7F8C8D" - enabled: MOTIONInterface.consoleConnected && !MOTIONInterface.fpgaFirmwareUpdateBusy - Text { anchors.centerIn: parent; text: "Upload…"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } - MouseArea { - anchors.fill: parent; enabled: parent.enabled - onClicked: _startFpgaFromLocal("SEED") - onEntered: if (parent.enabled) parent.color = "#2471A3" - onExited: if (parent.enabled) parent.color = "#2980B9" + Rectangle { + width: 70; height: 28; radius: 8 + color: enabled ? "#E74C3C" : "#7F8C8D" + enabled: MOTIONInterface.consoleConnected && !MOTIONInterface.fpgaFirmwareUpdateBusy + Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } + MouseArea { + anchors.fill: parent; enabled: parent.enabled + onClicked: { + if (seedFpgaLatestVersion === "N/A") + _startFpgaFromLocal("SEED") + else + _startFpgaUpdate("SEED", seedFpgaLatestVersion) } - Behavior on color { ColorAnimation { duration: 200 } } + onEntered: if (parent.enabled) parent.color = "#C0392B" + onExited: if (parent.enabled) parent.color = "#E74C3C" } + Behavior on color { ColorAnimation { duration: 200 } } } } @@ -1415,36 +1389,23 @@ Rectangle { Layout.fillWidth: true Text { text: "Safety EE"; font.pixelSize: 16; color: "#BDC3C7" } Item { Layout.fillWidth: true } - RowLayout { - spacing: 4 - Rectangle { - width: 70; height: 28; radius: 8 - color: enabled ? "#E74C3C" : "#7F8C8D" - enabled: MOTIONInterface.consoleConnected - && safetyFpgaLatestVersion !== "N/A" - && !MOTIONInterface.fpgaFirmwareUpdateBusy - Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } - MouseArea { - anchors.fill: parent; enabled: parent.enabled - onClicked: _startFpgaUpdate("SAFETY_EE", safetyFpgaLatestVersion) - onEntered: if (parent.enabled) parent.color = "#C0392B" - onExited: if (parent.enabled) parent.color = "#E74C3C" + Rectangle { + width: 70; height: 28; radius: 8 + color: enabled ? "#E74C3C" : "#7F8C8D" + enabled: MOTIONInterface.consoleConnected && !MOTIONInterface.fpgaFirmwareUpdateBusy + Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } + MouseArea { + anchors.fill: parent; enabled: parent.enabled + onClicked: { + if (safetyFpgaLatestVersion === "N/A") + _startFpgaFromLocal("SAFETY_EE") + else + _startFpgaUpdate("SAFETY_EE", safetyFpgaLatestVersion) } - Behavior on color { ColorAnimation { duration: 200 } } - } - Rectangle { - width: 70; height: 28; radius: 8 - color: enabled ? "#2980B9" : "#7F8C8D" - enabled: MOTIONInterface.consoleConnected && !MOTIONInterface.fpgaFirmwareUpdateBusy - Text { anchors.centerIn: parent; text: "Upload…"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } - MouseArea { - anchors.fill: parent; enabled: parent.enabled - onClicked: _startFpgaFromLocal("SAFETY_EE") - onEntered: if (parent.enabled) parent.color = "#2471A3" - onExited: if (parent.enabled) parent.color = "#2980B9" - } - Behavior on color { ColorAnimation { duration: 200 } } + onEntered: if (parent.enabled) parent.color = "#C0392B" + onExited: if (parent.enabled) parent.color = "#E74C3C" } + Behavior on color { ColorAnimation { duration: 200 } } } } @@ -1489,36 +1450,23 @@ Rectangle { Layout.fillWidth: true Text { text: "Safety OPT"; font.pixelSize: 16; color: "#BDC3C7" } Item { Layout.fillWidth: true } - RowLayout { - spacing: 4 - Rectangle { - width: 70; height: 28; radius: 8 - color: enabled ? "#E74C3C" : "#7F8C8D" - enabled: MOTIONInterface.consoleConnected - && safetyFpgaLatestVersion !== "N/A" - && !MOTIONInterface.fpgaFirmwareUpdateBusy - Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } - MouseArea { - anchors.fill: parent; enabled: parent.enabled - onClicked: _startFpgaUpdate("SAFETY_OPT", safetyFpgaLatestVersion) - onEntered: if (parent.enabled) parent.color = "#C0392B" - onExited: if (parent.enabled) parent.color = "#E74C3C" - } - Behavior on color { ColorAnimation { duration: 200 } } - } - Rectangle { - width: 70; height: 28; radius: 8 - color: enabled ? "#2980B9" : "#7F8C8D" - enabled: MOTIONInterface.consoleConnected && !MOTIONInterface.fpgaFirmwareUpdateBusy - Text { anchors.centerIn: parent; text: "Upload…"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } - MouseArea { - anchors.fill: parent; enabled: parent.enabled - onClicked: _startFpgaFromLocal("SAFETY_OPT") - onEntered: if (parent.enabled) parent.color = "#2471A3" - onExited: if (parent.enabled) parent.color = "#2980B9" + Rectangle { + width: 70; height: 28; radius: 8 + color: enabled ? "#E74C3C" : "#7F8C8D" + enabled: MOTIONInterface.consoleConnected && !MOTIONInterface.fpgaFirmwareUpdateBusy + Text { anchors.centerIn: parent; text: "Update"; color: parent.enabled ? "white" : "#BDC3C7"; font.pixelSize: 13; font.weight: Font.Bold } + MouseArea { + anchors.fill: parent; enabled: parent.enabled + onClicked: { + if (safetyFpgaLatestVersion === "N/A") + _startFpgaFromLocal("SAFETY_OPT") + else + _startFpgaUpdate("SAFETY_OPT", safetyFpgaLatestVersion) } - Behavior on color { ColorAnimation { duration: 200 } } + onEntered: if (parent.enabled) parent.color = "#C0392B" + onExited: if (parent.enabled) parent.color = "#E74C3C" } + Behavior on color { ColorAnimation { duration: 200 } } } } From 70d967d0c9648c6c5057a0a03b180c41a763f6ca Mon Sep 17 00:00:00 2001 From: boringethan Date: Wed, 8 Apr 2026 12:20:41 -0700 Subject: [PATCH 4/6] fix alignment of fan test box --- pages/Console.qml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/pages/Console.qml b/pages/Console.qml index 09bb476..90dcf71 100644 --- a/pages/Console.qml +++ b/pages/Console.qml @@ -1031,37 +1031,37 @@ Rectangle { Rectangle { id: fanTestsBox Layout.preferredWidth: 320 - height: 200 + height: 148 radius: 8 color: "#1E1E20" border.color: "#3E4E6F" border.width: 2 - // Title at Top-Center with 5px Spacing Text { text: "Fan Tests" color: "#BDC3C7" - font.pixelSize: 18 + font.pixelSize: 16 anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter - anchors.topMargin: 5 + anchors.topMargin: 3 } Column { anchors.top: parent.top - anchors.topMargin: 32 + anchors.topMargin: 22 anchors.horizontalCenter: parent.horizontalCenter - spacing: 6 + spacing: 2 Text { text: "Console Fan: " + (fanSlider.value === 0 ? "OFF" : fanSlider.value.toFixed(0) + "%") color: "#BDC3C7" - font.pixelSize: 14 + font.pixelSize: 13 } Slider { id: fanSlider width: 280 + height: 22 from: 0 to: 100 stepSize: 10 @@ -1085,15 +1085,14 @@ Rectangle { Rectangle { width: 280; height: 1; color: "#3E4E6F" } - // Per-fan PWM feedback readout (read-only) Text { width: 280 horizontalAlignment: Text.AlignHCenter text: "1: " + (fan1Rpm < 0 ? "--" : fan1Rpm + " RPM") + - " 2: " + (fan2Rpm < 0 ? "--" : fan2Rpm + " RPM") + - " 3: " + (fan3Rpm < 0 ? "--" : fan3Rpm + " RPM") + " 2: " + (fan2Rpm < 0 ? "--" : fan2Rpm + " RPM") + + " 3: " + (fan3Rpm < 0 ? "--" : fan3Rpm + " RPM") color: "#2ECC71" - font.pixelSize: 14 + font.pixelSize: 13 font.weight: Font.Bold } @@ -1101,6 +1100,8 @@ Rectangle { text: "Get Fan Feedback" enabled: MOTIONInterface.consoleConnected width: 280 + height: 32 + font.pixelSize: 12 onClicked: MOTIONInterface.readFanFeedback() } } From 58fec48a4bc9f14f688c2a729cd43f50d1c06b33 Mon Sep 17 00:00:00 2001 From: boringethan Date: Wed, 8 Apr 2026 15:19:03 -0700 Subject: [PATCH 5/6] fix box sizes on console screen to make things look even --- pages/Console.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/Console.qml b/pages/Console.qml index 90dcf71..5c530d1 100644 --- a/pages/Console.qml +++ b/pages/Console.qml @@ -1111,7 +1111,7 @@ Rectangle { Rectangle { id: tecTripBox Layout.preferredWidth: 320 - height: 140 + height: 148 radius: 8 color: "#1E1E20" border.color: "#3E4E6F" From 1c0346ef4190deee3d8e161af7c52e128a1ceef1 Mon Sep 17 00:00:00 2001 From: boringethan Date: Wed, 8 Apr 2026 16:10:24 -0700 Subject: [PATCH 6/6] fix fpga programmer and fix flasher to use new SDK properly --- motion_connector.py | 381 ++++++++++++++++++++------------------------ 1 file changed, 171 insertions(+), 210 deletions(-) diff --git a/motion_connector.py b/motion_connector.py index 70f361b..8f57c94 100644 --- a/motion_connector.py +++ b/motion_connector.py @@ -478,6 +478,10 @@ def on_progress(p): self.failed.emit(str(exc)) +class _FpgaSourceError(Exception): + """Raised when a .jed source (local file or GitHub release) can't be resolved.""" + + class _ConsoleFpgaUpdateThread(QThread): progress = pyqtSignal(int, str) # percent (0-100, -1 indeterminate), message failed = pyqtSignal(str) @@ -496,159 +500,136 @@ def __init__( def run(self): try: - logger.info(f"[FPGA-UPD] thread start target={self._target} tag={self._tag} local={bool(self._local_jed_path)}") - - # FpgaPageProgrammer is always required if FpgaPageProgrammer is None or MuxChannel is None: - logger.info("[FPGA-UPD] FPGA programmer components unavailable in environment") self.failed.emit("FPGA programmer is unavailable (omotion SDK FPGA components missing).") return channels = _FPGA_PROGRAM_CHANNELS.get(self._target) if not channels: - logger.info(f"[FPGA-UPD] invalid target mapping target={self._target}") self.failed.emit(f"Invalid FPGA update target: {self._target}") return - if self._local_jed_path: - # --- Local file path: skip GitHub entirely --- - jed_path = Path(self._local_jed_path).resolve() - if not jed_path.exists(): - self.failed.emit(f"Local .jed file not found: {self._local_jed_path}") - return - self.progress.emit(35, f"Using local file {jed_path.name}…") - logger.info(f"[FPGA-UPD] using local jed path={jed_path}") - else: - # --- GitHub download path --- - if self._connector._github_disabled: - logger.info("[FPGA-UPD] GitHub disabled (--no-github flag)") - self.failed.emit("GitHub access is disabled (--no-github). Cannot download FPGA firmware.") - return - if GitHubReleases is None: - logger.info("[FPGA-UPD] GitHubReleases unavailable in environment") - self.failed.emit("GitHubReleases is unavailable (omotion SDK not found in environment).") - return - - repo = _FPGA_FW_REPO_MAP.get(self._target) - if not repo: - logger.info(f"[FPGA-UPD] invalid target repo mapping target={self._target}") - self.failed.emit(f"Invalid FPGA update target: {self._target}") - return - - logger.info(f"[FPGA-UPD] using repo={repo} channels={channels}") + try: + jed_path = ( + self._resolve_local_jed() + if self._local_jed_path + else self._download_jed_from_github() + ) + except _FpgaSourceError as exc: + self.failed.emit(str(exc)) + return - self.progress.emit(5, f"Fetching {self._target} release {self._tag}…") - gh = GitHubReleases(_CONSOLE_FW_REPO_OWNER, repo, timeout=30) + self._program_jed(jed_path, channels) - release = None - last_exc: Exception | None = None - for candidate_tag in _candidate_console_fw_tags(self._tag): - try: - logger.info(f"[FPGA-UPD] try get_release_by_tag tag={candidate_tag}") - release = gh.get_release_by_tag(candidate_tag) - logger.info(f"[FPGA-UPD] release resolved tag={candidate_tag}") - break - except Exception as exc: - last_exc = exc - logger.info(f"[FPGA-UPD] tag lookup failed tag={candidate_tag} err={exc}") - - if release is None: - msg = f"Release '{self._tag}' not found for {self._target}." - if last_exc is not None: - msg += f" ({last_exc})" - logger.info(f"[FPGA-UPD] release resolution failed msg={msg}") - self.failed.emit(msg) - return - - self.progress.emit(15, "Resolving .jed asset…") - assets = gh.get_asset_list(release=release) - if not isinstance(assets, list): - assets = [] - logger.info(f"[FPGA-UPD] assets discovered count={len(assets)}") + self.progress.emit(100, "FPGA programming complete") + self.finished_ok.emit(f"{self._target} FPGA updated successfully.") - jed_assets = [] - for asset in assets: - if not isinstance(asset, dict): - continue - name = str(asset.get("name") or "") - if name.lower().endswith(".jed"): - jed_assets.append(asset) + except (FpgaUpdateError, CommandError) as exc: + logger.error(f"FPGA programmer error target={self._target}: {exc}") + self.failed.emit(str(exc)) + except Exception as exc: + logger.exception(f"FPGA update unexpected error target={self._target}") + self.failed.emit(str(exc)) - logger.info(f"[FPGA-UPD] jed assets count={len(jed_assets)}") + # ---- jed source helpers -------------------------------------------------- - if not jed_assets: - logger.info(f"[FPGA-UPD] no .jed assets in release target={self._target} tag={self._tag}") - self.failed.emit(f"No .jed asset found in release '{self._tag}' for {self._target}.") - return + def _resolve_local_jed(self) -> Path: + jed_path = Path(self._local_jed_path).resolve() + if not jed_path.exists(): + raise _FpgaSourceError(f"Local .jed file not found: {self._local_jed_path}") + self.progress.emit(35, f"Using local file {jed_path.name}…") + return jed_path - jed_assets.sort(key=lambda a: str(a.get("created_at") or ""), reverse=True) - jed_name = str(jed_assets[0].get("name") or "") - if not jed_name: - logger.info("[FPGA-UPD] resolved .jed asset missing name") - self.failed.emit("Resolved .jed asset has no filename.") - return + def _download_jed_from_github(self) -> Path: + if self._connector._github_disabled: + raise _FpgaSourceError("GitHub access is disabled (--no-github). Cannot download FPGA firmware.") + if GitHubReleases is None: + raise _FpgaSourceError("GitHubReleases is unavailable (omotion SDK not found in environment).") - logger.info(f"[FPGA-UPD] selected jed asset={jed_name}") - - self.progress.emit(25, f"Downloading {jed_name}…") - dl_dir = _downloads_dir() - dl_dir.mkdir(parents=True, exist_ok=True) - jed_path = Path(gh.download_asset(release, jed_name, output_dir=dl_dir)).resolve() - self.progress.emit(35, f"Downloaded {jed_name}") - logger.info(f"[FPGA-UPD] downloaded jed path={jed_path}") - - # --- Programming (shared for local and GitHub paths) --- - programmer = FpgaPageProgrammer( - motion_interface.console_module, - verify=self._verify, - erase_timeout=35.0, - refresh_timeout=10.0, - ) - logger.info(f"[FPGA-UPD] FpgaPageProgrammer initialized verify={self._verify} erase_timeout=35 refresh_timeout=10") + repo = _FPGA_FW_REPO_MAP.get(self._target) + if not repo: + raise _FpgaSourceError(f"Invalid FPGA update target: {self._target}") - total = len(channels) - for idx, channel in enumerate(channels): - base = 35 + int((55 * idx) / total) - span = max(1, int(55 / total)) + self.progress.emit(5, f"Fetching {self._target} release {self._tag}…") + gh = GitHubReleases(_CONSOLE_FW_REPO_OWNER, repo, timeout=30) - def _on_progress(pages_done: int, total_pages: int, ch=channel, b=base, s=span): - local_pct = 0.0 if total_pages <= 0 else (100.0 * float(pages_done) / float(total_pages)) - overall = min(95, b + int((s * local_pct) / 100.0)) - self.progress.emit(overall, f"Programming channel {ch}…") + release = None + last_exc: Exception | None = None + for candidate_tag in _candidate_console_fw_tags(self._tag): + try: + release = gh.get_release_by_tag(candidate_tag) + break + except Exception as exc: + last_exc = exc + + if release is None: + msg = f"Release '{self._tag}' not found for {self._target}." + if last_exc is not None: + msg += f" ({last_exc})" + raise _FpgaSourceError(msg) + + self.progress.emit(15, "Resolving .jed asset…") + assets = gh.get_asset_list(release=release) or [] + jed_assets = [ + a for a in assets + if isinstance(a, dict) and str(a.get("name") or "").lower().endswith(".jed") + ] + if not jed_assets: + raise _FpgaSourceError(f"No .jed asset found in release '{self._tag}' for {self._target}.") + + jed_assets.sort(key=lambda a: str(a.get("created_at") or ""), reverse=True) + jed_name = str(jed_assets[0].get("name") or "") + if not jed_name: + raise _FpgaSourceError("Resolved .jed asset has no filename.") + + self.progress.emit(25, f"Downloading {jed_name}…") + dl_dir = _downloads_dir() + dl_dir.mkdir(parents=True, exist_ok=True) + jed_path = Path(gh.download_asset(release, jed_name, output_dir=dl_dir)).resolve() + self.progress.emit(35, f"Downloaded {jed_name}") + return jed_path + + # ---- programming --------------------------------------------------------- + + def _program_jed(self, jed_path: Path, channels: list) -> None: + programmer = FpgaPageProgrammer( + motion_interface.console_module, + verify=self._verify, + erase_timeout=35.0, + refresh_timeout=10.0, + ) - self.progress.emit(base, f"Programming channel {channel}…") - logger.info(f"[FPGA-UPD] programming start target={self._target} channel={channel} ({idx + 1}/{total})") - self._connector._console_mutex.lock() - try: - attempt = 0 - while True: - try: - programmer.program_from_jedec( - target_fpga=MuxChannel(channel), - jedec_path=str(jed_path), - on_progress=_on_progress, - ) - break - except Exception as exc_inner: - attempt += 1 - logger.warning(f"[FPGA-UPD] programming attempt {attempt} failed target={self._target} channel={channel} err={exc_inner}") - if attempt >= 2: - raise - time.sleep(0.5) - finally: - self._connector._console_mutex.unlock() - logger.info(f"[FPGA-UPD] programming done target={self._target} channel={channel}") + total = len(channels) + for idx, channel in enumerate(channels): + base = 35 + int((55 * idx) / total) + span = max(1, int(55 / total)) - self.progress.emit(100, "FPGA programming complete") - logger.info(f"[FPGA-UPD] thread complete target={self._target} tag={self._tag}") - self.finished_ok.emit(f"{self._target} FPGA updated successfully.") + def _on_progress(pages_done: int, total_pages: int, ch=channel, b=base, s=span): + local_pct = 0.0 if total_pages <= 0 else (100.0 * float(pages_done) / float(total_pages)) + overall = min(95, b + int((s * local_pct) / 100.0)) + self.progress.emit(overall, f"Programming channel {ch}…") - except (FpgaUpdateError, CommandError) as exc: - logger.error(f"[FPGA-UPD] programmer error target={self._target} tag={self._tag}: {exc}") - self.failed.emit(str(exc)) - except Exception as exc: - logger.exception(f"[FPGA-UPD] unexpected error target={self._target} tag={self._tag}") - self.failed.emit(str(exc)) + self.progress.emit(base, f"Programming channel {channel}…") + self._connector._console_mutex.lock() + try: + for attempt in range(2): + try: + programmer.program_from_jedec( + target_fpga=MuxChannel(channel), + jedec_path=str(jed_path), + on_progress=_on_progress, + ) + break + except Exception as exc_inner: + logger.warning( + f"FPGA program attempt {attempt + 1} failed " + f"target={self._target} channel={channel}: {exc_inner}" + ) + if attempt == 1: + raise + time.sleep(0.5) + finally: + self._connector._console_mutex.unlock() class CaptureThread(QThread): @@ -2518,64 +2499,39 @@ def beginFpgaFirmwareUpdate(self, target: str, tag: str) -> None: """ target = (target or "").upper() tag = (tag or "").strip() - verify = bool(getattr(self, "_fpga_fw_verify", False)) - logger.info( - f"beginFpgaFirmwareUpdate target={target} tag={tag} verify={verify}" - ) - - if target not in _FPGA_PROGRAM_CHANNELS: - logger.info(f"[FPGA-UPD] reject invalid target target={target}") - self.fpgaFirmwareUpdateError.emit( - target or "UNKNOWN", "Invalid FPGA target." - ) - return if not tag or tag == "N/A": - logger.info(f"[FPGA-UPD] reject missing tag target={target} tag={tag}") - self.fpgaFirmwareUpdateError.emit(target, "No FPGA release tag selected.") + self.fpgaFirmwareUpdateError.emit(target or "UNKNOWN", "No FPGA release tag selected.") return - if not self._consoleConnected: - logger.info(f"[FPGA-UPD] reject console disconnected target={target}") - self.fpgaFirmwareUpdateError.emit(target, "Console is not connected.") - return - if self.fpgaFirmwareUpdateBusy: - logger.info(f"[FPGA-UPD] reject busy target={target}") - self.fpgaFirmwareUpdateError.emit( - target, "An FPGA update is already in progress." - ) - return - - self._set_fpga_fw_busy(True) - self._fpga_update_thread = _ConsoleFpgaUpdateThread( - self, target, tag, verify=verify - ) - logger.info( - f"[FPGA-UPD] thread created target={target} tag={tag} verify={verify}" - ) - self._fpga_update_thread.progress.connect( - lambda pct, msg: self.fpgaFirmwareUpdateProgress.emit( - target, int(pct), str(msg) - ) - ) - self._fpga_update_thread.failed.connect( - lambda msg: self._on_fpga_fw_failed(target, str(msg)) - ) - self._fpga_update_thread.finished_ok.connect( - lambda msg: self._on_fpga_fw_finished(target, True, str(msg)) - ) - self._fpga_update_thread.finished.connect( - lambda: setattr(self, "_fpga_update_thread", None) - ) - self._fpga_update_thread.start() - logger.info( - f"[FPGA-UPD] thread started target={target} tag={tag} verify={verify}" - ) + self._launch_fpga_update_thread(target, tag=tag) @pyqtSlot(str, str) def beginFpgaFirmwareFromLocal(self, target: str, local_path: str) -> None: """Program an FPGA from a local .jed file (no GitHub download).""" target = (target or "").upper() - logger.info(f"beginFpgaFirmwareFromLocal target={target} path={local_path}") + p = Path(local_path) if local_path else None + if p is None or not p.exists(): + self.fpgaFirmwareUpdateError.emit(target or "UNKNOWN", f"File not found: {local_path}") + return + if p.suffix.lower() != ".jed": + self.fpgaFirmwareUpdateError.emit(target or "UNKNOWN", "Selected file must be a .jed file.") + return + self._launch_fpga_update_thread(target, local_jed_path=str(p.resolve())) + def _launch_fpga_update_thread( + self, + target: str, + *, + tag: str | None = None, + local_jed_path: str | None = None, + ) -> None: + """Shared launcher for both GitHub-tag and local-file FPGA updates. + + Validates target / connection / busy state, then spins up a single + _ConsoleFpgaUpdateThread and wires its signals into the connector's + progress/finished/failed handlers. Mirrors the device-firmware path, + which routes both downloaded and locally-uploaded .bin files through + a single install code path. + """ if target not in _FPGA_PROGRAM_CHANNELS: self.fpgaFirmwareUpdateError.emit(target or "UNKNOWN", "Invalid FPGA target.") return @@ -2586,34 +2542,22 @@ def beginFpgaFirmwareFromLocal(self, target: str, local_path: str) -> None: self.fpgaFirmwareUpdateError.emit(target, "An FPGA update is already in progress.") return - p = Path(local_path) - if not p.exists(): - self.fpgaFirmwareUpdateError.emit(target, f"File not found: {local_path}") - return - if p.suffix.lower() != ".jed": - self.fpgaFirmwareUpdateError.emit(target, "Selected file must be a .jed file.") - return - verify = bool(getattr(self, "_fpga_fw_verify", False)) + source = f"local={Path(local_jed_path).name}" if local_jed_path else f"tag={tag}" + logger.info(f"[FPGA-UPD] launch target={target} {source} verify={verify}") + self._set_fpga_fw_busy(True) - self._fpga_update_thread = _ConsoleFpgaUpdateThread( - self, target, "local", verify=verify, local_jed_path=str(p.resolve()) - ) - logger.info(f"[FPGA-UPD] local thread created target={target} path={p} verify={verify}") - self._fpga_update_thread.progress.connect( - lambda pct, msg: self.fpgaFirmwareUpdateProgress.emit(target, int(pct), str(msg)) - ) - self._fpga_update_thread.failed.connect( - lambda msg: self._on_fpga_fw_failed(target, str(msg)) - ) - self._fpga_update_thread.finished_ok.connect( - lambda msg: self._on_fpga_fw_finished(target, True, str(msg)) + thread = _ConsoleFpgaUpdateThread( + self, target, tag or "local", verify=verify, local_jed_path=local_jed_path ) - self._fpga_update_thread.finished.connect( - lambda: setattr(self, "_fpga_update_thread", None) + thread.progress.connect( + lambda pct, msg, t=target: self.fpgaFirmwareUpdateProgress.emit(t, int(pct), str(msg)) ) - self._fpga_update_thread.start() - logger.info(f"[FPGA-UPD] local thread started target={target}") + thread.failed.connect(lambda msg, t=target: self._on_fpga_fw_failed(t, str(msg))) + thread.finished_ok.connect(lambda msg, t=target: self._on_fpga_fw_finished(t, True, str(msg))) + thread.finished.connect(lambda: setattr(self, "_fpga_update_thread", None)) + self._fpga_update_thread = thread + thread.start() def _on_fpga_fw_failed(self, target: str, message: str) -> None: logger.info(f"[FPGA-UPD] failed target={target} message={message}") @@ -2933,12 +2877,29 @@ def configureCamera(self, target: str, cam_mask: int): mutex.lock() try: - passed_flash = motion_interface.sensors[sensor_tag].program_fpga( + # Mirror the bloodflow ScanWorkflow sequence: status precheck, + # program, short settle delay, then configure registers. The + # firmware ACKs OW_FPGA_PROG_SRAM before the FPGA is actually + # usable, so an immediate camera_configure_registers races the + # FPGA bringup and intermittently fails. + sensor = motion_interface.sensors[sensor_tag] + cam_pos = cam_mask.bit_length() - 1 + + status_map = sensor.get_camera_status(cam_mask) + if not status_map or cam_pos not in status_map or not (status_map[cam_pos] & 0x01): + logger.error( + f"Camera {sensor_tag}/{cam_pos} not READY before program (status={status_map})" + ) + self.cameraConfigUpdated.emit(cam_mask, False) + return + + passed_flash = sensor.program_fpga( camera_position=cam_mask, manual_process=False ) - passed_configure = motion_interface.sensors[ - sensor_tag - ].camera_configure_registers(camera_position=cam_mask) + time.sleep(0.1) # FPGA bringup settle delay + passed_configure = sensor.camera_configure_registers( + camera_position=cam_mask + ) if not passed_flash or not passed_configure: logger.error(