Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/pymmcore_widgets/hcwizard/_dev_setup_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,9 +362,11 @@ def rebuild_port(self, port_dev_name: str, port_library_name: str = "") -> None:
self._port_dev_name = port_dev_name
if port_dev_name not in self._core.getLoadedDevices():
if not port_library_name:
self.hide()
return
self._core.loadDevice(port_dev_name, port_library_name, port_dev_name)
prop_names = self._core.getDevicePropertyNames(port_dev_name)
self.show()
return super().rebuild([(port_dev_name, p) for p in prop_names])


Expand Down
14 changes: 4 additions & 10 deletions src/pymmcore_widgets/hcwizard/_simple_prop_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import TYPE_CHECKING

from pymmcore_plus import CMMCorePlus, Keyword
from qtpy.QtCore import QSize, Signal
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QComboBox, QTableWidget, QTableWidgetItem, QWidget

from pymmcore_widgets.device_properties._property_widget import PropertyWidget
Expand Down Expand Up @@ -54,15 +54,6 @@ def __init__(self, core: CMMCorePlus, parent: QWidget | None = None) -> None:
self.verticalHeader().setDefaultSectionSize(24)
self.setColumnWidth(0, 200)

def sizeHint(self) -> QSize:
"""Return a size hint that accounts for the number of rows."""
hint = super().sizeHint()
if self.rowCount() > 0:
header_h = self.horizontalHeader().height()
rows_h = sum(self.rowHeight(r) for r in range(self.rowCount()))
hint.setHeight(header_h + rows_h + 2)
return hint

def iterRows(self) -> Iterator[tuple[str, str]]:
"""Iterate over rows, yielding (prop_name, prop_value)."""
for r in range(self.rowCount()):
Expand Down Expand Up @@ -98,3 +89,6 @@ def rebuild(
device, prop_name, mmcore=self._core, connect_core=False
)
self.setCellWidget(i, 1, wdg)
header_h = self.horizontalHeader().height()
rows_h = self.verticalHeader().defaultSectionSize() * self.rowCount()
self.setFixedHeight(header_h + rows_h + 2)
103 changes: 79 additions & 24 deletions src/pymmcore_widgets/hcwizard/config_wizard.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING

