Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 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 @@ -50,7 +56,8 @@ $(actions)</tooltip>
<style>3</style>
<transparent>true</transparent>
<widget type="symbol" version="2.0.0">
<name>diode1</name>
<name>{name= diode1, label= Diode 1, extras= [BL01T-DI-PHDGN-01, BL01T-DI-PHDGM-01]} </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 @@ -35,7 +35,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 @@ -69,7 +69,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
95 changes: 95 additions & 0 deletions src/techui_builder/bob_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import logging
from dataclasses import dataclass
from pathlib import Path

from lxml.objectify import ObjectifiedElement

from techui_builder.models import BobFile, BobWidget, MacroMap
from techui_builder.utils import _INDEX_BOB_NAME_RE, WidgetType, read_bob

logger_ = logging.getLogger(__name__)


@dataclass
class BobParser:
Comment thread
OCopping marked this conversation as resolved.
"""
The BobParser class is responsible for parsing .bob files and extracting relevant
information such as widgets and macros. It provides methods to read the .bob file,
parse its contents, and return a structured representation of the data.
"""

bob_path: Path

def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]:
"""
Parses the widgets from the given container element
and returns a list of BobWidget instances.
"""
result = []

for element in container.iterchildren("widget"):
if element.get("type", default="") == WidgetType.GROUP:
self._parse_widgets(element)
if element.get("type", default="") not in WidgetType:
continue
# Find the name and prefix of the widget
name_element = element.find("name")
name = (
name_element.text
if (name_element is not None and name_element.text is not None)
else ""
)
prefix_element = element.find("pv_name")
prefix = prefix_element.text if prefix_element is not None else ""

macros: MacroMap = {}
matches = _INDEX_BOB_NAME_RE.finditer(name)
macro_pairs = {
match.group("key") or "name": match.group("val") for match in matches
}

if macro_pairs is None:
print(f"No match for {name!r}")
continue

for key, value in macro_pairs.items():
print(f"Match: {key, value}")

list_types = ("extras", "status")
if key not in list_types:
macros[key] = value
else:
value = value[1:-1]
macros[key] = [v.strip() for v in value.split(",") if v.strip()]

# Create a BobWidget instance for the widget
widget = BobWidget(
name=name or "",
widget_type=element.get("type", default=""),
macros=macros,
element=element,
children=None,
prefix=prefix or "",
)

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

return result

def parse_bob(
Comment thread
OCopping marked this conversation as resolved.
self,
) -> BobFile:
"""
Parses the .bob file specified by the bob_path
attribute and returns a BobFile instance
"""
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),
)
57 changes: 48 additions & 9 deletions src/techui_builder/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,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 @@ -31,17 +38,45 @@ class Builder:

"""

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

entities: dict[str, Entity] = field(default_factory=dict, 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:
yield widget

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 @@ -59,7 +94,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 @@ -106,7 +141,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 @@ -206,6 +243,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 All @@ -221,7 +260,7 @@ def create_screens(self):
matched = self.get_entities_for_prefix(component.prefix)
if not matched:
logger_.warning(
f"{self.techui.name}: prefix [bold]{component.prefix}[/bold] "
f"{techui_name}: prefix [bold]{component.prefix}[/bold] "
f"for component [bold]{component_name}[/bold] matched no entities."
)
continue
Expand Down
26 changes: 26 additions & 0 deletions src/techui_builder/jsonmap/naming.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
"""Display-name rules for ScreenNodes and duplicate-name fixing."""

import logging
import re
from collections import defaultdict
from collections.abc import Mapping
from pathlib import Path

from techui_builder.jsonmap.nodes import ScreenNode
from techui_builder.models import Component
from techui_builder.utils import _INDEX_BOB_NAME_RE

logger_ = logging.getLogger(__name__)


def get_name_from_list(name: str) -> str:
"""Extract the 'name' value from a string using the index_bob_name_pattern regex."""

macro_pairs = re.findall(_INDEX_BOB_NAME_RE, name)

try:
if macro_pairs:
name_dict = dict(macro_pairs)
return name_dict.get("name", "")
except Exception as e:
# Log the error and the input name for debugging purposes
logger_.warning(f"Error extracting name from list: {e}. Input name: {name}")
return name # Return the original name if extraction fails

return name


def find_techui_label(
Expand All @@ -17,6 +39,8 @@ def find_techui_label(
if name is None:
return None

name = get_name_from_list(name)

if name in components and components[name].label is not None:
return components[name].label

Expand All @@ -43,6 +67,8 @@ def find_techui_label(
def name_or_file_stem(name: str | None, file_path: Path) -> str | None:
"""The name if it is non-empty, else the file name without suffixes, else None."""

name = get_name_from_list(name) if name else None

if name:
# Return name tag text as displayName
return name
Expand Down
Loading
Loading