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
6 changes: 4 additions & 2 deletions src/opensemantic/lab/_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down
25 changes: 25 additions & 0 deletions src/opensemantic/lab/_controller_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 @@ -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}
Expand Down
6 changes: 4 additions & 2 deletions src/opensemantic/lab/v1/_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down
75 changes: 75 additions & 0 deletions tests/test_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<uuid> 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 --


Expand Down Expand Up @@ -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 --


Expand Down
Loading