From a0086f022107226e01401d388d973b9416222d4d Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Wed, 5 Aug 2026 15:17:57 +0200 Subject: [PATCH 1/4] fix: cast typed writes to int for integer OPC UA channels - write_channel_typed converts to the channel's unit, which yields a float - asyncua does not coerce it: encoding a float into an Int32 variant raises "required argument is not an integer" - cast when the channel declares an integer type; float channels untouched --- src/opensemantic/lab/_controller_mixin.py | 24 +++++++ tests/test_controller.py | 79 +++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/src/opensemantic/lab/_controller_mixin.py b/src/opensemantic/lab/_controller_mixin.py index 20de35c..0838b61 100644 --- a/src/opensemantic/lab/_controller_mixin.py +++ b/src/opensemantic/lab/_controller_mixin.py @@ -24,6 +24,24 @@ _OPCUA_UUID_NAMESPACE = UUID("35a708e3-aee9-44d7-abe7-fe3f85362def") +_INTEGER_STRUCT_CODES = "bBhHiIqQ" + + +def _is_integer_data_type(opcua_data_type) -> bool: + """True if the channel's OPC UA data type encodes as an integer. + + Read from asyncua's own struct formats rather than a hard-coded list, so + it cannot drift from the encoder that rejects a float. The OpcUaDataType + enum names the types but does not classify them. + """ + if opcua_data_type is None: + return False + from asyncua.ua.ua_binary import Primitives + + name = getattr(opcua_data_type, "value", opcua_data_type) + fmt = getattr(getattr(Primitives, str(name), None), "_fmt", None) + return bool(fmt) and fmt[-1] in _INTEGER_STRUCT_CODES + class OpcUaDataChannelMixin: """Mixin for OpcUaDataChannel controller methods.""" @@ -334,6 +352,12 @@ async def write_channel_typed(self, params): else: raw_value = value + # A unit conversion yields a float, but asyncua does not coerce it: + # encoding a float into an integer variant raises "required argument + # is not an integer". Cast to match the channel's declared type. + if isinstance(raw_value, float) and _is_integer_data_type(opcua_type): + raw_value = int(raw_value) + return await self.write_channel( type(self).WriteChannelParams( channel=channel, diff --git a/tests/test_controller.py b/tests/test_controller.py index d2a2510..ad24ab5 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -920,3 +920,82 @@ async def run(): assert len(received_before) > 0, "No data before server restart" assert len(received_after) > 0, "Client did not reconnect after server restart" + + +# -- Typed write tests -- + + +def _capture_raw_write(server): + """Replace write_channel with a recorder and return the recorded list.""" + recorded = [] + + async def fake_write_channel(params): + recorded.append((params.channel.name, params.value)) + + server.write_channel = fake_write_channel + return recorded + + +def test_write_channel_typed_casts_to_integer_channel(): + """A converted Characteristic must be cast to int for integer channels. + + asyncua does not coerce: encoding a float into an Int32 variant raises + "required argument is not an integer". + """ + channel = OpcUaDataChannel( + uuid=str(compute_scoped_uuid(_TEST_SERVER_UUID, "ns=2;s=Duration")), + node_id="ns=2;s=Duration", + name="Duration", + opcua_data_type="Int32", + unit=TimeUnit.milli_second, + ) + server = OpcUaServer( + uuid=_TEST_SERVER_UUID, + name="TestServer", + label=[Label(text="Test")], + url="opc.tcp://localhost:48400", + data_channels=[channel], + ) + recorded = _capture_raw_write(server) + + asyncio.run( + server.write_channel_typed( + OpcUaServer.WriteChannelParams( + channel=server.data_channels[0], + value=Time(value=5, unit=TimeUnit.minute), + ) + ) + ) + + assert recorded == [("Duration", 300000)] + assert isinstance(recorded[0][1], int) + + +def test_write_channel_typed_keeps_float_for_float_channel(): + channel = OpcUaDataChannel( + uuid=str(compute_scoped_uuid(_TEST_SERVER_UUID, "ns=2;s=Seconds")), + node_id="ns=2;s=Seconds", + name="Seconds", + opcua_data_type="Float", + unit=TimeUnit.second, + ) + server = OpcUaServer( + uuid=_TEST_SERVER_UUID, + name="TestServer", + label=[Label(text="Test")], + url="opc.tcp://localhost:48400", + data_channels=[channel], + ) + recorded = _capture_raw_write(server) + + asyncio.run( + server.write_channel_typed( + OpcUaServer.WriteChannelParams( + channel=server.data_channels[0], + value=Time(value=1.5, unit=TimeUnit.second), + ) + ) + ) + + assert recorded == [("Seconds", 1.5)] + assert isinstance(recorded[0][1], float) From a5265a490fa6aef9d173ce7266e9ad0a10773dda Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Wed, 5 Aug 2026 10:44:43 +0200 Subject: [PATCH 2/4] refactor: classify integer channels via ua.VariantType OpcUaDataType mirrors ua.VariantType, whose numbering is fixed by the OPC UA specification, so SByte (2) through UInt64 (9) identifies the integer types without a hard-coded name list. --- src/opensemantic/lab/_controller_mixin.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/opensemantic/lab/_controller_mixin.py b/src/opensemantic/lab/_controller_mixin.py index 0838b61..5e28935 100644 --- a/src/opensemantic/lab/_controller_mixin.py +++ b/src/opensemantic/lab/_controller_mixin.py @@ -24,23 +24,27 @@ _OPCUA_UUID_NAMESPACE = UUID("35a708e3-aee9-44d7-abe7-fe3f85362def") -_INTEGER_STRUCT_CODES = "bBhHiIqQ" +_FIRST_INTEGER_VARIANT_TYPE = 2 # SByte +_LAST_INTEGER_VARIANT_TYPE = 9 # UInt64 def _is_integer_data_type(opcua_data_type) -> bool: """True if the channel's OPC UA data type encodes as an integer. - Read from asyncua's own struct formats rather than a hard-coded list, so - it cannot drift from the encoder that rejects a float. The OpcUaDataType - enum names the types but does not classify them. + OpcUaDataType mirrors ua.VariantType, whose numbering is fixed by the OPC + UA specification: SByte (2) through UInt64 (9) are the integer types, with + Boolean below and Float/Double above. Resolved against asyncua rather than + a hard-coded name list so it cannot drift from the encoder. """ if opcua_data_type is None: return False - from asyncua.ua.ua_binary import Primitives + from asyncua import ua name = getattr(opcua_data_type, "value", opcua_data_type) - fmt = getattr(getattr(Primitives, str(name), None), "_fmt", None) - return bool(fmt) and fmt[-1] in _INTEGER_STRUCT_CODES + member = getattr(ua.VariantType, str(name), None) + if member is None: + return False + return _FIRST_INTEGER_VARIANT_TYPE <= member.value <= _LAST_INTEGER_VARIANT_TYPE class OpcUaDataChannelMixin: From be6e39e989aff4053d1e84e2904a07713d5e930e Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Wed, 5 Aug 2026 13:14:20 +0200 Subject: [PATCH 3/4] refactor: use the shared channel osw_id lookup from base get_channel_by_osw_id moved to DataToolMixin, where the surrounding channel traversal helpers already live. --- src/opensemantic/lab/_controller_mixin.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/opensemantic/lab/_controller_mixin.py b/src/opensemantic/lab/_controller_mixin.py index 5e28935..a3f5360 100644 --- a/src/opensemantic/lab/_controller_mixin.py +++ b/src/opensemantic/lab/_controller_mixin.py @@ -181,25 +181,6 @@ async def stop(self): await super().stop() self._state = ControllerState.stopping - def get_channel_by_osw_id(self, osw_id: str): - """Look up a channel by osw_id across self and all subdevices. - - Subchannel references are stored in the OSW backend as the osw_id of - a sibling channel on the same page. Accepts the full subobject IRI - (``Item:OSW#OSW``) as well as the bare ``OSW`` - suffix, since only the suffix is stable across pages. - - Raises ValueError if no channel with the given osw_id is found. - """ - suffix = osw_id.split("#")[-1] - for ch in self.get_all_channels(): - ch_osw_id = getattr(ch, "osw_id", None) - if ch_osw_id and ( - ch_osw_id == osw_id or ch_osw_id.split("#")[-1] == suffix - ): - return ch - raise ValueError(f"No channel with osw_id '{osw_id}' found.") - # -- OPC UA protocol methods -- def _get_ua_data_value( From bcd99a9823c8e3f68f32e9742b6e605d16d72942 Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Thu, 6 Aug 2026 06:19:41 +0200 Subject: [PATCH 4/4] build: require the base release with the shared osw_id lookup --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 29db45f..2d64dae 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,7 +48,7 @@ python_requires = >=3.10 # new major versions. This works if the required packages follow Semantic Versioning. # For more information, check out https://semver.org/. install_requires = - opensemantic.base>=0.42.8.post1000002004004 + opensemantic.base>=0.42.8.post1000002004006 opensemantic.characteristics.quantitative>=0.4.0.post1000002001001 [options.packages.find]