From 7155bed63aab121ab3b0ee4f8a5398523f280e2a Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Wed, 2 Sep 2026 18:04:51 +0000 Subject: [PATCH 1/4] feat: allow multuple aliases per fastcs pv --- src/fastcs/demo/schema.json | 12 ++++ src/fastcs/transports/epics/ca/ioc.py | 69 +++++++++++++-------- src/fastcs/transports/epics/options.py | 4 +- tests/data/schema.json | 84 +++++++++++++++++++++----- 4 files changed, 128 insertions(+), 41 deletions(-) diff --git a/src/fastcs/demo/schema.json b/src/fastcs/demo/schema.json index 486d7da20..3164efe85 100644 --- a/src/fastcs/demo/schema.json +++ b/src/fastcs/demo/schema.json @@ -46,6 +46,18 @@ }, { "$ref": "#/$defs/EnumMapping" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "items": { + "$ref": "#/$defs/EnumMapping" + }, + "type": "array" } ] }, diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index d9f657796..f4a73cde5 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -35,11 +35,19 @@ class EpicsCAIOC: def __init__( self, controller_apis: list[ControllerAPI], - aliases: dict[str, str | EnumMapping], + aliases: dict[str, str | EnumMapping | list[str] | list[EnumMapping]], ): - alias_pvs = [ - value if isinstance(value, str) else value.pv for value in aliases.values() - ] + alias_pvs = [] + + for value in aliases.values(): + if isinstance(value, str): + alias_pvs.append(value) + elif isinstance(value, EnumMapping): + alias_pvs.append(value.pv) + else: + alias_pvs.extend( + item if isinstance(item, str) else item.pv for item in value + ) if duplicate_aliases := [ alias for alias, count in Counter(alias_pvs).items() if count > 1 @@ -130,7 +138,8 @@ def _add_sub_controller_pvi_info(parent: ControllerAPI): def _create_and_link_attribute_pvs( - root_controller_api: ControllerAPI, aliases: dict[str, str | EnumMapping] + root_controller_api: ControllerAPI, + aliases: dict[str, str | EnumMapping | list[str] | list[EnumMapping]], ) -> None: for controller_api in root_controller_api.walk_api(): pv_prefix = pv_prefix_from_path(controller_api.path) @@ -222,20 +231,23 @@ def _create_and_link_read_pv( pv_prefix: str, pv_name: str, attr_name: str, - alias: str | EnumMapping | None, + alias: str | EnumMapping | list[str] | list[EnumMapping] | None, attribute: AttrR[DType_T], ) -> None: pv = f"{pv_prefix}:{pv_name}" record = _make_in_record_and_add_callback(pv, attribute) - if isinstance(alias, str): - _add_alias(record, alias) - elif isinstance(alias, EnumMapping): - enum_attr = _get_read_enum_attr_from_type(alias) - _add_read_enum_alias(alias, attribute, enum_attr) + aliases = alias if isinstance(alias, list) else [alias] - _add_attr_pvi_info(record, pv_prefix, attr_name, "r") + for alias in aliases: + if isinstance(alias, str): + _add_alias(record, alias) + elif isinstance(alias, EnumMapping): + enum_attr = _get_read_enum_attr_from_type(alias) + _add_read_enum_alias(alias, attribute, enum_attr) + + _add_attr_pvi_info(record, pv_prefix, attr_name, "r") def _make_out_record_and_sync_setpoint( @@ -263,24 +275,28 @@ def _create_and_link_write_pv( pv_prefix: str, pv_name: str, attr_name: str, - alias: str | EnumMapping | None, + alias: str | EnumMapping | list[str] | list[EnumMapping] | None, attribute: AttrW[DType_T], ): pv = f"{pv_prefix}:{pv_name}" record = _make_out_record_and_sync_setpoint(pv, attribute) - if isinstance(alias, str): - _add_alias(record, alias) - elif isinstance(alias, EnumMapping): - enum_attr = _get_write_enum_attr_from_type(alias) - _add_write_enum_alias(alias, attribute, enum_attr) + aliases = alias if isinstance(alias, list) else [alias] + + for alias in aliases: + if isinstance(alias, str): + _add_alias(record, alias) + elif isinstance(alias, EnumMapping): + enum_attr = _get_write_enum_attr_from_type(alias) + _add_write_enum_alias(alias, attribute, enum_attr) _add_attr_pvi_info(record, pv_prefix, attr_name, "w") def _create_and_link_command_pvs( - root_controller_api: ControllerAPI, aliases: dict[str, str | EnumMapping] + root_controller_api: ControllerAPI, + aliases: dict[str, str | EnumMapping | list[str] | list[EnumMapping]], ) -> None: for controller_api in root_controller_api.walk_api(): pv_prefix = pv_prefix_from_path(controller_api.path) @@ -309,7 +325,7 @@ def _create_and_link_command_pv( pv_prefix: str, pv_name: str, attr_name: str, - alias: str | EnumMapping | None, + alias: str | EnumMapping | list[str] | list[EnumMapping] | None, method: Command, ) -> None: pv = f"{pv_prefix}:{pv_name}" @@ -327,11 +343,14 @@ async def wrapped_method(_: Any): ONAM="Active", ) - if isinstance(alias, str): - _add_alias(record, alias) - elif isinstance(alias, EnumMapping): - enum_attr = _get_write_enum_attr_from_type(alias) - _add_command_enum_alias(alias, method, enum_attr) + aliases = alias if isinstance(alias, list) else [alias] + + for alias in aliases: + if isinstance(alias, str): + _add_alias(record, alias) + elif isinstance(alias, EnumMapping): + enum_attr = _get_write_enum_attr_from_type(alias) + _add_command_enum_alias(alias, method, enum_attr) _add_attr_pvi_info(record, pv_prefix, attr_name, "x") diff --git a/src/fastcs/transports/epics/options.py b/src/fastcs/transports/epics/options.py index ea3b17fc2..85e835a84 100644 --- a/src/fastcs/transports/epics/options.py +++ b/src/fastcs/transports/epics/options.py @@ -49,7 +49,9 @@ class EpicsCAOptions: __pydantic_config__: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - aliases: dict[str, str | EnumMapping] = field(default_factory=dict) + aliases: dict[str, str | EnumMapping | list[str] | list[EnumMapping]] = field( + default_factory=dict + ) """Mapping of fastcs PV names to their aliases. Setpoint and readback PVs must be aliased separately. diff --git a/tests/data/schema.json b/tests/data/schema.json index ce2d1e1ff..48b306e11 100644 --- a/tests/data/schema.json +++ b/tests/data/schema.json @@ -1,11 +1,65 @@ { "$defs": { + "EnumMapping": { + "additionalProperties": false, + "properties": { + "pv": { + "title": "Pv", + "type": "string" + }, + "mapping": { + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "title": "Mapping", + "type": "object" + } + }, + "required": [ + "pv", + "mapping" + ], + "title": "EnumMapping", + "type": "object" + }, "EpicsCAOptions": { "additionalProperties": false, "properties": { "aliases": { "additionalProperties": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/EnumMapping" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "items": { + "$ref": "#/$defs/EnumMapping" + }, + "type": "array" + } + ] }, "title": "Aliases", "type": "object" @@ -181,14 +235,14 @@ "title": "Id", "type": "string" }, + "name": { + "title": "Name", + "type": "string" + }, "type": { "const": "tests.IsHinted", "title": "Type", "type": "string" - }, - "name": { - "title": "Name", - "type": "string" } }, "required": [ @@ -206,15 +260,15 @@ "title": "Host", "type": "string" }, - "port": { - "default": 8080, - "title": "Port", - "type": "integer" - }, "log_level": { "default": "info", "title": "Log Level", "type": "string" + }, + "port": { + "default": 8080, + "title": "Port", + "type": "integer" } }, "title": "RestServerOptions", @@ -232,15 +286,15 @@ }, "TangoDSROptions": { "properties": { - "dsr_instance": { - "default": "MY_SERVER_INSTANCE", - "title": "Dsr Instance", - "type": "string" - }, "debug": { "default": false, "title": "Debug", "type": "boolean" + }, + "dsr_instance": { + "default": "MY_SERVER_INSTANCE", + "title": "Dsr Instance", + "type": "string" } }, "title": "TangoDSROptions", From bfbe69306f1c8fd2b49706c0f683e3f6289468a3 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Thu, 3 Sep 2026 09:45:03 +0000 Subject: [PATCH 2/4] tests: add test for multiple aliases --- tests/example_softioc.py | 2 +- tests/transports/epics/ca/test_softioc_system.py | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/example_softioc.py b/tests/example_softioc.py index 3688ff4fb..a8650e6fd 100644 --- a/tests/example_softioc.py +++ b/tests/example_softioc.py @@ -46,7 +46,7 @@ def run(id="SOFTIOC_TEST_DEVICE"): EpicsCATransport( epicsca=EpicsCAOptions( aliases={ - f"{id}:B": f"{id}:AliasB", + f"{id}:B": [f"{id}:AliasB1", f"{id}:AliasB2"], f"{id}:B_RBV": f"{id}:AliasB_RBV", } ), diff --git a/tests/transports/epics/ca/test_softioc_system.py b/tests/transports/epics/ca/test_softioc_system.py index f898fe06b..87f3e7647 100644 --- a/tests/transports/epics/ca/test_softioc_system.py +++ b/tests/transports/epics/ca/test_softioc_system.py @@ -47,12 +47,15 @@ def test_ioc(softioc_subprocess: tuple[str, Queue]): } # Assert alias. Aliases do not show up in PVI structure - assert ctxt.get(f"{pv_prefix}:B") == ctxt.get(f"{pv_prefix}:AliasB") == 0 + assert ctxt.get(f"{pv_prefix}:B") == ctxt.get(f"{pv_prefix}:AliasB1") == 0 ctxt.put(f"{pv_prefix}:B", 10, wait=True) - assert ctxt.get(f"{pv_prefix}:AliasB") == 10 - ctxt.put(f"{pv_prefix}:AliasB", 20, wait=True) + assert ctxt.get(f"{pv_prefix}:AliasB1") == 10 + assert ctxt.get(f"{pv_prefix}:AliasB2") == 10 + ctxt.put(f"{pv_prefix}:AliasB1", 20, wait=True) assert ctxt.get(f"{pv_prefix}:B") == 20 - assert ctxt.get(f"{pv_prefix}:B_RBV") == ctxt.get(f"{pv_prefix}:AliasB_RBV") == 20 + ctxt.put(f"{pv_prefix}:AliasB2", 30, wait=True) + assert ctxt.get(f"{pv_prefix}:B") == 30 + assert ctxt.get(f"{pv_prefix}:B_RBV") == ctxt.get(f"{pv_prefix}:AliasB_RBV") == 30 # Assert command exceptions set record alarm states ctxt.put(f"{pv_prefix}:ChildVector:0:D", True, wait=True) From 836d8452127b152299f25e3d7d8d7ec2ec6700ec Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Thu, 3 Sep 2026 10:53:53 +0000 Subject: [PATCH 3/4] chore: amend aliases to Mapping from dict --- src/fastcs/transports/epics/ca/ioc.py | 14 +++++++------- src/fastcs/transports/epics/options.py | 5 +++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index f4a73cde5..cc8b503de 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -1,6 +1,6 @@ import asyncio from collections import Counter -from collections.abc import Awaitable +from collections.abc import Awaitable, Mapping from enum import IntEnum from typing import Any, Literal, TypeVar @@ -35,7 +35,7 @@ class EpicsCAIOC: def __init__( self, controller_apis: list[ControllerAPI], - aliases: dict[str, str | EnumMapping | list[str] | list[EnumMapping]], + aliases: Mapping[str, str | EnumMapping | list[str] | list[EnumMapping]], ): alias_pvs = [] @@ -139,7 +139,7 @@ def _add_sub_controller_pvi_info(parent: ControllerAPI): def _create_and_link_attribute_pvs( root_controller_api: ControllerAPI, - aliases: dict[str, str | EnumMapping | list[str] | list[EnumMapping]], + aliases: Mapping[str, str | EnumMapping | list[str] | list[EnumMapping]], ) -> None: for controller_api in root_controller_api.walk_api(): pv_prefix = pv_prefix_from_path(controller_api.path) @@ -166,7 +166,7 @@ def _create_and_link_attribute_pvs( ) continue - alias = aliases.pop(f"{pv_prefix}:{pv_name}", None) + alias = aliases.get(f"{pv_prefix}:{pv_name}", None) match attribute: case AttrRW(): if full_pv_name_length > (EPICS_MAX_NAME_LENGTH - 4): @@ -177,7 +177,7 @@ def _create_and_link_attribute_pvs( ) attribute.enabled = False else: - alias_rbv = aliases.pop( + alias_rbv = aliases.get( f"{pv_prefix}:{pv_name}{RBV_SUFFIX}", None ) _create_and_link_read_pv( @@ -296,14 +296,14 @@ def _create_and_link_write_pv( def _create_and_link_command_pvs( root_controller_api: ControllerAPI, - aliases: dict[str, str | EnumMapping | list[str] | list[EnumMapping]], + aliases: Mapping[str, str | EnumMapping | list[str] | list[EnumMapping]], ) -> None: for controller_api in root_controller_api.walk_api(): pv_prefix = pv_prefix_from_path(controller_api.path) for attr_name, method in controller_api.command_methods.items(): pv_name = snake_to_pascal(attr_name) - alias = aliases.pop(f"{pv_prefix}:{pv_name}", None) + alias = aliases.get(f"{pv_prefix}:{pv_name}", None) if len(f"{pv_prefix}:{pv_name}") > EPICS_MAX_NAME_LENGTH: print( diff --git a/src/fastcs/transports/epics/options.py b/src/fastcs/transports/epics/options.py index 85e835a84..c81682450 100644 --- a/src/fastcs/transports/epics/options.py +++ b/src/fastcs/transports/epics/options.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from dataclasses import dataclass, field from enum import Enum from pathlib import Path @@ -36,7 +37,7 @@ class EnumMapping: __pydantic_config__: ClassVar[ConfigDict] = ConfigDict(extra="forbid") pv: str - mapping: dict[str, int | str | float | bool] + mapping: Mapping[str, int | str | float | bool] @dataclass @@ -49,7 +50,7 @@ class EpicsCAOptions: __pydantic_config__: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - aliases: dict[str, str | EnumMapping | list[str] | list[EnumMapping]] = field( + aliases: Mapping[str, str | EnumMapping | list[str] | list[EnumMapping]] = field( default_factory=dict ) """Mapping of fastcs PV names to their aliases. From bbad002b7a551f06d64aa320eed902ffe355ae90 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Thu, 3 Sep 2026 11:04:49 +0000 Subject: [PATCH 4/4] chore: regenerate schema --- tests/data/schema.json | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/data/schema.json b/tests/data/schema.json index 48b306e11..0eeea1e4b 100644 --- a/tests/data/schema.json +++ b/tests/data/schema.json @@ -11,16 +11,16 @@ "additionalProperties": { "anyOf": [ { - "type": "integer" + "type": "boolean" }, { - "type": "string" + "type": "integer" }, { "type": "number" }, { - "type": "boolean" + "type": "string" } ] }, @@ -235,14 +235,14 @@ "title": "Id", "type": "string" }, - "name": { - "title": "Name", - "type": "string" - }, "type": { "const": "tests.IsHinted", "title": "Type", "type": "string" + }, + "name": { + "title": "Name", + "type": "string" } }, "required": [ @@ -260,15 +260,15 @@ "title": "Host", "type": "string" }, - "log_level": { - "default": "info", - "title": "Log Level", - "type": "string" - }, "port": { "default": 8080, "title": "Port", "type": "integer" + }, + "log_level": { + "default": "info", + "title": "Log Level", + "type": "string" } }, "title": "RestServerOptions", @@ -286,15 +286,15 @@ }, "TangoDSROptions": { "properties": { - "debug": { - "default": false, - "title": "Debug", - "type": "boolean" - }, "dsr_instance": { "default": "MY_SERVER_INSTANCE", "title": "Dsr Instance", "type": "string" + }, + "debug": { + "default": false, + "title": "Debug", + "type": "boolean" } }, "title": "TangoDSROptions",