nbs-bl 0.2.0__py3-none-any.whl → 0.2.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.
- nbs_bl/beamline.py +4 -1
- nbs_bl/configuration.py +1 -2
- nbs_bl/plans/xas.py +1 -1
- nbs_bl/qt/models/config_model.py +384 -0
- nbs_bl/qt/widgets/config_editor.py +770 -0
- {nbs_bl-0.2.0.dist-info → nbs_bl-0.2.1.dist-info}/METADATA +2 -1
- {nbs_bl-0.2.0.dist-info → nbs_bl-0.2.1.dist-info}/RECORD +10 -8
- nbs_bl-0.2.1.dist-info/entry_points.txt +5 -0
- nbs_bl-0.2.0.dist-info/entry_points.txt +0 -2
- {nbs_bl-0.2.0.dist-info → nbs_bl-0.2.1.dist-info}/WHEEL +0 -0
- {nbs_bl-0.2.0.dist-info → nbs_bl-0.2.1.dist-info}/licenses/LICENSE +0 -0
nbs_bl/beamline.py
CHANGED
|
@@ -20,6 +20,7 @@ class BeamlineModel:
|
|
|
20
20
|
"mirrors",
|
|
21
21
|
"controllers",
|
|
22
22
|
"vacuum",
|
|
23
|
+
"source",
|
|
23
24
|
"misc",
|
|
24
25
|
]
|
|
25
26
|
|
|
@@ -31,6 +32,7 @@ class BeamlineModel:
|
|
|
31
32
|
"intensity_detector",
|
|
32
33
|
"primary_sampleholder",
|
|
33
34
|
"reference_sampleholder",
|
|
35
|
+
"mode",
|
|
34
36
|
"slits",
|
|
35
37
|
]
|
|
36
38
|
|
|
@@ -228,7 +230,8 @@ class BeamlineModel:
|
|
|
228
230
|
If loading the device fails
|
|
229
231
|
"""
|
|
230
232
|
if device_name not in self._deferred_devices:
|
|
231
|
-
|
|
233
|
+
print(f"Device {device_name} is not in deferred devices")
|
|
234
|
+
return
|
|
232
235
|
|
|
233
236
|
# If it's an alias, get and load the root device
|
|
234
237
|
config = self._deferred_config.get(device_name, {})
|
nbs_bl/configuration.py
CHANGED
|
@@ -50,8 +50,7 @@ def load_and_configure_everything(startup_dir=None, initializer=None):
|
|
|
50
50
|
ip.user_ns["request_update"] = request_update
|
|
51
51
|
|
|
52
52
|
if initializer is None:
|
|
53
|
-
initializer =
|
|
54
|
-
ip.user_ns["beamline_initializer"] = initializer
|
|
53
|
+
initializer = get_default_initializer()
|
|
55
54
|
initializer.initialize(startup_dir, ip.user_ns)
|
|
56
55
|
|
|
57
56
|
|
nbs_bl/plans/xas.py
CHANGED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
try:
|
|
2
|
+
import tomllib
|
|
3
|
+
except ModuleNotFoundError:
|
|
4
|
+
import tomli as tomllib
|
|
5
|
+
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from qtpy.QtCore import QObject, Signal
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ConfigIssue:
|
|
16
|
+
"""A validation issue found in a device configuration.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
device_key : str
|
|
21
|
+
The device table name in the TOML file.
|
|
22
|
+
message : str
|
|
23
|
+
A human-readable description of the issue.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
device_key: str
|
|
27
|
+
message: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigModel(QObject):
|
|
31
|
+
"""Model for editing NBS-BL device TOML configuration files.
|
|
32
|
+
|
|
33
|
+
This model is responsible for:
|
|
34
|
+
- Loading/parsing TOML into a Python mapping
|
|
35
|
+
- Serializing the mapping back to TOML
|
|
36
|
+
- Providing a small validation layer for NBS-BL special keys
|
|
37
|
+
- Translating special keys to/from human-readable GUI labels
|
|
38
|
+
|
|
39
|
+
Notes
|
|
40
|
+
-----
|
|
41
|
+
This model does not attempt to preserve TOML comments. Saving will
|
|
42
|
+
rewrite the file content.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
changed = Signal()
|
|
46
|
+
issues_changed = Signal(list)
|
|
47
|
+
file_path_changed = Signal(object)
|
|
48
|
+
dirty_changed = Signal(bool)
|
|
49
|
+
|
|
50
|
+
SPECIAL_KEY_LABELS = {
|
|
51
|
+
"_target": "Target Class",
|
|
52
|
+
"_group": "Group",
|
|
53
|
+
"_role": "Role",
|
|
54
|
+
"_defer_loading": "Defer Loading",
|
|
55
|
+
"_add_to_ns": "Add to Namespace",
|
|
56
|
+
"_load_order": "Load Order",
|
|
57
|
+
"_baseline": "Baseline",
|
|
58
|
+
"_modes": "Modes",
|
|
59
|
+
"_alias": "Alias",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
SPECIAL_KEYS = set(SPECIAL_KEY_LABELS.keys())
|
|
63
|
+
|
|
64
|
+
def __init__(self, parent: QObject | None = None):
|
|
65
|
+
super().__init__(parent=parent)
|
|
66
|
+
self._devices: dict[str, dict[str, Any]] = {}
|
|
67
|
+
self._saved_devices: dict[str, dict[str, Any]] = {}
|
|
68
|
+
self._file_path: Path | None = None
|
|
69
|
+
self._issues: list[ConfigIssue] = []
|
|
70
|
+
self._label_to_key = {v: k for k, v in self.SPECIAL_KEY_LABELS.items()}
|
|
71
|
+
self._dirty = False
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def file_path(self) -> Path | None:
|
|
75
|
+
"""Path to the currently loaded file, if any."""
|
|
76
|
+
|
|
77
|
+
return self._file_path
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def devices(self) -> dict[str, dict[str, Any]]:
|
|
81
|
+
"""Return the device configuration mapping."""
|
|
82
|
+
|
|
83
|
+
return self._devices
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def issues(self) -> list[ConfigIssue]:
|
|
87
|
+
"""Return current validation issues."""
|
|
88
|
+
|
|
89
|
+
return self._issues
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def dirty(self) -> bool:
|
|
93
|
+
"""Whether the configuration has unsaved changes."""
|
|
94
|
+
|
|
95
|
+
return self._dirty
|
|
96
|
+
|
|
97
|
+
def is_device_dirty(self, device_key: str) -> bool:
|
|
98
|
+
"""Return whether a device has been modified since last load/save.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
device_key : str
|
|
103
|
+
Device key to check.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
bool
|
|
108
|
+
True if the device differs from the last saved snapshot.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
return self._devices.get(device_key, {}) != self._saved_devices.get(device_key, {})
|
|
112
|
+
|
|
113
|
+
def clear(self) -> None:
|
|
114
|
+
"""Clear all devices and reset file path."""
|
|
115
|
+
|
|
116
|
+
self._devices = {}
|
|
117
|
+
self._saved_devices = deepcopy(self._devices)
|
|
118
|
+
self._file_path = None
|
|
119
|
+
self._revalidate()
|
|
120
|
+
self._update_dirty()
|
|
121
|
+
self.file_path_changed.emit(self._file_path)
|
|
122
|
+
self.changed.emit()
|
|
123
|
+
|
|
124
|
+
def load_from_file(self, file_path: str | Path) -> None:
|
|
125
|
+
"""Load devices configuration from a TOML file.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
file_path : str or pathlib.Path
|
|
130
|
+
Path to a TOML file containing device tables.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
path = Path(file_path)
|
|
134
|
+
with path.open("rb") as f:
|
|
135
|
+
data = tomllib.load(f)
|
|
136
|
+
if not isinstance(data, dict):
|
|
137
|
+
raise ValueError("Device config TOML must be a table of device tables")
|
|
138
|
+
devices: dict[str, dict[str, Any]] = {}
|
|
139
|
+
for key, value in data.items():
|
|
140
|
+
if isinstance(value, dict):
|
|
141
|
+
devices[str(key)] = dict(value)
|
|
142
|
+
else:
|
|
143
|
+
raise ValueError(f"Device '{key}' must be a table")
|
|
144
|
+
self._devices = devices
|
|
145
|
+
self._saved_devices = deepcopy(self._devices)
|
|
146
|
+
self._file_path = path
|
|
147
|
+
self._revalidate()
|
|
148
|
+
self._update_dirty()
|
|
149
|
+
self.file_path_changed.emit(self._file_path)
|
|
150
|
+
self.changed.emit()
|
|
151
|
+
|
|
152
|
+
def save_to_file(self, file_path: str | Path | None = None) -> None:
|
|
153
|
+
"""Save devices configuration to a TOML file.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
file_path : str or pathlib.Path or None, optional
|
|
158
|
+
Output path. If None, uses the currently loaded file path.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
path = Path(file_path) if file_path is not None else self._file_path
|
|
162
|
+
if path is None:
|
|
163
|
+
raise ValueError("No file path provided for saving")
|
|
164
|
+
|
|
165
|
+
text = self.dumps()
|
|
166
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
path.write_text(text, encoding="utf-8")
|
|
168
|
+
self._file_path = path
|
|
169
|
+
self._saved_devices = deepcopy(self._devices)
|
|
170
|
+
self._update_dirty()
|
|
171
|
+
self.file_path_changed.emit(self._file_path)
|
|
172
|
+
|
|
173
|
+
def dumps(self) -> str:
|
|
174
|
+
"""Serialize current devices mapping to TOML text.
|
|
175
|
+
|
|
176
|
+
Returns
|
|
177
|
+
-------
|
|
178
|
+
str
|
|
179
|
+
TOML representation of the devices mapping.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
lines: list[str] = []
|
|
183
|
+
for device_key, config in self._devices.items():
|
|
184
|
+
lines.append(self._format_table_header(device_key))
|
|
185
|
+
for key, value in config.items():
|
|
186
|
+
lines.append(f"{self._format_key(key)} = {self._format_value(value)}")
|
|
187
|
+
lines.append("")
|
|
188
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
189
|
+
|
|
190
|
+
def device_keys(self) -> list[str]:
|
|
191
|
+
"""Return device keys in display order."""
|
|
192
|
+
|
|
193
|
+
return list(self._devices.keys())
|
|
194
|
+
|
|
195
|
+
def get_device_config(self, device_key: str) -> dict[str, Any]:
|
|
196
|
+
"""Get a shallow copy of a device configuration."""
|
|
197
|
+
|
|
198
|
+
return dict(self._devices.get(device_key, {}))
|
|
199
|
+
|
|
200
|
+
def set_device_config(self, device_key: str, config: dict[str, Any]) -> None:
|
|
201
|
+
"""Replace a device configuration entirely."""
|
|
202
|
+
|
|
203
|
+
self._devices[device_key] = dict(config)
|
|
204
|
+
self._revalidate()
|
|
205
|
+
self._update_dirty()
|
|
206
|
+
self.changed.emit()
|
|
207
|
+
|
|
208
|
+
def add_device(self, device_key: str) -> None:
|
|
209
|
+
"""Add a new device with an empty configuration."""
|
|
210
|
+
|
|
211
|
+
if device_key in self._devices:
|
|
212
|
+
raise ValueError(f"Device '{device_key}' already exists")
|
|
213
|
+
self._devices[device_key] = {}
|
|
214
|
+
self._revalidate()
|
|
215
|
+
self._update_dirty()
|
|
216
|
+
self.changed.emit()
|
|
217
|
+
|
|
218
|
+
def remove_device(self, device_key: str) -> None:
|
|
219
|
+
"""Remove a device from the configuration."""
|
|
220
|
+
|
|
221
|
+
self._devices.pop(device_key, None)
|
|
222
|
+
self._revalidate()
|
|
223
|
+
self._update_dirty()
|
|
224
|
+
self.changed.emit()
|
|
225
|
+
|
|
226
|
+
def set_value(self, device_key: str, key: str, value: Any) -> None:
|
|
227
|
+
"""Set a configuration key on a device."""
|
|
228
|
+
|
|
229
|
+
if device_key not in self._devices:
|
|
230
|
+
raise KeyError(device_key)
|
|
231
|
+
self._devices[device_key][key] = value
|
|
232
|
+
self._revalidate()
|
|
233
|
+
self._update_dirty()
|
|
234
|
+
self.changed.emit()
|
|
235
|
+
|
|
236
|
+
def unset_value(self, device_key: str, key: str) -> None:
|
|
237
|
+
"""Remove a configuration key from a device."""
|
|
238
|
+
|
|
239
|
+
if device_key not in self._devices:
|
|
240
|
+
raise KeyError(device_key)
|
|
241
|
+
self._devices[device_key].pop(key, None)
|
|
242
|
+
self._revalidate()
|
|
243
|
+
self._update_dirty()
|
|
244
|
+
self.changed.emit()
|
|
245
|
+
|
|
246
|
+
def special_key_to_label(self, key: str) -> str:
|
|
247
|
+
"""Convert a special TOML key to a GUI label."""
|
|
248
|
+
|
|
249
|
+
return self.SPECIAL_KEY_LABELS.get(key, key)
|
|
250
|
+
|
|
251
|
+
def label_to_special_key(self, label: str) -> str:
|
|
252
|
+
"""Convert a GUI label back to a TOML special key."""
|
|
253
|
+
|
|
254
|
+
return self._label_to_key.get(label, label)
|
|
255
|
+
|
|
256
|
+
def parse_value_text(self, text: str) -> Any:
|
|
257
|
+
"""Parse a user-provided value string into a TOML-compatible value.
|
|
258
|
+
|
|
259
|
+
Parameters
|
|
260
|
+
----------
|
|
261
|
+
text : str
|
|
262
|
+
Text representing a TOML value.
|
|
263
|
+
|
|
264
|
+
Returns
|
|
265
|
+
-------
|
|
266
|
+
Any
|
|
267
|
+
Parsed value. If parsing fails, the original text is returned.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
stripped = text.strip()
|
|
271
|
+
if stripped == "":
|
|
272
|
+
return ""
|
|
273
|
+
try:
|
|
274
|
+
parsed = tomllib.loads(f"v = {stripped}\n")
|
|
275
|
+
return parsed["v"]
|
|
276
|
+
except Exception:
|
|
277
|
+
return text
|
|
278
|
+
|
|
279
|
+
def _revalidate(self) -> None:
|
|
280
|
+
issues = self.validate()
|
|
281
|
+
self._issues = issues
|
|
282
|
+
self.issues_changed.emit(issues)
|
|
283
|
+
|
|
284
|
+
def _update_dirty(self) -> None:
|
|
285
|
+
dirty = self._devices != self._saved_devices
|
|
286
|
+
if dirty != self._dirty:
|
|
287
|
+
self._dirty = dirty
|
|
288
|
+
self.dirty_changed.emit(dirty)
|
|
289
|
+
|
|
290
|
+
def validate(self) -> list[ConfigIssue]:
|
|
291
|
+
"""Validate device configurations for known special keys and types.
|
|
292
|
+
|
|
293
|
+
Returns
|
|
294
|
+
-------
|
|
295
|
+
list[ConfigIssue]
|
|
296
|
+
A list of issues found in the current configuration.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
issues: list[ConfigIssue] = []
|
|
300
|
+
for device_key, config in self._devices.items():
|
|
301
|
+
if not isinstance(config, dict):
|
|
302
|
+
issues.append(ConfigIssue(device_key, "Device table must be a mapping"))
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
special_keys = [k for k in config.keys() if isinstance(k, str) and k.startswith("_")]
|
|
306
|
+
for k in special_keys:
|
|
307
|
+
if k not in self.SPECIAL_KEYS:
|
|
308
|
+
issues.append(ConfigIssue(device_key, f"Unknown special key: {k}"))
|
|
309
|
+
|
|
310
|
+
has_target = "_target" in config
|
|
311
|
+
has_alias = "_alias" in config
|
|
312
|
+
if not has_target and not has_alias:
|
|
313
|
+
issues.append(ConfigIssue(device_key, "Missing Target Class or Alias"))
|
|
314
|
+
if has_target and not isinstance(config.get("_target"), str):
|
|
315
|
+
issues.append(ConfigIssue(device_key, "Target Class must be a string"))
|
|
316
|
+
if has_alias and not isinstance(config.get("_alias"), str):
|
|
317
|
+
issues.append(ConfigIssue(device_key, "Alias must be a string"))
|
|
318
|
+
|
|
319
|
+
if "_role" in config and not isinstance(config.get("_role"), str):
|
|
320
|
+
issues.append(ConfigIssue(device_key, "Role must be a string"))
|
|
321
|
+
|
|
322
|
+
if "_group" in config:
|
|
323
|
+
group_val = config.get("_group")
|
|
324
|
+
if isinstance(group_val, str):
|
|
325
|
+
pass
|
|
326
|
+
elif isinstance(group_val, list) and all(isinstance(x, str) for x in group_val):
|
|
327
|
+
pass
|
|
328
|
+
else:
|
|
329
|
+
issues.append(ConfigIssue(device_key, "Group must be a string or list of strings"))
|
|
330
|
+
|
|
331
|
+
if "_modes" in config:
|
|
332
|
+
modes_val = config.get("_modes")
|
|
333
|
+
if not (isinstance(modes_val, list) and all(isinstance(x, str) for x in modes_val)):
|
|
334
|
+
issues.append(ConfigIssue(device_key, "Modes must be a list of strings"))
|
|
335
|
+
|
|
336
|
+
if "_load_order" in config and not isinstance(config.get("_load_order"), int):
|
|
337
|
+
issues.append(ConfigIssue(device_key, "Load Order must be an integer"))
|
|
338
|
+
|
|
339
|
+
for bool_key, label in (
|
|
340
|
+
("_defer_loading", "Defer Loading"),
|
|
341
|
+
("_add_to_ns", "Add to Namespace"),
|
|
342
|
+
("_baseline", "Baseline"),
|
|
343
|
+
):
|
|
344
|
+
if bool_key in config and not isinstance(config.get(bool_key), bool):
|
|
345
|
+
issues.append(ConfigIssue(device_key, f"{label} must be a boolean"))
|
|
346
|
+
|
|
347
|
+
return issues
|
|
348
|
+
|
|
349
|
+
def _format_table_header(self, name: str) -> str:
|
|
350
|
+
if self._is_bare_key(name):
|
|
351
|
+
return f"[{name}]"
|
|
352
|
+
return f'[{self._format_string(name)}]'
|
|
353
|
+
|
|
354
|
+
def _format_key(self, key: str) -> str:
|
|
355
|
+
if self._is_bare_key(key):
|
|
356
|
+
return key
|
|
357
|
+
return self._format_string(key)
|
|
358
|
+
|
|
359
|
+
def _format_value(self, value: Any) -> str:
|
|
360
|
+
if isinstance(value, bool):
|
|
361
|
+
return "true" if value else "false"
|
|
362
|
+
if isinstance(value, int):
|
|
363
|
+
return str(value)
|
|
364
|
+
if isinstance(value, float):
|
|
365
|
+
return repr(value)
|
|
366
|
+
if isinstance(value, str):
|
|
367
|
+
return self._format_string(value)
|
|
368
|
+
if isinstance(value, list):
|
|
369
|
+
inner = ", ".join(self._format_value(v) for v in value)
|
|
370
|
+
return f"[{inner}]"
|
|
371
|
+
raise TypeError(f"Unsupported value type: {type(value).__name__}")
|
|
372
|
+
|
|
373
|
+
def _format_string(self, s: str) -> str:
|
|
374
|
+
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
|
|
375
|
+
return f'"{escaped}"'
|
|
376
|
+
|
|
377
|
+
def _is_bare_key(self, key: str) -> bool:
|
|
378
|
+
if key == "":
|
|
379
|
+
return False
|
|
380
|
+
for ch in key:
|
|
381
|
+
if not (ch.isalnum() or ch in ("_", "-", ".")):
|
|
382
|
+
return False
|
|
383
|
+
return True
|
|
384
|
+
|
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from qtpy.QtCore import Qt
|
|
6
|
+
from qtpy.QtWidgets import (
|
|
7
|
+
QAbstractItemView,
|
|
8
|
+
QApplication,
|
|
9
|
+
QCheckBox,
|
|
10
|
+
QComboBox,
|
|
11
|
+
QFileDialog,
|
|
12
|
+
QFormLayout,
|
|
13
|
+
QGroupBox,
|
|
14
|
+
QHBoxLayout,
|
|
15
|
+
QInputDialog,
|
|
16
|
+
QLabel,
|
|
17
|
+
QLineEdit,
|
|
18
|
+
QListWidget,
|
|
19
|
+
QMainWindow,
|
|
20
|
+
QMessageBox,
|
|
21
|
+
QPushButton,
|
|
22
|
+
QSpinBox,
|
|
23
|
+
QSplitter,
|
|
24
|
+
QTableWidget,
|
|
25
|
+
QTableWidgetItem,
|
|
26
|
+
QVBoxLayout,
|
|
27
|
+
QWidget,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from nbs_bl.qt.models.config_model import ConfigIssue, ConfigModel
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConfigEditorWidget(QWidget):
|
|
34
|
+
"""Widget for editing NBS-BL `devices.toml` files."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, parent: QWidget | None = None):
|
|
37
|
+
super().__init__(parent=parent)
|
|
38
|
+
self.model = ConfigModel(parent=self)
|
|
39
|
+
self._current_device_key: str | None = None
|
|
40
|
+
self._updating = False
|
|
41
|
+
self._device_type_overrides: dict[str, str] = {}
|
|
42
|
+
self._device_type_values: dict[str, dict[str, str]] = {}
|
|
43
|
+
self._special_bool_defaults = {
|
|
44
|
+
"_defer_loading": False,
|
|
45
|
+
"_add_to_ns": True,
|
|
46
|
+
"_baseline": True,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
self._build_ui()
|
|
50
|
+
self._connect_signals()
|
|
51
|
+
self._refresh_device_list()
|
|
52
|
+
self._set_current_device(None)
|
|
53
|
+
|
|
54
|
+
def _build_ui(self) -> None:
|
|
55
|
+
root = QVBoxLayout()
|
|
56
|
+
|
|
57
|
+
self.file_label = QLabel("")
|
|
58
|
+
self.file_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
59
|
+
root.addWidget(self.file_label)
|
|
60
|
+
|
|
61
|
+
toolbar = QHBoxLayout()
|
|
62
|
+
self.new_button = QPushButton("New")
|
|
63
|
+
self.open_button = QPushButton("Open")
|
|
64
|
+
self.save_button = QPushButton("Save")
|
|
65
|
+
self.save_as_button = QPushButton("Save As")
|
|
66
|
+
toolbar.addWidget(self.new_button)
|
|
67
|
+
toolbar.addWidget(self.open_button)
|
|
68
|
+
toolbar.addWidget(self.save_button)
|
|
69
|
+
toolbar.addWidget(self.save_as_button)
|
|
70
|
+
toolbar.addStretch(1)
|
|
71
|
+
root.addLayout(toolbar)
|
|
72
|
+
|
|
73
|
+
splitter = QSplitter()
|
|
74
|
+
|
|
75
|
+
left = QWidget()
|
|
76
|
+
left_layout = QVBoxLayout()
|
|
77
|
+
left.setLayout(left_layout)
|
|
78
|
+
self.device_list = QListWidget()
|
|
79
|
+
self.device_list.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
80
|
+
left_layout.addWidget(self.device_list)
|
|
81
|
+
|
|
82
|
+
left_buttons = QHBoxLayout()
|
|
83
|
+
self.add_device_button = QPushButton("Add Device")
|
|
84
|
+
self.remove_device_button = QPushButton("Remove Device")
|
|
85
|
+
left_buttons.addWidget(self.add_device_button)
|
|
86
|
+
left_buttons.addWidget(self.remove_device_button)
|
|
87
|
+
left_layout.addLayout(left_buttons)
|
|
88
|
+
|
|
89
|
+
splitter.addWidget(left)
|
|
90
|
+
|
|
91
|
+
right = QWidget()
|
|
92
|
+
right_layout = QVBoxLayout()
|
|
93
|
+
right.setLayout(right_layout)
|
|
94
|
+
|
|
95
|
+
self.device_key_label = QLabel("")
|
|
96
|
+
self.device_key_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
97
|
+
right_layout.addWidget(self.device_key_label)
|
|
98
|
+
|
|
99
|
+
self.special_group = QGroupBox("Special Keys")
|
|
100
|
+
special_form = QFormLayout()
|
|
101
|
+
self.special_group.setLayout(special_form)
|
|
102
|
+
|
|
103
|
+
self.device_type = QComboBox()
|
|
104
|
+
self.device_type.addItems(["Target Class", "Alias"])
|
|
105
|
+
|
|
106
|
+
self.type_value = QLineEdit()
|
|
107
|
+
type_row = QWidget()
|
|
108
|
+
type_row_layout = QHBoxLayout()
|
|
109
|
+
type_row_layout.setContentsMargins(0, 0, 0, 0)
|
|
110
|
+
type_row.setLayout(type_row_layout)
|
|
111
|
+
type_row_layout.addWidget(self.device_type)
|
|
112
|
+
type_row_layout.addWidget(self.type_value)
|
|
113
|
+
|
|
114
|
+
self.group = QLineEdit()
|
|
115
|
+
self.role = QLineEdit()
|
|
116
|
+
self.modes = QLineEdit()
|
|
117
|
+
|
|
118
|
+
self.defer_loading = QCheckBox()
|
|
119
|
+
self.add_to_namespace = QCheckBox()
|
|
120
|
+
self.baseline = QCheckBox()
|
|
121
|
+
|
|
122
|
+
self.load_order = QSpinBox()
|
|
123
|
+
self.load_order.setMinimum(-999999)
|
|
124
|
+
self.load_order.setMaximum(999999)
|
|
125
|
+
|
|
126
|
+
special_form.addRow("Type", type_row)
|
|
127
|
+
special_form.addRow("Group", self.group)
|
|
128
|
+
special_form.addRow("Role", self.role)
|
|
129
|
+
special_form.addRow("Modes", self.modes)
|
|
130
|
+
special_form.addRow("Defer Loading", self.defer_loading)
|
|
131
|
+
special_form.addRow("Add to Namespace", self.add_to_namespace)
|
|
132
|
+
special_form.addRow("Baseline", self.baseline)
|
|
133
|
+
special_form.addRow("Load Order", self.load_order)
|
|
134
|
+
|
|
135
|
+
right_layout.addWidget(self.special_group)
|
|
136
|
+
|
|
137
|
+
self.normal_group = QGroupBox("Normal Keys")
|
|
138
|
+
normal_layout = QVBoxLayout()
|
|
139
|
+
self.normal_group.setLayout(normal_layout)
|
|
140
|
+
|
|
141
|
+
normal_form = QFormLayout()
|
|
142
|
+
self.ophyd_name = QLineEdit()
|
|
143
|
+
self.prefix = QLineEdit()
|
|
144
|
+
normal_form.addRow("Name", self.ophyd_name)
|
|
145
|
+
normal_form.addRow("Prefix", self.prefix)
|
|
146
|
+
normal_layout.addLayout(normal_form)
|
|
147
|
+
|
|
148
|
+
self.extra_table = QTableWidget()
|
|
149
|
+
self.extra_table.setColumnCount(2)
|
|
150
|
+
self.extra_table.setHorizontalHeaderLabels(["Key", "Value"])
|
|
151
|
+
self.extra_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
152
|
+
self.extra_table.setSelectionMode(QAbstractItemView.SingleSelection)
|
|
153
|
+
self.extra_table.verticalHeader().setVisible(False)
|
|
154
|
+
self.extra_table.horizontalHeader().setStretchLastSection(True)
|
|
155
|
+
normal_layout.addWidget(self.extra_table)
|
|
156
|
+
|
|
157
|
+
extra_buttons = QHBoxLayout()
|
|
158
|
+
self.add_key_button = QPushButton("Add Key")
|
|
159
|
+
self.remove_key_button = QPushButton("Remove Key")
|
|
160
|
+
extra_buttons.addWidget(self.add_key_button)
|
|
161
|
+
extra_buttons.addWidget(self.remove_key_button)
|
|
162
|
+
extra_buttons.addStretch(1)
|
|
163
|
+
normal_layout.addLayout(extra_buttons)
|
|
164
|
+
|
|
165
|
+
right_layout.addWidget(self.normal_group)
|
|
166
|
+
|
|
167
|
+
self.validation_group = QGroupBox("Validation")
|
|
168
|
+
validation_layout = QVBoxLayout()
|
|
169
|
+
self.validation_group.setLayout(validation_layout)
|
|
170
|
+
self.validation_list = QListWidget()
|
|
171
|
+
validation_layout.addWidget(self.validation_list)
|
|
172
|
+
self.remove_unknown_button = QPushButton("Remove Unknown Special Keys")
|
|
173
|
+
validation_layout.addWidget(self.remove_unknown_button)
|
|
174
|
+
right_layout.addWidget(self.validation_group)
|
|
175
|
+
|
|
176
|
+
right_layout.addStretch(1)
|
|
177
|
+
splitter.addWidget(right)
|
|
178
|
+
|
|
179
|
+
splitter.setStretchFactor(0, 0)
|
|
180
|
+
splitter.setStretchFactor(1, 1)
|
|
181
|
+
|
|
182
|
+
root.addWidget(splitter)
|
|
183
|
+
self.setLayout(root)
|
|
184
|
+
|
|
185
|
+
self._update_type_placeholder()
|
|
186
|
+
self._update_file_label()
|
|
187
|
+
|
|
188
|
+
def _connect_signals(self) -> None:
|
|
189
|
+
self.new_button.clicked.connect(self._new_file)
|
|
190
|
+
self.open_button.clicked.connect(self._open_file)
|
|
191
|
+
self.save_button.clicked.connect(self._save_file)
|
|
192
|
+
self.save_as_button.clicked.connect(self._save_file_as)
|
|
193
|
+
|
|
194
|
+
self.add_device_button.clicked.connect(self._add_device)
|
|
195
|
+
self.remove_device_button.clicked.connect(self._remove_device)
|
|
196
|
+
self.device_list.currentItemChanged.connect(self._on_device_item_changed)
|
|
197
|
+
|
|
198
|
+
self.device_type.currentTextChanged.connect(self._on_type_changed)
|
|
199
|
+
self.type_value.editingFinished.connect(self._on_special_edited)
|
|
200
|
+
self.group.editingFinished.connect(self._on_special_edited)
|
|
201
|
+
self.role.editingFinished.connect(self._on_special_edited)
|
|
202
|
+
self.modes.editingFinished.connect(self._on_special_edited)
|
|
203
|
+
self.defer_loading.stateChanged.connect(self._on_special_edited)
|
|
204
|
+
self.add_to_namespace.stateChanged.connect(self._on_special_edited)
|
|
205
|
+
self.baseline.stateChanged.connect(self._on_special_edited)
|
|
206
|
+
self.load_order.valueChanged.connect(self._on_special_edited)
|
|
207
|
+
|
|
208
|
+
self.ophyd_name.editingFinished.connect(self._on_normal_edited)
|
|
209
|
+
self.prefix.editingFinished.connect(self._on_normal_edited)
|
|
210
|
+
self.add_key_button.clicked.connect(self._add_extra_key)
|
|
211
|
+
self.remove_key_button.clicked.connect(self._remove_extra_key)
|
|
212
|
+
self.extra_table.itemChanged.connect(self._on_extra_item_changed)
|
|
213
|
+
self.remove_unknown_button.clicked.connect(self._remove_unknown_special_keys)
|
|
214
|
+
|
|
215
|
+
self.model.changed.connect(self._refresh_device_list)
|
|
216
|
+
self.model.issues_changed.connect(self._refresh_validation)
|
|
217
|
+
self.model.file_path_changed.connect(self._update_file_label)
|
|
218
|
+
self.model.dirty_changed.connect(self._update_file_label)
|
|
219
|
+
|
|
220
|
+
def _update_file_label(self) -> None:
|
|
221
|
+
suffix = " (modified)" if self.model.dirty else ""
|
|
222
|
+
if self.model.file_path is None:
|
|
223
|
+
self.file_label.setText(f"File: (unsaved){suffix}")
|
|
224
|
+
else:
|
|
225
|
+
self.file_label.setText(f"File: {self.model.file_path}{suffix}")
|
|
226
|
+
|
|
227
|
+
def _refresh_device_list(self) -> None:
|
|
228
|
+
current = self._current_device_key
|
|
229
|
+
self.device_list.blockSignals(True)
|
|
230
|
+
self.device_list.clear()
|
|
231
|
+
for key in self.model.device_keys():
|
|
232
|
+
text = f"{key} *" if self.model.is_device_dirty(key) else key
|
|
233
|
+
self.device_list.addItem(text)
|
|
234
|
+
self.device_list.item(self.device_list.count() - 1).setData(Qt.UserRole, key)
|
|
235
|
+
self.device_list.blockSignals(False)
|
|
236
|
+
|
|
237
|
+
if current in self.model.devices:
|
|
238
|
+
for i in range(self.device_list.count()):
|
|
239
|
+
item = self.device_list.item(i)
|
|
240
|
+
if item is not None and item.data(Qt.UserRole) == current:
|
|
241
|
+
self.device_list.setCurrentItem(item)
|
|
242
|
+
break
|
|
243
|
+
elif self.model.device_keys():
|
|
244
|
+
self.device_list.setCurrentRow(0)
|
|
245
|
+
else:
|
|
246
|
+
self._set_current_device(None)
|
|
247
|
+
|
|
248
|
+
def _on_device_item_changed(self, current, previous) -> None:
|
|
249
|
+
if current is None:
|
|
250
|
+
self._set_current_device(None)
|
|
251
|
+
return
|
|
252
|
+
key = current.data(Qt.UserRole)
|
|
253
|
+
self._set_current_device(str(key) if key is not None else current.text())
|
|
254
|
+
|
|
255
|
+
def _set_current_device(self, device_key: str | None) -> None:
|
|
256
|
+
self._current_device_key = device_key if device_key else None
|
|
257
|
+
if self._current_device_key is None:
|
|
258
|
+
self.device_key_label.setText("No device selected")
|
|
259
|
+
self._set_editor_enabled(False)
|
|
260
|
+
self._clear_editor()
|
|
261
|
+
return
|
|
262
|
+
self.device_key_label.setText(f"Device Key: {self._current_device_key}")
|
|
263
|
+
self._set_editor_enabled(True)
|
|
264
|
+
self._load_device_to_editor(self._current_device_key)
|
|
265
|
+
|
|
266
|
+
def _set_editor_enabled(self, enabled: bool) -> None:
|
|
267
|
+
self.special_group.setEnabled(enabled)
|
|
268
|
+
self.normal_group.setEnabled(enabled)
|
|
269
|
+
|
|
270
|
+
def _clear_editor(self) -> None:
|
|
271
|
+
self._updating = True
|
|
272
|
+
try:
|
|
273
|
+
self.device_type.setCurrentText("Target Class")
|
|
274
|
+
self.type_value.setText("")
|
|
275
|
+
self.group.setText("")
|
|
276
|
+
self.role.setText("")
|
|
277
|
+
self.modes.setText("")
|
|
278
|
+
self.defer_loading.setChecked(bool(self._special_bool_defaults["_defer_loading"]))
|
|
279
|
+
self.add_to_namespace.setChecked(bool(self._special_bool_defaults["_add_to_ns"]))
|
|
280
|
+
self.baseline.setChecked(bool(self._special_bool_defaults["_baseline"]))
|
|
281
|
+
self.load_order.setValue(0)
|
|
282
|
+
self.ophyd_name.setText("")
|
|
283
|
+
self.prefix.setText("")
|
|
284
|
+
self.extra_table.setRowCount(0)
|
|
285
|
+
self.validation_list.clear()
|
|
286
|
+
self._update_type_placeholder()
|
|
287
|
+
self._update_normal_keys_enabled()
|
|
288
|
+
finally:
|
|
289
|
+
self._updating = False
|
|
290
|
+
|
|
291
|
+
def _load_device_to_editor(self, device_key: str) -> None:
|
|
292
|
+
config = self.model.get_device_config(device_key)
|
|
293
|
+
self._updating = True
|
|
294
|
+
try:
|
|
295
|
+
if device_key not in self._device_type_values:
|
|
296
|
+
self._device_type_values[device_key] = {"Target Class": "", "Alias": ""}
|
|
297
|
+
if "_target" in config and isinstance(config.get("_target"), str):
|
|
298
|
+
self._device_type_values[device_key]["Target Class"] = str(config.get("_target"))
|
|
299
|
+
if "_alias" in config and isinstance(config.get("_alias"), str):
|
|
300
|
+
self._device_type_values[device_key]["Alias"] = str(config.get("_alias"))
|
|
301
|
+
|
|
302
|
+
if "_alias" in config and "_target" not in config:
|
|
303
|
+
selected_type = "Alias"
|
|
304
|
+
elif "_target" in config and "_alias" not in config:
|
|
305
|
+
selected_type = "Target Class"
|
|
306
|
+
else:
|
|
307
|
+
selected_type = self._device_type_overrides.get(device_key, "Target Class")
|
|
308
|
+
|
|
309
|
+
self.device_type.setCurrentText(selected_type)
|
|
310
|
+
self.type_value.setText(self._device_type_values.get(device_key, {}).get(selected_type, ""))
|
|
311
|
+
|
|
312
|
+
group_val = config.get("_group", "")
|
|
313
|
+
if isinstance(group_val, list):
|
|
314
|
+
self.group.setText(", ".join(group_val))
|
|
315
|
+
else:
|
|
316
|
+
self.group.setText(str(group_val) if group_val is not None else "")
|
|
317
|
+
|
|
318
|
+
self.role.setText(str(config.get("_role", "")) if "_role" in config else "")
|
|
319
|
+
|
|
320
|
+
modes_val = config.get("_modes", "")
|
|
321
|
+
if isinstance(modes_val, list):
|
|
322
|
+
self.modes.setText(", ".join(modes_val))
|
|
323
|
+
else:
|
|
324
|
+
self.modes.setText("")
|
|
325
|
+
|
|
326
|
+
self.defer_loading.setChecked(self._get_bool_with_default(config, "_defer_loading"))
|
|
327
|
+
self.add_to_namespace.setChecked(self._get_bool_with_default(config, "_add_to_ns"))
|
|
328
|
+
self.baseline.setChecked(self._get_bool_with_default(config, "_baseline"))
|
|
329
|
+
|
|
330
|
+
load_order_val = config.get("_load_order", 0)
|
|
331
|
+
self.load_order.setValue(int(load_order_val) if isinstance(load_order_val, int) else 0)
|
|
332
|
+
|
|
333
|
+
self.ophyd_name.setText(str(config.get("name", "")) if "name" in config else "")
|
|
334
|
+
self.prefix.setText(str(config.get("prefix", "")) if "prefix" in config else "")
|
|
335
|
+
|
|
336
|
+
self._populate_extra_table(config)
|
|
337
|
+
self._update_type_placeholder()
|
|
338
|
+
self._update_normal_keys_enabled()
|
|
339
|
+
finally:
|
|
340
|
+
self._updating = False
|
|
341
|
+
|
|
342
|
+
self._refresh_validation(self.model.issues)
|
|
343
|
+
|
|
344
|
+
def _populate_extra_table(self, config: dict) -> None:
|
|
345
|
+
excluded = set(self.model.SPECIAL_KEYS) | {"name", "prefix"}
|
|
346
|
+
extra_items = [
|
|
347
|
+
(k, v)
|
|
348
|
+
for k, v in config.items()
|
|
349
|
+
if k not in excluded and not (isinstance(k, str) and k.startswith("_"))
|
|
350
|
+
]
|
|
351
|
+
self.extra_table.blockSignals(True)
|
|
352
|
+
self.extra_table.setRowCount(0)
|
|
353
|
+
for key, value in extra_items:
|
|
354
|
+
row = self.extra_table.rowCount()
|
|
355
|
+
self.extra_table.insertRow(row)
|
|
356
|
+
key_item = QTableWidgetItem(str(key))
|
|
357
|
+
key_item.setData(Qt.UserRole, str(key))
|
|
358
|
+
val_item = QTableWidgetItem(self._value_to_text(value))
|
|
359
|
+
self.extra_table.setItem(row, 0, key_item)
|
|
360
|
+
self.extra_table.setItem(row, 1, val_item)
|
|
361
|
+
self.extra_table.blockSignals(False)
|
|
362
|
+
|
|
363
|
+
def _refresh_validation(self, issues: list[ConfigIssue]) -> None:
|
|
364
|
+
self.validation_list.clear()
|
|
365
|
+
if not issues:
|
|
366
|
+
self.validation_list.addItem("No issues")
|
|
367
|
+
return
|
|
368
|
+
for issue in issues:
|
|
369
|
+
self.validation_list.addItem(f"{issue.device_key}: {issue.message}")
|
|
370
|
+
|
|
371
|
+
def _value_to_text(self, value: object) -> str:
|
|
372
|
+
if isinstance(value, str):
|
|
373
|
+
return value
|
|
374
|
+
if isinstance(value, bool):
|
|
375
|
+
return "true" if value else "false"
|
|
376
|
+
if isinstance(value, (int, float)):
|
|
377
|
+
return str(value)
|
|
378
|
+
if isinstance(value, list):
|
|
379
|
+
parts = []
|
|
380
|
+
for v in value:
|
|
381
|
+
if isinstance(v, str):
|
|
382
|
+
parts.append(f'"{v}"')
|
|
383
|
+
else:
|
|
384
|
+
parts.append(str(v))
|
|
385
|
+
return f"[{', '.join(parts)}]"
|
|
386
|
+
return str(value)
|
|
387
|
+
|
|
388
|
+
def _update_type_placeholder(self) -> None:
|
|
389
|
+
if self.device_type.currentText() == "Alias":
|
|
390
|
+
self.type_value.setPlaceholderText("Alias")
|
|
391
|
+
else:
|
|
392
|
+
self.type_value.setPlaceholderText("Target class path")
|
|
393
|
+
|
|
394
|
+
def _update_normal_keys_enabled(self) -> None:
|
|
395
|
+
enabled = self._current_device_key is not None and self.device_type.currentText() != "Alias"
|
|
396
|
+
self.normal_group.setEnabled(enabled)
|
|
397
|
+
|
|
398
|
+
def _get_bool_with_default(self, config: dict, key: str) -> bool:
|
|
399
|
+
default = bool(self._special_bool_defaults[key])
|
|
400
|
+
if key not in config:
|
|
401
|
+
return default
|
|
402
|
+
value = config.get(key)
|
|
403
|
+
return value if isinstance(value, bool) else default
|
|
404
|
+
|
|
405
|
+
def _on_type_changed(self) -> None:
|
|
406
|
+
if self._current_device_key is not None:
|
|
407
|
+
key = self._current_device_key
|
|
408
|
+
self._device_type_overrides[key] = self.device_type.currentText()
|
|
409
|
+
if key not in self._device_type_values:
|
|
410
|
+
self._device_type_values[key] = {"Target Class": "", "Alias": ""}
|
|
411
|
+
self._update_type_placeholder()
|
|
412
|
+
self._update_normal_keys_enabled()
|
|
413
|
+
if self._current_device_key is None:
|
|
414
|
+
return
|
|
415
|
+
key = self._current_device_key
|
|
416
|
+
current_type = self.device_type.currentText()
|
|
417
|
+
self._updating = True
|
|
418
|
+
try:
|
|
419
|
+
self.type_value.setText(self._device_type_values.get(key, {}).get(current_type, ""))
|
|
420
|
+
finally:
|
|
421
|
+
self._updating = False
|
|
422
|
+
self._apply_type_selection_to_config()
|
|
423
|
+
|
|
424
|
+
def _apply_type_selection_to_config(self) -> None:
|
|
425
|
+
if self._current_device_key is None:
|
|
426
|
+
return
|
|
427
|
+
key = self._current_device_key
|
|
428
|
+
config = self.model.get_device_config(key)
|
|
429
|
+
selected_type = self.device_type.currentText()
|
|
430
|
+
|
|
431
|
+
if key not in self._device_type_values:
|
|
432
|
+
self._device_type_values[key] = {"Target Class": "", "Alias": ""}
|
|
433
|
+
|
|
434
|
+
if selected_type == "Alias":
|
|
435
|
+
config.pop("_target", None)
|
|
436
|
+
alias_text = self._device_type_values[key].get("Alias", "").strip()
|
|
437
|
+
if alias_text:
|
|
438
|
+
config["_alias"] = alias_text
|
|
439
|
+
else:
|
|
440
|
+
config.pop("_alias", None)
|
|
441
|
+
for k in list(config.keys()):
|
|
442
|
+
if isinstance(k, str) and not k.startswith("_"):
|
|
443
|
+
config.pop(k, None)
|
|
444
|
+
else:
|
|
445
|
+
config.pop("_alias", None)
|
|
446
|
+
target_text = self._device_type_values[key].get("Target Class", "").strip()
|
|
447
|
+
if target_text:
|
|
448
|
+
config["_target"] = target_text
|
|
449
|
+
else:
|
|
450
|
+
config.pop("_target", None)
|
|
451
|
+
|
|
452
|
+
self.model.set_device_config(key, config)
|
|
453
|
+
|
|
454
|
+
def _on_special_edited(self) -> None:
|
|
455
|
+
if self._updating or self._current_device_key is None:
|
|
456
|
+
return
|
|
457
|
+
|
|
458
|
+
key = self._current_device_key
|
|
459
|
+
config = self.model.get_device_config(key)
|
|
460
|
+
|
|
461
|
+
if key not in self._device_type_values:
|
|
462
|
+
self._device_type_values[key] = {"Target Class": "", "Alias": ""}
|
|
463
|
+
|
|
464
|
+
if self.device_type.currentText() == "Alias":
|
|
465
|
+
alias_text = self.type_value.text().strip()
|
|
466
|
+
self._device_type_values[key]["Alias"] = alias_text
|
|
467
|
+
config.pop("_target", None)
|
|
468
|
+
if alias_text:
|
|
469
|
+
config["_alias"] = alias_text
|
|
470
|
+
else:
|
|
471
|
+
config.pop("_alias", None)
|
|
472
|
+
for k in list(config.keys()):
|
|
473
|
+
if isinstance(k, str) and not k.startswith("_"):
|
|
474
|
+
config.pop(k, None)
|
|
475
|
+
else:
|
|
476
|
+
target_text = self.type_value.text().strip()
|
|
477
|
+
self._device_type_values[key]["Target Class"] = target_text
|
|
478
|
+
config.pop("_alias", None)
|
|
479
|
+
if target_text:
|
|
480
|
+
config["_target"] = target_text
|
|
481
|
+
else:
|
|
482
|
+
config.pop("_target", None)
|
|
483
|
+
|
|
484
|
+
group_text = self.group.text().strip()
|
|
485
|
+
if group_text:
|
|
486
|
+
parts = [p.strip() for p in group_text.split(",") if p.strip()]
|
|
487
|
+
if len(parts) == 1:
|
|
488
|
+
config["_group"] = parts[0]
|
|
489
|
+
else:
|
|
490
|
+
config["_group"] = parts
|
|
491
|
+
else:
|
|
492
|
+
config.pop("_group", None)
|
|
493
|
+
|
|
494
|
+
role_text = self.role.text().strip()
|
|
495
|
+
if role_text:
|
|
496
|
+
config["_role"] = role_text
|
|
497
|
+
else:
|
|
498
|
+
config.pop("_role", None)
|
|
499
|
+
|
|
500
|
+
modes_text = self.modes.text().strip()
|
|
501
|
+
if modes_text:
|
|
502
|
+
parts = [p.strip() for p in modes_text.split(",") if p.strip()]
|
|
503
|
+
config["_modes"] = parts
|
|
504
|
+
else:
|
|
505
|
+
config.pop("_modes", None)
|
|
506
|
+
|
|
507
|
+
for key_name, widget in (
|
|
508
|
+
("_defer_loading", self.defer_loading),
|
|
509
|
+
("_add_to_ns", self.add_to_namespace),
|
|
510
|
+
("_baseline", self.baseline),
|
|
511
|
+
):
|
|
512
|
+
default = bool(self._special_bool_defaults[key_name])
|
|
513
|
+
value = bool(widget.isChecked())
|
|
514
|
+
if value == default:
|
|
515
|
+
config.pop(key_name, None)
|
|
516
|
+
else:
|
|
517
|
+
config[key_name] = value
|
|
518
|
+
|
|
519
|
+
load_order_val = int(self.load_order.value())
|
|
520
|
+
if load_order_val == 0:
|
|
521
|
+
config.pop("_load_order", None)
|
|
522
|
+
else:
|
|
523
|
+
config["_load_order"] = load_order_val
|
|
524
|
+
|
|
525
|
+
self.model.set_device_config(key, config)
|
|
526
|
+
self._update_normal_keys_enabled()
|
|
527
|
+
|
|
528
|
+
def _on_normal_edited(self) -> None:
|
|
529
|
+
if self._updating or self._current_device_key is None:
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
key = self._current_device_key
|
|
533
|
+
config = self.model.get_device_config(key)
|
|
534
|
+
|
|
535
|
+
name_text = self.ophyd_name.text()
|
|
536
|
+
prefix_text = self.prefix.text()
|
|
537
|
+
|
|
538
|
+
if name_text.strip() == "":
|
|
539
|
+
config.pop("name", None)
|
|
540
|
+
else:
|
|
541
|
+
config["name"] = name_text
|
|
542
|
+
|
|
543
|
+
if prefix_text.strip() == "":
|
|
544
|
+
config.pop("prefix", None)
|
|
545
|
+
else:
|
|
546
|
+
config["prefix"] = prefix_text
|
|
547
|
+
|
|
548
|
+
self.model.set_device_config(key, config)
|
|
549
|
+
|
|
550
|
+
def _add_extra_key(self) -> None:
|
|
551
|
+
if self._current_device_key is None:
|
|
552
|
+
return
|
|
553
|
+
row = self.extra_table.rowCount()
|
|
554
|
+
self.extra_table.insertRow(row)
|
|
555
|
+
key_item = QTableWidgetItem("")
|
|
556
|
+
key_item.setData(Qt.UserRole, "")
|
|
557
|
+
val_item = QTableWidgetItem("")
|
|
558
|
+
self.extra_table.setItem(row, 0, key_item)
|
|
559
|
+
self.extra_table.setItem(row, 1, val_item)
|
|
560
|
+
self.extra_table.setCurrentCell(row, 0)
|
|
561
|
+
|
|
562
|
+
def _remove_extra_key(self) -> None:
|
|
563
|
+
if self._current_device_key is None:
|
|
564
|
+
return
|
|
565
|
+
rows = self.extra_table.selectionModel().selectedRows()
|
|
566
|
+
if not rows:
|
|
567
|
+
return
|
|
568
|
+
row = rows[0].row()
|
|
569
|
+
key_item = self.extra_table.item(row, 0)
|
|
570
|
+
prev_key = key_item.data(Qt.UserRole) if key_item is not None else ""
|
|
571
|
+
if prev_key:
|
|
572
|
+
self.model.unset_value(self._current_device_key, str(prev_key))
|
|
573
|
+
self.extra_table.removeRow(row)
|
|
574
|
+
|
|
575
|
+
def _on_extra_item_changed(self, item: QTableWidgetItem) -> None:
|
|
576
|
+
if self._updating or self._current_device_key is None:
|
|
577
|
+
return
|
|
578
|
+
row = item.row()
|
|
579
|
+
key_item = self.extra_table.item(row, 0)
|
|
580
|
+
val_item = self.extra_table.item(row, 1)
|
|
581
|
+
if key_item is None or val_item is None:
|
|
582
|
+
return
|
|
583
|
+
|
|
584
|
+
new_key = key_item.text().strip()
|
|
585
|
+
prev_key = str(key_item.data(Qt.UserRole) or "").strip()
|
|
586
|
+
value_text = val_item.text()
|
|
587
|
+
|
|
588
|
+
if prev_key and prev_key != new_key:
|
|
589
|
+
self.model.unset_value(self._current_device_key, prev_key)
|
|
590
|
+
|
|
591
|
+
if new_key == "":
|
|
592
|
+
key_item.setData(Qt.UserRole, "")
|
|
593
|
+
return
|
|
594
|
+
|
|
595
|
+
if new_key.startswith("_"):
|
|
596
|
+
QMessageBox.warning(
|
|
597
|
+
self,
|
|
598
|
+
"Reserved Key",
|
|
599
|
+
"Keys beginning with '_' are special keys and are edited in the Special Keys section.",
|
|
600
|
+
)
|
|
601
|
+
self._updating = True
|
|
602
|
+
try:
|
|
603
|
+
key_item.setText(prev_key)
|
|
604
|
+
finally:
|
|
605
|
+
self._updating = False
|
|
606
|
+
return
|
|
607
|
+
|
|
608
|
+
value = self.model.parse_value_text(value_text)
|
|
609
|
+
self.model.set_value(self._current_device_key, new_key, value)
|
|
610
|
+
key_item.setData(Qt.UserRole, new_key)
|
|
611
|
+
|
|
612
|
+
def _add_device(self) -> None:
|
|
613
|
+
device_key, ok = QInputDialog.getText(self, "Add Device", "Device key")
|
|
614
|
+
if not ok:
|
|
615
|
+
return
|
|
616
|
+
key = device_key.strip()
|
|
617
|
+
if key == "":
|
|
618
|
+
return
|
|
619
|
+
try:
|
|
620
|
+
self.model.add_device(key)
|
|
621
|
+
items = self.device_list.findItems(key, Qt.MatchExactly)
|
|
622
|
+
if items:
|
|
623
|
+
self.device_list.setCurrentItem(items[0])
|
|
624
|
+
except Exception as exc:
|
|
625
|
+
QMessageBox.critical(self, "Add Device Failed", str(exc))
|
|
626
|
+
|
|
627
|
+
def _remove_device(self) -> None:
|
|
628
|
+
if self._current_device_key is None:
|
|
629
|
+
return
|
|
630
|
+
key = self._current_device_key
|
|
631
|
+
ret = QMessageBox.question(
|
|
632
|
+
self,
|
|
633
|
+
"Remove Device",
|
|
634
|
+
f"Remove device '{key}'?",
|
|
635
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
636
|
+
QMessageBox.No,
|
|
637
|
+
)
|
|
638
|
+
if ret != QMessageBox.Yes:
|
|
639
|
+
return
|
|
640
|
+
self._device_type_overrides.pop(key, None)
|
|
641
|
+
self._device_type_values.pop(key, None)
|
|
642
|
+
self.model.remove_device(key)
|
|
643
|
+
self._set_current_device(None)
|
|
644
|
+
|
|
645
|
+
def _new_file(self) -> None:
|
|
646
|
+
ret = QMessageBox.question(
|
|
647
|
+
self,
|
|
648
|
+
"New File",
|
|
649
|
+
"Create a new configuration? Unsaved changes will be lost.",
|
|
650
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
651
|
+
QMessageBox.No,
|
|
652
|
+
)
|
|
653
|
+
if ret != QMessageBox.Yes:
|
|
654
|
+
return
|
|
655
|
+
self.model.clear()
|
|
656
|
+
self._refresh_device_list()
|
|
657
|
+
|
|
658
|
+
def _open_file(self) -> None:
|
|
659
|
+
path, _ = QFileDialog.getOpenFileName(self, "Open TOML", "", "TOML Files (*.toml)")
|
|
660
|
+
if not path:
|
|
661
|
+
return
|
|
662
|
+
try:
|
|
663
|
+
self.model.load_from_file(path)
|
|
664
|
+
except Exception as exc:
|
|
665
|
+
QMessageBox.critical(self, "Open Failed", str(exc))
|
|
666
|
+
return
|
|
667
|
+
self._refresh_device_list()
|
|
668
|
+
|
|
669
|
+
def _save_file(self) -> None:
|
|
670
|
+
if self.model.file_path is None:
|
|
671
|
+
self._save_file_as()
|
|
672
|
+
return
|
|
673
|
+
self._save_with_warning(self.model.file_path)
|
|
674
|
+
|
|
675
|
+
def _save_file_as(self) -> None:
|
|
676
|
+
path, _ = QFileDialog.getSaveFileName(self, "Save TOML", "", "TOML Files (*.toml)")
|
|
677
|
+
if not path:
|
|
678
|
+
return
|
|
679
|
+
self._save_with_warning(Path(path))
|
|
680
|
+
|
|
681
|
+
def _save_with_warning(self, path: Path) -> None:
|
|
682
|
+
if self.model.file_path is None or Path(self.model.file_path) != Path(path):
|
|
683
|
+
msg = "Saving will rewrite the file and will not preserve TOML comments."
|
|
684
|
+
else:
|
|
685
|
+
msg = "Saving will rewrite the file and will not preserve TOML comments."
|
|
686
|
+
ret = QMessageBox.question(
|
|
687
|
+
self,
|
|
688
|
+
"Save",
|
|
689
|
+
msg,
|
|
690
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
691
|
+
QMessageBox.Yes,
|
|
692
|
+
)
|
|
693
|
+
if ret != QMessageBox.Yes:
|
|
694
|
+
return
|
|
695
|
+
try:
|
|
696
|
+
self.model.save_to_file(path)
|
|
697
|
+
except Exception as exc:
|
|
698
|
+
QMessageBox.critical(self, "Save Failed", str(exc))
|
|
699
|
+
|
|
700
|
+
def _remove_unknown_special_keys(self) -> None:
|
|
701
|
+
if self._current_device_key is None:
|
|
702
|
+
return
|
|
703
|
+
key = self._current_device_key
|
|
704
|
+
config = self.model.get_device_config(key)
|
|
705
|
+
unknown = [
|
|
706
|
+
k
|
|
707
|
+
for k in list(config.keys())
|
|
708
|
+
if isinstance(k, str) and k.startswith("_") and k not in self.model.SPECIAL_KEYS
|
|
709
|
+
]
|
|
710
|
+
if not unknown:
|
|
711
|
+
QMessageBox.information(self, "Remove Unknown Special Keys", "No unknown special keys found.")
|
|
712
|
+
return
|
|
713
|
+
ret = QMessageBox.question(
|
|
714
|
+
self,
|
|
715
|
+
"Remove Unknown Special Keys",
|
|
716
|
+
"Remove unknown special keys from this device?\n\n" + "\n".join(unknown),
|
|
717
|
+
QMessageBox.Yes | QMessageBox.No,
|
|
718
|
+
QMessageBox.No,
|
|
719
|
+
)
|
|
720
|
+
if ret != QMessageBox.Yes:
|
|
721
|
+
return
|
|
722
|
+
for k in unknown:
|
|
723
|
+
config.pop(k, None)
|
|
724
|
+
self.model.set_device_config(key, config)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
class ConfigEditorMainWindow(QMainWindow):
|
|
728
|
+
"""Standalone window wrapper for the configuration editor."""
|
|
729
|
+
|
|
730
|
+
def __init__(self, parent: QWidget | None = None):
|
|
731
|
+
super().__init__(parent=parent)
|
|
732
|
+
self.setWindowTitle("NBS-BL Config Editor")
|
|
733
|
+
self.editor = ConfigEditorWidget(self)
|
|
734
|
+
self.setCentralWidget(self.editor)
|
|
735
|
+
self._build_menu()
|
|
736
|
+
|
|
737
|
+
def _build_menu(self) -> None:
|
|
738
|
+
file_menu = self.menuBar().addMenu("File")
|
|
739
|
+
|
|
740
|
+
new_action = file_menu.addAction("New")
|
|
741
|
+
open_action = file_menu.addAction("Open")
|
|
742
|
+
save_action = file_menu.addAction("Save")
|
|
743
|
+
save_as_action = file_menu.addAction("Save As")
|
|
744
|
+
file_menu.addSeparator()
|
|
745
|
+
exit_action = file_menu.addAction("Exit")
|
|
746
|
+
|
|
747
|
+
new_action.triggered.connect(self.editor._new_file)
|
|
748
|
+
open_action.triggered.connect(self.editor._open_file)
|
|
749
|
+
save_action.triggered.connect(self.editor._save_file)
|
|
750
|
+
save_as_action.triggered.connect(self.editor._save_file_as)
|
|
751
|
+
exit_action.triggered.connect(self.close)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def main() -> None:
|
|
755
|
+
"""Launch the configuration editor as a standalone application."""
|
|
756
|
+
|
|
757
|
+
app = QApplication.instance()
|
|
758
|
+
owns_app = app is None
|
|
759
|
+
if app is None:
|
|
760
|
+
app = QApplication([])
|
|
761
|
+
win = ConfigEditorMainWindow()
|
|
762
|
+
win.resize(1100, 750)
|
|
763
|
+
win.show()
|
|
764
|
+
if owns_app:
|
|
765
|
+
app.exec_()
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
if __name__ == "__main__":
|
|
769
|
+
main()
|
|
770
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nbs-bl
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: NBS Beamline Framework
|
|
5
5
|
Project-URL: homepage, https://github.com/xraygui/nbs-bl
|
|
6
6
|
Author-email: Charles Titus <ctitus@bnl.gov>
|
|
@@ -12,6 +12,7 @@ Classifier: Operating System :: OS Independent
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
13
|
Requires-Python: >=3.9
|
|
14
14
|
Requires-Dist: bluesky
|
|
15
|
+
Requires-Dist: bluesky-live
|
|
15
16
|
Requires-Dist: bluesky-queueserver
|
|
16
17
|
Requires-Dist: databroker>=2.0.0b67
|
|
17
18
|
Requires-Dist: ipython
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
nbs_bl/__init__.py,sha256=L6YdoI4Fn6jg6fWl76pNXkWJ3_pu-msrFH3rxY4y5AY,329
|
|
2
|
-
nbs_bl/beamline.py,sha256=
|
|
3
|
-
nbs_bl/configuration.py,sha256=
|
|
2
|
+
nbs_bl/beamline.py,sha256=cT7RGKzIdu5UcnuiRUBlxPBV_GumNdAniNWVXAoTHqc,14880
|
|
3
|
+
nbs_bl/configuration.py,sha256=9c0eyYPo1JTGYJ3EQ-bdtPpnk91sj0RyrT3LT-W5-hE,26542
|
|
4
4
|
nbs_bl/detectors.py,sha256=ID2LW3lN1GOejCFBlHo9Q4T9aQb_FK15oCnpJJcj2Rw,2159
|
|
5
5
|
nbs_bl/gGrEqns.py,sha256=N56e_KTJIZkyno9Zecn2ofD3tnb1xZ9mcBHTiSJx5lU,7206
|
|
6
6
|
nbs_bl/help.py,sha256=xDAHH1spoL2hfZUhytKLha45EZ7KvJSbgKbWWjOzcMU,3746
|
|
@@ -44,9 +44,11 @@ nbs_bl/plans/scan_decorators.py,sha256=ehmzbyoU4Ld2Kmx-DBHR0GJg_koK7ElCLu9TYCR4A
|
|
|
44
44
|
nbs_bl/plans/scans.py,sha256=iErg7xckjbqUI3DlWBd2QCFthxyCe2Wzrb3N70dhrWM,4332
|
|
45
45
|
nbs_bl/plans/suspenders.py,sha256=JQWt07o8R8wFVzjkKtYhkrrJXujs0Rtdvy3Er-OYEPs,2885
|
|
46
46
|
nbs_bl/plans/time_estimation.py,sha256=u40I6P51ySSaXdb-mqdl3UMdlZ2PDhlXWC7QHq_FLmY,4931
|
|
47
|
-
nbs_bl/plans/xas.py,sha256=
|
|
47
|
+
nbs_bl/plans/xas.py,sha256=2rqh-eTi4HZpa1Rm76PyRueUFsrKdsL6f_inVv_k6nU,3831
|
|
48
48
|
nbs_bl/qt/models/beamline.py,sha256=PUYXUkShx3XS6kYrdFZzzccsFjSit5RBP3I-JYCU3ws,410
|
|
49
|
+
nbs_bl/qt/models/config_model.py,sha256=1TthS1UeGk_9AfpqgCw9tMC9NH1s_cdgk3UvwA0FRVE,12745
|
|
49
50
|
nbs_bl/qt/models/energy.py,sha256=z3_g8UXGw4Xj1r9b2554Bh94k0uqOT5u2iSlAOkqsZg,1460
|
|
51
|
+
nbs_bl/qt/widgets/config_editor.py,sha256=anqt1RKSJAG5Thzy0v1zvb7GH5JWbgiFouhlG1e4yUw,29035
|
|
50
52
|
nbs_bl/qt/widgets/energy.py,sha256=YeQXBxp29TS_YVLgmHKpAr-Jc7RDAEFS1HJndcIGlJQ,7760
|
|
51
53
|
nbs_bl/sim/__init__.py,sha256=UZ9yiwTvtx0Rv5Ypqu_RoR5soNI65nBQxtA7tLEGgUs,64
|
|
52
54
|
nbs_bl/sim/energy.py,sha256=-Mw7HHrjDMyOH-hxgA1bz4vCjdNvzGmP-8yfeK8m5lc,13356
|
|
@@ -57,8 +59,8 @@ nbs_bl/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
57
59
|
nbs_bl/tests/modify_regions.py,sha256=OoY0cndFuffAM3oicL6hcH4UADlYIyJ0sku08BBOq_4,4490
|
|
58
60
|
nbs_bl/tests/test_frames.py,sha256=p0RpAL7T7_Q7DQnlc5P2TQ7SLbiJWijsHzGSkchWsWg,3036
|
|
59
61
|
nbs_bl/tests/test_panels.py,sha256=66Xhfonwhr8-O9LQneDricg71X5lBMZ5f1WmQaD_xrY,2465
|
|
60
|
-
nbs_bl-0.2.
|
|
61
|
-
nbs_bl-0.2.
|
|
62
|
-
nbs_bl-0.2.
|
|
63
|
-
nbs_bl-0.2.
|
|
64
|
-
nbs_bl-0.2.
|
|
62
|
+
nbs_bl-0.2.1.dist-info/METADATA,sha256=UtOdRihS3X3UjVKjLLXOCPMX9-Jw1uXBXdtuA40Ry0A,1956
|
|
63
|
+
nbs_bl-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
64
|
+
nbs_bl-0.2.1.dist-info/entry_points.txt,sha256=T3mrRIIjujIy1Gsxr68-teFrNR3w32YuAhZ0PJr3zfc,133
|
|
65
|
+
nbs_bl-0.2.1.dist-info/licenses/LICENSE,sha256=ICdbdLm_mtXdM6ItExNyqZRhmz1yUXwWdG4uvumFhHc,1879
|
|
66
|
+
nbs_bl-0.2.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|