Skip to content
22 changes: 14 additions & 8 deletions example/t01-services/synoptic/index.bob
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
<?xml version='1.0' encoding='UTF-8'?>
<!--Saved on 2026-07-20 15:06:49 by ryi58813-->
<?xml version="1.0" encoding="UTF-8"?>
<!--Saved on 2026-09-30 12:56:26 by uns32131-->
<display version="2.0.0">
<name>Display</name>
<macros>
<location>bl01t</location>
<domain>t01</domain>
<desc>Test Beamline</desc>
<url>t01-opis.diamond.ac.uk</url>
</macros>
<widget type="group" version="3.0.0">
<name>dcam1_Group</name>
<x>250</x>
Expand All @@ -12,6 +18,7 @@
<transparent>true</transparent>
<widget type="symbol" version="2.0.0">
<name>dcam1</name>
<pv_name>BL01T-DI-CAM-01:STA</pv_name>
<symbols>
<symbol>techui-support/symbols/camera.svg</symbol>
</symbols>
Expand All @@ -26,11 +33,10 @@
<target>replace</target>
</action>
</actions>
<run_actions_on_mouse_click>true</run_actions_on_mouse_click>
<tooltip>$(pv_name)
$(pv_value)
$(actions)</tooltip>
<pv_name>BL01T-DI-CAM-01:STA</pv_name>
<run_actions_on_mouse_click>true</run_actions_on_mouse_click>
</widget>
<widget type="label" version="2.0.0">
<name>dcam1_L:abel</name>
Expand All @@ -51,6 +57,7 @@ $(actions)</tooltip>
<transparent>true</transparent>
<widget type="symbol" version="2.0.0">
<name>diode1</name>
<pv_name>BL01T-DI-PHDGN-01:STA</pv_name>
<symbols>
<symbol>techui-support/symbols/oscilloscope.svg</symbol>
</symbols>
Expand All @@ -65,11 +72,10 @@ $(actions)</tooltip>
<target>replace</target>
</action>
</actions>
<run_actions_on_mouse_click>true</run_actions_on_mouse_click>
<tooltip>$(pv_name)
$(pv_value)
$(actions)</tooltip>
<pv_name>BL01T-DI-PHDGN-01:STA</pv_name>
<run_actions_on_mouse_click>true</run_actions_on_mouse_click>
</widget>
<widget type="label" version="2.0.0">
<name>diode1_Label</name>
Expand Down Expand Up @@ -102,10 +108,10 @@ $(actions)</tooltip>
<target>replace</target>
</action>
</actions>
<run_actions_on_mouse_click>true</run_actions_on_mouse_click>
<tooltip>$(pv_name)
$(pv_value)
$(actions)</tooltip>
<run_actions_on_mouse_click>true</run_actions_on_mouse_click>
</widget>
<widget type="label" version="2.0.0">
<name>temp1_label</name>
Expand Down Expand Up @@ -141,10 +147,10 @@ $(actions)</tooltip>
<target>replace</target>
</action>
</actions>
<run_actions_on_mouse_click>true</run_actions_on_mouse_click>
<tooltip>$(pv_name)
$(pv_value)
$(actions)</tooltip>
<run_actions_on_mouse_click>true</run_actions_on_mouse_click>
</widget>
<widget type="label" version="2.0.0">
<name>motor1_label</name>
Expand Down
2 changes: 1 addition & 1 deletion src/techui_builder/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
`-- synoptic\n
. |-- techui-support/\n
| | `-- ...\n
. |-- techui.yaml\n
. |-- techui.yaml?\n
. `-- index.bob\n
""",
no_args_is_help=True,
Expand Down
4 changes: 2 additions & 2 deletions src/techui_builder/autofill.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class Autofiller:

def read_bobs(self) -> None:
for path in self.index_paths:
tree, widget_dict = read_bob(path)
tree, widget_dict, _ = read_bob(path)
self.index_trees[path] = (tree, widget_dict)

def autofill_bobs(self) -> None:
Expand Down Expand Up @@ -63,7 +63,7 @@ def _autofill_from_path(self, path: Path):
self._autofill_from_path(resolved_path)
else:
if resolved_path.exists():
nav_tree, nav_widgets = read_bob(resolved_path)
nav_tree, nav_widgets, _ = read_bob(resolved_path)
self.index_trees[resolved_path] = (
nav_tree,
nav_widgets,
Expand Down
53 changes: 53 additions & 0 deletions src/techui_builder/bob_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import logging
from dataclasses import dataclass
from pathlib import Path

from lxml.objectify import ObjectifiedElement

from techui_builder.models import BobFile, BobWidget
from techui_builder.utils import WidgetType, _get_macros, read_bob

logger_ = logging.getLogger(__name__)


@dataclass
class BobParser:
bob_path: Path

def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]:
result = []

for element in container.iterchildren("widget"):
name_element = element.find("name")
name = name_element.text if name_element is not None else ""
prefix_element = element.find("pv_name")
prefix = prefix_element.text if prefix_element is not None else ""

widget = BobWidget(
name=name or "",
widget_type=element.get("type", default=""),
macros=_get_macros(element),
element=element,
children=None,
prefix=prefix or "",
)
if widget.widget_type == "group":
widget.children = self._parse_widgets(element)

if widget.widget_type in WidgetType:
result.append(widget)

return result

def parse_bob(
self,
) -> BobFile:
tree, _, display_macros = read_bob(self.bob_path)
root = tree.getroot()

return BobFile(
path=self.bob_path,
macros=display_macros,
tree=tree,
widgets=self._parse_widgets(root),
)
60 changes: 51 additions & 9 deletions src/techui_builder/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@
from jinja2 import Template

from techui_builder.generate import Generator
from techui_builder.models import Entity, SupportEntity, TechUi, TechUiSupport
from techui_builder.models import (
BobFile,
Component,
Entity,
SupportEntity,
TechUi,
TechUiSupport,
)
from techui_builder.validator import Validator

logger_ = logging.getLogger(__name__)
Expand All @@ -32,19 +39,50 @@ class Builder:

"""

techui: Path = field(default=Path("techui.yaml"))
bob_description: BobFile
techui: Path | None = field(init=True, repr=False)

entities: defaultdict[str, list[Entity]] = field(
default_factory=lambda: defaultdict(list), init=False
)
_services_dir: Path = field(init=False, repr=False)
_write_directory: Path = field(init=False, repr=False)

def _iter_prefixed_widgets(self, widgets):
for widget in widgets:
if widget.prefix and widget.prefix.strip():
yield widget

if widget.widget_type == "group":
yield from self._iter_prefixed_widgets(widget.children or [])

def __post_init__(self):
# Populate beamline and components
self.conf = TechUi.model_validate(
yaml.safe_load(self.techui.read_text(encoding="utf-8"))
)
components = {}

for widget in self._iter_prefixed_widgets(self.bob_description.widgets):
values = {
key: value
for key, value in widget.macros.items()
if key in Component.model_fields and key != "prefix"
}
values["prefix"] = widget.prefix.strip()
values.setdefault("label", widget.name)
components[widget.name] = values

macros = self.bob_description.macros
techui_data = {
"beamline": {
key: macros[key] for key in ("location", "domain", "desc", "url")
},
"components": components,
}

if self.techui is not None and self.techui.is_file():
yaml_data = yaml.safe_load(self.techui.read_text(encoding="utf-8")) or {}
techui_data["beamline"].update(yaml_data.get("beamline", {}))
components.update(yaml_data.get("components", {}))

self.conf = TechUi.model_validate(techui_data)

def setup(self):
"""
Expand All @@ -62,7 +100,7 @@ def setup(self):

self.generator = Generator(
self._write_directory,
self.conf.beamline.url,
self.bob_description.macros["url"],
self.support_path,
self.techui_support,
)
Expand Down Expand Up @@ -109,7 +147,9 @@ def _extract_services(self):
"""

# Loop over every dir in services, ignoring anything that isn't a service
for service in self._services_dir.glob(f"{self.conf.beamline.location}-*-*-*"):
for service in self._services_dir.glob(
f"{self.bob_description.macros['location']}-*-*-*"
):
service_name = service.name
# If service doesn't exist, file open will fail throwing exception
try:
Expand Down Expand Up @@ -198,6 +238,8 @@ def _validate_screen(self, screen_name: str):

def create_screens(self):
"""Create the screens for each component in techui.yaml"""

techui_name = self.techui.name if self.techui is not None else "techui.yaml"
if len(self.entities) == 0:
logger_.critical(
"No ioc entities found. This [italic]normally[/italic]"
Expand Down Expand Up @@ -244,7 +286,7 @@ def create_screens(self):

else:
logger_.warning(
f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold] "
f"{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"
)
59 changes: 39 additions & 20 deletions src/techui_builder/main_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from techui_builder._logger import log_level
from techui_builder.autofill import Autofiller
from techui_builder.bob_parser import BobParser
from techui_builder.builder import Builder

logger_ = logging.getLogger(__name__)
Expand All @@ -20,13 +21,13 @@
def find_dirs(file_path: Path, beamline: str) -> tuple:
# Get the relative path to the techui file from working dir
abs_path = file_path.absolute()
logger_.debug(f"techui.yaml absolute path: {abs_path}")
logger_.debug(f"index.bob absolute path: {abs_path}")

# Get the current working dir
cwd = Path.cwd()
logger_.debug(f"Working directory: {cwd}")

directory = beamline
directory = beamline.lower()

# Get the relative path of ixx-services to techui.yaml
ixx_services_dir = next(
Expand Down Expand Up @@ -114,16 +115,9 @@ def find_index_bobs(


# This is the 'build' behaviour
@app.command("build", help="Run `techui-builder build` for a given techui.yaml")
@app.command("build", help="Run `techui-builder build` for a given index.bob")
def main(
filename: Annotated[Path, typer.Argument(help="The path to techui.yaml")],
bobfile: Annotated[
Path | None,
typer.Argument(
help="Override for template bob file location. This will be used to find"
" and other template bob files in the same location with similar names."
),
] = None,
filename: Annotated[Path, typer.Argument(help="The path to index.bob")],
loglevel: Annotated[
str,
typer.Option(
Expand All @@ -137,23 +131,49 @@ def main(
) -> None:
"""Function to run when `techui-builder build` is called."""

gui = Builder(techui=filename)

ixx_services_dir, synoptic_dir = find_dirs(filename, gui.conf.beamline.domain)
# ixx_services_dir, synoptic_dir = find_dirs(filename, gui.conf.beamline.domain)

index_bob_path, bob_files = find_index_bobs(bobfile, synoptic_dir)
# index_bob_path, bob_files = find_index_bobs(filename, synoptic_dir)

# # Overwrite after initialised to make sure this is picked up
# gui._services_dir = ixx_services_dir / "services" # noqa: SLF001
# gui._write_directory = synoptic_dir # noqa: SLF001

# logger_.debug(
# f"""

# Builder created for {gui.conf.beamline.domain}.
# Services directory: {gui._services_dir}
# Write directory: {gui._write_directory}
# """, # noqa: SLF001
# )

# gui.setup()
# gui.create_screens()

# logger_.info(f"Screens generated for {gui.conf.beamline.domain}.")

# autofiller = Autofiller(bob_files, index_bob_path, gui.conf.components)
# Extract information from index.bob
bp = BobParser(filename)
synoptic_macros = bp.parse_bob()
ixx_services_dir, synoptic_dir = find_dirs(
filename, synoptic_macros.macros["domain"]
)
# Need to provide builder the macros to work on generating and validating bob files.
index_bob_path, bob_files = find_index_bobs(filename, synoptic_dir)

gui = Builder(synoptic_macros, synoptic_dir / "techui.yaml")
gui._services_dir = ixx_services_dir / "services" # noqa: SLF001
gui._write_directory = synoptic_dir # noqa: SLF001

logger_.debug(
f"""

Builder created for {gui.conf.beamline.domain}.
Services directory: {gui._services_dir}
Write directory: {gui._write_directory}
""", # noqa: SLF001
Builder created for {gui.conf.beamline.domain}.
Services directory: {gui._services_dir}
Write directory: {gui._write_directory}
""", # noqa: SLF001
)

gui.setup()
Expand All @@ -162,7 +182,6 @@ def main(
logger_.info(f"Screens generated for {gui.conf.beamline.domain}.")

autofiller = Autofiller(bob_files, index_bob_path, gui.conf.components)
autofiller.read_bobs()
autofiller.autofill_bobs()
autofiller.write_bobs()

Expand Down
Loading
Loading