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.
This commit is contained in:
173
tests/functional/test_write_e2e.py
Normal file
173
tests/functional/test_write_e2e.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""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
|
||||
@@ -140,6 +140,36 @@ async def test_write_engine_flags_off_omit_statuscode_and_timestamp():
|
||||
assert dv.SourceTimestamp is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_engine_timestamp_off_keeps_statuscode():
|
||||
eng = make_engine(write_timestamp=False)
|
||||
node = make_node()
|
||||
eng.pool.get.return_value = make_client(node)
|
||||
|
||||
await eng.write(base_cmd())
|
||||
|
||||
dv = node.write_value.await_args.args[0]
|
||||
assert dv.Value.Value == 42.0
|
||||
assert dv.SourceTimestamp is None
|
||||
assert dv.StatusCode is not None
|
||||
assert dv.StatusCode.is_good()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_engine_quality_off_keeps_timestamp():
|
||||
# The command has no write_quality, so the engine (env) default applies.
|
||||
eng = make_engine(write_quality=False)
|
||||
node = make_node()
|
||||
eng.pool.get.return_value = make_client(node)
|
||||
|
||||
await eng.write(base_cmd())
|
||||
|
||||
dv = node.write_value.await_args.args[0]
|
||||
assert dv.Value.Value == 42.0
|
||||
assert dv.StatusCode is None
|
||||
assert dv.SourceTimestamp == datetime.fromisoformat('2026-07-13T12:00:00Z')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_message_flag_override_disables_timestamp():
|
||||
# Engine default is True, but the command explicitly turns it off.
|
||||
|
||||
@@ -41,6 +41,7 @@ def test_from_env_overrides(monkeypatch):
|
||||
monkeypatch.setenv('GRPC_ENABLED', 'true')
|
||||
monkeypatch.setenv('GRPC_PORT', '6000')
|
||||
monkeypatch.setenv('WRITE_MIN_PAUSE_S', '0.5')
|
||||
monkeypatch.setenv('WRITE_TIMESTAMP', 'false')
|
||||
monkeypatch.setenv('WRITE_QUALITY', 'false')
|
||||
|
||||
cfg = GatewayConfig.from_env()
|
||||
@@ -48,6 +49,7 @@ def test_from_env_overrides(monkeypatch):
|
||||
assert cfg.grpc_enabled is True
|
||||
assert cfg.grpc_port == 6000
|
||||
assert cfg.write_min_pause_s == 0.5
|
||||
assert cfg.write_timestamp is False
|
||||
assert cfg.write_quality is False
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user