seq-quince 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seq_quince/__init__.py +0 -0
- seq_quince/config_manager.py +129 -0
- seq_quince/device/__init__.py +0 -0
- seq_quince/device/device_abs.py +96 -0
- seq_quince/device/extensions.py +211 -0
- seq_quince/device/factory.py +75 -0
- seq_quince/device/lifecycle.py +417 -0
- seq_quince/device_concrete/__init__.py +0 -0
- seq_quince/device_concrete/_option_lookup.py +42 -0
- seq_quince/device_concrete/actuator.py +88 -0
- seq_quince/device_concrete/hexapod.py +210 -0
- seq_quince/device_concrete/mono_zero_order.py +528 -0
- seq_quince/device_concrete/ophyd_motor.py +85 -0
- seq_quince/device_concrete/scan.py +195 -0
- seq_quince/device_concrete/sequence.py +189 -0
- seq_quince/device_concrete/sim.py +67 -0
- seq_quince/device_concrete/toggle.py +124 -0
- seq_quince/device_concrete/wait_condition.py +536 -0
- seq_quince/device_runner.py +71 -0
- seq_quince/loader.py +52 -0
- seq_quince/runner.py +230 -0
- seq_quince/scheduler.py +314 -0
- seq_quince/scheduler_check_devices.py +71 -0
- seq_quince/scheduler_runner.py +115 -0
- seq_quince/sequence.py +101 -0
- seq_quince/snapshot.py +116 -0
- seq_quince/tools.py +69 -0
- seq_quince-0.1.1.dist-info/LICENSE +21 -0
- seq_quince-0.1.1.dist-info/METADATA +145 -0
- seq_quince-0.1.1.dist-info/RECORD +33 -0
- seq_quince-0.1.1.dist-info/WHEEL +5 -0
- seq_quince-0.1.1.dist-info/entry_points.txt +3 -0
- seq_quince-0.1.1.dist-info/top_level.txt +1 -0
seq_quince/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from seq_quince.loader import j2_yaml_loader
|
|
8
|
+
from seq_quince.sequence import MoveStep, build_sequence
|
|
9
|
+
from seq_quince.snapshot import SnapshotManager
|
|
10
|
+
from seq_quince.tools import get_scheduler_config_dir, merge_replacements
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger("seq_quince.loader")
|
|
13
|
+
|
|
14
|
+
#TODO: Expand macros
|
|
15
|
+
#TODO: Templates with Jinja2
|
|
16
|
+
#TODO: Defaults
|
|
17
|
+
class SchedulerConfigManager:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
config_dir: Optional[str] = None,
|
|
21
|
+
replacements: Optional[Dict[str, str]] = None,
|
|
22
|
+
config_file: str = 'beamline.yaml',
|
|
23
|
+
preset_file: str = 'presets.yaml',
|
|
24
|
+
log_level: int = logging.INFO,
|
|
25
|
+
):
|
|
26
|
+
log.setLevel(log_level)
|
|
27
|
+
self.replacements = replacements or {}
|
|
28
|
+
self.config_dir = (
|
|
29
|
+
config_dir or
|
|
30
|
+
os.environ.get("SCHEDULER_CONFIG_DIR") or
|
|
31
|
+
get_scheduler_config_dir()
|
|
32
|
+
)
|
|
33
|
+
log.info(f"Scheduler config directory: {self.config_dir}")
|
|
34
|
+
self.config_path = os.path.join(self.config_dir, config_file)
|
|
35
|
+
self.snapshot_manager = SnapshotManager(config_dir=self.config_dir, preset_file=preset_file, replacements=self.replacements)
|
|
36
|
+
self.config = self._load_config()
|
|
37
|
+
log.info(f"Scheduler config loaded: {self.config_path}")
|
|
38
|
+
self.devices_cfg = self.config.get("devices", [])
|
|
39
|
+
self.macros = self.config.get("macros", {})
|
|
40
|
+
self.presets = self.config.get("presets", {})
|
|
41
|
+
self.sequence = self.config.get("sequence", {})
|
|
42
|
+
|
|
43
|
+
def _load_config(self) -> Dict[str, Any]:
|
|
44
|
+
log.info(f"yaml_load: {self.config_path}, {self.replacements}")
|
|
45
|
+
cfg = j2_yaml_loader(self.config_path, self.replacements)
|
|
46
|
+
log.debug(f"cfg: {cfg}")
|
|
47
|
+
user_presets = self.snapshot_manager.load_snapshot()
|
|
48
|
+
|
|
49
|
+
# Merge presets: user_presets overrides cfg
|
|
50
|
+
cfg_presets = cfg.get("presets", {})
|
|
51
|
+
user_presets_only = user_presets.get("presets", {})
|
|
52
|
+
merged_presets = {**cfg_presets, **user_presets_only}
|
|
53
|
+
|
|
54
|
+
# Insert merged presets back into cfg
|
|
55
|
+
cfg["presets"] = merged_presets
|
|
56
|
+
log.debug(f"cfg merged: {cfg}")
|
|
57
|
+
return cfg
|
|
58
|
+
|
|
59
|
+
def get_device_configs(self) -> List[Dict[str, Any]]:
|
|
60
|
+
return self.devices_cfg
|
|
61
|
+
|
|
62
|
+
def create_devices(self, factory) -> Dict[str, Any]:
|
|
63
|
+
names = set()
|
|
64
|
+
for cfg in self.devices_cfg:
|
|
65
|
+
name = cfg.get("name")
|
|
66
|
+
if name in names:
|
|
67
|
+
raise ValueError(f"Duplicate device name '{name}' in config.")
|
|
68
|
+
names.add(name)
|
|
69
|
+
for cfg in self.devices_cfg:
|
|
70
|
+
merge_replacements(cfg, self.replacements)
|
|
71
|
+
devices = [factory.create(cfg) for cfg in self.devices_cfg]
|
|
72
|
+
return {d.name: d for d in devices}
|
|
73
|
+
|
|
74
|
+
def get_device_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
|
75
|
+
return next((cfg for cfg in self.devices_cfg if cfg.get("name") == name), None)
|
|
76
|
+
|
|
77
|
+
def get_preset(self, preset_name: str) -> Dict[str, Any]:
|
|
78
|
+
if preset_name not in self.presets:
|
|
79
|
+
raise ValueError(f"Preset '{preset_name}' not found in config.")
|
|
80
|
+
return self.presets[preset_name]
|
|
81
|
+
|
|
82
|
+
def get_sequence_config(self) -> Dict[str, Any]:
|
|
83
|
+
return self.sequence
|
|
84
|
+
|
|
85
|
+
def get_macros(self) -> Dict[str, str]:
|
|
86
|
+
return self.macros
|
|
87
|
+
|
|
88
|
+
def build_sequence_from_preset(
|
|
89
|
+
self,
|
|
90
|
+
preset_name: str,
|
|
91
|
+
devices: dict,
|
|
92
|
+
*,
|
|
93
|
+
default_grouping: str = "parallel",
|
|
94
|
+
build_sequence_func=None, # allow for test/mock override
|
|
95
|
+
) -> List["MoveStep"]:
|
|
96
|
+
"""
|
|
97
|
+
Build a MoveStep sequence for a given preset using the provided devices dict.
|
|
98
|
+
"""
|
|
99
|
+
# Get the preset mapping
|
|
100
|
+
preset = self.get_preset(preset_name)
|
|
101
|
+
if preset is None:
|
|
102
|
+
raise ValueError(f"Preset '{preset_name}' not found in config.")
|
|
103
|
+
# Use external or default build_sequence function
|
|
104
|
+
if build_sequence_func is None:
|
|
105
|
+
build_sequence_func = build_sequence
|
|
106
|
+
# Use the preset as the 'target'
|
|
107
|
+
return build_sequence_func(devices, preset, default_grouping=default_grouping)
|
|
108
|
+
|
|
109
|
+
# TODO: Implement a method to update full config (takes effact before device creation)
|
|
110
|
+
def update_preset_item(self, preset_name: str, device_name: str, updates: dict) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Update one device's preset entry with new values.
|
|
113
|
+
Example: update_preset_item('init', 'energy_scan', {'start': 355.0})
|
|
114
|
+
"""
|
|
115
|
+
preset = self.presets.setdefault(preset_name, {})
|
|
116
|
+
device_preset = preset.setdefault(device_name, {})
|
|
117
|
+
if not isinstance(device_preset, dict):
|
|
118
|
+
raise TypeError(f"Preset entry for {device_name} in '{preset_name}' must be a dict.")
|
|
119
|
+
device_preset.update(updates)
|
|
120
|
+
|
|
121
|
+
def save_snapshot(self, name: str, structure: Dict[str, Dict[str, float]]) -> None:
|
|
122
|
+
self.snapshot_manager.save_snapshot(name, structure)
|
|
123
|
+
|
|
124
|
+
def save_config(self, path: Optional[str] = None) -> None:
|
|
125
|
+
path = path or self.config_path
|
|
126
|
+
# TODO: If you have self.replacements, replace macros before writing if needed
|
|
127
|
+
with open(path, "w") as f:
|
|
128
|
+
yaml.dump(self.config, f, default_flow_style=False)
|
|
129
|
+
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Dict, Optional, Type, TypeVar
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_param(config: Dict[str, Any], key: str, typ, default=None, required=False):
|
|
6
|
+
value = config.get(key, default)
|
|
7
|
+
if required and value is None:
|
|
8
|
+
raise ValueError(f"Missing required parameter: '{key}'")
|
|
9
|
+
if value is not None and not isinstance(value, typ):
|
|
10
|
+
try:
|
|
11
|
+
value = typ(value)
|
|
12
|
+
except (TypeError, ValueError) as e:
|
|
13
|
+
raise TypeError(
|
|
14
|
+
f"Parameter '{key}' expects type {typ.__name__} "
|
|
15
|
+
f"but got {value!r} ({e})"
|
|
16
|
+
) from e
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T", bound="DeviceABC")
|
|
22
|
+
|
|
23
|
+
class DeviceABC(ABC):
|
|
24
|
+
"""Abstract base class for devices."""
|
|
25
|
+
|
|
26
|
+
# init free configurable
|
|
27
|
+
# ------ Data & state ----------------------------------------------------
|
|
28
|
+
name: str # human-readable identifier
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def from_config_dict(cls: Type[T], config: Dict[str, Any]) -> T:
|
|
34
|
+
"""
|
|
35
|
+
Create an instance of the device from a config dictionary.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# ------ Motion primitives ----------------------------------------------
|
|
39
|
+
# async def move_to_pose(self, pose: Dict[str, float]) -> None:
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def process(self, pose: Dict[str, float]) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Drive the device so every axis reaches the *pose* target (value in
|
|
44
|
+
engineering units).
|
|
45
|
+
Must raise error *only* after an orderly rollback has completed or is impossible.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
async def snapshot(self) -> Dict[str, float]:
|
|
50
|
+
"""Return the *current* read-back values for every axis."""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def busy(self) -> bool:
|
|
55
|
+
"""True while **any** axis is still moving or the device is faulted."""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
async def stop(self) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Best-effort immediate stop (e.g. user pressed Ctrl-C). Must leave the
|
|
61
|
+
device in a recoverable, well-defined state.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
async def close(self) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Release owned resources after operations settle; safe to repeat.
|
|
67
|
+
"""
|
|
68
|
+
from seq_quince.device.lifecycle import close_resources
|
|
69
|
+
|
|
70
|
+
await close_resources(self)
|
|
71
|
+
|
|
72
|
+
def get_capability(self, name: str) -> Optional[Any]:
|
|
73
|
+
"""Return the capability object by name, or None if not present."""
|
|
74
|
+
return None # Default: no capability
|
|
75
|
+
def supports_rollback(self) -> bool:
|
|
76
|
+
"""
|
|
77
|
+
Whether the scheduler should attempt rollback for this device.
|
|
78
|
+
|
|
79
|
+
Motion devices usually return True.
|
|
80
|
+
Wait-only / monitor-only devices should return False.
|
|
81
|
+
"""
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
def scheduler_timeout(self, pose: Optional[Dict[str, Any]] = None) -> Optional[float]:
|
|
85
|
+
"""
|
|
86
|
+
Optional per-step timeout override used by Scheduler.
|
|
87
|
+
|
|
88
|
+
Return:
|
|
89
|
+
* None -> inherit the scheduler's global step_timeout
|
|
90
|
+
* > 0 -> scheduler-enforced timeout in seconds
|
|
91
|
+
* <= 0 -> treated as disabled (no scheduler timeout)
|
|
92
|
+
|
|
93
|
+
The default is None, so the global timeout applies when configured.
|
|
94
|
+
"""
|
|
95
|
+
return None
|
|
96
|
+
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Load explicitly exported devices from SEQ_QUINCE_DEVICE_PATH directories."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib.util
|
|
5
|
+
import inspect
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from importlib.abc import ExecutionLoader
|
|
9
|
+
from importlib.machinery import ModuleSpec
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
from typing import Any, Dict, List, Mapping, Set, Tuple, Type
|
|
13
|
+
|
|
14
|
+
from seq_quince.device.device_abs import DeviceABC
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DeviceExtensionError(RuntimeError):
|
|
18
|
+
"""An extension could not be imported or registered."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeviceExtensionLoader:
|
|
22
|
+
"""
|
|
23
|
+
Finds additional external device implementations.
|
|
24
|
+
One thread at a time can perform registry operations.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._loaded_paths: Set[Path] = set()
|
|
29
|
+
self._loading = False
|
|
30
|
+
|
|
31
|
+
def load(self, registry: Mapping[str, Type[DeviceABC]]) -> Dict[str, Type[DeviceABC]]:
|
|
32
|
+
"""Return validated additions, without modifying *registry*."""
|
|
33
|
+
if self._loading:
|
|
34
|
+
raise DeviceExtensionError(
|
|
35
|
+
"Recursive device extension discovery: extension imports must "
|
|
36
|
+
"not create devices or query the factory during discovery"
|
|
37
|
+
)
|
|
38
|
+
self._loading = True
|
|
39
|
+
try:
|
|
40
|
+
return self._load(registry)
|
|
41
|
+
finally:
|
|
42
|
+
self._loading = False
|
|
43
|
+
|
|
44
|
+
def _load(self, registry: Mapping[str, Type[DeviceABC]]) -> Dict[str, Type[DeviceABC]]:
|
|
45
|
+
roots = self._new_roots()
|
|
46
|
+
if not roots:
|
|
47
|
+
return {}
|
|
48
|
+
additions: Dict[str, Type[DeviceABC]] = {}
|
|
49
|
+
sources: Dict[str, Path] = {}
|
|
50
|
+
namespaces = [self._namespace(root) for root in roots]
|
|
51
|
+
previous_modules = {
|
|
52
|
+
name: module
|
|
53
|
+
for name, module in sys.modules.copy().items()
|
|
54
|
+
if self._belongs_to(name, namespaces)
|
|
55
|
+
}
|
|
56
|
+
try:
|
|
57
|
+
# associate each extension directory with its corresponding Python module
|
|
58
|
+
for root, namespace in zip(roots, namespaces):
|
|
59
|
+
self._create_namespace(root, namespace)
|
|
60
|
+
for entry, is_package in self._entries(root):
|
|
61
|
+
module = self._import_entry(entry, namespace, is_package)
|
|
62
|
+
if "DEVICE_CLASSES" not in vars(module):
|
|
63
|
+
continue
|
|
64
|
+
exports = vars(module)["DEVICE_CLASSES"]
|
|
65
|
+
for name, device_cls in self._validate_exports(exports, entry):
|
|
66
|
+
if name in registry or name in additions:
|
|
67
|
+
source = (
|
|
68
|
+
str(sources[name]) if name in additions
|
|
69
|
+
else self._class_source(registry[name])
|
|
70
|
+
)
|
|
71
|
+
raise DeviceExtensionError(
|
|
72
|
+
f"Duplicate device type {name!r} in extension "
|
|
73
|
+
f"'{entry}'; already registered by '{source}'"
|
|
74
|
+
)
|
|
75
|
+
additions[name] = device_cls
|
|
76
|
+
sources[name] = entry
|
|
77
|
+
except BaseException:
|
|
78
|
+
# Roll back only our module namespaces, including imported helpers.
|
|
79
|
+
# Preserve KeyboardInterrupt/SystemExit while cleaning up as well.
|
|
80
|
+
for name in list(sys.modules):
|
|
81
|
+
if self._belongs_to(name, namespaces):
|
|
82
|
+
del sys.modules[name]
|
|
83
|
+
sys.modules.update(previous_modules)
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
self._loaded_paths.update(roots)
|
|
87
|
+
return additions
|
|
88
|
+
|
|
89
|
+
def _new_roots(self) -> List[Path]:
|
|
90
|
+
roots: List[Path] = []
|
|
91
|
+
seen: Set[Path] = set()
|
|
92
|
+
for value in os.environ.get("SEQ_QUINCE_DEVICE_PATH", "").split(os.pathsep):
|
|
93
|
+
if not value:
|
|
94
|
+
continue
|
|
95
|
+
root = Path(os.path.realpath(os.path.expanduser(value)))
|
|
96
|
+
if root in seen or root in self._loaded_paths:
|
|
97
|
+
continue
|
|
98
|
+
if not root.is_dir():
|
|
99
|
+
raise DeviceExtensionError(
|
|
100
|
+
f"SEQ_QUINCE_DEVICE_PATH entry '{value}' is not a directory "
|
|
101
|
+
f"(resolved to '{root}')"
|
|
102
|
+
)
|
|
103
|
+
seen.add(root)
|
|
104
|
+
roots.append(root)
|
|
105
|
+
return roots
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _namespace(root: Path) -> str:
|
|
109
|
+
digest = hashlib.sha256(os.fsencode(str(root))).hexdigest()
|
|
110
|
+
return "_seq_quince_device_extensions_" + digest
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _belongs_to(name: str, namespaces: List[str]) -> bool:
|
|
114
|
+
return any(name == prefix or name.startswith(prefix + ".")
|
|
115
|
+
for prefix in namespaces)
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _create_namespace(root: Path, namespace: str) -> None:
|
|
119
|
+
if namespace in sys.modules:
|
|
120
|
+
return
|
|
121
|
+
module = ModuleType(namespace)
|
|
122
|
+
spec = ModuleSpec(namespace, loader=None, is_package=True)
|
|
123
|
+
spec.submodule_search_locations = [str(root)]
|
|
124
|
+
module.__spec__ = spec
|
|
125
|
+
module.__package__ = namespace
|
|
126
|
+
module.__path__ = [str(root)]
|
|
127
|
+
sys.modules[namespace] = module
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _entries(root: Path) -> List[Tuple[Path, bool]]:
|
|
131
|
+
""" Cheks if each path is a package directory """
|
|
132
|
+
try:
|
|
133
|
+
children = sorted(root.iterdir())
|
|
134
|
+
entries: List[Tuple[Path, bool]] = []
|
|
135
|
+
names: Set[str] = set()
|
|
136
|
+
for entry in children:
|
|
137
|
+
if entry.name.startswith(("_", ".")):
|
|
138
|
+
continue
|
|
139
|
+
is_package = entry.is_dir() and (entry / "__init__.py").is_file()
|
|
140
|
+
if not is_package and not (entry.is_file() and entry.suffix == ".py"):
|
|
141
|
+
continue
|
|
142
|
+
name = entry.name if is_package else entry.stem
|
|
143
|
+
if not name.isidentifier():
|
|
144
|
+
raise DeviceExtensionError(f"Extension '{entry}' must have a valid Python module name")
|
|
145
|
+
if name in names:
|
|
146
|
+
raise DeviceExtensionError(
|
|
147
|
+
f"Ambiguous extension module name {name!r} in '{root}' "
|
|
148
|
+
"(both a Python file and package directory exist)"
|
|
149
|
+
)
|
|
150
|
+
names.add(name)
|
|
151
|
+
entries.append((entry, is_package))
|
|
152
|
+
return entries
|
|
153
|
+
except OSError as exc:
|
|
154
|
+
raise DeviceExtensionError(f"Cannot scan extension directory '{root}': {exc}") from exc
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _import_entry(entry: Path, namespace: str, is_package: bool) -> ModuleType:
|
|
158
|
+
""" Loads a Python file or package from a given path and returns the module object """
|
|
159
|
+
name = entry.name if is_package else entry.stem
|
|
160
|
+
module_name = namespace + "." + name
|
|
161
|
+
if module_name in sys.modules:
|
|
162
|
+
return sys.modules[module_name]
|
|
163
|
+
source = entry / "__init__.py" if is_package else entry
|
|
164
|
+
try:
|
|
165
|
+
# import both, files and directories
|
|
166
|
+
spec = importlib.util.spec_from_file_location(
|
|
167
|
+
module_name, str(source),
|
|
168
|
+
submodule_search_locations=[str(entry)] if is_package else None,
|
|
169
|
+
)
|
|
170
|
+
if spec is None or not isinstance(spec.loader, ExecutionLoader): # for pyright: SourceFileLoader provides the methods expected from an ExecutionLoader
|
|
171
|
+
raise ImportError(f"Cannot create an executable import specification for '{source}'")
|
|
172
|
+
module = importlib.util.module_from_spec(spec)
|
|
173
|
+
sys.modules[module_name] = module # register loaded module
|
|
174
|
+
spec.loader.exec_module(module) # Execute the code of the module object
|
|
175
|
+
parent = sys.modules[namespace] # seq_quince.device.extensions
|
|
176
|
+
setattr(parent, name, module)
|
|
177
|
+
return module
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
raise DeviceExtensionError(
|
|
180
|
+
f"Cannot import device extension '{source}': "
|
|
181
|
+
f"{type(exc).__name__}: {exc}"
|
|
182
|
+
) from exc
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _validate_exports(
|
|
186
|
+
exports: Any, entry: Path
|
|
187
|
+
) -> List[Tuple[str, Type[DeviceABC]]]:
|
|
188
|
+
if not isinstance(exports, Mapping):
|
|
189
|
+
raise DeviceExtensionError(f"DEVICE_CLASSES in extension '{entry}' must be a mapping")
|
|
190
|
+
validated: List[Tuple[str, Type[DeviceABC]]] = []
|
|
191
|
+
for name, device_cls in exports.items():
|
|
192
|
+
if not isinstance(name, str) or not name.strip():
|
|
193
|
+
raise DeviceExtensionError(
|
|
194
|
+
f"DEVICE_CLASSES key {name!r} in extension '{entry}' "
|
|
195
|
+
"must be a nonempty string"
|
|
196
|
+
)
|
|
197
|
+
if (not inspect.isclass(device_cls)
|
|
198
|
+
or not issubclass(device_cls, DeviceABC)
|
|
199
|
+
or inspect.isabstract(device_cls)):
|
|
200
|
+
raise DeviceExtensionError(
|
|
201
|
+
f"DEVICE_CLASSES[{name!r}] in extension '{entry}' "
|
|
202
|
+
"must be a concrete DeviceABC subclass"
|
|
203
|
+
)
|
|
204
|
+
validated.append((name, device_cls))
|
|
205
|
+
return validated
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _class_source(device_cls: Type[DeviceABC]) -> str:
|
|
209
|
+
module = sys.modules.get(device_cls.__module__)
|
|
210
|
+
source = getattr(module, "__file__", device_cls.__module__)
|
|
211
|
+
return f"{source} ({device_cls.__name__})"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from threading import RLock
|
|
2
|
+
from typing import Any, Dict, List, Type
|
|
3
|
+
|
|
4
|
+
from seq_quince.device.device_abs import DeviceABC
|
|
5
|
+
from seq_quince.device.extensions import DeviceExtensionLoader
|
|
6
|
+
from seq_quince.device.lifecycle import own_device
|
|
7
|
+
from seq_quince.device_concrete.actuator import ActuatorDevice
|
|
8
|
+
from seq_quince.device_concrete.hexapod import HexapodDevice
|
|
9
|
+
from seq_quince.device_concrete.mono_zero_order import MonoZeroOrderDevice
|
|
10
|
+
from seq_quince.device_concrete.ophyd_motor import OphydMotorDevice
|
|
11
|
+
from seq_quince.device_concrete.scan import ScanDevice
|
|
12
|
+
from seq_quince.device_concrete.sequence import SequenceDevice
|
|
13
|
+
from seq_quince.device_concrete.sim import SimDevice
|
|
14
|
+
from seq_quince.device_concrete.toggle import ToggleDevice
|
|
15
|
+
from seq_quince.device_concrete.wait_condition import WaitConditionDevice
|
|
16
|
+
|
|
17
|
+
DEVICE_REGISTRY: Dict[str, Type[DeviceABC]] = {
|
|
18
|
+
"OphydMotorDevice": OphydMotorDevice,
|
|
19
|
+
"ScanDevice": ScanDevice,
|
|
20
|
+
"ToggleDevice": ToggleDevice,
|
|
21
|
+
"SequenceDevice": SequenceDevice,
|
|
22
|
+
"ActuatorDevice": ActuatorDevice,
|
|
23
|
+
"HexapodDevice": HexapodDevice,
|
|
24
|
+
"SimDevice": SimDevice,
|
|
25
|
+
"MonoZeroOrderDevice": MonoZeroOrderDevice,
|
|
26
|
+
"WaitConditionDevice": WaitConditionDevice,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_extension_loader = DeviceExtensionLoader()
|
|
30
|
+
_registry_lock = RLock()
|
|
31
|
+
|
|
32
|
+
# ---- Factory Class ---------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
class DeviceFactory:
|
|
35
|
+
@classmethod
|
|
36
|
+
def load_extensions(cls) -> None:
|
|
37
|
+
"""
|
|
38
|
+
Register devices exported by new SEQ_QUINCE_DEVICE_PATH directories.
|
|
39
|
+
|
|
40
|
+
Discovery happens automatically during creation and type listing. This
|
|
41
|
+
method also lets callers check extensions before creating any devices.
|
|
42
|
+
"""
|
|
43
|
+
with _registry_lock:
|
|
44
|
+
DEVICE_REGISTRY.update(_extension_loader.load(DEVICE_REGISTRY))
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def register(cls, name: str, device_cls: Type[DeviceABC]) -> None:
|
|
48
|
+
# Preserve the existing explicit-registration replacement behavior.
|
|
49
|
+
with _registry_lock:
|
|
50
|
+
DEVICE_REGISTRY[name] = device_cls
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def create(cls, config: Dict[str, Any]) -> DeviceABC:
|
|
54
|
+
"""Create a device from config dict. Must include 'type' key."""
|
|
55
|
+
device_type = config.get("type")
|
|
56
|
+
if not device_type:
|
|
57
|
+
raise ValueError("Device config missing required key: 'type'")
|
|
58
|
+
|
|
59
|
+
with _registry_lock:
|
|
60
|
+
cls.load_extensions()
|
|
61
|
+
device_cls = DEVICE_REGISTRY.get(device_type)
|
|
62
|
+
if not device_cls:
|
|
63
|
+
raise ValueError(f"Unknown device type '{device_type}'. Available: {list(DEVICE_REGISTRY.keys())}")
|
|
64
|
+
|
|
65
|
+
# Use the device's own config loader
|
|
66
|
+
device = device_cls.from_config_dict(config)
|
|
67
|
+
own_device(device)
|
|
68
|
+
return device
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def available_types(cls) -> List[str]:
|
|
72
|
+
with _registry_lock:
|
|
73
|
+
cls.load_extensions()
|
|
74
|
+
return list(DEVICE_REGISTRY.keys())
|
|
75
|
+
|