from pymmcore_plus import CMMCorePlus
from pymmcore_plus import CMMCorePlus, Keyword
from pymmcore_plus.model import Microscope
from qtpy.QtCore import QSize
from qtpy.QtWidgets import (
QFileDialog,
QLabel,
QMessageBox,
QVBoxLayout,
Expand All @@ -25,6 +26,8 @@
if TYPE_CHECKING:
from qtpy.QtGui import QCloseEvent

logger = logging.getLogger(__name__)


class ConfigWizard(QWizard):
"""Hardware Configuration Wizard for Micro-Manager.
Expand All @@ -49,6 +52,7 @@ def __init__(
):
super().__init__(parent)
self._core = core or CMMCorePlus.instance()
self._original_config = self._core.systemConfigurationFile() or ""
self._model = Microscope()
self._model.load_available_devices(self._core)
self.setWizardStyle(QWizard.WizardStyle.ModernStyle)
Expand Down Expand Up @@ -98,42 +102,93 @@ def closeEvent(self, event: QCloseEvent | None) -> None:
if self._model.is_dirty():
answer = QMessageBox.question(
self,
"Save changes?",
"Would you like to save your changes before exiting?",
QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
"Discard changes?",
"You have unsaved changes. Discard and close?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if answer == QMessageBox.StandardButton.Cancel:
if answer == QMessageBox.StandardButton.No:
event.ignore()
return
elif answer == QMessageBox.StandardButton.Save:
(fname, _) = QFileDialog.getSaveFileName(
self, "Select Destination", "", "Config Files (*.cfg)"
)
if fname:
self.setField(DEST_CONFIG, fname)
self.accept()
else:
event.ignore()
return
else:
self.reject()
self.reject()
super().closeEvent(event)

def accept(self) -> None:
"""Accept the wizard and save the configuration to a file."""
dest = self.field(DEST_CONFIG)
dest_path = Path(dest)

# Remove stale config entries referencing devices no longer in the model.
# Matches Java's MicroscopeModel.checkConfigurations().
self._check_configurations()

self._model.save(dest_path)

# Unload all devices and reload from the saved file so that the core
# state cleanly matches the file on disk. This matches the Java
# ConfigMenu.runHardwareWizard() post-wizard reload step.
try:
self._core.unloadAllDevices()
except Exception: # pragma: no cover
logger.exception("Failed to unload devices after save")
try:
self._core.loadSystemConfiguration(str(dest_path))
except Exception: # pragma: no cover
logger.exception("Failed to reload saved configuration")

super().accept()

def reject(self) -> None:
"""Reject the wizard and reload the prior configuration."""
"""Reject the wizard and restore the prior core state."""
super().reject()
last_config_file = self._core.systemConfigurationFile()
if last_config_file is not None:
self._core.loadSystemConfiguration(last_config_file)
try:
self._core.unloadAllDevices()
except Exception: # pragma: no cover
pass
if self._original_config and os.path.isfile(self._original_config):
try:
self._core.loadSystemConfiguration(self._original_config)
except Exception: # pragma: no cover
pass

def _check_configurations(self) -> None:
"""Remove stale settings from config groups and pixel size presets.

Mirrors Java MicroscopeModel.checkConfigurations(): for every config
group / pixel-size preset, drop any Setting whose device_name does not
match a device currently in the model. Empty presets and empty groups
are removed entirely.
"""
device_names = {d.name for d in self._model.devices}
device_names.add(Keyword.CoreDevice.value)

# --- config groups ---
groups_to_remove: list[str] = []
for group in self._model.config_groups.values():
presets_to_remove: list[str] = []
for preset in group.presets.values():
preset.settings = [
s for s in preset.settings if s.device_name in device_names
]
if not preset.settings:
presets_to_remove.append(preset.name)
for name in presets_to_remove:
del group.presets[name]
if not group.presets:
groups_to_remove.append(group.name)
for name in groups_to_remove:
del self._model.config_groups[name]

# --- pixel size presets ---
px = self._model.pixel_size_group
px_presets_to_remove: list[str] = []
for preset in px.presets.values():
preset.settings = [
s for s in preset.settings if s.device_name in device_names
]
if not preset.settings:
px_presets_to_remove.append(preset.name)
for name in px_presets_to_remove:
del px.presets[name]

def _update_step(self, current_index: int) -> None:
"""Change text on the left when the page changes."""
Expand Down
133 changes: 126 additions & 7 deletions src/pymmcore_widgets/hcwizard/devices_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,13 @@ def _edit_selected_device(self) -> None:
)
idx = self._model.devices.index(device)
self._model.devices[idx] = dev

# If a hub was re-initialized, rediscover peripherals
if dev.device_type == DeviceType.Hub:
self._model.load_available_devices(self._core)
dlg2 = PeripheralSetupDlg(dev, self._model, self._core, self)
dlg2.exec()

self.rebuild_table()


Expand Down Expand Up @@ -459,13 +466,125 @@ def __init__(self, model: Microscope, core: CMMCorePlus):

def initializePage(self) -> None:
"""Called to prepare the page just before it is shown."""
err = {}
# TODO: there are errors that occur outside of this call that could also be
# shown in the tooltip above...
self._model.initialize(
self._core, on_fail=lambda d, e: err.update({d.name: str(e)})
)
err = self._two_pass_initialize()
self._model.mark_clean()
self.current.rebuild_table(err)
self.available.rebuild_table()
return

def _two_pass_initialize(self) -> dict[str, str]:
"""Initialize devices using a two-pass approach.

First load ALL devices into core (without initializing), read their
actual device types from core, then initialize in the correct order:
serial ports -> hubs (with peripheral discovery) -> remaining devices.
"""
err: dict[str, str] = {}

# --- Load phase ---
# Load all devices into core without initializing so we can query
# their real device types. Also load any assigned COM ports that
# are not already in the device list (matching Java's loading of
# all available serial ports).
loaded: set[str] = set()
for device in self._model.devices:
if device.device_type == DeviceType.Core:
continue
try:
device.load(self._core, reload=True)
loaded.add(device.name)
except Exception as e:
err[device.name] = str(e)

# Set parent labels (requires all devices to be loaded)
for device in self._model.devices:
if device.name not in loaded:
continue
if device.parent_label:
try:
self._core.setParentLabel(device.name, device.parent_label)
except Exception:
pass

# Read actual device types from core so we can categorise correctly.
# Config-file parsing leaves most devices as DeviceType.Any.
for device in self._model.devices:
if device.name not in loaded:
continue
try:
device.device_type = self._core.getDeviceType(device.name)
except Exception:
pass

# --- Categorise phase ---
serial_devs: list[Device] = []
hub_devs: list[Device] = []
other_devs: list[Device] = []

for device in self._model.devices:
if device.device_type == DeviceType.Core or device.name not in loaded:
continue
if device.device_type == DeviceType.Serial:
serial_devs.append(device)
elif device.device_type == DeviceType.Hub:
hub_devs.append(device)
else:
other_devs.append(device)

# --- Initialize phase ---

# Phase 1a: Initialize serial ports
for device in serial_devs:
try:
for prop in device.properties:
if prop.is_pre_init:
prop.apply_to_core(self._core, then_update=False)
self._core.initializeDevice(device.name)
device.initialized = True
device.apply_to_core(self._core)
except Exception as e:
err[device.name] = str(e)

# Phase 1b: Initialize hub devices, discover peripherals after each
for device in hub_devs:
try:
for prop in device.properties:
if prop.is_pre_init:
prop.apply_to_core(self._core, then_update=False)
self._core.initializeDevice(device.name)
device.initialized = True
device.apply_to_core(self._core)
except Exception as e:
err[device.name] = str(e)
# Reload available devices so hub children become discoverable
self._model.load_available_devices(self._core)

# Phase 2: Initialize remaining devices
for device in other_devs:
try:
for prop in device.properties:
if prop.is_pre_init:
prop.apply_to_core(self._core, then_update=False)
self._core.initializeDevice(device.name)
device.initialized = True
device.apply_to_core(self._core)
except Exception as e:
err[device.name] = str(e)

return err

def validatePage(self) -> bool:
"""Validate the page when the user clicks Next.

Refreshes parent hub references from core for devices that may have had
their parent set during hub initialization but not in the config file.
"""
for dev in self._model.devices:
if dev.name not in self._core.getLoadedDevices():
continue
try:
parent = self._core.getParentLabel(dev.name)
if parent and not dev.parent_label:
dev.parent_label = parent
except RuntimeError:
pass
return super().validatePage() # type: ignore
Loading
Loading