From 62052701e8906f4797e68b8f4d980723813840e9 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Tue, 14 Jul 2026 15:07:45 +0000 Subject: [PATCH 01/15] feat: handle put exceptions and set records into alarm --- src/fastcs/attributes/attr_w.py | 1 + src/fastcs/transports/epics/ca/ioc.py | 35 +++++++++++++------ .../transports/epics/pva/_pv_handlers.py | 21 ++++++++--- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/fastcs/attributes/attr_w.py b/src/fastcs/attributes/attr_w.py index 3e6a4517d..74c9a4828 100644 --- a/src/fastcs/attributes/attr_w.py +++ b/src/fastcs/attributes/attr_w.py @@ -59,6 +59,7 @@ async def put(self, setpoint: DType_T, sync_setpoint: bool = False) -> None: logger.opt(exception=e).error( "Put failed", attribute=self, setpoint=setpoint ) + raise if sync_setpoint: try: diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 536cdcfa5..084f56ba1 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -2,7 +2,7 @@ from collections import Counter from typing import Any, Literal -from softioc import builder, softioc +from softioc import alarm, builder, softioc from softioc.asyncio_dispatcher import AsyncioDispatcher from softioc.pythonSoftIoc import RecordWrapper @@ -218,18 +218,25 @@ async def async_record_set(value: DType_T): def _create_and_link_write_pv( - pv_prefix: str, - pv_name: str, - attr_name: str, - alias: str | None, - attribute: AttrW[DType_T], -) -> None: + pv_prefix, + pv_name, + attr_name, + alias, + attribute, +): pv = f"{pv_prefix}:{pv_name}" async def on_update(value): logger.info("PV put: {pv} = {value}", pv=pv, value=repr(value)) - - await attribute.put(cast_from_epics_type(attribute.datatype, value)) + try: + await attribute.put(cast_from_epics_type(attribute.datatype, value)) + except Exception: + record.set( + record.get(), + process=False, + severity=alarm.MAJOR_ALARM, + alarm=alarm.MAJOR_ALARM, + ) async def set_setpoint_without_process(value: DType_T): tracer.log_event( @@ -281,7 +288,15 @@ def _create_and_link_command_pv( async def wrapped_method(_: Any): tracer.log_event("Command PV put", topic=method, pv=pv) - await method.fn() + try: + await method.fn() + except Exception: + record.set( + record.get(), + process=False, + severity=alarm.MAJOR_ALARM, + alarm=alarm.MAJOR_ALARM, + ) record = builder.Action( f"{pv_prefix}:{pv_name}", diff --git a/src/fastcs/transports/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py index 5ba819f98..f254e7c19 100644 --- a/src/fastcs/transports/epics/pva/_pv_handlers.py +++ b/src/fastcs/transports/epics/pva/_pv_handlers.py @@ -53,8 +53,16 @@ async def put(self, pv: SharedPV, op: ServerOperation): else: pv.post(value) - await self._attr_w.put(cast_value) - op.done() + try: + await self._attr_w.put(cast_value) + op.done() + except Exception as e: + error_msg = f"Exception raised during put operation: {e!r}" + op.done(error=error_msg) + alarm_states = p4p_alarm_states( + MAJOR_ALARM_SEVERITY, RECORD_ALARM_STATUS, error_msg + ) + pv.post({**alarm_states}) class CommandPvHandler: @@ -68,8 +76,9 @@ async def _run_command(self) -> dict: try: await self._command() except Exception as e: + error_msg = f"Exception raised during command put: {e!r}" alarm_states = p4p_alarm_states( - MAJOR_ALARM_SEVERITY, RECORD_ALARM_STATUS, str(e) + MAJOR_ALARM_SEVERITY, RECORD_ALARM_STATUS, error_msg ) else: alarm_states = p4p_alarm_states() @@ -102,7 +111,11 @@ async def put(self, pv: SharedPV, op: ServerOperation): alarm_states = await self._run_command() pv.post({"value": False, **p4p_timestamp_now(), **alarm_states}) if blocking: - op.done() + # Check if we are in alarm + if msg := alarm_states["alarm"]["message"]: + op.done(error=msg) + else: + op.done() else: raise RuntimeError("Commands should only take the value `True`.") From b2b5e9b38247a926f3d2f2cc315152ff68fc3205 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Wed, 15 Jul 2026 14:06:05 +0000 Subject: [PATCH 02/15] tests: assert attrw put raises --- tests/test_attributes.py | 5 +++-- tests/transports/epics/pva/test_p4p.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_attributes.py b/tests/test_attributes.py index cdd428911..41bd8547e 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -458,7 +458,8 @@ async def do_sync_setpoint(setpoint: int): attr.set_on_put_callback(do_put) attr.add_sync_setpoint_callback(do_sync_setpoint) - await attr.put(5) + with pytest.raises(ValueError, match="do_put failed"): + await attr.put(5) - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="Attribute already has an on put callback"): attr.set_on_put_callback(do_put) diff --git a/tests/transports/epics/pva/test_p4p.py b/tests/transports/epics/pva/test_p4p.py index 947ade79d..cbd73cac0 100644 --- a/tests/transports/epics/pva/test_p4p.py +++ b/tests/transports/epics/pva/test_p4p.py @@ -148,7 +148,9 @@ async def test_command_method(p4p_subprocess: tuple[str, Queue]): assert after_command_value["value"] is False assert after_command_value["alarm"]["severity"] == 2 assert ( - after_command_value["alarm"]["message"] == "I: FAILED WITH THIS WEIRD ERROR" + after_command_value["alarm"]["message"] + == "Exception raised during command put: " + "RuntimeError('I: FAILED WITH THIS WEIRD ERROR')" ) # Failed I process does not increment J assert j_values.empty() From 48791ccda2623fbf35e50f82e714899dd8393cff Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Wed, 15 Jul 2026 15:51:04 +0000 Subject: [PATCH 03/15] tests: assert alarm states set in tests --- .../transports/epics/pva/_pv_handlers.py | 3 +- tests/example_softioc.py | 11 +++++- .../epics/ca/test_softioc_system.py | 10 ++++++ tests/transports/epics/pva/test_p4p.py | 36 +++++++++++++++++-- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/fastcs/transports/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py index f254e7c19..4b2f47c78 100644 --- a/src/fastcs/transports/epics/pva/_pv_handlers.py +++ b/src/fastcs/transports/epics/pva/_pv_handlers.py @@ -55,7 +55,6 @@ async def put(self, pv: SharedPV, op: ServerOperation): try: await self._attr_w.put(cast_value) - op.done() except Exception as e: error_msg = f"Exception raised during put operation: {e!r}" op.done(error=error_msg) @@ -63,6 +62,8 @@ async def put(self, pv: SharedPV, op: ServerOperation): MAJOR_ALARM_SEVERITY, RECORD_ALARM_STATUS, error_msg ) pv.post({**alarm_states}) + else: + op.done() class CommandPvHandler: diff --git a/tests/example_softioc.py b/tests/example_softioc.py index 14c1a0278..540ffa359 100644 --- a/tests/example_softioc.py +++ b/tests/example_softioc.py @@ -1,3 +1,4 @@ +import asyncio from pathlib import Path from fastcs.attributes import AttrR, AttrRW, AttrW @@ -18,11 +19,19 @@ class ParentController(Controller): class ChildController(Controller): + fail_on_next_e: bool = True c: AttrW = AttrW(Int()) @command() async def d(self): - pass + print("D: RUNNING") + await asyncio.sleep(0.1) + if self.fail_on_next_e: + self.fail_on_next_e = False + raise RuntimeError("D: FAILED WITH THIS WEIRD ERROR") + else: + self.fail_on_next_e = True + print("D: FINISHED") def run(id="SOFTIOC_TEST_DEVICE"): diff --git a/tests/transports/epics/ca/test_softioc_system.py b/tests/transports/epics/ca/test_softioc_system.py index 64df3750d..dba6a1dcd 100644 --- a/tests/transports/epics/ca/test_softioc_system.py +++ b/tests/transports/epics/ca/test_softioc_system.py @@ -2,6 +2,7 @@ from p4p import Value from p4p.client.thread import Context +from softioc import alarm def test_ioc(softioc_subprocess: tuple[str, Queue]): @@ -51,3 +52,12 @@ def test_ioc(softioc_subprocess: tuple[str, Queue]): assert ctxt.get(f"{pv_prefix}:AliasB") == 10 ctxt.put(f"{pv_prefix}:AliasB", 20) assert ctxt.get(f"{pv_prefix}:B") == 20 + + # Assert command exceptions set record alarm states + ctxt.put(f"{pv_prefix}:ChildVector:0:D", True, wait=True) + # Need to put twice due to softioc bug + # where second put will propogates first put's alarm + ctxt.put(f"{pv_prefix}:ChildVector:0:D", True, wait=True) + d_alarm = ctxt.get(f"{pv_prefix}:ChildVector:0:D.SEVR") + + assert d_alarm == alarm.MAJOR_ALARM diff --git a/tests/transports/epics/pva/test_p4p.py b/tests/transports/epics/pva/test_p4p.py index cbd73cac0..8bfff9950 100644 --- a/tests/transports/epics/pva/test_p4p.py +++ b/tests/transports/epics/pva/test_p4p.py @@ -1,5 +1,6 @@ import asyncio import enum +from dataclasses import dataclass from datetime import datetime from multiprocessing import Queue from unittest.mock import ANY @@ -13,9 +14,9 @@ from p4p.client.thread import Context as ThreadContext from p4p.nt import NTTable -from fastcs.attributes import AttrR, AttrRW, AttrW +from fastcs.attributes import AttributeIO, AttributeIORef, AttrR, AttrRW, AttrW from fastcs.controllers import Controller, ControllerVector -from fastcs.datatypes import Bool, Enum, Float, Int, String, Table, Waveform +from fastcs.datatypes import Bool, DType_T, Enum, Float, Int, String, Table, Waveform from fastcs.launch import FastCS from fastcs.methods import command from fastcs.transports.epics.pva.transport import EpicsPVATransport @@ -211,6 +212,37 @@ async def test_numeric_alarms(p4p_subprocess: tuple[str, Queue]): a_monitor.close() +def test_alarms_set_if_put_fails(caplog): + @dataclass + class SimpleAttributeIORef(AttributeIORef): + pass + + class SimpleAttributeIO(AttributeIO[DType_T, SimpleAttributeIORef]): + async def send(self, attr: AttrW[DType_T, SimpleAttributeIORef], value): + raise ValueError("Failed") + + class SomeController(Controller): + a = AttrW(Int(), io_ref=SimpleAttributeIORef()) + + controller = SomeController(ios=[SimpleAttributeIO()]) + pv_prefix = str(uuid4()) + fastcs = make_fastcs(pv_prefix, controller) + + async def put_pvs(): + await asyncio.sleep(0.1) + ctxt = Context("pva") + await ctxt.put(f"{pv_prefix}:A", 1) + + serve = asyncio.ensure_future(fastcs.serve(interactive=False)) + try: + asyncio.get_event_loop().run_until_complete( + asyncio.wait_for(asyncio.gather(serve, put_pvs()), timeout=1) + ) + except RuntimeError as exc: + assert str(exc) == "Exception raised during put operation: ValueError('Failed')" + serve.cancel() + + def make_fastcs(pv_prefix: str, controller: Controller) -> FastCS: controller.set_path([pv_prefix]) return FastCS(controller, [EpicsPVATransport()]) From ed6398c932c8ae06a9b0e98ef0a6685f6eec961a Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Wed, 15 Jul 2026 16:32:01 +0000 Subject: [PATCH 04/15] wip: use pythonsoftioc master as dep --- pyproject.toml | 9 ++++++--- uv.lock | 50 +++++++++++++++++++------------------------------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f178a375c..23d42ad38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,8 +5,8 @@ build-backend = "setuptools.build_meta" [project] name = "fastcs" authors = [ - {name = "Martin Gaughran", email = "martin.gaughran@diamond.ac.uk"}, - {name = "Gary Yendell", email = "gary.yendell@diamond.ac.uk"}, + { name = "Martin Gaughran", email = "martin.gaughran@diamond.ac.uk" }, + { name = "Gary Yendell", email = "gary.yendell@diamond.ac.uk" }, ] classifiers = [ "Development Status :: 3 - Alpha", @@ -34,7 +34,10 @@ requires-python = ">=3.11" [project.optional-dependencies] demo = ["tickit~=0.4.3"] -epicsca = ["pvi~=0.12.0", "softioc>=4.5.0"] +epicsca = [ + "pvi~=0.12.0", + "softioc @ git+https://github.com/DiamondLightSource/pythonSoftIOC.git@master", +] epicspva = ["p4p", "pvi~=0.12.0"] epics = ["fastcs[epicsca]", "fastcs[epicspva]"] tango = ["pytango"] diff --git a/uv.lock b/uv.lock index 9a6d3c727..eb1866d0d 100644 --- a/uv.lock +++ b/uv.lock @@ -698,29 +698,29 @@ wheels = [ [[package]] name = "epicscorelibs" -version = "7.0.10.99.0.0" +version = "7.0.10.99.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "setuptools" }, { name = "setuptools-dso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/0d/721e5299603b585dd6dfb97295ceb15a48170cef8ce373faa76f69322c51/epicscorelibs-7.0.10.99.0.0.tar.gz", hash = "sha256:d21c84807bb329f4b8752d1c9638a4e60f42df2dc837b6286f0b2d5515129a3d", size = 1669631, upload-time = "2026-01-08T01:29:46.207Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/8e/3ab7107913f567947eb9c4ff1c948823b0e7069361358a7567e136e527d3/epicscorelibs-7.0.10.99.0.1.tar.gz", hash = "sha256:e8c8d9730faef7df55f4d329c359a0e4c044f03a524dd2e4587e90132873edfd", size = 1669710, upload-time = "2026-02-11T01:39:48.704Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/55/972ecdd386d28db0a5b18f0a84d5210401a623abec0cfe25dff2d3202c0b/epicscorelibs-7.0.10.99.0.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:1e4a64189b04649802a325b65e70aa566e62fc2ac827652b2f0fcd86ad784c4a", size = 7636875, upload-time = "2026-01-08T01:29:14.189Z" }, - { url = "https://files.pythonhosted.org/packages/a2/94/f79d480f538086a23871a88574baee4934f88c46a3002adb9895fe1f7fca/epicscorelibs-7.0.10.99.0.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:5a78ce633f6f0bae6675c9e128145480a1e428cc47495e2580eb05db2f6b37ba", size = 5421557, upload-time = "2026-01-08T01:29:15.995Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/f8899630e6beb535596f7408016df4a16ef57bf2221214d307373771bd6f/epicscorelibs-7.0.10.99.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:227b16e9f4ba64350943f96ad866f69e104de2ea8f8bf31e8de6b4c3b99b20b5", size = 2533327, upload-time = "2026-01-08T01:29:17.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/66/a559c144bd0993c4e0ae5b91e9cee8dd2edb171242c5e4937cef1404bf0e/epicscorelibs-7.0.10.99.0.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:c20fbcec49effb9658e917317c091c099d00204dc0084881cb61c4a18f856cfb", size = 7636035, upload-time = "2026-01-08T01:29:19.235Z" }, - { url = "https://files.pythonhosted.org/packages/6e/b4/03e3d2a6b8c9ed552674e7b47715861719f02e08ca6754adc863e6644112/epicscorelibs-7.0.10.99.0.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:b62015fb52b315fe5b612d14f570fa2da7de5e6adf80a108deccbdcff6651f4b", size = 5423699, upload-time = "2026-01-08T01:29:21.785Z" }, - { url = "https://files.pythonhosted.org/packages/dd/73/62942246aad56d98bff18c2603a64be3f800763d01e69fd89f9fcbf5c0a0/epicscorelibs-7.0.10.99.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c04ca38cacecbac749a7d6520b8d0b038ac7257e6c161780ae99f9168dd1d9c", size = 2533275, upload-time = "2026-01-08T01:29:23.514Z" }, - { url = "https://files.pythonhosted.org/packages/fe/69/d9225563e9aac120db7461aa81c558f245db6a1e5a08e82dd538d6ca5d8d/epicscorelibs-7.0.10.99.0.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8620955b611983181e8b283f06ce2690a8d2a7955d9bcd2b48abb623bdbc736", size = 7635896, upload-time = "2026-01-08T01:29:25.367Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5e/8dcec23dd61a60f12d6c90b580ba32d356261790eb7aac59a03190a4f7d0/epicscorelibs-7.0.10.99.0.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:b25b511955020138f13bb7231cf05840034fa092e47e8950e870519ac60e58ed", size = 5423685, upload-time = "2026-01-08T01:29:27.311Z" }, - { url = "https://files.pythonhosted.org/packages/82/d5/a300e6605f54a7daae3828aeee14b8c3b6f64c5fde931a058a8fd8653b1b/epicscorelibs-7.0.10.99.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:eb4e533efa6edfced2bf7d0f243087df123b5d9556a30074ba20f8ad75c8e195", size = 2533255, upload-time = "2026-01-08T01:29:28.727Z" }, - { url = "https://files.pythonhosted.org/packages/a4/20/0034825cf77ee96ec1de6c5d3272bf6caa791e171dbdf4f7ed7067695ab6/epicscorelibs-7.0.10.99.0.0-cp313-cp313t-manylinux2014_x86_64.whl", hash = "sha256:0700a6025166f4ae2c1c5dde50795c063f9616925a7df0b5ef736c27b3965926", size = 5423710, upload-time = "2026-01-08T01:29:30.571Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a5/7b6112e32379fd004357fdb5a3b004fddba41d38fce1ad1044aff9444853/epicscorelibs-7.0.10.99.0.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:6599d4625d6e0cb7bfbba4d12952c5ba2a5f20ef64e5c936da4169d0ac172013", size = 7636181, upload-time = "2026-01-08T01:29:31.929Z" }, - { url = "https://files.pythonhosted.org/packages/3b/a1/a76f33feb57c85ebe326cb42867b8387f9ce2383e589cf88516632f89411/epicscorelibs-7.0.10.99.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d7ba960dcf02628398dc29464dafbf70a3d689cfdf0418f7597c3e13a45590da", size = 5358457, upload-time = "2026-01-08T01:29:33.268Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/8e0069ceb5c8cd1286c0fe3a3fedafdd29c5825b96c3429d628f1e1c36b1/epicscorelibs-7.0.10.99.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:7b4b3da922b69e637126b6eb5e65d77a594d58021d74010fdcef376245be40c0", size = 2587368, upload-time = "2026-01-08T01:29:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/04/e8/464a91826103cddeb772d60250ce84ea3fdd84ff90bdc387c9483a777ad7/epicscorelibs-7.0.10.99.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:f0569ab084e149385f6fcfa3c441f9f43099357ad54ae21f9496220861b02d38", size = 5358471, upload-time = "2026-01-08T01:29:36.529Z" }, + { url = "https://files.pythonhosted.org/packages/14/9b/69a26ca4c0f6afda101f790339f3c05deef379f3ee3c12bb7be730542b05/epicscorelibs-7.0.10.99.0.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:98be3cea1239bc3076cc8e86759b4fa00903123e42c96664215af1063d177f55", size = 7636846, upload-time = "2026-02-11T01:39:20.647Z" }, + { url = "https://files.pythonhosted.org/packages/12/29/ce9c8e81a6b9be7722ac00cb592ce6b652405eecb178bf410810732957ec/epicscorelibs-7.0.10.99.0.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:91f112790a31c296bdb2c0f9b661e993c3ded9df9f4731a92887c5abfbaf568a", size = 5421614, upload-time = "2026-02-11T01:39:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/46/b5/dd2b4277ad1eaf49593e1fda042446423326075e991047f50269541585bc/epicscorelibs-7.0.10.99.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ca9784fd25d362d9248174c55df3f3cd902b7f51fb8cd165b4f1748c713293e", size = 2533399, upload-time = "2026-02-11T01:39:23.25Z" }, + { url = "https://files.pythonhosted.org/packages/10/fc/27b418beb7e7598783fe9a9eea830144b9e1a130c338d7da3ecd354447ad/epicscorelibs-7.0.10.99.0.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:cbc6457df698f916bec7bd9eb242b280e3e6e4a6a2379da86974a571566d618e", size = 7636199, upload-time = "2026-02-11T01:39:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7b/55f41ec7746713db21aff5e794fc98b5a2e4c40fc72d69f6a1cdb04df360/epicscorelibs-7.0.10.99.0.1-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:b7b9cb9dc1b533f6bb569dffe246256ba4d553a5843ddf494645c9b33a38ba2a", size = 5423739, upload-time = "2026-02-11T01:39:27.582Z" }, + { url = "https://files.pythonhosted.org/packages/09/28/fe6e34a35bb1f9c1873e56e7561cfb2646a47299e7fce5f757d70fafd2cc/epicscorelibs-7.0.10.99.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:28cc23c97c9ddbe7be67c036b37ecf945923351dec5894c5894afb7883ac099f", size = 2533296, upload-time = "2026-02-11T01:39:28.795Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0f/0643fffd4852c4af6d40415c0689e45947a5fc51e6093a15543ca375ae43/epicscorelibs-7.0.10.99.0.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:fa350350587b87080a8200554fae2fdbffe92fa0c9fb9e9d68c7d69577070ce8", size = 7636035, upload-time = "2026-02-11T01:39:30.03Z" }, + { url = "https://files.pythonhosted.org/packages/7c/aa/3b0faa7eea6a2957af7ee116f08b5edd99eff7e7461e22ac613b0a8d38ea/epicscorelibs-7.0.10.99.0.1-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:8ac0a67067d06c42d4ac83b685f8020d4e993858bc4e36d58631983b1117aa39", size = 5423737, upload-time = "2026-02-11T01:39:31.355Z" }, + { url = "https://files.pythonhosted.org/packages/45/a0/10099c41bdae2312b45816802a496bd1dee301e6f00108eec29dd498f6c9/epicscorelibs-7.0.10.99.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:5d22a8fbb26414e613fba77a9422a289fc30b05b32548c79644ef39d04f76743", size = 2533281, upload-time = "2026-02-11T01:39:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/f8/01/2182e903255db558c77b2a455de5381235259cf03737ea99b089adf4d905/epicscorelibs-7.0.10.99.0.1-cp313-cp313t-manylinux2014_x86_64.whl", hash = "sha256:4b2f8228844f73b06412075e50c6787e5d19af6d3ed230c7804b3765f1aa7274", size = 5423756, upload-time = "2026-02-11T01:39:35.109Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/245e2f0c3155b00a49a9376bcedb9d4b72cc9c36de5d73b4b55d0b69b293/epicscorelibs-7.0.10.99.0.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d747ad55055a5031630063f24695223f9d983f3389fd0a2133640c6f6f92043", size = 7636529, upload-time = "2026-02-11T01:39:36.388Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/42c5bb50507c528d9af10879067920d27c5a6b33fbef16c2e1b55de77ccc/epicscorelibs-7.0.10.99.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:7de58f4e8a7dd8edf7b808ffc99391e95ca3516c4f2780258c31f4f844d63239", size = 5358518, upload-time = "2026-02-11T01:39:37.699Z" }, + { url = "https://files.pythonhosted.org/packages/13/83/ba7b1534760786b45bdbe590bf9b972ba594582c80ca4ac473371321a6c4/epicscorelibs-7.0.10.99.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e2f294e60a0b3a50af5c26cda8c62b1ba926742f3a7d562e9ae65432dd16c18", size = 2587325, upload-time = "2026-02-11T01:39:39.181Z" }, + { url = "https://files.pythonhosted.org/packages/e6/94/5c7b17b3ff1962f9fe04b07d45008bccb3a6236887bb426babaa5690d372/epicscorelibs-7.0.10.99.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:045751cdbb9bda21e830bd92753c259d10c8582c2e2bbd3dfa768158a57f541b", size = 5358535, upload-time = "2026-02-11T01:39:40.386Z" }, ] [[package]] @@ -1008,7 +1008,7 @@ requires-dist = [ { name = "pygelf" }, { name = "pytango", marker = "extra == 'tango'" }, { name = "ruamel-yaml" }, - { name = "softioc", marker = "extra == 'epicsca'", specifier = ">=4.5.0" }, + { name = "softioc", marker = "extra == 'epicsca'", git = "https://github.com/DiamondLightSource/pythonSoftIOC.git?rev=master" }, { name = "stdio-socket" }, { name = "strawberry-graphql", marker = "extra == 'graphql'" }, { name = "tickit", marker = "extra == 'demo'", specifier = "~=0.4.3" }, @@ -3007,8 +3007,8 @@ wheels = [ [[package]] name = "softioc" -version = "4.7.0" -source = { registry = "https://pypi.org/simple" } +version = "4.7.0+10.g1711080" +source = { git = "https://github.com/DiamondLightSource/pythonSoftIOC.git?rev=master#1711080f541b36175bf59de05bb46794008390cb" } dependencies = [ { name = "epicscorelibs" }, { name = "epicsdbbuilder" }, @@ -3016,18 +3016,6 @@ dependencies = [ { name = "pvxslibs" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/fd/7b42c8c57703abbae0e78c351291eee1414bdc8219754ac8fcb2160605c5/softioc-4.7.0.tar.gz", hash = "sha256:8514c6f6b9df1191462171df6a2b9d9b33e6492fe252d3c5839d2f132d307add", size = 92691, upload-time = "2026-01-14T14:07:48.473Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/34/2e64ab5adc6708cd4344a757746d842d86b18b818d986e9b341917a7bfb3/softioc-4.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8a7c66f3ed372659ede74ff145b6c5ff041c33ff7921d744c01e6b4f1a1df5e", size = 118815, upload-time = "2026-01-14T14:07:31.079Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/e16232e8bcf05ab633c39bb7ae2f1ed334b686383db41d20e2a59500ceeb/softioc-4.7.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:1bcf79de8cbe49599cecbafa01c145058e60a22c055408609da95afd3fcbdc4b", size = 124586, upload-time = "2026-01-14T14:07:32.462Z" }, - { url = "https://files.pythonhosted.org/packages/d9/bc/622aa9138057c8c8249a1ad94bd2a12d69e814f327b988b8545891d93a15/softioc-4.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:4413a2ed7ae4f0bddc9179a9ec1afa2b514343de1aa72bb5a327fdfe1a2f1729", size = 118430, upload-time = "2026-01-14T14:07:33.682Z" }, - { url = "https://files.pythonhosted.org/packages/57/cc/5b4a59eebfb419ab2e9ffa58b4c09ce89af896b13c60516b5c5e42e86c89/softioc-4.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8e101f4f3ad35c32fecbca422dcf6496ec3c98545965fedc5a9dc48ba0dbe37", size = 118811, upload-time = "2026-01-14T14:07:34.519Z" }, - { url = "https://files.pythonhosted.org/packages/23/62/d59270acbec972aea0c36d65cc13673e786c12664070cf437869678655e9/softioc-4.7.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:4fe5cb8fa3c156b81940b7e9a456fe90a55ec1194eb75d4c286af405ff27ab22", size = 124622, upload-time = "2026-01-14T14:07:35.429Z" }, - { url = "https://files.pythonhosted.org/packages/03/81/f976b094616fb605dad6d21a0b4b78278e3585439ec5cb2ae4da1ff52441/softioc-4.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:3b2ac39cb9223fd936a9ffe5676dce6d7c499a5d1f5919380085829c1c38bf07", size = 118457, upload-time = "2026-01-14T14:07:36.822Z" }, - { url = "https://files.pythonhosted.org/packages/58/fe/8c9bf481416f8f9a6bd6e47b3ee7fb5282c320731e66ab47ccbbca9164b2/softioc-4.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eda2a04105c0c2f397068ec074e43db2264dd330baa2d7ac2a94702f2fa4cab3", size = 118819, upload-time = "2026-01-14T14:07:37.727Z" }, - { url = "https://files.pythonhosted.org/packages/47/90/d96f39b25f4cca886ac3f36a1bbfb1b04daef9512ec295d6319bcb7e2707/softioc-4.7.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:3a662a934af3c46eb7f5390539aa210a72fef5b48dd92167c4dd248bdc234597", size = 124619, upload-time = "2026-01-14T14:07:39.761Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b2/bf80cc846aed9aa2d87c29e613e9d55932c9a180962da9c5de97d6da8b73/softioc-4.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:f93647ab279856e9653c545a626ed40aea71d3432391c3df721655d4888c656b", size = 118454, upload-time = "2026-01-14T14:07:40.637Z" }, -] [[package]] name = "soupsieve" From 494eed709c3afee3a314fc4953ac7c0bcef78c2c Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Fri, 17 Jul 2026 12:02:33 +0000 Subject: [PATCH 05/15] fix: reset alarm on successful put --- src/fastcs/transports/epics/ca/ioc.py | 25 ++++++++++--------- .../transports/epics/pva/_pv_handlers.py | 2 +- src/fastcs/transports/epics/pva/pvi.py | 2 +- src/fastcs/transports/epics/pva/types.py | 10 +++++--- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 084f56ba1..6d6a3d793 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -230,13 +230,9 @@ async def on_update(value): logger.info("PV put: {pv} = {value}", pv=pv, value=repr(value)) try: await attribute.put(cast_from_epics_type(attribute.datatype, value)) + _set_alarm(record, alarm.NO_ALARM) except Exception: - record.set( - record.get(), - process=False, - severity=alarm.MAJOR_ALARM, - alarm=alarm.MAJOR_ALARM, - ) + _set_alarm(record, alarm.MAJOR_ALARM) async def set_setpoint_without_process(value: DType_T): tracer.log_event( @@ -290,13 +286,9 @@ async def wrapped_method(_: Any): try: await method.fn() + _set_alarm(record, alarm.NO_ALARM) except Exception: - record.set( - record.get(), - process=False, - severity=alarm.MAJOR_ALARM, - alarm=alarm.MAJOR_ALARM, - ) + _set_alarm(record, alarm.MAJOR_ALARM) record = builder.Action( f"{pv_prefix}:{pv_name}", @@ -350,3 +342,12 @@ def _add_alias(record: RecordWrapper, alias: str | None): ) else: record.add_alias(alias) + + +def _set_alarm(record: RecordWrapper, alarm_state: int): + record.set( + record.get(), + process=False, + severity=alarm_state, + alarm=alarm_state, + ) diff --git a/src/fastcs/transports/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py index 4b2f47c78..feff81c7f 100644 --- a/src/fastcs/transports/epics/pva/_pv_handlers.py +++ b/src/fastcs/transports/epics/pva/_pv_handlers.py @@ -51,7 +51,7 @@ async def put(self, pv: SharedPV, op: ServerOperation): if isinstance(self._attr_w.datatype, Enum): pv.post(cast_to_p4p_value(self._attr_w, cast_value)) else: - pv.post(value) + pv.post({"value": cast_value, **p4p_alarm_states()}) try: await self._attr_w.put(cast_value) diff --git a/src/fastcs/transports/epics/pva/pvi.py b/src/fastcs/transports/epics/pva/pvi.py index a68b00fde..c86000710 100644 --- a/src/fastcs/transports/epics/pva/pvi.py +++ b/src/fastcs/transports/epics/pva/pvi.py @@ -61,7 +61,7 @@ def _make_p4p_raw_value(pv_prefix: str, controller_api: ControllerAPI) -> dict: # Sub-device of a ControllerVector p4p_raw_value[f"__{int(pv_leaf)}"]["d"] = pv else: - p4p_raw_value[pv_leaf]["d"] = pv + p4p_raw_value[pv_leaf.lower()]["d"] = pv for pv_leaf, attribute in controller_api.attributes.items(): # Add attribute entry pv = f"{pv_prefix}:{snake_to_pascal(pv_leaf)}" diff --git a/src/fastcs/transports/epics/pva/types.py b/src/fastcs/transports/epics/pva/types.py index 75e979996..da14c6bf3 100644 --- a/src/fastcs/transports/epics/pva/types.py +++ b/src/fastcs/transports/epics/pva/types.py @@ -184,10 +184,14 @@ def cast_to_p4p_value(attribute: Attribute[DType_T], value: DType_T) -> object: """Converts a FastCS ``Attribute`` value to a p4p value""" match attribute.datatype: case Enum(): - return { - "index": attribute.datatype.index_of(value), - "choices": attribute.datatype.names, + record_fields = { + "value": { + "index": attribute.datatype.index_of(value), + "choices": attribute.datatype.names, + }, + **p4p_alarm_states(), } + return record_fields case Waveform(): return attribute.datatype.validate(value) case Table(): From a0be859325f647e3cc960e0681f0c824c7968a80 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Mon, 20 Jul 2026 11:42:06 +0000 Subject: [PATCH 06/15] fix: separate out alarm sets --- .../transports/epics/pva/_pv_handlers.py | 26 ++++++++++++++++--- src/fastcs/transports/epics/pva/types.py | 10 +++---- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/fastcs/transports/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py index feff81c7f..d1f1a154b 100644 --- a/src/fastcs/transports/epics/pva/_pv_handlers.py +++ b/src/fastcs/transports/epics/pva/_pv_handlers.py @@ -1,3 +1,5 @@ +from typing import Any + import numpy as np from p4p import Value from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable @@ -7,7 +9,7 @@ from p4p.server.asyncio import SharedPV from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW -from fastcs.datatypes import Enum, Table +from fastcs.datatypes import DataType, Enum, Table, Waveform from fastcs.methods import CommandCallback from fastcs.tracer import Tracer @@ -48,10 +50,15 @@ async def put(self, pv: SharedPV, op: ServerOperation): tracer.log_event("PV put", topic=self._attr_w, pv=pv, value=cast_value) - if isinstance(self._attr_w.datatype, Enum): + datatype = self._attr_w.datatype + + if isinstance(datatype, Enum): pv.post(cast_to_p4p_value(self._attr_w, cast_value)) else: - pv.post({"value": cast_value, **p4p_alarm_states()}) + pv.post(value) + + # Reset alarm on successful post + _set_alarm(pv, datatype, value, p4p_alarm_states()) try: await self._attr_w.put(cast_value) @@ -61,7 +68,8 @@ async def put(self, pv: SharedPV, op: ServerOperation): alarm_states = p4p_alarm_states( MAJOR_ALARM_SEVERITY, RECORD_ALARM_STATUS, error_msg ) - pv.post({**alarm_states}) + # Raise alarm on failed put + _set_alarm(pv, datatype, value, alarm_states) else: op.done() @@ -179,3 +187,13 @@ def _wrap(value: dict): ) return shared_pv + + +def _set_alarm(pv: SharedPV, dtype: DataType, value: Any, alarm_states: dict): + sub_states = alarm_states["alarm"] + # NTTable and NTNDArray don't accept 'status' + sub_states.pop("status", None) + if isinstance(dtype, Table | Waveform): + pv.post(value=value, **sub_states) + else: + pv.post({**alarm_states}) diff --git a/src/fastcs/transports/epics/pva/types.py b/src/fastcs/transports/epics/pva/types.py index da14c6bf3..75e979996 100644 --- a/src/fastcs/transports/epics/pva/types.py +++ b/src/fastcs/transports/epics/pva/types.py @@ -184,14 +184,10 @@ def cast_to_p4p_value(attribute: Attribute[DType_T], value: DType_T) -> object: """Converts a FastCS ``Attribute`` value to a p4p value""" match attribute.datatype: case Enum(): - record_fields = { - "value": { - "index": attribute.datatype.index_of(value), - "choices": attribute.datatype.names, - }, - **p4p_alarm_states(), + return { + "index": attribute.datatype.index_of(value), + "choices": attribute.datatype.names, } - return record_fields case Waveform(): return attribute.datatype.validate(value) case Table(): From 0e46ae8db75a8d5c44089c8395cff6200fda1f0c Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Mon, 20 Jul 2026 12:27:41 +0000 Subject: [PATCH 07/15] refactor: clean up code paths for different datatypes --- .../transports/epics/pva/_pv_handlers.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/fastcs/transports/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py index d1f1a154b..64d6fb940 100644 --- a/src/fastcs/transports/epics/pva/_pv_handlers.py +++ b/src/fastcs/transports/epics/pva/_pv_handlers.py @@ -52,13 +52,13 @@ async def put(self, pv: SharedPV, op: ServerOperation): datatype = self._attr_w.datatype - if isinstance(datatype, Enum): - pv.post(cast_to_p4p_value(self._attr_w, cast_value)) - else: - pv.post(value) + value_to_post = ( + cast_to_p4p_value(self._attr_w, cast_value) + if isinstance(datatype, Waveform | Table | Enum) + else cast_value + ) - # Reset alarm on successful post - _set_alarm(pv, datatype, value, p4p_alarm_states()) + _post_with_alarm_states(pv, datatype, value_to_post, p4p_alarm_states()) try: await self._attr_w.put(cast_value) @@ -69,7 +69,7 @@ async def put(self, pv: SharedPV, op: ServerOperation): MAJOR_ALARM_SEVERITY, RECORD_ALARM_STATUS, error_msg ) # Raise alarm on failed put - _set_alarm(pv, datatype, value, alarm_states) + _post_with_alarm_states(pv, datatype, value_to_post, alarm_states) else: op.done() @@ -189,11 +189,13 @@ def _wrap(value: dict): return shared_pv -def _set_alarm(pv: SharedPV, dtype: DataType, value: Any, alarm_states: dict): +def _post_with_alarm_states( + pv: SharedPV, dtype: DataType, value: Any, alarm_states: dict +): sub_states = alarm_states["alarm"] - # NTTable and NTNDArray don't accept 'status' - sub_states.pop("status", None) if isinstance(dtype, Table | Waveform): + # NTTable and NTNDArray don't accept 'status' + sub_states.pop("status", None) pv.post(value=value, **sub_states) else: - pv.post({**alarm_states}) + pv.post({"value": value, **alarm_states}) From f5661f65d6f02c978a411e1b8bc93d4a091a3e34 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Mon, 20 Jul 2026 12:41:15 +0000 Subject: [PATCH 08/15] chore: add comments to clarify logging exception in CA --- src/fastcs/transports/epics/ca/ioc.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 6d6a3d793..6271720b3 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -232,6 +232,8 @@ async def on_update(value): await attribute.put(cast_from_epics_type(attribute.datatype, value)) _set_alarm(record, alarm.NO_ALARM) except Exception: + # No need to log, as AttrW.put already logs exceptions + # So just need to set alarm _set_alarm(record, alarm.MAJOR_ALARM) async def set_setpoint_without_process(value: DType_T): @@ -288,6 +290,8 @@ async def wrapped_method(_: Any): await method.fn() _set_alarm(record, alarm.NO_ALARM) except Exception: + # No need to log, as Command.fn() already logs exceptions + # So just need to set alarm _set_alarm(record, alarm.MAJOR_ALARM) record = builder.Action( From 44b1bec2ed465e9b3f7d01b53f7610eb0c70ea2d Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Fri, 24 Jul 2026 10:15:38 +0000 Subject: [PATCH 09/15] chore: address review comments --- src/fastcs/transports/epics/ca/ioc.py | 48 ++++++------ .../transports/epics/pva/_pv_handlers.py | 37 +++++---- src/fastcs/transports/epics/pva/pvi.py | 2 +- src/fastcs/transports/epics/pva/types.py | 75 +++++++++++++------ 4 files changed, 101 insertions(+), 61 deletions(-) diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 6271720b3..78651bca6 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -1,5 +1,7 @@ import asyncio from collections import Counter +from collections.abc import Awaitable +from enum import IntEnum from typing import Any, Literal from softioc import alarm, builder, softioc @@ -218,23 +220,19 @@ async def async_record_set(value: DType_T): def _create_and_link_write_pv( - pv_prefix, - pv_name, - attr_name, - alias, - attribute, + pv_prefix: str, + pv_name: str, + attr_name: str, + alias: str | None, + attribute: AttrW[DType_T], ): pv = f"{pv_prefix}:{pv_name}" async def on_update(value): logger.info("PV put: {pv} = {value}", pv=pv, value=repr(value)) - try: - await attribute.put(cast_from_epics_type(attribute.datatype, value)) - _set_alarm(record, alarm.NO_ALARM) - except Exception: - # No need to log, as AttrW.put already logs exceptions - # So just need to set alarm - _set_alarm(record, alarm.MAJOR_ALARM) + await _run_and_set_alarm( + record, attribute.put(cast_from_epics_type(attribute.datatype, value)) + ) async def set_setpoint_without_process(value: DType_T): tracer.log_event( @@ -285,14 +283,7 @@ def _create_and_link_command_pv( async def wrapped_method(_: Any): tracer.log_event("Command PV put", topic=method, pv=pv) - - try: - await method.fn() - _set_alarm(record, alarm.NO_ALARM) - except Exception: - # No need to log, as Command.fn() already logs exceptions - # So just need to set alarm - _set_alarm(record, alarm.MAJOR_ALARM) + await _run_and_set_alarm(record, method.fn()) record = builder.Action( f"{pv_prefix}:{pv_name}", @@ -348,10 +339,25 @@ def _add_alias(record: RecordWrapper, alias: str | None): record.add_alias(alias) -def _set_alarm(record: RecordWrapper, alarm_state: int): +def _set_alarm(record: RecordWrapper, alarm_state: IntEnum): record.set( record.get(), process=False, severity=alarm_state, alarm=alarm_state, ) + + +async def _run_and_set_alarm(record, coro: Awaitable): + """Await `coro` and update `record`'s alarm state based on the outcome. + + On success, clears the alarm (NO_ALARM). On any exception, raises the + record into MAJOR_ALARM. The exception itself is not re-raised or + logged here, since `AttrW.put` already logs it; this function's only + job is to reflect the outcome in the record's alarm status. + """ + try: + await coro + _set_alarm(record, alarm.NO_ALARM) + except Exception: + _set_alarm(record, alarm.MAJOR_ALARM) diff --git a/src/fastcs/transports/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py index 64d6fb940..431cf89dd 100644 --- a/src/fastcs/transports/epics/pva/_pv_handlers.py +++ b/src/fastcs/transports/epics/pva/_pv_handlers.py @@ -14,8 +14,9 @@ from fastcs.tracer import Tracer from .types import ( - MAJOR_ALARM_SEVERITY, - RECORD_ALARM_STATUS, + P4PAlarmState, + Severity, + Status, cast_from_p4p_value, cast_to_p4p_value, make_p4p_type, @@ -65,9 +66,7 @@ async def put(self, pv: SharedPV, op: ServerOperation): except Exception as e: error_msg = f"Exception raised during put operation: {e!r}" op.done(error=error_msg) - alarm_states = p4p_alarm_states( - MAJOR_ALARM_SEVERITY, RECORD_ALARM_STATUS, error_msg - ) + alarm_states = p4p_alarm_states(Severity.MAJOR, Status.RECORD, error_msg) # Raise alarm on failed put _post_with_alarm_states(pv, datatype, value_to_post, alarm_states) else: @@ -79,16 +78,14 @@ def __init__(self, command: CommandCallback): self._command = command self._task_in_progress = False - async def _run_command(self) -> dict: + async def _run_command(self) -> P4PAlarmState: self._task_in_progress = True try: await self._command() except Exception as e: error_msg = f"Exception raised during command put: {e!r}" - alarm_states = p4p_alarm_states( - MAJOR_ALARM_SEVERITY, RECORD_ALARM_STATUS, error_msg - ) + alarm_states = p4p_alarm_states(Severity.MAJOR, Status.RECORD, error_msg) else: alarm_states = p4p_alarm_states() @@ -114,14 +111,22 @@ async def put(self, pv: SharedPV, op: ServerOperation): blocking = False # Flip to true once command task starts - pv.post({"value": True, **p4p_timestamp_now(), **p4p_alarm_states()}) + pv.post( + { + "value": True, + **p4p_timestamp_now(), + **p4p_alarm_states().model_dump(), + } + ) if not blocking: op.done() alarm_states = await self._run_command() - pv.post({"value": False, **p4p_timestamp_now(), **alarm_states}) + pv.post( + {"value": False, **p4p_timestamp_now(), **alarm_states.model_dump()} + ) if blocking: # Check if we are in alarm - if msg := alarm_states["alarm"]["message"]: + if msg := alarm_states.model_dump()["alarm"]["message"]: op.done(error=msg) else: op.done() @@ -175,7 +180,7 @@ async def set_setpoint(value): def make_command_pv(command: CommandCallback) -> SharedPV: type_ = NTScalar.buildType("?", display=True, control=True) - initial = Value(type_, {"value": False, **p4p_alarm_states()}) + initial = Value(type_, {"value": False, **p4p_alarm_states().model_dump()}) def _wrap(value: dict): return Value(type_, value) @@ -190,12 +195,12 @@ def _wrap(value: dict): def _post_with_alarm_states( - pv: SharedPV, dtype: DataType, value: Any, alarm_states: dict + pv: SharedPV, dtype: DataType, value: Any, alarm_states: P4PAlarmState ): - sub_states = alarm_states["alarm"] + sub_states = alarm_states.model_dump()["alarm"] if isinstance(dtype, Table | Waveform): # NTTable and NTNDArray don't accept 'status' sub_states.pop("status", None) pv.post(value=value, **sub_states) else: - pv.post({"value": value, **alarm_states}) + pv.post({"value": value, **alarm_states.model_dump()}) diff --git a/src/fastcs/transports/epics/pva/pvi.py b/src/fastcs/transports/epics/pva/pvi.py index c86000710..a3ac67884 100644 --- a/src/fastcs/transports/epics/pva/pvi.py +++ b/src/fastcs/transports/epics/pva/pvi.py @@ -41,7 +41,7 @@ def _make_p4p_value( return Value( p4p_type, { - **p4p_alarm_states(), + **p4p_alarm_states().model_dump(), **p4p_timestamp_now(), **display, "value": raw_value, diff --git a/src/fastcs/transports/epics/pva/types.py b/src/fastcs/transports/epics/pva/types.py index 75e979996..44a6cb22a 100644 --- a/src/fastcs/transports/epics/pva/types.py +++ b/src/fastcs/transports/epics/pva/types.py @@ -1,10 +1,12 @@ import math import time +from enum import IntEnum import numpy as np from numpy.typing import DTypeLike from p4p import Value from p4p.nt import NTEnum, NTNDArray, NTScalar, NTTable +from pydantic import BaseModel from fastcs.attributes import Attribute, AttrR, AttrW from fastcs.datatypes import Bool, DType, Enum, Float, Int, String, Table, Waveform @@ -12,12 +14,6 @@ P4P_ALLOWED_DATATYPES = (Int, Float, String, Bool, Enum, Waveform, Table) -# https://epics-base.github.io/pvxs/nt.html#alarm-t -RECORD_ALARM_STATUS = 3 -NO_ALARM_STATUS = 0 -MAJOR_ALARM_SEVERITY = 2 -NO_ALARM_SEVERITY = 0 - # https://numpy.org/devdocs/reference/arrays.dtypes.html#arrays-dtypes # Some numpy dtypes don't match directly with the p4p ones _NUMPY_DTYPE_TO_P4P_DTYPE = { @@ -109,19 +105,50 @@ def cast_from_p4p_value(attribute: Attribute[DType_T], value: object) -> DType_T raise ValueError(f"Unsupported datatype {attribute.datatype}") +# https://epics-base.github.io/pvxs/nt.html#alarm-t +class Severity(IntEnum): + NO_ALARM = 0 + MINOR = 1 + MAJOR = 2 + INVALID = 3 + + +# https://epics-base.github.io/pvxs/nt.html#alarm-t +class Status(IntEnum): + NO_ALARM = 0 + DEVICE = 1 + DRIVER = 2 + RECORD = 3 + DATABASE = 4 + CONFIGURATION = 5 + UNDEFINED = 6 + CLIENT = 7 + + +class AlarmInfo(BaseModel): + severity: Severity = Severity.NO_ALARM + status: Status = Status.NO_ALARM + message: str = "" + + +class P4PAlarmState(BaseModel): + alarm: AlarmInfo + + def p4p_alarm_states( - severity: int = NO_ALARM_SEVERITY, - status: int = NO_ALARM_STATUS, + severity: Severity = Severity.NO_ALARM, + status: Status = Status.NO_ALARM, message: str = "", -) -> dict: - """Returns the p4p alarm structure for a given severity, status, and message.""" - return { - "alarm": { - "severity": severity, - "status": status, - "message": message, - }, - } +) -> P4PAlarmState: + """Returns the p4p alarm structure for a given severity, status, and message, + validated as a Pydantic model.""" + return P4PAlarmState( + alarm=AlarmInfo( + severity=severity, + status=status, + message=message, + ) + ) def p4p_timestamp_now() -> dict: @@ -157,23 +184,25 @@ def p4p_display(attribute: Attribute) -> dict: return {} -def _p4p_check_numeric_for_alarm_states(datatype: Int | Float, value: DType) -> dict: +def _p4p_check_numeric_for_alarm_states( + datatype: Int | Float, value: DType +) -> P4PAlarmState: low = None if datatype.min_alarm is None else value < datatype.min_alarm # type: ignore high = None if datatype.max_alarm is None else value > datatype.max_alarm # type: ignore severity = ( - MAJOR_ALARM_SEVERITY + Severity.MAJOR if high not in (None, False) or low not in (None, False) - else NO_ALARM_SEVERITY + else Severity.NO_ALARM ) - status, message = NO_ALARM_SEVERITY, "No alarm" + status, message = Status.NO_ALARM, "No alarm" if low: status, message = ( - RECORD_ALARM_STATUS, + Status.RECORD, f"Below minimum alarm limit: {datatype.min_alarm}", ) if high: status, message = ( - RECORD_ALARM_STATUS, + Status.RECORD, f"Above maximum alarm limit: {datatype.max_alarm}", ) From 2d04f79f929072a738a4e73aa154e19ad88d122c Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Fri, 24 Jul 2026 10:34:19 +0000 Subject: [PATCH 10/15] chore: pass raw_value directly --- src/fastcs/transports/epics/pva/_pv_handlers.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/fastcs/transports/epics/pva/_pv_handlers.py b/src/fastcs/transports/epics/pva/_pv_handlers.py index 431cf89dd..0d102001b 100644 --- a/src/fastcs/transports/epics/pva/_pv_handlers.py +++ b/src/fastcs/transports/epics/pva/_pv_handlers.py @@ -9,7 +9,7 @@ from p4p.server.asyncio import SharedPV from fastcs.attributes import Attribute, AttrR, AttrRW, AttrW -from fastcs.datatypes import DataType, Enum, Table, Waveform +from fastcs.datatypes import DataType, Table, Waveform from fastcs.methods import CommandCallback from fastcs.tracer import Tracer @@ -53,13 +53,7 @@ async def put(self, pv: SharedPV, op: ServerOperation): datatype = self._attr_w.datatype - value_to_post = ( - cast_to_p4p_value(self._attr_w, cast_value) - if isinstance(datatype, Waveform | Table | Enum) - else cast_value - ) - - _post_with_alarm_states(pv, datatype, value_to_post, p4p_alarm_states()) + _post_with_alarm_states(pv, datatype, raw_value, p4p_alarm_states()) try: await self._attr_w.put(cast_value) @@ -68,7 +62,8 @@ async def put(self, pv: SharedPV, op: ServerOperation): op.done(error=error_msg) alarm_states = p4p_alarm_states(Severity.MAJOR, Status.RECORD, error_msg) # Raise alarm on failed put - _post_with_alarm_states(pv, datatype, value_to_post, alarm_states) + _post_with_alarm_states(pv, datatype, raw_value, alarm_states) + else: op.done() From cfc32b75888b1cdb0a01225a0d74abac7cd963ef Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Fri, 24 Jul 2026 11:00:31 +0000 Subject: [PATCH 11/15] chore address second review comments --- src/fastcs/transports/epics/pva/types.py | 2 +- tests/example_softioc.py | 2 +- tests/transports/epics/pva/test_p4p.py | 9 ++++----- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/fastcs/transports/epics/pva/types.py b/src/fastcs/transports/epics/pva/types.py index 44a6cb22a..13412f19a 100644 --- a/src/fastcs/transports/epics/pva/types.py +++ b/src/fastcs/transports/epics/pva/types.py @@ -151,7 +151,7 @@ def p4p_alarm_states( ) -def p4p_timestamp_now() -> dict: +def p4p_timestamp_now() -> dict[str, dict[str, int]]: """The p4p timestamp structure for the current time.""" now = time.time() seconds_past_epoch = int(now) diff --git a/tests/example_softioc.py b/tests/example_softioc.py index 540ffa359..3688ff4fb 100644 --- a/tests/example_softioc.py +++ b/tests/example_softioc.py @@ -25,7 +25,7 @@ class ChildController(Controller): @command() async def d(self): print("D: RUNNING") - await asyncio.sleep(0.1) + await asyncio.sleep(0) if self.fail_on_next_e: self.fail_on_next_e = False raise RuntimeError("D: FAILED WITH THIS WEIRD ERROR") diff --git a/tests/transports/epics/pva/test_p4p.py b/tests/transports/epics/pva/test_p4p.py index 8bfff9950..d08714540 100644 --- a/tests/transports/epics/pva/test_p4p.py +++ b/tests/transports/epics/pva/test_p4p.py @@ -212,7 +212,7 @@ async def test_numeric_alarms(p4p_subprocess: tuple[str, Queue]): a_monitor.close() -def test_alarms_set_if_put_fails(caplog): +def test_alarms_set_if_put_fails(): @dataclass class SimpleAttributeIORef(AttributeIORef): pass @@ -229,17 +229,16 @@ class SomeController(Controller): fastcs = make_fastcs(pv_prefix, controller) async def put_pvs(): - await asyncio.sleep(0.1) + await asyncio.sleep(0) ctxt = Context("pva") await ctxt.put(f"{pv_prefix}:A", 1) serve = asyncio.ensure_future(fastcs.serve(interactive=False)) - try: + + with pytest.raises(RuntimeError, match="Exception raised during put operation"): asyncio.get_event_loop().run_until_complete( asyncio.wait_for(asyncio.gather(serve, put_pvs()), timeout=1) ) - except RuntimeError as exc: - assert str(exc) == "Exception raised during put operation: ValueError('Failed')" serve.cancel() From 562859d1ffdc9b63fd73746c616375b509da2b11 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Fri, 24 Jul 2026 14:15:22 +0000 Subject: [PATCH 12/15] revert intenum change --- src/fastcs/transports/epics/ca/ioc.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/fastcs/transports/epics/ca/ioc.py b/src/fastcs/transports/epics/ca/ioc.py index 78651bca6..7f210c424 100644 --- a/src/fastcs/transports/epics/ca/ioc.py +++ b/src/fastcs/transports/epics/ca/ioc.py @@ -1,7 +1,6 @@ import asyncio from collections import Counter from collections.abc import Awaitable -from enum import IntEnum from typing import Any, Literal from softioc import alarm, builder, softioc @@ -339,7 +338,7 @@ def _add_alias(record: RecordWrapper, alias: str | None): record.add_alias(alias) -def _set_alarm(record: RecordWrapper, alarm_state: IntEnum): +def _set_alarm(record: RecordWrapper, alarm_state: int): record.set( record.get(), process=False, From 179a49d2c8d6eec386b6e8d341b196ea47d166ba Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Fri, 24 Jul 2026 14:44:44 +0000 Subject: [PATCH 13/15] docs: add docstrings for public alarm classes --- docs/conf.py | 2 ++ src/fastcs/transports/epics/pva/types.py | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 3ee2ad966..4c36118d1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -83,6 +83,8 @@ # docstring of collections.abc.Callable:1: WARNING: # 'any' reference target not found: self [ref.any] ("any", "self"), + # Inherited from pydantic.BaseModel.__init__, which has no intersphinx mapping + ("any", "ValidationError"), # p4p doesn't have intersphinx mapping ("py:class", "p4p.server.StaticProvider"), ("py:class", "p4p.nt.scalar.NTScalar"), diff --git a/src/fastcs/transports/epics/pva/types.py b/src/fastcs/transports/epics/pva/types.py index 13412f19a..6a260b881 100644 --- a/src/fastcs/transports/epics/pva/types.py +++ b/src/fastcs/transports/epics/pva/types.py @@ -105,16 +105,27 @@ def cast_from_p4p_value(attribute: Attribute[DType_T], value: object) -> DType_T raise ValueError(f"Unsupported datatype {attribute.datatype}") -# https://epics-base.github.io/pvxs/nt.html#alarm-t class Severity(IntEnum): + """Alarm severity for a PV, as defined by the PVXS alarm_t normative type. + + Whether MINOR or MAJOR applies is context-dependent and can vary per PV. + INVALID indicates the current value should not be treated as a meaningful + reading (e.g. of the underlying measured quantity) and typically reflects + the last known good value rather than a fresh one.""" + NO_ALARM = 0 MINOR = 1 MAJOR = 2 INVALID = 3 -# https://epics-base.github.io/pvxs/nt.html#alarm-t class Status(IntEnum): + """Alarm status for a PV. + + Identifies which part of the system raised the alarm, as defined by the + PVXS alarm_t normative type. + """ + NO_ALARM = 0 DEVICE = 1 DRIVER = 2 @@ -126,12 +137,19 @@ class Status(IntEnum): class AlarmInfo(BaseModel): + """The alarm state of a PV, following the PVXS alarm_t normative type. + + See https://epics-base.github.io/pvxs/nt.html#alarm-t. + """ + severity: Severity = Severity.NO_ALARM status: Status = Status.NO_ALARM message: str = "" class P4PAlarmState(BaseModel): + """Wraps AlarmInfo as the alarm sub-structure of a p4p NT* Value.""" + alarm: AlarmInfo From f158e33a2f3347af697734c5676f6f8ebc21bab0 Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Fri, 24 Jul 2026 15:29:35 +0000 Subject: [PATCH 14/15] tests: use finally block for serve.cancel() --- tests/transports/epics/pva/test_p4p.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/transports/epics/pva/test_p4p.py b/tests/transports/epics/pva/test_p4p.py index d08714540..9f97d692f 100644 --- a/tests/transports/epics/pva/test_p4p.py +++ b/tests/transports/epics/pva/test_p4p.py @@ -235,11 +235,13 @@ async def put_pvs(): serve = asyncio.ensure_future(fastcs.serve(interactive=False)) - with pytest.raises(RuntimeError, match="Exception raised during put operation"): - asyncio.get_event_loop().run_until_complete( - asyncio.wait_for(asyncio.gather(serve, put_pvs()), timeout=1) - ) - serve.cancel() + try: + with pytest.raises(RuntimeError, match="Exception raised during put operation"): + asyncio.get_event_loop().run_until_complete( + asyncio.wait_for(asyncio.gather(serve, put_pvs()), timeout=1) + ) + finally: + serve.cancel() def make_fastcs(pv_prefix: str, controller: Controller) -> FastCS: From debb24bdc13e221c2c5fb88c191cbd0f1d4f2a3b Mon Sep 17 00:00:00 2001 From: Shihab Suliman Date: Fri, 24 Jul 2026 16:31:50 +0000 Subject: [PATCH 15/15] chore: return dict to expand into record when checking numeric alarms --- src/fastcs/transports/epics/pva/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fastcs/transports/epics/pva/types.py b/src/fastcs/transports/epics/pva/types.py index 6a260b881..b457f3822 100644 --- a/src/fastcs/transports/epics/pva/types.py +++ b/src/fastcs/transports/epics/pva/types.py @@ -204,7 +204,7 @@ def p4p_display(attribute: Attribute) -> dict: def _p4p_check_numeric_for_alarm_states( datatype: Int | Float, value: DType -) -> P4PAlarmState: +) -> dict[str, dict[str, int | str]]: low = None if datatype.min_alarm is None else value < datatype.min_alarm # type: ignore high = None if datatype.max_alarm is None else value > datatype.max_alarm # type: ignore severity = ( @@ -224,7 +224,7 @@ def _p4p_check_numeric_for_alarm_states( f"Above maximum alarm limit: {datatype.max_alarm}", ) - return p4p_alarm_states(severity, status, message) + return p4p_alarm_states(severity, status, message).model_dump() def cast_to_p4p_value(attribute: Attribute[DType_T], value: DType_T) -> object: