techui-builder 0.3.0a1__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.

Potentially problematic release.


This version of techui-builder might be problematic. Click here for more details.

@@ -0,0 +1,405 @@
1
+ import logging
2
+ import os
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+ from lxml import objectify
9
+ from phoebusgen import screen as Screen
10
+ from phoebusgen import widget as Widget
11
+ from phoebusgen.widget.widgets import ActionButton, EmbeddedDisplay, Group
12
+
13
+ from techui_builder.models import Entity
14
+
15
+ LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class Generator:
20
+ services_dir: Path = field(repr=False)
21
+
22
+ screen_name: str = field(init=False)
23
+ screen_components: list[Entity] = field(init=False)
24
+
25
+ # These are global params for the class (not accessible by user)
26
+ ibek_map: dict = field(init=False, repr=False)
27
+ default_size: int = field(default=100, init=False, repr=False)
28
+ P: str = field(default="P", init=False, repr=False)
29
+ M: str = field(default="M", init=False, repr=False)
30
+ R: str = field(default="R", init=False, repr=False)
31
+ widgets: list[ActionButton | EmbeddedDisplay] = field(
32
+ default_factory=list[ActionButton | EmbeddedDisplay], init=False, repr=False
33
+ )
34
+
35
+ # Add group padding, and self.widget_x for placing widget in x direction relative to
36
+ # other widgets, with a widget count to reset the self.widget_x dimension when the
37
+ # allowed number of horizontal stacks is exceeded.
38
+ widget_x: int = field(default=0, init=False, repr=False)
39
+ widget_count: int = field(default=0, init=False, repr=False)
40
+ group_padding: int = field(default=50, init=False, repr=False)
41
+
42
+ def __post_init__(self):
43
+ self._read_map()
44
+
45
+ def _read_map(self):
46
+ """Read the ibek-mapping.yaml file from techui-support."""
47
+ ibek_map = self.services_dir.parent.parent.joinpath(
48
+ "src/techui_support/ibek_mapping.yaml"
49
+ ).absolute()
50
+ LOGGER.debug(f"ibek_mapping.yaml location: {ibek_map}")
51
+
52
+ with open(ibek_map) as map:
53
+ self.ibek_map = yaml.safe_load(map)
54
+
55
+ def load_screen(self, screen_name: str, screen_components: list[Entity]):
56
+ self.screen_name = screen_name
57
+ self.screen_components = screen_components
58
+
59
+ def _get_screen_dimensions(self, file: str) -> tuple[int, int]:
60
+ """
61
+ Parses the bob files for information on the height
62
+ and width of the screen
63
+ """
64
+ # Read the bob file
65
+ tree = objectify.parse(file)
66
+ root = tree.getroot()
67
+ height_element = root.height
68
+ if height_element is not None:
69
+ height = (
70
+ self.default_size if (val := height_element.text) is None else int(val)
71
+ )
72
+ else:
73
+ height = self.default_size
74
+ assert "Could not obtain the size of the widget"
75
+
76
+ width_element = root.width
77
+ if width_element is not None:
78
+ width = (
79
+ self.default_size if (val := width_element.text) is None else int(val)
80
+ )
81
+ else:
82
+ width = self.default_size
83
+ assert "Could not obtain the size of the widget"
84
+
85
+ return (height, width)
86
+
87
+ def _get_widget_dimensions(
88
+ self, widget: EmbeddedDisplay | ActionButton
89
+ ) -> tuple[int, int]:
90
+ """
91
+ Parses the widget for information on the height
92
+ and width of the widget
93
+ """
94
+ # Read the bob file
95
+ root = objectify.fromstring(str(widget))
96
+ height_element = root.height
97
+ if height_element is not None:
98
+ height = (
99
+ self.default_size if (val := height_element.text) is None else int(val)
100
+ )
101
+ else:
102
+ height = self.default_size
103
+ assert "Could not obtain the size of the widget"
104
+
105
+ width_element = root.width
106
+ if width_element is not None:
107
+ width = (
108
+ self.default_size if (val := width_element.text) is None else int(val)
109
+ )
110
+ else:
111
+ width = self.default_size
112
+ assert "Could not obtain the size of the widget"
113
+
114
+ return (height, width)
115
+
116
+ def _get_widget_position(
117
+ self, object: EmbeddedDisplay | ActionButton
118
+ ) -> tuple[int, int]:
119
+ """
120
+ Parses the widget for information on the y
121
+ and x of the widget
122
+ """
123
+ # Read the bob file
124
+ root = objectify.fromstring(str(object))
125
+ y_element = root.y
126
+ if y_element is not None:
127
+ y = self.default_size if (val := y_element.text) is None else int(val)
128
+ else:
129
+ y = self.default_size
130
+ assert "Could not obtain the size of the widget"
131
+
132
+ x_element = root.x
133
+ if x_element is not None:
134
+ x = self.default_size if (val := x_element.text) is None else int(val)
135
+ else:
136
+ x = self.default_size
137
+ assert "Could not obtain the size of the widget"
138
+
139
+ return (y, x)
140
+
141
+ # Make groups
142
+ def _get_group_dimensions(self, widget_list: list[EmbeddedDisplay | ActionButton]):
143
+ """
144
+ Takes in a list of widgets and finds the
145
+ maximum height in the list
146
+ """
147
+ x_list: list[int] = []
148
+ y_list: list[int] = []
149
+ height_list: list[int] = []
150
+ width_list: list[int] = []
151
+ for widget in widget_list:
152
+ root = objectify.fromstring(str(widget))
153
+ x = root.x
154
+ if x is not None:
155
+ x_list.append(
156
+ self.default_size if (val := x.text) is None else int(val)
157
+ )
158
+ else:
159
+ x_list.append(self.default_size)
160
+
161
+ height = root.height
162
+ if height is not None:
163
+ height_list.append(
164
+ self.default_size if (val := height.text) is None else int(val)
165
+ )
166
+ else:
167
+ height_list.append(self.default_size)
168
+
169
+ width = root.width
170
+ if width is not None:
171
+ width_list.append(
172
+ self.default_size if (val := width.text) is None else int(val)
173
+ )
174
+ else:
175
+ width_list.append(self.default_size)
176
+
177
+ y = root.y
178
+ if y is not None:
179
+ y_list.append(
180
+ self.default_size if (val := y.text) is None else int(val)
181
+ )
182
+ else:
183
+ y_list.append(self.default_size)
184
+
185
+ return (
186
+ max(y_list) + max(height_list) + self.group_padding,
187
+ max(x_list) + max(width_list) + self.group_padding,
188
+ )
189
+
190
+ def _create_widget(
191
+ self, component: Entity
192
+ ) -> EmbeddedDisplay | ActionButton | None:
193
+ # if statement below is check if the suffix is
194
+ # missing from the component description. If
195
+ # not missing, use as name of widget, if missing,
196
+ # use type as name.
197
+ if component.M is not None:
198
+ name: str = component.M
199
+ suffix: str = component.M
200
+ suffix_label: str | None = self.M
201
+ elif component.R is not None:
202
+ name = component.R
203
+ suffix = component.R
204
+ suffix_label = self.R
205
+ else:
206
+ name = component.type
207
+ suffix = ""
208
+ suffix_label = None
209
+
210
+ base_dir = self.services_dir.parent.parent.parent
211
+
212
+ # Get the relative path to techui-support
213
+ support_path = base_dir.joinpath("src/techui_support")
214
+
215
+ try:
216
+ scrn_mapping = self.ibek_map[component.type]
217
+ except KeyError:
218
+ LOGGER.warning(
219
+ f"No available widget for {component.type} in screen \
220
+ {self.screen_name}. Skipping..."
221
+ )
222
+ return None
223
+
224
+ # Get relative path to screen
225
+ scrn_path = support_path.joinpath(f"bob/{scrn_mapping['file']}")
226
+ LOGGER.debug(f"Screen path: {scrn_path}")
227
+
228
+ # Path of screen relative to data/ so it knows where to open the file from
229
+ data_scrn_path = scrn_path.relative_to(
230
+ self.services_dir.joinpath("synoptic/opis"), walk_up=True
231
+ )
232
+
233
+ # Get dimensions of screen from TechUI repository
234
+ if scrn_mapping["type"] == "embedded":
235
+ height, width = self._get_screen_dimensions(str(scrn_path))
236
+ new_widget = Widget.EmbeddedDisplay(
237
+ name,
238
+ str(data_scrn_path),
239
+ 0,
240
+ 0, # Change depending on the order
241
+ width,
242
+ height,
243
+ )
244
+ # Add macros to the widgets
245
+ new_widget.macro(self.P, component.P)
246
+ if suffix_label is not None:
247
+ new_widget.macro(f"{suffix_label}", suffix)
248
+
249
+ # The only other option is for related displays
250
+ else:
251
+ height, width = (40, 100)
252
+
253
+ new_widget = Widget.ActionButton(
254
+ name,
255
+ component.P,
256
+ f"{component.P}:{suffix_label}",
257
+ 0,
258
+ 0,
259
+ width,
260
+ height,
261
+ )
262
+
263
+ # Add action to action button: to open related display
264
+ if suffix_label is not None:
265
+ new_widget.action_open_display(
266
+ file=str(data_scrn_path),
267
+ target="tab",
268
+ macros={
269
+ "P": component.P,
270
+ f"{suffix_label}": suffix,
271
+ },
272
+ )
273
+ else:
274
+ new_widget.action_open_display(
275
+ file=str(data_scrn_path),
276
+ target="tab",
277
+ macros={
278
+ "P": component.P,
279
+ },
280
+ )
281
+
282
+ # For some reason the version of action buttons is 3.0.0?
283
+ new_widget.version("2.0.0")
284
+
285
+ return new_widget
286
+
287
+ def layout_widgets(self, widgets: list[EmbeddedDisplay | ActionButton]):
288
+ group_spacing: int = 30
289
+ max_group_height: int = 800
290
+ spacing_x: int = 20
291
+ spacing_y: int = 30
292
+ # Group tiles by size
293
+ groups: dict[tuple[int, int], list[EmbeddedDisplay | ActionButton]] = (
294
+ defaultdict(list)
295
+ )
296
+ for widget in widgets:
297
+ key = self._get_widget_dimensions(widget)
298
+
299
+ groups[key].append(widget)
300
+
301
+ # Sort groups by width (optional)
302
+ sorted_widgets: list[EmbeddedDisplay | ActionButton] = []
303
+ sorted_groups = sorted(groups.items(), key=lambda g: g[0][0], reverse=True)
304
+ current_x: int = 0
305
+ current_y: int = 0
306
+ column_width: int = 0
307
+ column_levels: list[list[EmbeddedDisplay | ActionButton]] = []
308
+
309
+ for (h, w), group in sorted_groups:
310
+ for widget in group:
311
+ placed = False
312
+ for level in column_levels:
313
+ level_y, _ = self._get_widget_position(level[0])
314
+ _, widget_width = self._get_widget_dimensions(widget)
315
+ level_width = (
316
+ sum(
317
+ (self._get_widget_dimensions(t))[1] + spacing_x
318
+ for t in level
319
+ )
320
+ - spacing_x
321
+ ) # Find the width of the row
322
+ if (
323
+ level_y + h <= max_group_height
324
+ and level_width + widget_width <= column_width
325
+ ):
326
+ _, width_1 = self._get_widget_dimensions(level[-1])
327
+ _, x_1 = self._get_widget_position(level[-1])
328
+ widget.x(x_1 + width_1 + spacing_x)
329
+ widget.y(level_y)
330
+ level.append(widget)
331
+ placed = True
332
+ break
333
+
334
+ if not placed:
335
+ if current_y + h > max_group_height:
336
+ # Moves to the next column
337
+ current_x += column_width + group_spacing
338
+ current_y = 0
339
+ column_width = 0
340
+ column_levels = []
341
+ # Places widgets in rows in one column
342
+ widget.x(current_x)
343
+ widget.y(current_y)
344
+ column_levels.append([widget])
345
+ current_y += h + spacing_y
346
+ column_width = max(column_width, w)
347
+ sorted_widgets.append(widget)
348
+
349
+ return sorted_widgets
350
+
351
+ def build_groups(self):
352
+ """
353
+ Create a group to fill with widgets
354
+ """
355
+ # Create screen
356
+ self.screen_ = Screen.Screen(self.screen_name)
357
+ # Empty widget buffer
358
+ self.widgets = []
359
+
360
+ # create widget and group objects
361
+
362
+ # order is an enumeration of the components, used to list them,
363
+ # and serves as functionality in the math for formatting.
364
+ for component in self.screen_components:
365
+ new_widget = self._create_widget(component=component)
366
+ if new_widget is None:
367
+ continue
368
+ self.widgets.append(new_widget)
369
+
370
+ if self.widgets == []:
371
+ # No widgets found, so just back out
372
+ return
373
+
374
+ self.widgets = self.layout_widgets(self.widgets)
375
+
376
+ # Create a list of dimensions for the groups
377
+ # that will be created.
378
+ height, width = self._get_group_dimensions(self.widgets)
379
+
380
+ self.group = Group(
381
+ self.screen_name,
382
+ 0,
383
+ 0,
384
+ width,
385
+ height,
386
+ )
387
+
388
+ self.group.version("2.0.0")
389
+ self.group.add_widget(self.widgets)
390
+ self.screen_.add_widget(self.group)
391
+
392
+ def write_screen(self, directory: Path):
393
+ """Write the screen to file"""
394
+
395
+ if self.widgets == []:
396
+ LOGGER.warning(
397
+ f"Could not write screen: {self.screen_name} \
398
+ as no widgets were available"
399
+ )
400
+ return
401
+
402
+ if not directory.exists():
403
+ os.mkdir(directory)
404
+ self.screen_.write_screen(f"{directory}/{self.screen_name}.bob")
405
+ LOGGER.info(f"{self.screen_name}.bob has been created successfully")
@@ -0,0 +1,173 @@
1
+ import logging
2
+ import re
3
+ from typing import Annotated, Literal
4
+
5
+ from pydantic import (
6
+ BaseModel,
7
+ ConfigDict,
8
+ Field,
9
+ RootModel,
10
+ StringConstraints,
11
+ computed_field,
12
+ field_validator,
13
+ )
14
+
15
+ LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ # Patterns:
19
+ # long: 'bl23b'
20
+ # short: 'b23' (non-branch)
21
+ # branch short: 'j23'
22
+
23
+
24
+ _DLS_PREFIX_RE = re.compile(
25
+ r"""
26
+ ^ # start of string
27
+ (?= # lookahead to ensure the following pattern matches
28
+ [A-Za-z0-9-]{13,16} # match 13 to 16 alphanumeric characters or hyphens
29
+ [:A-Za-z0-9]* # match zero or more colons or alphanumeric characters
30
+ [.A-Za-z0-9] # match a dot or alphanumeric character
31
+ )
32
+ (?!.*--) # negative lookahead to ensure no double hyphens
33
+ (?!.*:\..) # negative lookahead to ensure no colon followed by a dot
34
+ ( # start of capture group 1
35
+ (?:[A-Za-z0-9]{2,5}-){3} # match 2 to 5 alphanumeric characters followed
36
+ # by a hyphen, repeated 3 times
37
+ [\d]* # match zero or more digits
38
+ [^:]? # match zero or one non-colon character
39
+ )
40
+ (?::([a-zA-Z0-9:]*))? # match zero or one colon followed by zero or more
41
+ # alphanumeric characters or colons (capture group 2)
42
+ (?:\.([a-zA-Z0-9]+))? # match zero or one dot followed by one or more
43
+ # alphanumeric characters (capture group 3)
44
+ $ # end of string
45
+ """,
46
+ re.VERBOSE,
47
+ )
48
+ _LONG_DOM_RE = re.compile(r"^[a-z]{2}\d{2}[a-z]$")
49
+ _SHORT_DOM_RE = re.compile(r"^[a-z]\d{2}$")
50
+ _BRANCH_SHORT_DOM_RE = re.compile(r"^[a-z]\d{2}-\d$")
51
+
52
+
53
+ class Beamline(BaseModel):
54
+ dom: str = Field(
55
+ description="Domain e.g. 'bl23b' (long), 'b23' (short), or 'j23' (branch short)"
56
+ )
57
+ desc: str = Field(description="Description")
58
+ model_config = ConfigDict(extra="forbid")
59
+
60
+ @field_validator("dom")
61
+ @classmethod
62
+ def normalize_dom(cls, v: str) -> str:
63
+ v = v.strip().lower()
64
+ if _LONG_DOM_RE.fullmatch(v):
65
+ # already long: bl23b
66
+ return v
67
+ if _SHORT_DOM_RE.fullmatch(v):
68
+ # e.g. b23 -> bl23b
69
+ return f"bl{v[1:3]}{v[0]}"
70
+ if _BRANCH_SHORT_DOM_RE.fullmatch(v):
71
+ # e.g. j23 -> bl23j
72
+ return f"bl{v[1:3]}j"
73
+ raise ValueError("Invalid dom. Expected long or short")
74
+
75
+ @computed_field
76
+ @property
77
+ def long_dom(self) -> str:
78
+ # dom is normalized to long already
79
+ return self.dom
80
+
81
+ @computed_field
82
+ @property
83
+ def short_dom(self) -> str:
84
+ # Convert long -> short form: bl23b -> b23, bl23j -> j23
85
+ # long form is 'bl' + digits + tail-letter
86
+ return f"{self.dom[4]}{self.dom[2:4]}"
87
+
88
+
89
+ class Component(BaseModel):
90
+ prefix: str
91
+ desc: str | None = None
92
+ extras: list[str] | None = None
93
+ file: str | None = None
94
+ model_config = ConfigDict(extra="forbid")
95
+
96
+ @field_validator("prefix")
97
+ @classmethod
98
+ def _check_prefix(cls, v: str) -> str:
99
+ if not _DLS_PREFIX_RE.match(v):
100
+ raise ValueError(f"prefix '{v}' does not match DLS prefix pattern")
101
+ return v
102
+
103
+ @field_validator("extras")
104
+ @classmethod
105
+ def _check_extras(cls, v: list[str]) -> list[str]:
106
+ for p in v:
107
+ if not _DLS_PREFIX_RE.match(p):
108
+ raise ValueError(f"extras item '{p}' does not match DLS prefix pattern")
109
+ # ensure unique (schema enforces too)
110
+ if len(set(v)) != len(v):
111
+ raise ValueError("extras must contain unique items")
112
+ return v
113
+
114
+ @computed_field
115
+ @property
116
+ def P(self) -> str | None:
117
+ match = re.match(_DLS_PREFIX_RE, self.prefix)
118
+ if match:
119
+ return match.group(1)
120
+
121
+ @computed_field
122
+ @property
123
+ def R(self) -> str | None:
124
+ match = re.match(_DLS_PREFIX_RE, self.prefix)
125
+ if match:
126
+ return match.group(2)
127
+
128
+ @computed_field
129
+ @property
130
+ def attribute(self) -> str | None:
131
+ match = re.match(_DLS_PREFIX_RE, self.prefix)
132
+ if match:
133
+ return match.group(3)
134
+
135
+
136
+ class TechUi(BaseModel):
137
+ beamline: Beamline
138
+ components: dict[str, Component]
139
+ model_config = ConfigDict(extra="forbid")
140
+
141
+
142
+ """
143
+ Ibek mapping models
144
+ """
145
+
146
+ BobPath = Annotated[
147
+ str, StringConstraints(pattern=r"^(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+\.bob$")
148
+ ]
149
+ # Must contain at least one $(NAME) macro
150
+ MacroString = Annotated[
151
+ str,
152
+ StringConstraints(pattern=r"^[A-Za-z0-9_:\-./\s\$\(\)]+$"),
153
+ ]
154
+ ScreenType = Literal["embedded", "related"]
155
+
156
+
157
+ class GuiComponentEntry(BaseModel):
158
+ file: BobPath
159
+ prefix: MacroString
160
+ type: ScreenType
161
+ model_config = ConfigDict(extra="forbid")
162
+
163
+
164
+ class GuiComponents(RootModel[dict[str, GuiComponentEntry]]):
165
+ pass
166
+
167
+
168
+ class Entity(BaseModel):
169
+ type: str
170
+ P: str
171
+ desc: str | None = None
172
+ M: str | None
173
+ R: str | None
@@ -0,0 +1,28 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+ from techui_builder.models import (
6
+ GuiComponents,
7
+ TechUi,
8
+ )
9
+
10
+ SCHEMAS_DIR = Path("schemas")
11
+ SCHEMAS_DIR.mkdir(exist_ok=True)
12
+
13
+
14
+ def write_yaml_schema(model_name: str, schema_dict: dict) -> None:
15
+ out = SCHEMAS_DIR / f"{model_name}.schema.yml"
16
+ with out.open("w", encoding="utf-8") as f:
17
+ yaml.safe_dump(schema_dict, f, sort_keys=False)
18
+ print(f"✅ Wrote {out}")
19
+
20
+
21
+ def schema_generator() -> None:
22
+ # techui
23
+ tu = TechUi.model_json_schema()
24
+ write_yaml_schema("techui", tu)
25
+
26
+ # ibek_mapping
27
+ ibek = GuiComponents.model_json_schema()
28
+ write_yaml_schema("ibek_mapping", ibek)
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: techui-builder
3
+ Version: 0.3.0a1
4
+ Summary: A package for building Phoebus GUIs
5
+ Author-email: Oliver Copping <oliver.copping@diamond.ac.uk>, Adedamola Sode <adedamola.sode@diamond.ac.uk>, Niamh Dougan <niamh.dougan@diamond.ac.uk>
6
+ Project-URL: GitHub, https://github.com/DiamondLightSource/techui-builder
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Programming Language :: Python :: 3.12
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Requires-Python: <4.0,>=3.12
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: pyyaml>=6.0.2
14
+ Requires-Dist: phoebusgen>=3.0.0
15
+ Requires-Dist: lxml>=5.4.0
16
+ Requires-Dist: typer>=0.16.0
17
+ Requires-Dist: rich>=14.1.0
18
+ Requires-Dist: pydantic>=2.11.7
19
+ Dynamic: license-file
20
+
21
+ [![CI](https://github.com/DiamondLightSource/techui-builder/actions/workflows/ci.yml/badge.svg)](https://github.com/DiamondLightSource/techui-builder/actions/workflows/ci.yml)
22
+ [![Coverage](https://codecov.io/gh/DiamondLightSource/techui-builder/branch/main/graph/badge.svg)](https://codecov.io/gh/DiamondLightSource/techui-builder)
23
+ [![PyPI](https://img.shields.io/pypi/v/techui-builder.svg)](https://pypi.org/project/techui-builder)
24
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
25
+
26
+ # techui_builder
27
+
28
+ A package for building Phoebus GUIs
29
+
30
+ Techui-builder is a module for building and organising phoebus gui screens using a builder-ibek yaml description of an IOC, with a user created create_gui.yaml file containing a description of the screens the user wants to create.
31
+
32
+ Source | <https://github.com/DiamondLightSource/techui-builder>
33
+ :---: | :---:
34
+ PyPI | `pip install techui-builder`
35
+ Releases | <https://github.com/DiamondLightSource/techui-builder/releases>
36
+
37
+ The process to use this module goes as follows (WIP):
38
+
39
+ ## Requirements
40
+ 1. Docker
41
+ 2. VSCode
42
+ 3. CS-Studio (Phoebus)
43
+
44
+ ## Installation
45
+ 1. Clone this module with the `--recursive` flag to pull in [techui-support](git@github.com:DiamondLightSource/techui-support.git).
46
+ 2. Open the project using VSCode.
47
+ 3. Reopen the project in a container. Make sure you are using the vscode extension: Dev Containers by Microsoft.
48
+
49
+ ## Setting Up
50
+
51
+ > [!WARNING]
52
+ > This module currently only works for `example-synoptic/bl23b-services` - use this directory file structure as a guideline.
53
+
54
+ 1. Add the beamline `ixx-services` repo to your VSCode workspace, ensuring each IOC service has been converted to the [ibek](git@github.com:epics-containers/ibek.git) format:
55
+ ```
56
+ |-- ixx-services
57
+ | |-- services
58
+ | | |-- $(dom)-my-device-01
59
+ | | | |-- config
60
+ | | | | |-- ioc.yaml
61
+ ```
62
+ 2. Create your handmade synoptic screen in Phoebus and place in `ixx-services/src-bob/$(dom)-synoptic-src.bob`.
63
+ 3. Amend any references to `example-synoptic` with the path to your local `ixx-services` - [generate_synoptic.py](example-synoptic/generate_synoptic.py) and [generate.py](src/techui_builder/generate.py).
64
+ 4. Construct a `create_gui.yaml` file at the root of `ixx-services` containing all the components from the services:
65
+
66
+ ```
67
+ beamline:
68
+ dom: {beamline name}
69
+ desc: {beamline description}
70
+
71
+ components:
72
+ {component name}:
73
+ desc: {component description}
74
+ prefix: {PV prefix}
75
+ extras:
76
+ - {extra prefix 1}
77
+ - {extra prefix 2}
78
+ ```
79
+ > [!NOTE]
80
+ > `extras` is optional, but allows any embedded screen to be added to make a summary screen e.g. combining all imgs, pirgs and ionps associated with a vacuum space.
81
+
82
+ ## Generating Synoptic
83
+ > [!WARNING]
84
+ > Again, this is hardcoded to work for `example-synoptic/bl23b-services` so amend filepaths accordingly.
85
+
86
+ `$ python example-synoptic/generate_synoptic.py`
87
+
88
+ This generates the filled, top level blxxx-synoptic.bob and all component screens inside `ixx-services/services/data`.
89
+
90
+ ## Viewing the Synoptic
91
+
92
+ ```
93
+ $ module load phoebus
94
+ $ phoebus.sh -resource /path/to/blxxx-synoptic.bob
95
+ ```