From c3b002db222b9356c3c3a8446f937f6cece7b952 Mon Sep 17 00:00:00 2001 From: SimonTaurus Date: Wed, 5 Aug 2026 08:49:43 +0200 Subject: [PATCH] fix: accept subchannel references as stored in the OSW backend The schema defines subchannels as an array of strings picked from the sibling data_channels by osw_id, but the controller classes narrowed the field to a list of OpcUaDataChannel objects. Loading a server from the backend therefore failed validation with "value is not a valid dict". Widen the field to accept both forms and add OpcUaServerMixin.get_channel_by_osw_id(), used by the data change handler to resolve stored references before reading them. --- src/opensemantic/lab/_controller.py | 6 +- src/opensemantic/lab/_controller_mixin.py | 25 ++++++++ src/opensemantic/lab/v1/_controller.py | 6 +- tests/test_controller.py | 75 +++++++++++++++++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/opensemantic/lab/_controller.py b/src/opensemantic/lab/_controller.py index eeefc47..57f440a 100644 --- a/src/opensemantic/lab/_controller.py +++ b/src/opensemantic/lab/_controller.py @@ -5,7 +5,7 @@ import logging from enum import Enum -from typing import Any, Awaitable, Callable, Dict, List, Optional +from typing import Any, Awaitable, Callable, Dict, List, Optional, Union from pydantic import BaseModel, ConfigDict, PrivateAttr @@ -20,7 +20,9 @@ class OpcUaDataChannel(OpcUaDataChannelMixin, _OpcUaDataChannel): """Enhanced v2 OpcUaDataChannel with uuid5 generation and helper methods.""" - subchannels: Optional[List["OpcUaDataChannel"]] = None + subchannels: Optional[List[Union[str, "OpcUaDataChannel"]]] = None + """IRIs of the subchannels, as stored in the OSW backend. Inline channel + objects are accepted too, but do not round-trip through the backend.""" class ControllerMode(str, Enum): diff --git a/src/opensemantic/lab/_controller_mixin.py b/src/opensemantic/lab/_controller_mixin.py index 8b870a6..20de35c 100644 --- a/src/opensemantic/lab/_controller_mixin.py +++ b/src/opensemantic/lab/_controller_mixin.py @@ -159,6 +159,25 @@ 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( @@ -391,6 +410,12 @@ async def _handle_datachange_notification(self, node, val, data): # instead of being defined only on the controller's OpcUaDataChannel subchannels = getattr(channel, "subchannels", None) if subchannels and len(subchannels) > 0: + # Stored as osw_id references in the OSW backend, but inline + # channel objects are accepted as well. + subchannels = [ + self.get_channel_by_osw_id(sc) if isinstance(sc, str) else sc + for sc in subchannels + ] srs = await self.read_channels(subchannels) _val = val.isoformat() if isinstance(val, datetime.datetime) else val val = {channel.name: _val} diff --git a/src/opensemantic/lab/v1/_controller.py b/src/opensemantic/lab/v1/_controller.py index 95d19d9..ea2058e 100644 --- a/src/opensemantic/lab/v1/_controller.py +++ b/src/opensemantic/lab/v1/_controller.py @@ -4,7 +4,7 @@ """ import logging -from typing import Any, Awaitable, Callable, Dict, List, Optional +from typing import Any, Awaitable, Callable, Dict, List, Optional, Union from pydantic import ConfigDict, PrivateAttr @@ -19,7 +19,9 @@ class OpcUaDataChannel(OpcUaDataChannelMixin, _OpcUaDataChannel): """Enhanced v1 OpcUaDataChannel with uuid5 generation and helper methods.""" - subchannels: Optional[List["OpcUaDataChannel"]] = None + subchannels: Optional[List[Union[str, "OpcUaDataChannel"]]] = None + """IRIs of the subchannels, as stored in the OSW backend. Inline channel + objects are accepted too, but do not round-trip through the backend.""" try: diff --git a/tests/test_controller.py b/tests/test_controller.py index d133468..d2a2510 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -129,6 +129,55 @@ def test_channel_subchannels(): assert main.subchannels[0].name == "SubChannel" +def test_channel_subchannels_as_iris(): + """Subchannels stored in the OSW backend are IRIs, not inline objects.""" + main = OpcUaDataChannel( + uuid=str(compute_scoped_uuid(_TEST_SERVER_UUID, "ns=2;s=Main")), + node_id="ns=2;s=Main", + name="MainChannel", + subchannels=["Item:OSWaaa#OSWbbb"], + ) + assert main.subchannels == ["Item:OSWaaa#OSWbbb"] + + +def test_server_resolves_subchannel_iris(server_with_channels): + """A subchannel reference resolves to the channel object it points at.""" + server = server_with_channels + press = server.get_channel_by_name("Pressure") + assert server.get_channel_by_osw_id(press.osw_id) is press + # The bare OSW suffix resolves as well + assert server.get_channel_by_osw_id(press.osw_id.split("#")[-1]) is press + + +def test_server_resolves_subchannel_iri_of_subdevice(server_with_channels): + """Resolution spans the whole subdevice hierarchy.""" + server = server_with_channels + sub_temp = server.get_channel_by_name("SubTemperature") + assert server.get_channel_by_osw_id(sub_temp.osw_id) is sub_temp + + +def test_server_resolve_unknown_osw_id_raises(server_with_channels): + with pytest.raises(ValueError): + server_with_channels.get_channel_by_osw_id("Item:OSWaaa#OSWbbb") + + +def test_subchannel_references_roundtrip(server_with_channels): + """Subchannel osw_ids survive serialization and still resolve on reload.""" + import json + + server = server_with_channels + temp = server.get_channel_by_name("Temperature") + press = server.get_channel_by_name("Pressure") + temp.subchannels = [press.osw_id] + + reloaded = OpcUaServer(**json.loads(server.model_dump_json(exclude_none=True))) + reloaded_temp = reloaded.get_channel_by_name("Temperature") + assert reloaded_temp.subchannels == [press.osw_id] + assert reloaded.get_channel_by_osw_id(reloaded_temp.subchannels[0]).name == ( + "Pressure" + ) + + # -- Subobject ID tests -- @@ -383,6 +432,32 @@ def test_v1_channel_subchannels(): assert main.subchannels[0].name == "V1SubChannel" +def test_v1_channel_subchannels_as_iris(): + main = OpcUaDataChannel_v1( + uuid=str(compute_scoped_uuid(_TEST_SERVER_UUID, "ns=2;s=V1Main")), + node_id="ns=2;s=V1Main", + name="V1MainChannel", + subchannels=["Item:OSWaaa#OSWbbb"], + ) + assert main.subchannels == ["Item:OSWaaa#OSWbbb"] + + +def test_v1_server_resolves_subchannel_iris(): + ch = OpcUaDataChannel_v1( + uuid=str(compute_scoped_uuid(_TEST_SERVER_UUID, "ns=2;s=V1Press")), + node_id="ns=2;s=V1Press", + name="V1Pressure", + ) + server = OpcUaServer_v1( + uuid=_TEST_SERVER_UUID, + name="V1Server", + label=[Label(text="V1", lang="en")], + url="opc.tcp://localhost:48402", + data_channels=[ch], + ) + assert server.get_channel_by_osw_id(ch.osw_id) is server.data_channels[0] + + # -- v1 OpcUaServer tests --