1111from frappy .client import get_datatype
1212from frappy .datatypes import CommandType , DataType , EnumType , StructOf
1313from jinja2 import Environment , PackageLoader , select_autoescape
14- from ophyd_async .core import SignalR , SignalRW
14+ from ophyd_async .core import SignalR , SignalRW , SignalW
1515from ophyd_async .core import StandardReadableFormat as Format
1616
1717from secop_ophyd .SECoPDevices import (
1818 IGNORED_PROPS ,
19+ ParameterMemberType ,
1920 ParameterType ,
2021 PropertyType ,
2122 class_from_interface ,
2223)
2324from secop_ophyd .SECoPSignal import secop_dtype_obj_from_json
2425from secop_ophyd .util import (
26+ CompositeKind ,
2527 SECoPdtype ,
2628 build_command_signature ,
29+ classify_datatype ,
2730 command_dtype_to_annotation_str ,
31+ get_composite_members ,
2832 secop_enum_name_to_python ,
2933)
3034
@@ -222,6 +226,7 @@ def __init__(self, path: str | None = None, log=None):
222226 self .add_import ("typing" , "Annotated as A" )
223227 self .add_import ("ophyd_async.core" , "SignalR" )
224228 self .add_import ("ophyd_async.core" , "SignalRW" )
229+ self .add_import ("ophyd_async.core" , "SignalW" )
225230 self .add_import ("ophyd_async.core" , "Command" )
226231 self .add_import ("ophyd_async.core" , "TriggerableCommand" )
227232 self .add_import ("ophyd_async.core" , "StandardReadableFormat as Format" )
@@ -230,6 +235,9 @@ def __init__(self, path: str | None = None, log=None):
230235 self .add_import ("typing" , "Any" )
231236 self .add_import ("numpy" , "ndarray" )
232237 self .add_import ("secop_ophyd.SECoPDevices" , "ParameterType as ParamT" )
238+ self .add_import (
239+ "secop_ophyd.SECoPDevices" , "ParameterMemberType as ParamMemberT"
240+ )
233241 self .add_import ("secop_ophyd.SECoPDevices" , "PropertyType as PropT" )
234242 # Add necessary Device imports
235243 self .add_import ("secop_ophyd.SECoPDevices" , "SECoPDevice" )
@@ -535,6 +543,28 @@ def from_json_describe(self, json_data: str | dict):
535543
536544 mod_parameters : list [ParameterAttribute ] = []
537545
546+ def _camel (identifier : str ) -> str :
547+ words = identifier .replace (" " , "_" ).replace ("-" , "_" ).split ("_" )
548+ return "" .join (word .capitalize () for word in words )
549+
550+ def _enum_type_param (
551+ type_param : str | None ,
552+ enum_class_name : str ,
553+ members : dict ,
554+ enum_descr : str ,
555+ ) -> str | None :
556+ """If `type_param` is the generic 'StrictEnum', generate a
557+ concrete named enum class for it and return that class's name
558+ instead; otherwise return `type_param` unchanged."""
559+ if not (type_param and "StrictEnum" in type_param ):
560+ return type_param
561+
562+ enum_cls = _build_enum_class (enum_class_name , members , enum_descr )
563+ if enum_cls :
564+ module_enum_classes .append (enum_cls )
565+ return enum_class_name
566+ return type_param
567+
538568 for param_name , param_data in parameters .items ():
539569
540570 descr = self ._normalize_description (param_data .get ("description" , "" ))
@@ -546,7 +576,6 @@ def from_json_describe(self, json_data: str | dict):
546576 )
547577 else :
548578 param_descr = descr
549- signal_base = SignalR if param_data ["readonly" ] else SignalRW
550579
551580 format = None
552581
@@ -567,40 +596,74 @@ def from_json_describe(self, json_data: str | dict):
567596 format = format or Format .HINTED_SIGNAL
568597
569598 # Remove "StandardReadable" prefix from format for cleaner annotation
570- format = (
599+ format_str = (
571600 str (format ).removeprefix ("StandardReadable" ) if format else None
572601 )
573602
574- datainfo = param_data . get ( "datainfo" , {})
575-
576- # infer the ophyd type from secop datatype
577- type_param = get_type_param ( param_data [ "datatype" ])
578-
579- # Handle StrictEnum types - generate enum class
580- if type_param and "StrictEnum" in type_param :
581- # Generate unique enum class name:
582- # ModuleClass + ParamName + Enum
583- param_name_list = (
584- param_name . replace ( " " , "_" ). replace ( "-" , "_" ). split ( "_" )
585- )
603+ raw_datatype = param_data [ "datatype" ]
604+ kind = classify_datatype ( raw_datatype )
605+
606+ if kind is CompositeKind . UNSUPPORTED :
607+ # mirrors SECoPDeviceConnector.connect_real(): no signal is
608+ # generated for a struct/tuple nested inside another
609+ # struct/tuple/array. Unlike the runtime path, codegen has
610+ # no notion of "mandatory for this instance's interface
611+ # class" at generation time, so it always just skips --
612+ # the runtime path is still the one that raises for a
613+ # genuinely mandatory parameter when actually connecting.
614+ continue
586615
587- param_name_camel = "" .join (
588- word .capitalize () for word in param_name_list
589- )
616+ if kind is CompositeKind .DECOMPOSABLE :
617+ for member_key , member_dt in get_composite_members (raw_datatype ):
618+ member_type_param : str | None
619+ if isinstance (member_dt , EnumType ):
620+ # an EnumType member embedded in a struct/tuple
621+ # stays as its raw numeric code at runtime (see
622+ # SECoPBackend.init_member_from_introspection's
623+ # _is_enum_member handling), not a resolved enum
624+ # class -- so no enum class is generated here
625+ member_type_param = "int"
626+ else :
627+ member_type_param = get_type_param (member_dt )
628+
629+ mod_parameters .append (
630+ ParameterAttribute (
631+ name = f"{ param_name } _{ member_key } " ,
632+ type = SignalR .__name__ ,
633+ type_param = member_type_param ,
634+ description = param_descr ,
635+ path_annotation = str (ParameterMemberType ()),
636+ format_annotation = format_str ,
637+ )
638+ )
590639
591- enum_class_name = f"{ module_class } _{ param_name_camel } _Enum"
640+ if not param_data ["readonly" ]:
641+ mod_parameters .append (
642+ ParameterAttribute (
643+ name = param_name ,
644+ type = SignalW .__name__ ,
645+ type_param = get_type_param (raw_datatype ),
646+ description = param_descr ,
647+ path_annotation = str (ParameterType ()),
648+ # a write-only SignalW can't carry a
649+ # StandardReadableFormat (nothing to read)
650+ format_annotation = None ,
651+ )
652+ )
653+ continue
592654
593- enum_cls = _build_enum_class (
594- enum_class_name ,
595- datainfo .get ("members" , {}),
596- f"{ param_name } enum for `{ module_class } `." ,
597- )
598- if enum_cls :
599- module_enum_classes .append (enum_cls )
655+ # ATOMIC: single signal, unchanged from previous behaviour
656+ signal_base = SignalR if param_data ["readonly" ] else SignalRW
657+ datainfo = param_data .get ("datainfo" , {})
600658
601- # Use the specific enum class name instead of generic
602- # StrictEnum
603- type_param = enum_class_name
659+ # infer the ophyd type from secop datatype
660+ type_param = get_type_param (raw_datatype )
661+ type_param = _enum_type_param (
662+ type_param ,
663+ f"{ module_class } _{ _camel (param_name )} _Enum" ,
664+ datainfo .get ("members" , {}),
665+ f"{ param_name } enum for `{ module_class } `." ,
666+ )
604667
605668 # Default format for parameters is CONFIG_SIGNAL
606669
@@ -611,7 +674,7 @@ def from_json_describe(self, json_data: str | dict):
611674 type_param = type_param ,
612675 description = param_descr ,
613676 path_annotation = str (ParameterType ()),
614- format_annotation = format ,
677+ format_annotation = format_str ,
615678 )
616679 )
617680
0 commit comments