From dc067ac1dd3d0ff80b98f8a1bb352c5ffc1ff96b Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Wed, 20 May 2026 14:19:36 +0000 Subject: [PATCH 1/6] Remove unused _gui_map variable in Builder --- src/techui_builder/builder.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 05f8c07..097915c 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -50,7 +50,6 @@ class Builder: ) status_pvs: dict[str, Record] = field(default_factory=dict, init=False) _services_dir: Path = field(init=False, repr=False) - _gui_map: dict = field(init=False, repr=False) _write_directory: Path = field(default=Path("opis"), init=False, repr=False) def __post_init__(self): From 69b934fee67bdebd13d53fbec613373baa67c586 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Thu, 21 May 2026 09:44:18 +0000 Subject: [PATCH 2/6] Update models Includes new SupportEntity for structure in techui-support.yaml --- src/techui_builder/models.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index 61e6440..508ac4b 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -1,6 +1,6 @@ import logging import re -from typing import Annotated, Literal +from typing import Annotated, Any, Literal from pydantic import ( BaseModel, @@ -272,7 +272,7 @@ class Entity(BaseModel): "ADAravis.aravisCamera" ), ] - P: Annotated[str, Field(description="PV Prefix for module entity")] + prefix: Annotated[str, Field(description="PV Prefix for module entity")] desc: Annotated[ str | None, Field(description="Optional description of module entity") ] = None @@ -280,7 +280,23 @@ class Entity(BaseModel): dict[str, str] | None, Field(description="Optional child labels for module entity"), ] = None - M: Annotated[str | None, Field(description="Optional PV suffix for a motor")] - R: Annotated[ - str | None, Field(description="Optional PV suffix for an ADAravis plugin") + macros: Annotated[ + dict[str, Any], + Field(description="Macros for the matching screen (can be empty)"), + ] + + +class SupportEntity(BaseModel): + """ + Table of variables from corresponding support module in techui-support.yaml file + """ + + prefix: Annotated[str, Field(description="Prefix for techui-support screen")] + macros: Annotated[ + dict[str, Any], + Field(description="Macros for the matching screen (can be empty)"), + ] + screens: Annotated[ + dict[str, str], + Field(description="Dictionary of available screens for the support module"), ] From 6f19891aa36a367c099f5e2da8382c5d6a91999e Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Thu, 21 May 2026 13:23:17 +0000 Subject: [PATCH 3/6] [BREAKING CHANGES] Massive changes to rework how macros are handled between ioc.yaml and techui-support.yaml files This changes the logic so the macros to store from the entity in ioc.yaml are fetched from the corresponding support module entry in techui-support.yaml. This means that techui-support.yaml is the 'ground truth' of what to extract, instead of hard-coding thing inside of techui-builder like what was previously done (i.e. P, M and R). This opens up the possibilty of having other variable names in support modules. Also, the prefix structure is fetched from techui-support.yaml along with the macros, which are used to now Jinja substitute in to the prefix. --- src/techui_builder/builder.py | 48 +++++++++++++++++----- src/techui_builder/generate.py | 73 ++++++++++++---------------------- src/techui_builder/models.py | 11 ++++- 3 files changed, 73 insertions(+), 59 deletions(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 097915c..f6df12e 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -8,12 +8,13 @@ import yaml from epicsdbbuilder.recordbase import Record +from jinja2 import Template from lxml import etree, objectify from lxml.objectify import ObjectifiedElement from softioc.builder import records from techui_builder.generate import Generator -from techui_builder.models import Entity, TechUi +from techui_builder.models import Entity, SupportEntity, TechUi, TechUiSupport from techui_builder.validator import Validator logger_ = logging.getLogger(__name__) @@ -49,8 +50,10 @@ class Builder: default_factory=lambda: defaultdict(list), init=False ) status_pvs: dict[str, Record] = field(default_factory=dict, init=False) + + # These are global params for the class (not accessible by user) _services_dir: Path = field(init=False, repr=False) - _write_directory: Path = field(default=Path("opis"), init=False, repr=False) + _write_directory: Path = field(init=False, repr=False) def __post_init__(self): # Populate beamline and components @@ -60,12 +63,30 @@ def __post_init__(self): def setup(self): """Run intial setup, e.g. extracting entries from service ioc.yaml.""" + # This needs to be before _read_map() + self.support_path = self._write_directory.joinpath("techui-support") + + self._read_map() + self._extract_services() - synoptic_dir = self._write_directory self.clean_files() - self.generator = Generator(synoptic_dir, self.conf.beamline.url) + self.generator = Generator( + self._write_directory, + self.conf.beamline.url, + self.support_path, + self.techui_support, + ) + + def _read_map(self): + """Read the techui-support.yaml file from techui-support.""" + support_yaml = self.support_path.joinpath("techui-support.yaml").absolute() + logger_.debug(f"techui-support.yaml location: {support_yaml}") + + self.techui_support = TechUiSupport.model_validate( + yaml.safe_load(support_yaml.read_text(encoding="utf-8")) + ) def clean_files(self): exclude = {"index.bob"} @@ -176,17 +197,26 @@ def _extract_entities(self, service_name: str, ioc_yaml: Path): with open(ioc_yaml) as ioc: ioc_conf: dict[str, list[dict[str, str]]] = yaml.safe_load(ioc) for entity in ioc_conf["entities"]: - if "P" in entity.keys(): + if entity["type"] in self.techui_support.support_modules: + support_mapping: SupportEntity = ( + self.techui_support.support_modules[entity["type"]] + ) + support_macros = support_mapping.macros + + macros = {k: v for k, v in entity.items() if k in support_macros} + + prefix_template = Template(support_mapping.prefix) + prefix = prefix_template.render(macros) + # Create Entity and append to entity list new_entity = Entity( service_name=service_name, type=entity["type"], desc=entity.get("desc", None), - P=entity["P"], - M=None if (val := entity.get("M")) is None else val, - R=None if (val := entity.get("R")) is None else val, + prefix=prefix, + macros=macros, ) - self.entities[new_entity.P].append(new_entity) + self.entities[new_entity.prefix].append(new_entity) def _generate_screen(self, screen_name: str): self.generator.build_screen(screen_name) diff --git a/src/techui_builder/generate.py b/src/techui_builder/generate.py index 80c986d..f9217ff 100644 --- a/src/techui_builder/generate.py +++ b/src/techui_builder/generate.py @@ -2,17 +2,16 @@ import os import re from collections import defaultdict -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path -import yaml from lxml import 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 +from techui_builder.models import Component, Entity, TechUiSupport logger_ = logging.getLogger(__name__) @@ -23,12 +22,10 @@ class Generator: beamline_url: str = field(repr=False) # These are global params for the class (not accessible by user) - support_path: Path = field(init=False, repr=False) - techui_support: dict = field(init=False, repr=False) + support_path: Path = field(repr=False) + techui_support: TechUiSupport = field(repr=False) default_size: int = field(default=100, init=False, repr=False) - P: str = field(default="P", init=False, repr=False) - M: str = field(default="M", init=False, repr=False) - R: str = field(default="R", init=False, repr=False) + prefix: str = field(default="P", init=False, repr=False) widgets: list[ActionButton | EmbeddedDisplay] = field( default_factory=list[ActionButton | EmbeddedDisplay], init=False, repr=False ) @@ -42,20 +39,6 @@ class Generator: group_padding: int = field(default=50, init=False, repr=False) label_flag: bool = field(default=False, init=False, repr=False) - def __post_init__(self): - # This needs to be before _read_map() - self.support_path = self.synoptic_dir.joinpath("techui-support") - - self._read_map() - - def _read_map(self): - """Read the techui-support.yaml file from techui-support.""" - support_yaml = self.support_path.joinpath("techui-support.yaml").absolute() - logger_.debug(f"techui-support.yaml location: {support_yaml}") - - with open(support_yaml) as map: - self.techui_support = yaml.safe_load(map) - def _get_screen_dimensions(self, file: str) -> tuple[int, int]: """ Parses the bob files for information on the height @@ -161,16 +144,18 @@ def _get_group_dimensions(self, widget_list: list[EmbeddedDisplay | ActionButton ) def _initialise_name_suffix(self, component: Entity) -> tuple[str, str, str | None]: - if component.M is not None: - name: str = component.M - suffix: str = component.M - suffix_label: str | None = self.M - elif component.R is not None: - name = component.R - suffix = component.R - suffix_label = self.R + if "M" in component.macros.keys(): + m: str = component.macros["M"] + name: str = m + suffix: str = m + suffix_label: str = m.removeprefix(":").removesuffix(":") + elif "R" in component.macros.keys(): + r: str = component.macros["R"] + name: str = r + suffix: str = r + suffix_label: str = r.removeprefix(":").removesuffix(":") else: - name = component.P + name = component.prefix suffix = "" suffix_label = "" @@ -184,11 +169,6 @@ def _initialise_name_suffix(self, component: Entity) -> tuple[str, str, str | No return (name, suffix, suffix_label) - def _is_list_of_dicts(self, scrn_mapping: Mapping) -> bool: - return isinstance(scrn_mapping, Sequence) and all( - isinstance(scrn, Mapping) for scrn in scrn_mapping - ) - def _allocate_widget( self, scrn_mapping: Mapping, component: Entity ) -> EmbeddedDisplay | ActionButton | None | list[EmbeddedDisplay | ActionButton]: @@ -238,7 +218,7 @@ def _allocate_widget( height, ) # Add macros to the widgets - new_widget.macro(self.P, component.P) + new_widget.macro(self.prefix, component.prefix) if suffix_label != "": new_widget.macro(f"{suffix_label}", suffix) new_widget.macro("label", name.removeprefix(":").removesuffix(":")) @@ -266,7 +246,7 @@ def _allocate_widget( file=str(data_scrn_path), target="tab", macros={ - "P": component.P, + "P": component.prefix, f"{suffix_label}": suffix, }, ) @@ -275,7 +255,7 @@ def _allocate_widget( file=str(data_scrn_path), target="tab", macros={ - "P": component.P, + "P": component.prefix, }, ) @@ -294,7 +274,7 @@ def _create_widget( new_widget = [] try: - scrn_mapping = self.techui_support[component.type] + scrn_mapping = self.techui_support.support_modules[component.type].screens except KeyError: logger_.warning( f"No available widget for {component.type} in screen \ @@ -302,11 +282,8 @@ def _create_widget( ) return None - if self._is_list_of_dicts(scrn_mapping): - for value in scrn_mapping: - new_widget.append(self._allocate_widget(value, component)) - else: - new_widget = self._allocate_widget(scrn_mapping, component) + for value in scrn_mapping: + new_widget.append(self._allocate_widget(value, component)) return new_widget @@ -374,14 +351,14 @@ def layout_widgets(self, widgets: list[EmbeddedDisplay | ActionButton]): return sorted_widgets - def build_widgets(self, screen_name: str, screen_components: list[Entity]): + def build_widgets(self, screen_name: str, screen_entities: list[Entity]): # Empty widget buffer self.widgets = [] # order is an enumeration of the components, used to list them, # and serves as functionality in the math for formatting. - for component in screen_components: - new_widget = self._create_widget(name=screen_name, component=component) + for entity in screen_entities: + new_widget = self._create_widget(name=screen_name, component=entity) if new_widget is None: continue if isinstance(new_widget, list): diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index 508ac4b..72133ae 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -293,10 +293,17 @@ class SupportEntity(BaseModel): prefix: Annotated[str, Field(description="Prefix for techui-support screen")] macros: Annotated[ - dict[str, Any], + list[str], Field(description="Macros for the matching screen (can be empty)"), ] screens: Annotated[ - dict[str, str], + list[dict[str, str]], Field(description="Dictionary of available screens for the support module"), ] + + +class TechUiSupport(BaseModel): + support_modules: Annotated[ + dict[str, SupportEntity], + Field(description="The dictionary of techui-support.yaml entities"), + ] From 4dcb3c4252a32adf3358df98e4238a006037faff Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 22 May 2026 08:00:49 +0000 Subject: [PATCH 4/6] Fix screen generation, but needs reworking to not rely on "P" macro --- src/techui_builder/builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index f6df12e..87d565f 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -216,7 +216,7 @@ def _extract_entities(self, service_name: str, ioc_yaml: Path): prefix=prefix, macros=macros, ) - self.entities[new_entity.prefix].append(new_entity) + self.entities[macros["P"]].append(new_entity) def _generate_screen(self, screen_name: str): self.generator.build_screen(screen_name) From 436a3763bb769a02a036783e3b0d557e400b499f Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 22 May 2026 08:12:34 +0000 Subject: [PATCH 5/6] Remove reliance on P by just calculating the 'root' of the PV (split by a ':') --- src/techui_builder/builder.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 87d565f..79c3f40 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -206,7 +206,7 @@ def _extract_entities(self, service_name: str, ioc_yaml: Path): macros = {k: v for k, v in entity.items() if k in support_macros} prefix_template = Template(support_mapping.prefix) - prefix = prefix_template.render(macros) + prefix: str = prefix_template.render(macros) # Create Entity and append to entity list new_entity = Entity( @@ -216,7 +216,9 @@ def _extract_entities(self, service_name: str, ioc_yaml: Path): prefix=prefix, macros=macros, ) - self.entities[macros["P"]].append(new_entity) + + pv_root = prefix.split(":", maxsplit=1)[0] + self.entities[pv_root].append(new_entity) def _generate_screen(self, screen_name: str): self.generator.build_screen(screen_name) From f54843efcf7a7f55020addf878a4597622db05ed Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 22 May 2026 08:59:53 +0000 Subject: [PATCH 6/6] Tidy _initialise_name_suffix function --- src/techui_builder/generate.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/techui_builder/generate.py b/src/techui_builder/generate.py index f9217ff..1cecd52 100644 --- a/src/techui_builder/generate.py +++ b/src/techui_builder/generate.py @@ -144,30 +144,23 @@ def _get_group_dimensions(self, widget_list: list[EmbeddedDisplay | ActionButton ) def _initialise_name_suffix(self, component: Entity) -> tuple[str, str, str | None]: - if "M" in component.macros.keys(): - m: str = component.macros["M"] - name: str = m - suffix: str = m - suffix_label: str = m.removeprefix(":").removesuffix(":") - elif "R" in component.macros.keys(): - r: str = component.macros["R"] - name: str = r - suffix: str = r - suffix_label: str = r.removeprefix(":").removesuffix(":") - else: - name = component.prefix - suffix = "" - suffix_label = "" + try: + component_name = component.prefix.split(":", maxsplit=1)[1] + raw_name = component_name.removesuffix(":") + except IndexError: + component_name = "" + raw_name = "" + + suffix = component_name - name = name.removeprefix(":").removesuffix(":") # Try to get name from child labels if they exist, # if not, just use the name as it is. if component.child_labels is not None: - if name in component.child_labels.keys(): - name = component.child_labels[name] + if raw_name in component.child_labels.keys(): + component_name = component.child_labels[raw_name] self.label_flag = True - return (name, suffix, suffix_label) + return (component_name, suffix, raw_name) def _allocate_widget( self, scrn_mapping: Mapping, component: Entity