diff --git a/README.md b/README.md index 3dca4e04..4c1a69a6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ A package for building Phoebus GUIs -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 techui.yaml file containing a description of the screens the user wants to create. +Techui-builder is a module for building and organising Phoebus CS-Studio `.bob` screens from a `techui.yaml` description of a beamline's IOC services. It also can auto-generate a synoptic `index.bob` showing devices laid out along the beam pipe and vacuum pipe, eliminating the need to hand-craft the overview screen in Phoebus. + +The `.bob` file screens are intended to be served to [Daedalus](https://github.com/DiamondLightSource/daedalus), Diamond's web-based control system UI. Source | :---: | :---: @@ -17,40 +19,70 @@ Releases | [!NOTE] - > `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. + > `extras` is optional, but allows any embedded screen to be added to a summary screen + + Devices are rendered left-to-right in the order they appear in the file. + + For devices not on the beam/vacuum pipe (e.g. detectors, sample environments etc), declare they under `components`. These generate `.bob` screens but do not appear on the synoptic overview. + + > [!NOTE] + > If you already have a hand-crafted `index.bob` and do not define `beam_pipe` or `vacuum_pipe` in `techui.yaml`, it will be preserved as is. Defining either section will auto-generate and overwrite `index.bob`. + +## Icon Type Naming Convention ## + +`icon_type` values must use underscores. The corresponding SVG in `techui-support/symbols/` must use hyphens. For example: + +| `icon_type` | SVG file +| ----------- | ----------- +| `ion_pump` | `ion-pump.svg` +| `camera` | `camera.svg` + +See the techui-support README for the full list of available symbols. + +## Schema Generation ## + 1. Run this command to locally generate a schema, which can be used for validation testing ```$ techui-builder schema``` @@ -63,15 +95,19 @@ The process to use this module goes as follows (WIP): ## Generating the Synoptic -`$ techui-builder build /path/to/synoptic/techui.yaml` +`$ techui-builder generate /path/to/synoptic/techui.yaml` This populates `index.bob` and individual component screens inside `ixx-services/synoptic`. +Output files are written to `ixx-services/synoptic/`. + ## Generating the JsonMap `$ techui-builder generate-jsonmap /path/to/synoptic/index.bob` -This populates `JsonMap.json` with the tree of component screens inside `ixx-services/synoptic/index.bob`. +This populates `JsonMap.json` with the tree of component screens inside `ixx-services/synoptic/index.bob`. This is used for Daedalus navigation to create the tree view in the side panel. + +Output files are written to `ixx-services/synoptic/`. ## Generating the Status PV database file diff --git a/src/techui_builder/__main__.py b/src/techui_builder/__main__.py index 980e57cd..6fcf0aec 100644 --- a/src/techui_builder/__main__.py +++ b/src/techui_builder/__main__.py @@ -7,6 +7,7 @@ from techui_builder._version import __version__ from techui_builder.generate_jsonmap import app as generate_jsonmap_app from techui_builder.main_app import app as main_app +from techui_builder.main_app import main as build_main from techui_builder.schema_generator import app as schema_app from techui_builder.status import app as status_app @@ -60,6 +61,7 @@ def _( app.add_typer(main_app) +app.command("generate", help="Run techui-builder for a given techui.yaml")(build_main) app.add_typer(schema_app, name="schema") app.add_typer(generate_jsonmap_app, name="generate-jsonmap") app.add_typer(status_app, name="status") diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 2f15ce34..212ec0fc 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -178,55 +178,78 @@ def _validate_screen(self, screen_name: str): widget_group_name = widget_group.get_element_value("name") self.validator.validate_bob(screen_name, widget_group_name, widgets) - def create_screens(self): - """Create the screens for each component in techui.yaml""" - if len(self.entities) == 0: - logger_.critical( - "No ioc entities found. This [italic]normally[/italic]" - " suggests an issue with finding ixx-services." - ) - exit() - - # Loop over every component defined in techui.yaml and locate - # any extras defined + def _create_component_screens(self): + """Create screens for components defined in techui.yaml""" for component_name, component in self.conf.components.items(): screen_entities: list[Entity] = [] - # ONLY IF there is a matching component and entity, generate a screen - if component.prefix in self.entities.keys(): - # Populate child labels for any entities - # with the same prefix as the component - for entity in self.entities[component.prefix]: - entity.child_labels = component.child_labels - - screen_entities.extend(self.entities[component.prefix]) - - if component.extras is not None: - # If component has any extras, add them to the entries to generate - for extra_p in component.extras: - if extra_p not in self.entities.keys(): - logger_.error( - f"Extra prefix {extra_p} for {component_name} does not" - " exist." - ) - continue - screen_entities.extend(self.entities[extra_p]) + if component.prefix not in self.entities.keys(): + logger_.warning( + f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold] " + f"set in the component [bold]{component_name}[/bold] does not match" + " any P field in the ioc.yaml files in services" + ) + continue + + for entity in self.entities[component.prefix]: + entity.child_labels = component.child_labels - # This is used by both generate and validate, - # so called beforehand for tidyness - self.generator.build_widgets(component_name, screen_entities) - self.generator.build_groups(component_name, self.conf.components) + screen_entities.extend(self.entities[component.prefix]) - screens_to_validate = list(self.validator.validate.keys()) + if component.extras is not None: + for extra_p in component.extras: + if extra_p not in self.entities.keys(): + logger_.error( + f"Extra prefix {extra_p} for {component_name} does not" + " exist." + ) + continue + screen_entities.extend(self.entities[extra_p]) - if component_name in screens_to_validate: - self._validate_screen(component_name) - else: - self._generate_screen(component_name) + self.generator.build_widgets(component_name, screen_entities) + self.generator.build_groups(component_name, self.conf.components) + if component_name in list(self.validator.validate.keys()): + self._validate_screen(component_name) else: + self._generate_screen(component_name) + + def _create_pipe_screens(self): + """Create screens for beam_pipe and vacuum_pipe components""" + all_pipe_components = { + **(self.conf.beam_pipe or {}), + **(self.conf.vacuum_pipe or {}), + } + + for component_name, pipe_component in all_pipe_components.items(): + pv_root = pipe_component.prefix.split(":", maxsplit=1)[0] + + if pv_root not in self.entities: logger_.warning( - f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold] " - f"set in the component [bold]{component_name}[/bold] does not match" - " any P field in the ioc.yaml files in services" + f"Pipe component '{component_name}' with prefix " + f"'{pipe_component.prefix}' does not match any entity " + f"in the ioc.yaml files — skipping screen generation." ) + continue + + screen_entities = self.entities[pv_root] + + self.generator.build_widgets(component_name, screen_entities) + self.generator.build_groups(component_name, {}) + + if component_name in list(self.validator.validate.keys()): + self._validate_screen(component_name) + else: + self._generate_screen(component_name) + + def create_screens(self): + """Create the screens for each component in techui.yaml""" + if len(self.entities) == 0: + logger_.critical( + "No ioc entities found. This [italic]normally[/italic]" + " suggests an issue with finding ixx-services." + ) + return + + self._create_component_screens() + self._create_pipe_screens() diff --git a/src/techui_builder/generate.py b/src/techui_builder/generate.py index c51246d3..46aeda42 100644 --- a/src/techui_builder/generate.py +++ b/src/techui_builder/generate.py @@ -6,12 +6,18 @@ from dataclasses import dataclass, field from pathlib import Path -from lxml import objectify +from lxml import etree, objectify from phoebusgen import screen as pscreen from phoebusgen import widget as pwidget from phoebusgen.widget.widgets import ActionButton, EmbeddedDisplay, Group -from techui_builder.models import Component, Entity, TechUiSupport +from techui_builder.models import ( + Component, + Entity, + PipeComponent, + TechUi, + TechUiSupport, +) logger_ = logging.getLogger(__name__) @@ -156,7 +162,12 @@ def _update_macros(self, component: Entity) -> tuple[str, dict[str, str]]: :2 ] component_name = suffix.removeprefix(":").removesuffix(":") - suffix_key = next(k for k, v in component.macros.items() if v == suffix) + suffix_key = next( + (k for k, v in component.macros.items() if v == suffix), None + ) + if suffix_key is None: + # Suffix not found in macros, use empty suffix + raise ValueError("Suffix not in macros") except (IndexError, ValueError): prefix = component.prefix component_name = component.type @@ -169,7 +180,14 @@ def _update_macros(self, component: Entity) -> tuple[str, dict[str, str]]: component_name = component.child_labels[suffix] self.label_flag = True - prefix_key = next(k for k, v in component.macros.items() if v == prefix) + prefix_key = next((k for k, v in component.macros.items() if v == prefix), None) + if prefix_key is None: + # Prefix not found in macros - skip this entity + # This shouldn't happen with properly formed entities, but handle gracefully + logger_.warning( + f"Could not find P={prefix} in entity macros for {component.type}" + ) + return component_name, {} new_macros[prefix_key] = prefix if suffix_key != "": @@ -266,6 +284,259 @@ def _allocate_widget( self.label_flag = False return new_widget + def _new_widget_element(self, widget_type: str, **attrs) -> etree.Element: + widget = etree.Element("widget", type=widget_type, version="2.0.0") + for name, value in attrs.items(): + if value is None: + continue + child = etree.SubElement(widget, name) + child.text = str(value) + return widget + + def _make_color_element( + self, + parent: etree.Element, + red: int, + green: int, + blue: int, + ): + color_el = etree.SubElement(parent, "color") + color_el.set("red", str(red)) + color_el.set("green", str(green)) + color_el.set("blue", str(blue)) + return color_el + + def _create_beamline_widget( + self, + x: int, + y: int, + width: int, + height: int, + color: tuple[int, int, int] = (0, 120, 215), + ) -> etree.Element: + widget = self._new_widget_element( + "rectangle", + name="BeamPipe", + x=x, + y=y, + width=width, + height=height, + line_width=1, + ) + line_color = etree.SubElement(widget, "line_color") + self._make_color_element( + line_color, + *color, + ) + background_color = etree.SubElement(widget, "background_color") + self._make_color_element(background_color, *color) + return widget + + def _symbol_path(self, icon_type: str) -> Path | None: + """Derive SVG path from icon_type by converting underscores to hyphens.""" + filename = icon_type.replace("_", "-") + ".svg" + path = self.support_path / "symbols" / filename + return path if path.exists() else None + + def _create_symbol_widget( + self, + component_name: str, + label: str, + symbol_path: Path, + x: int, + y: int, + width: int, + height: int, + ) -> etree.Element: + """Create a symbol widget that opens the component's bob on click.""" + try: + rel_symbol_path = symbol_path.relative_to( + self.synoptic_dir, + walk_up=True, + ) + except ValueError: + rel_symbol_path = symbol_path + + widget = self._new_widget_element( + "symbol", + name=label, + x=x, + y=y, + width=width, + height=height, + ) + symbols = etree.SubElement(widget, "symbols") + symbol = etree.SubElement(symbols, "symbol") + symbol.text = str(rel_symbol_path) + + actions = etree.SubElement(widget, "actions") + actions.set("execute_as_one", "true") + action = etree.SubElement(actions, "action") + action.set("type", "open_display") + file_el = etree.SubElement(action, "file") + file_el.text = f"{component_name}.bob" + target_el = etree.SubElement(action, "target") + target_el.text = "tab" + + run_actions = etree.SubElement(widget, "run_actions_on_mouse_click") + run_actions.text = "true" + + desc_el = etree.SubElement(action, "description") + desc_el.text = f"Open {label}" + + return widget + + def _create_label_widget( + self, + label: str, + x: int, + y: int, + width: int = 80, + ) -> etree.Element: + """Create a label widget centered below a component symbol.""" + # Center label under icon by offsetting its x position + label_x = x - (width - 60) // 2 + widget = self._new_widget_element( + "label", + name=f"Label_{label}", + text=label, + x=label_x, + y=y, + width=width, + horizontal_alignment="1", + ) + return widget + + def _format_pipe_section( + self, + display: etree.Element, + section_name: str, + components: dict[str, PipeComponent], + pipe_left: int, + pipe_top: int, + pipe_width: int, + pipe_height: int, + button_y: int, + color: tuple[int, int, int], + ) -> None: + """Add a pipe line and ordered components to the display.""" + display.append( + self._create_beamline_widget( + pipe_left, + pipe_top, + pipe_width, + pipe_height, + color, + ) + ) + + if not components: + return + + symbol_width = 60 + symbol_height = 60 + count = len(components) + spacing = max(20, int((pipe_width - count * symbol_width) / (count + 1))) + + x = pipe_left + spacing + for component_name, component in components.items(): + label = component.label or component_name + symbol_path = self._symbol_path(component.icon_type) + + if symbol_path: + display.append( + self._create_symbol_widget( + component_name, + label, + symbol_path, + x, + button_y, + symbol_width, + symbol_height, + ) + ) + else: + logger_.warning( + f"No SVG found for icon_type '{component.icon_type}' " + f"(component '{component_name}'): expected " + f"{component.icon_type.replace('_', '-')}.svg in " + f"{self.support_path / 'symbols'}. " + f"Add the SVG to techui-support or fix the icon_type string." + ) + + display.append( + self._create_label_widget( + label, + x, + button_y + symbol_height + 5, + ) + ) + x += symbol_width + spacing + + def generate_index_bob( + self, + techui: TechUi, + output_dir: Path | None = None, + ) -> None: + """Generate an index.bob from beam_pipe and vacuum_pipe in techui.yaml.""" + if output_dir is None: + output_dir = self.synoptic_dir + + if not techui.beam_pipe and not techui.vacuum_pipe: + logger_.warning( + "No beam_pipe or vacuum_pipe defined; skipping index.bob generation." + ) + return + + pipe_left = 100 + pipe_height = 8 + component_count = max( + len(techui.beam_pipe or {}), + len(techui.vacuum_pipe or {}), + ) + pipe_width = max(1200, component_count * 120 + 200) + + display = etree.Element("display", version="2.0.0") + title = etree.SubElement(display, "name") + title.text = techui.beamline.location + + if techui.vacuum_pipe: + self._format_pipe_section( + display, + "vacuum_pipe", + techui.vacuum_pipe, + pipe_left, + pipe_top=120, + pipe_width=pipe_width, + pipe_height=pipe_height, + button_y=80, + color=(180, 180, 180), + ) + + if techui.beam_pipe: + self._format_pipe_section( + display, + "beam_pipe", + techui.beam_pipe, + pipe_left, + pipe_top=260, + pipe_width=pipe_width, + pipe_height=pipe_height, + button_y=180, + color=(0, 255, 255), + ) + + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / "index.bob" + tree = etree.ElementTree(display) + tree.write( + output_path, + pretty_print=True, + xml_declaration=True, + encoding="utf-8", + ) + logger_.info(f"Generated index.bob at {output_path}") + def _create_widgets( self, name: str, component: Entity ) -> list[EmbeddedDisplay | ActionButton] | None: diff --git a/src/techui_builder/main_app.py b/src/techui_builder/main_app.py index 8cd6f06f..6ff9bf41 100644 --- a/src/techui_builder/main_app.py +++ b/src/techui_builder/main_app.py @@ -46,7 +46,7 @@ def find_dirs(file_path: Path, beamline: str) -> tuple: return ixx_services_dir, synoptic_dir -def find_bob(bob_file: Path | None, synoptic_dir: Path): +def find_bob(bob_file: Path | None, synoptic_dir: Path) -> Path | None: if bob_file is None: # Search default relative dir to techui filename # There will only ever be one file, but if not return None @@ -55,11 +55,10 @@ def find_bob(bob_file: Path | None, synoptic_dir: Path): None, ) if bob_file is None: - logging.critical( - f"Source bob file '{default_bobfile}' not found in \ -{synoptic_dir}. Does it exist?" + logger_.debug( + f"Default bob file '{default_bobfile}' not found in {synoptic_dir}." ) - exit() + return None elif not bob_file.exists(): logging.critical(f"Source bob file '{bob_file}' not found. Does it exist?") exit() @@ -113,12 +112,18 @@ def main( logger_.info(f"Screens generated for {gui.conf.beamline.location}.") - autofiller = Autofiller(bob_file, gui.conf.components) - autofiller.read_bob() - autofiller.autofill_bob() - - dest_bob = gui._write_directory.joinpath("index.bob") # noqa: SLF001 - - autofiller.write_bob(dest_bob) - - logger_.info(f"Screens autofilled for {gui.conf.beamline.location}.") + if gui.conf.beam_pipe is not None or gui.conf.vacuum_pipe is not None: + gui.generator.generate_index_bob(gui.conf, gui._write_directory) # noqa: SLF001 + elif bob_file is not None: + autofiller = Autofiller(bob_file, gui.conf.components) + autofiller.read_bob() + autofiller.autofill_bob() + + dest_bob = gui._write_directory.joinpath("index.bob") # noqa: SLF001 + autofiller.write_bob(dest_bob) + logger_.info(f"Screens autofilled for {gui.conf.beamline.location}.") + else: + logging.critical( + "No source index.bob found and no beam_pipe/vacuum_pipe sections defined." + ) + exit() diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index 20130b4e..c20d2465 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -102,6 +102,36 @@ def check_url(cls, url: str) -> str: raise ValueError("Invalid opis URL.") +class PipeComponent(BaseModel): + """A device on a beam pipe or vacuum pipe.""" + + label: Annotated[ + str | None, + Field(default=None, description="Display label for the component"), + ] = None + prefix: Annotated[ + str, + Field(description="PV prefix for this device"), + ] + icon_type: Annotated[ + str, + Field( + description="Device type — must match SVG filename as kebab-case, " + "e.g. 'ion_pump' -> 'ion-pump.svg'" + ), + ] + + @field_validator("icon_type") + @classmethod + def icon_type_must_be_snake_case(cls, v: str) -> str: + if "-" in v: + raise ValueError( + f"icon_type '{v}' should use underscores not hyphens " + f"(the SVG filename will use hyphens automatically)" + ) + return v + + class Component(BaseModel): """One UI Component from techui.yaml `components:` dictionary""" @@ -224,7 +254,21 @@ class TechUi(BaseModel): components: Annotated[ dict[str, Component], Field(description="Components dictionary from techui.yaml"), - ] + ] = Field(default_factory=dict) + beam_pipe: Annotated[ + dict[str, PipeComponent] | None, + Field( + default=None, + description="Ordered devices on the beam pipe", + ), + ] = None + vacuum_pipe: Annotated[ + dict[str, PipeComponent] | None, + Field( + default=None, + description="Ordered devices on the vacuum pipe", + ), + ] = None model_config = ConfigDict( extra="forbid", hide_input_in_errors=True, diff --git a/tests/test_cli.py b/tests/test_cli.py index 526ce775..6d0fb7a9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -40,6 +40,12 @@ def test_app_version(): assert "techui-builder version:" in result.output +def test_app_generate_alias(): + result = runner.invoke(app, ["generate", "--help"]) + assert result.exit_code == 0 + assert "Run techui-builder for a given techui.yaml" in result.output + + def test_app_schema(): result = runner.invoke(schema_app) assert result.exit_code == 0 @@ -130,17 +136,14 @@ def test_find_bob_no_bob_file_found(caplog): mock_synoptic_dir = MagicMock(spec=Path) mock_synoptic_dir.glob.return_value = iter([]) - with caplog.at_level(logging.CRITICAL) and pytest.raises(SystemExit) as exc_info: - _ = find_bob(None, mock_synoptic_dir) - - for log_output in caplog.records: - assert ( - f"Source bob file '{default_bobfile}' not found in {mock_synoptic_dir}" - in log_output.message - ) + with caplog.at_level(logging.DEBUG): + bob_file = find_bob(None, mock_synoptic_dir) - # The function calls exit() with no value code - assert exc_info.value.code is None + assert bob_file is None + assert ( + f"Default bob file '{default_bobfile}' not found in {mock_synoptic_dir}." + in caplog.text + ) @patch("techui_builder.main_app.find_bob") diff --git a/tests/test_generate.py b/tests/test_generate.py index ddd3309c..97c1fa17 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -7,7 +7,8 @@ from phoebusgen import screen as pscreen from phoebusgen import widget as pwidget -from techui_builder.models import Entity +from techui_builder.generate import Generator +from techui_builder.models import Beamline, Component, Entity, TechUi, TechUiSupport @dataclass @@ -391,6 +392,35 @@ def test_generator_build_screen(generator, components): assert objectify.fromstring(str(generator.screen_)).xpath("//widget[@type='group']") +def test_generator_generate_index_bob(tmp_path): + techui = TechUi( + beamline=Beamline( + location="t01", + domain="bl01t", + desc="Test Beamline", + url="t01-opis.diamond.ac.uk", + ), + components={ + "fshtr": Component(prefix="BL01T-EA-FSHTR-01", label="Fast Shutter"), + "valve": Component(prefix="BL01T-VA-VALVE-01", label="Vacuum Valve"), + }, + beam_pipe=["fshtr"], + vacuum_pipe=["valve"], + ) + generator = Generator( + tmp_path, + "test_url", + tmp_path, + TechUiSupport(support_modules={}), + ) + generator.generate_index_bob(techui, tmp_path) + assert (tmp_path / "index.bob").exists() + xml = objectify.parse(tmp_path / "index.bob").getroot() + names = [name.text for name in xml.xpath("//widget/name")] + assert "Fast Shutter" in names + assert "Vacuum Valve" in names + + def test_build_groups_with_label(generator, components): screen_name = "motor" generator.widgets = [Mock(), Mock(), Mock()]