Files
sientia-dataops-opc-ingestor/tests/functional/test_write_e2e.py
vitor-aignosi ca9f4ce188 SIENTIAPDE-2202
SIENTIAPDE-2202-escrita-opc-timestamp-qualidade: Enable granular control over OPC UA SourceTimestamp and StatusCode in write commands. This implements write_timestamp and write_quality flags (command > env > default true) for DataValue construction. Upgraded asyncua to 2.0+ to fix StatusCode field naming. Enhanced README.md with new gateway environment variables and detailed write command behavior. Added comprehensive unit and E2E tests for this feature, and removed obsolete planning documents.
2026-10-29 16:38:03 -03:00

174 lines
5.5 KiB
Python

"""End-to-end OPC write (SIENTIAPDE-2202): env → GatewayConfig → WriteEngine →
OpcConnectionPool → a real asyncua server, in process.
The server keeps the SourceTimestamp it receives, so the timestamp is checked on the
server. An omitted StatusCode decodes as Good on the server (OPC UA default), so the
quality is checked on the wire: the DataValue encoding mask of the WriteRequest the
client sent.
"""
import socket
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from asyncua import Server, ua
from asyncua.client import ua_client
from asyncua.ua.ua_binary import Primitives, nodeid_to_binary
from ingestor.config import GatewayConfig
from ingestor.connection.pool import OpcConnectionPool
from ingestor.engines.write import WriteEngine
TIMESTAMP = '2026-07-13T12:00:00Z'
# DataValue encoding mask bits (OPC UA Part 6, 5.2.2.17).
_STATUS_CODE_BIT = 0x02
_SOURCE_TIMESTAMP_BIT = 0x04
def _free_port() -> int:
with socket.socket() as s:
s.bind(('127.0.0.1', 0))
return s.getsockname()[1]
@pytest_asyncio.fixture
async def opc_server():
url = f'opc.tcp://127.0.0.1:{_free_port()}'
server = Server()
await server.init()
server.set_endpoint(url)
idx = await server.register_namespace('urn:sientia:e2e')
var = await server.nodes.objects.add_variable(idx, 'Pred', 0.0, ua.VariantType.Float)
await var.set_writable()
async with server:
yield url, var
@pytest.fixture
def wire(monkeypatch):
"""Serialized WriteRequests, as sent by the client."""
sent: list[bytes] = []
original = ua_client.struct_to_binary
def capture(obj):
data = original(obj)
if isinstance(obj, ua.WriteRequest):
sent.append(data)
return data
monkeypatch.setattr(ua_client, 'struct_to_binary', capture)
return sent
def _datavalue_mask(request: bytes, addr: str) -> int:
# WriteValue = NodeId, AttributeId, IndexRange, DataValue: the byte right after
# the first three is the DataValue encoding mask. The NodeId is parsed from the
# address as the client does (its binary encoding depends on how it was built).
prefix = (
nodeid_to_binary(ua.NodeId.from_string(addr))
+ Primitives.UInt32.pack(ua.AttributeIds.Value)
+ Primitives.String.pack(None)
)
return request[request.index(prefix) + len(prefix)]
async def _engine(url: str, pools: list) -> WriteEngine:
config = GatewayConfig.from_env()
logger, notification_handler = MagicMock(), MagicMock()
# emit() is awaited, start()/shutdown_sync() are not.
metrics_controller = MagicMock(emit=AsyncMock(), shutdown=AsyncMock())
pool = OpcConnectionPool(MagicMock(), logger, notification_handler, metrics_controller)
pools.append(pool)
servers = MagicMock()
servers.resolve = AsyncMock(
return_value={
'server_id': 'e2e',
'url': url,
'security': {'mode': 'None', 'policy': 'None'},
'certificate': None,
}
)
return WriteEngine(
pool,
AsyncMock(),
logger,
notification_handler,
metrics_controller,
write_timestamp=config.write_timestamp,
write_quality=config.write_quality,
servers=servers,
)
@pytest_asyncio.fixture
async def pools():
created: list[OpcConnectionPool] = []
yield created
for pool in created:
for client, _ in pool._connections.values():
await client.disconnect()
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.parametrize(
'env,cmd_flags,expect_timestamp,expect_quality',
[
pytest.param({}, {}, True, True, id='defaults-on'),
pytest.param({'WRITE_TIMESTAMP': 'false'}, {}, False, True, id='timestamp-off'),
pytest.param({'WRITE_QUALITY': 'false'}, {}, True, False, id='quality-off'),
pytest.param(
{'WRITE_TIMESTAMP': 'false', 'WRITE_QUALITY': 'false'}, {}, False, False, id='both-off'
),
pytest.param(
{'WRITE_TIMESTAMP': 'true'},
{'write_timestamp': False},
False,
True,
id='command-overrides-env',
),
pytest.param(
{'WRITE_TIMESTAMP': 'false', 'WRITE_QUALITY': 'false'},
{'write_timestamp': True, 'write_quality': True},
True,
True,
id='command-turns-on-over-env',
),
],
)
async def test_write_datavalue_parts(
monkeypatch, opc_server, wire, pools, env, cmd_flags, expect_timestamp, expect_quality
):
monkeypatch.delenv('WRITE_TIMESTAMP', raising=False)
monkeypatch.delenv('WRITE_QUALITY', raising=False)
for name, value in env.items():
monkeypatch.setenv(name, value)
url, var = opc_server
engine = await _engine(url, pools)
cmd = {
'server_id': 'e2e',
'addr': var.nodeid.to_string(),
'value': 1.5,
'data_type': 'float',
'model_id': 'm1',
'timestamp': TIMESTAMP,
**cmd_flags,
}
result = await engine.write(cmd)
assert result.status == 'ok', result.message
stored = await var.read_data_value()
assert stored.Value.Value == 1.5
if expect_timestamp:
assert stored.SourceTimestamp == datetime.fromisoformat(TIMESTAMP)
else:
assert stored.SourceTimestamp is None
assert len(wire) == 1
mask = _datavalue_mask(wire[0], cmd['addr'])
assert bool(mask & _SOURCE_TIMESTAMP_BIT) is expect_timestamp
assert bool(mask & _STATUS_CODE_BIT) is expect_quality