diff --git a/.gitignore b/.gitignore index 5ae0cea19..e342c3207 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,4 @@ config/ # generated test files tests/samples/epics/runtime +tests/samples/epics/opi diff --git a/src/ibek/gen_scripts.py b/src/ibek/gen_scripts.py index 06fc6ff87..616bed821 100644 --- a/src/ibek/gen_scripts.py +++ b/src/ibek/gen_scripts.py @@ -4,16 +4,16 @@ import logging import re from pathlib import Path -from typing import List, Type +from typing import List, Tuple, Type from jinja2 import Template from ruamel.yaml.main import YAML from .globals import TEMPLATES -from .ioc import IOC, clear_entity_model_ids, make_entity_models, make_ioc_model +from .ioc import IOC, Entity, clear_entity_model_ids, make_entity_models, make_ioc_model from .render import Render from .render_db import RenderDb -from .support import Support +from .support import Database, Support log = logging.getLogger(__name__) @@ -62,7 +62,9 @@ def ioc_deserialize(ioc_instance_yaml: Path, definition_yaml: List[Path]) -> IOC return ioc_instance -def create_db_script(ioc_instance: IOC) -> str: +def create_db_script( + ioc_instance: IOC, extra_databases: List[Tuple[Database, Entity]] +) -> str: """ Create make_db.sh script for expanding the database templates """ @@ -71,7 +73,7 @@ def create_db_script(ioc_instance: IOC) -> str: renderer = RenderDb(ioc_instance) - templates = renderer.render_database() + templates = renderer.render_database(extra_databases) return Template(jinja_txt).render(templates=templates) diff --git a/src/ibek/globals.py b/src/ibek/globals.py index 91f1cf478..5ebc51f61 100644 --- a/src/ibek/globals.py +++ b/src/ibek/globals.py @@ -39,6 +39,7 @@ # Assets generated at runtime RUNTIME_OUTPUT_PATH = EPICS_ROOT / "runtime" +OPI_OUTPUT_PATH = EPICS_ROOT / "opi" IOC_DBDS = SUPPORT / "configure/dbd_list" IOC_LIBS = SUPPORT / "configure/lib_list" diff --git a/src/ibek/ioc_cmds/assets.py b/src/ibek/ioc_cmds/assets.py index 1d9d263e9..76c494fd9 100644 --- a/src/ibek/ioc_cmds/assets.py +++ b/src/ibek/ioc_cmds/assets.py @@ -6,7 +6,7 @@ import typer -from ibek.globals import EPICS_ROOT, IBEK_DEFS, IOC_FOLDER +from ibek.globals import EPICS_ROOT, IBEK_DEFS, IOC_FOLDER, PVI_DEFS def get_ioc_source() -> Path: @@ -69,6 +69,7 @@ def extract_assets(destination: Path, source: Path, extras: List[Path], defaults default_assets = [ get_ioc_source() / "ibek-support", source / "support" / "configure", + PVI_DEFS, IBEK_DEFS, IOC_FOLDER, Path("/venv"), diff --git a/src/ibek/render_db.py b/src/ibek/render_db.py index 51a1695f4..55a499fb1 100644 --- a/src/ibek/render_db.py +++ b/src/ibek/render_db.py @@ -4,10 +4,11 @@ """ from dataclasses import dataclass -from typing import Any, Dict, List +from typing import Any, Dict, List, Mapping, Optional, Tuple from ibek.globals import render_with_utils from ibek.ioc import IOC, Entity +from ibek.support import Database class RenderDb: @@ -22,7 +23,7 @@ def __init__(self, ioc_instance: IOC) -> None: # a mapping from template file name to details of instances of that template self.render_templates: Dict[str, RenderDb.RenderDbTemplate] = {} - def add_row(self, filename: str, args: Dict[str, Any], entity: Entity) -> None: + def add_row(self, filename: str, args: Mapping[str, Any], entity: Entity) -> None: """ Accumulate rows of arguments for each template file, Adding a new template file if it does not already exist. @@ -57,23 +58,42 @@ def parse_instances(self) -> None: while validating the arguments """ for entity in self.ioc_instance.entities: - templates = entity.__definition__.databases + databases = entity.__definition__.databases # Not all entities instantiate database templates - if templates is None or not entity.entity_enabled: - continue + if entity.entity_enabled and databases is not None: + for database in databases: + self.add_database(database, entity) - for template in templates: - template.file = template.file.strip("\n") + def add_database(self, database: Database, entity: Entity) -> None: + """Validate database and add row using entity as context. - for arg, value in template.args.items(): - if value is None: - if arg not in entity.__dict__: - raise ValueError( - f"database arg '{arg}' in database template " - f"'{template.file}' not found in context" - ) - self.add_row(template.file, template.args, entity) + Args: + database: Database to add row for + entity: Entity to use as context for Jinja template expansion + + """ + database.file = database.file.strip("\n") + + for arg, value in database.args.items(): + if value is None: + if arg not in entity.__dict__: + raise ValueError( + f"database arg '{arg}' in database template " + f"'{database.file}' not found in context" + ) + + self.add_row(database.file, database.args, entity) + + def add_extra_databases(self, databases: List[Tuple[Database, Entity]]) -> None: + """Add databases that are not part of Entity definitions + + Args: + databases: Databases to add, each mapped against an Entity to use as context + + """ + for database, entity in databases: + self.add_database(database, entity) def align_columns(self) -> None: """ @@ -97,11 +117,19 @@ def align_columns(self) -> None: for i, arg in enumerate(row): row[i] = arg.ljust(template.columns[i]) - def render_database(self) -> Dict[str, List[str]]: - """ - Render a database substitution file + def render_database( + self, extra_databases: Optional[List[Tuple[Database, Entity]]] = None + ) -> Dict[str, List[str]]: + """Render a database substitution file. + + Args: + extra_databases: Databases to add that are not included on an Entity + """ + extra_databases = [] if extra_databases is None else extra_databases + self.parse_instances() + self.add_extra_databases(extra_databases) self.align_columns() results = {} diff --git a/src/ibek/runtime_cmds/commands.py b/src/ibek/runtime_cmds/commands.py index d0d2253bf..4e90fc13f 100644 --- a/src/ibek/runtime_cmds/commands.py +++ b/src/ibek/runtime_cmds/commands.py @@ -1,13 +1,28 @@ +import shutil from pathlib import Path -from typing import List +from typing import List, Tuple import typer +from pvi._format.base import IndexEntry +from pvi._format.dls import DLSFormatter +from pvi._format.template import format_template +from pvi.device import Device from ibek.gen_scripts import create_boot_script, create_db_script, ioc_deserialize -from ibek.globals import RUNTIME_OUTPUT_PATH, NaturalOrderGroup +from ibek.globals import ( + OPI_OUTPUT_PATH, + PVI_DEFS, + RUNTIME_OUTPUT_PATH, + NaturalOrderGroup, + render_with_utils, +) +from ibek.ioc import IOC, Entity +from ibek.support import Database runtime_cli = typer.Typer(cls=NaturalOrderGroup) +PVI_PV_PREFIX = "${prefix}" + @runtime_cli.command() def generate( @@ -29,15 +44,16 @@ def generate( """ Build a startup script for an IOC instance """ + ioc_instance = ioc_deserialize(instance, definitions) - ## TODO TODO - # here we want add generation of bob files from PVI files - # and also make a bob index file of buttons - # - # you can access the 'opi' definition like this: - # ioc_instance.entities[0].__definition__.opis() + # Clear out generated files so developers know if something stop being generated + shutil.rmtree(RUNTIME_OUTPUT_PATH, ignore_errors=True) + RUNTIME_OUTPUT_PATH.mkdir(exist_ok=True) + shutil.rmtree(OPI_OUTPUT_PATH, ignore_errors=True) + OPI_OUTPUT_PATH.mkdir(exist_ok=True) - ioc_instance = ioc_deserialize(instance, definitions) + pvi_index_entries, pvi_databases = generate_pvi(ioc_instance) + generate_index(ioc_instance.ioc_name, pvi_index_entries) script_txt = create_boot_script(ioc_instance) @@ -46,7 +62,76 @@ def generate( with out.open("w") as stream: stream.write(script_txt) - db_txt = create_db_script(ioc_instance) + db_txt = create_db_script(ioc_instance, pvi_databases) with db_out.open("w") as stream: stream.write(db_txt) + + +def generate_pvi(ioc: IOC) -> Tuple[List[IndexEntry], List[Tuple[Database, Entity]]]: + """Generate pvi bob and template files to add to UI index and IOC database. + + Args: + ioc: IOC instance to extract entity pvi definitions from + + Returns: + List of bob files to add as buttons on index and databases to add to IOC + substitution file + + """ + index_entries: List[IndexEntry] = [] + databases: List[Tuple[Database, Entity]] = [] + + formatter = DLSFormatter() + + formatted_pvi_devices: List[str] = [] + for entity in ioc.entities: + entity_pvi = entity.__definition__.pvi + if entity_pvi is None: + continue + + pvi_yaml = PVI_DEFS / entity_pvi.yaml_path + device_name = pvi_yaml.name.split(".")[0] + device_bob = OPI_OUTPUT_PATH / f"{device_name}.pvi.bob" + + # Skip deserializing yaml if not needed + if entity_pvi.pva or device_name not in formatted_pvi_devices: + device = Device.deserialize(pvi_yaml) + device.deserialize_parents([PVI_DEFS]) + + # Render the prefix value for the device from the instance parameters + macros = { + "prefix": render_with_utils(entity.model_dump(), entity_pvi.prefix) + } + + if entity_pvi.pva: + # Create a template with the V4 structure defining a PVI interface + output_template = RUNTIME_OUTPUT_PATH / f"{device_name}.pvi.template" + format_template(device, PVI_PV_PREFIX, output_template) + + # Add to extra databases to be added into substitution file + databases.append( + (Database(file=output_template.name, args=macros), entity) + ) + + if device_name not in formatted_pvi_devices: + formatter.format(device, PVI_PV_PREFIX, device_bob) + + # Don't format further instance of this device + formatted_pvi_devices.append(device_name) + + if entity_pvi.index: + index_entries.append(IndexEntry(device_name, device_bob.name, macros)) + + return index_entries, databases + + +def generate_index(title: str, index_entries: List[IndexEntry]): + """Generate an index bob using pvi. + + Args: + title: Title of index UI + index_entries: List of entries to include as buttons on index UI + + """ + DLSFormatter().format_index(title, index_entries, OPI_OUTPUT_PATH / "index.bob") diff --git a/src/ibek/support.py b/src/ibek/support.py index 972f604cd..a0ea0248f 100644 --- a/src/ibek/support.py +++ b/src/ibek/support.py @@ -5,7 +5,7 @@ import json from enum import Enum -from typing import Any, Dict, Optional, Sequence, Union +from typing import Any, Dict, Mapping, Optional, Sequence, Union from pydantic import Field, PydanticUndefinedAnnotation from typing_extensions import Literal @@ -103,7 +103,7 @@ class Database(BaseSettings): description="Filename of the database template in /db" ) - args: Dict[str, Optional[str]] = Field( + args: Mapping[str, Optional[str]] = Field( description=( "Dictionary of args and values to pass through to database. " "A value of None is equivalent to ARG: '{{ ARG }}'" @@ -156,19 +156,19 @@ class Value(BaseSettings): Script = Sequence[Union[Text, Comment]] -class OpiType(Enum): - pvi = "pvi" - """declares a PVI YAML file that generates bob GUI at runtime""" - bob = "bob" - """declares a hand crafted bob file""" +class EntityPVI(BaseSettings): + """Entity PVI definition""" - -class Opi(BaseSettings): - """Describes one PVI YAML file""" - - type: OpiType = Field(description="bob or pvi") - file: str = Field(description="filename of bob or pvi") - prefix: str = Field(description="Args to make unique screen") + yaml_path: str = Field( + description="Path to .pvi.device.yaml - absolute or relative to PVI_DEFS" + ) + index: bool = Field( + description="Whether to add generated UI to index for Entity", default=True + ) + prefix: str = Field(description="PV prefix to pass as $(prefix) on index button") + pva: bool = Field( + description="Whether to generate PVA structure template", default=False + ) class Definition(BaseSettings): @@ -190,7 +190,7 @@ class Definition(BaseSettings): description="The values IOC instance should supply", default=() ) databases: Sequence[Database] = Field( - description="Databases to instantiate", default=() + description="Databases to instantiate", default=[] ) pre_init: Script = Field( description="Startup script snippets to add before iocInit()", default=() @@ -202,9 +202,7 @@ class Definition(BaseSettings): env_vars: Sequence[EnvironmentVariable] = Field( description="Environment variables to set in the boot script", default=() ) - opis: Sequence[Opi] = Field( - description="Declares the PVI YAML for generating screens", default=() - ) + pvi: EntityPVI = Field(description="PVI definition for Entity", default=None) def _get_id_arg(self): """Returns the name of the ID argument for this definition, if it exists""" diff --git a/tests/generate_samples.sh b/tests/generate_samples.sh index 7804ef3fd..6e9058235 100755 --- a/tests/generate_samples.sh +++ b/tests/generate_samples.sh @@ -16,7 +16,11 @@ cd $SAMPLES_DIR mkdir -p schemas mkdir -p outputs -set -x +set -ex + +mkdir -p epics/pvi-defs +cp yaml/simple.pvi.device.yaml epics/pvi-defs/simple.pvi.device.yaml + echo making the support yaml schema ibek support generate-schema --output schemas/ibek.support.schema.json @@ -37,3 +41,4 @@ ibek runtime generate yaml/utils.ibek.ioc.yaml yaml/utils.ibek.support.yaml --ou echo making ioc based on mutiple support yaml ibek runtime generate yaml/all.ibek.ioc.yaml yaml/objects.ibek.support.yaml yaml/all.ibek.support.yaml --out outputs/all.st.cmd --db-out outputs/all.ioc.subst +cp epics/opi/* outputs/ diff --git a/tests/samples/epics/pvi-defs/simple.pvi.device.yaml b/tests/samples/epics/pvi-defs/simple.pvi.device.yaml deleted file mode 120000 index 4322e165c..000000000 --- a/tests/samples/epics/pvi-defs/simple.pvi.device.yaml +++ /dev/null @@ -1 +0,0 @@ -../../yaml/simple.pvi.device.yaml \ No newline at end of file diff --git a/tests/samples/epics/pvi-defs/simple.pvi.device.yaml b/tests/samples/epics/pvi-defs/simple.pvi.device.yaml new file mode 100644 index 000000000..eacf3d918 --- /dev/null +++ b/tests/samples/epics/pvi-defs/simple.pvi.device.yaml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=../../../../pvi/schemas/pvi.device.schema.json +label: Simple Device +children: + - type: SignalR + name: SimplePV + pv: Simple + widget: + type: TextRead diff --git a/tests/samples/outputs/all.ioc.subst b/tests/samples/outputs/all.ioc.subst index 13ad05aaa..3f64c0324 100644 --- a/tests/samples/outputs/all.ioc.subst +++ b/tests/samples/outputs/all.ioc.subst @@ -28,3 +28,10 @@ pattern { "name", "ip", "value" } { "Consumer Two With DB", "127.0.0.1", "Ref1.127.0.0.1" } } + +file "simple.pvi.template" { +pattern + { "prefix" } + { "AllObject One" } + { "AllObject Two" } +} diff --git a/tests/samples/outputs/index.bob b/tests/samples/outputs/index.bob new file mode 100644 index 000000000..fedd23c30 --- /dev/null +++ b/tests/samples/outputs/index.bob @@ -0,0 +1,136 @@ + + Display + 0 + 0 + 273 + 130 + 4 + 4 + + Title + TITLE + test-multiple-ioc - Index + 0 + 0 + 273 + 25 + + + + + + + + + true + 1 + + + Label + imple + 23 + 30 + 115 + 20 + + + OpenDisplay + + + simple.pvi.bob + tab + Open Display + + Ref1 + + + + imple + 143 + 30 + 125 + 20 + $(actions) + + + Label + imple + 23 + 55 + 115 + 20 + + + OpenDisplay + + + simple.pvi.bob + tab + Open Display + + AllObject One + + + + imple + 143 + 55 + 125 + 20 + $(actions) + + + Label + imple + 23 + 80 + 115 + 20 + + + OpenDisplay + + + simple.pvi.bob + tab + Open Display + + AllObject Two + + + + imple + 143 + 80 + 125 + 20 + $(actions) + + + Label + imple + 23 + 105 + 115 + 20 + + + OpenDisplay + + + simple.pvi.bob + tab + Open Display + + AllObject Two + + + + imple + 143 + 105 + 125 + 20 + $(actions) + + diff --git a/tests/samples/outputs/simple.pvi.bob b/tests/samples/outputs/simple.pvi.bob new file mode 100644 index 000000000..61c53372c --- /dev/null +++ b/tests/samples/outputs/simple.pvi.bob @@ -0,0 +1,49 @@ + + Display + 0 + 0 + 273 + 55 + 4 + 4 + + Title + TITLE + Simple Device - ${prefix} + 0 + 0 + 273 + 25 + + + + + + + + + true + 1 + + + Label + Simple PV + 23 + 30 + 115 + 20 + + + TextUpdate + ${prefix}Simple + 143 + 30 + 125 + 20 + + + + + 1 + + diff --git a/tests/samples/outputs/simple.pvi.template b/tests/samples/outputs/simple.pvi.template new file mode 100644 index 000000000..ed3f5255b --- /dev/null +++ b/tests/samples/outputs/simple.pvi.template @@ -0,0 +1,15 @@ + +### PV Interface for Simple Device ### + +record("*", "${prefix}Simple") { + info(Q:group, { + "${prefix}PVI": { + "pvi.Simple.r": { + "+channel": "NAME", + "+type": "plain", + } + } + }) +} + +### End of PV Interface for Simple Device ### diff --git a/tests/samples/schemas/ibek.support.schema.json b/tests/samples/schemas/ibek.support.schema.json index 51304930d..da6550454 100644 --- a/tests/samples/schemas/ibek.support.schema.json +++ b/tests/samples/schemas/ibek.support.schema.json @@ -203,14 +203,14 @@ "title": "Env Vars", "type": "array" }, - "opis": { - "default": [], - "description": "Declares the PVI YAML for generating screens", - "items": { - "$ref": "#/$defs/Opi" - }, - "title": "Opis", - "type": "array" + "pvi": { + "allOf": [ + { + "$ref": "#/$defs/EntityPVI" + } + ], + "default": null, + "description": "PVI definition for Entity" } }, "required": [ @@ -220,6 +220,40 @@ "title": "Definition", "type": "object" }, + "EntityPVI": { + "additionalProperties": false, + "description": "Entity PVI definition", + "properties": { + "yaml_path": { + "description": "Path to .pvi.device.yaml - absolute or relative to PVI_DEFS", + "title": "Yaml Path", + "type": "string" + }, + "index": { + "default": true, + "description": "Whether to add generated UI to index for Entity", + "title": "Index", + "type": "boolean" + }, + "prefix": { + "description": "PV prefix to pass as $(prefix) on index button", + "title": "Prefix", + "type": "string" + }, + "pva": { + "default": false, + "description": "Whether to generate PVA structure template", + "title": "Pva", + "type": "boolean" + } + }, + "required": [ + "yaml_path", + "prefix" + ], + "title": "EntityPVI", + "type": "object" + }, "EnumArg": { "additionalProperties": false, "description": "An argument with an enum value", @@ -441,45 +475,6 @@ "title": "ObjectArg", "type": "object" }, - "Opi": { - "additionalProperties": false, - "description": "Describes one PVI YAML file", - "properties": { - "type": { - "allOf": [ - { - "$ref": "#/$defs/OpiType" - } - ], - "description": "bob or pvi" - }, - "file": { - "description": "filename of bob or pvi", - "title": "File", - "type": "string" - }, - "prefix": { - "description": "Args to make unique screen", - "title": "Prefix", - "type": "string" - } - }, - "required": [ - "type", - "file", - "prefix" - ], - "title": "Opi", - "type": "object" - }, - "OpiType": { - "enum": [ - "pvi", - "bob" - ], - "title": "OpiType", - "type": "string" - }, "StrArg": { "additionalProperties": false, "description": "An argument with a str value", diff --git a/tests/samples/yaml/all.ibek.support.yaml b/tests/samples/yaml/all.ibek.support.yaml index b6f5dcf27..fbff68342 100644 --- a/tests/samples/yaml/all.ibek.support.yaml +++ b/tests/samples/yaml/all.ibek.support.yaml @@ -118,3 +118,8 @@ defs: - file: jinjified{{ my_int }}.db args: name: + + pvi: + yaml_path: simple.pvi.device.yaml + prefix: "{{ name }}" + pva: true diff --git a/tests/samples/yaml/objects.ibek.support.yaml b/tests/samples/yaml/objects.ibek.support.yaml index 83a6eb7c0..a8e038b9f 100644 --- a/tests/samples/yaml/objects.ibek.support.yaml +++ b/tests/samples/yaml/objects.ibek.support.yaml @@ -36,6 +36,10 @@ defs: # TestValues testValue TestValues {{ test_value }} + pvi: + yaml_path: simple.pvi.device.yaml + prefix: "{{ name }}" + - name: Consumer description: | A class that uses RefObject diff --git a/tests/test_cli.py b/tests/test_cli.py index 54339ca68..fe41359e0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -136,6 +136,14 @@ def test_build_runtime_multiple(tmp_path: Path, samples: Path): actual_db = out_db.read_text() assert example_db == actual_db + example_index = (samples / "outputs" / "index.bob").read_text() + actual_index = (samples / "epics" / "opi" / "index.bob").read_text() + assert example_index == actual_index + + example_pvi = (samples / "outputs" / "simple.pvi.bob").read_text() + actual_pvi = (samples / "epics" / "opi" / "simple.pvi.bob").read_text() + assert example_pvi == actual_pvi + def test_build_utils_features(tmp_path: Path, samples: Path): """