Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
47 changes: 28 additions & 19 deletions src/opensemantic/lab/_controller_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,28 @@

_OPCUA_UUID_NAMESPACE = UUID("35a708e3-aee9-44d7-abe7-fe3f85362def")

_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.

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 import ua

name = getattr(opcua_data_type, "value", opcua_data_type)
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:
"""Mixin for OpcUaDataChannel controller methods."""
Expand Down Expand Up @@ -159,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<tool>#OSW<channel>``) as well as the bare ``OSW<channel>``
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(
Expand Down Expand Up @@ -334,6 +337,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,
Expand Down
79 changes: 79 additions & 0 deletions tests/test_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading