Files
sientia-dataops-opc-ingestor/ingestor/connection/servers.py
Eduardo Rios 5318c82677 feat(SIENTIAPDE-2202): write timestamp and quality per OPC server
- write_timestamp/write_quality come from the command, then the server
  document (connectors/opc-servers, read even when the reads have the
  connection open, same 60 s cache), then the WRITE_TIMESTAMP/WRITE_QUALITY
  env defaults.
- The quality comes from the command's `quality` (the prediction status:
  Good/Bad/Uncertain, Good when absent); an unknown one is a bad_quality error.
- A server that refuses them (BadWriteNotSupported, e.g. Kepware) gets the
  value written again alone and is remembered until restart: one warning and
  opc_write_value_only_fallback_total per server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:29:33 -03:00

154 lines
6.1 KiB
Python

"""Connection settings of the OPC server a write command targets (SIENTIAPDE-1975 GW1).
The Laborious only knows the `server_id` of a write tag (pipeline `write_tags`); the
connection (url, server URI, security) belongs to the gateway, which owns every OPC
session. It is resolved with the same rule the orchestrator uses to build the read slots
(SIENTIAPDE-1974): the tag database `connectors` first, the legacy `opc-servers` otherwise.
The same document may also set the write options of the server (SIENTIAPDE-2202).
"""
from typing import Any
from sientia_do.observability.logger import Logger
from ingestor.connection.clock import MonotonicClock
from ingestor.connection.pool import OpcConnectionPool
CONNECTORS = 'connectors'
OPC_SERVERS = 'opc-servers'
WRITE_OPTIONS = ('write_timestamp', 'write_quality')
def _id_forms(server_id: str) -> list[Any]:
"""`server_id` as stored: the tag database keeps ints, opc-servers strings or ints."""
forms: list[Any] = [server_id]
if server_id.isdigit():
forms.append(int(server_id))
return forms
def _connector_cfg(connector: dict[str, Any]) -> dict[str, Any] | None:
if not connector.get('url'):
return None
return {
'url': connector['url'],
'server_uri': connector.get('server_uri'),
'use_security': bool(connector.get('use_security', False)),
}
def _opc_server_cfg(server: dict[str, Any]) -> dict[str, Any] | None:
if not server.get('url'):
return None
return {
'url': server['url'],
'server_uri': server.get('uri'),
# The certificate comes from MinIO now: a cert_path only says the server uses
# security, as in the orchestrator's slot (SIENTIAPDE-1974 RF4).
'use_security': bool(server.get('cert_path')),
}
def _write_options(document: dict[str, Any]) -> dict[str, bool]:
"""The write options the server document sets. Only booleans count: a hand-edited
string such as "false" must not turn an option on."""
return {key: document[key] for key in WRITE_OPTIONS if isinstance(document.get(key), bool)}
class ServerConfigResolver:
"""Resolve and cache the connection settings (and write options) of an OPC server by
`server_id`.
Order: settings carried by the command itself (an explicit override; the C2
command of the Laborious carries none), the live pooled connection (what the read
path opened, so writes never fight the reads over settings), then MongoDB. Lookups
are cached for `ttl_s`; a server that is not found, for `missing_ttl_s`, so a
pipeline pointing at a removed server does not query MongoDB on every command."""
def __init__(
self,
pool: OpcConnectionPool,
mongo,
logger: Logger,
ttl_s: float = 60.0,
missing_ttl_s: float = 10.0,
metadata: dict | None = None,
):
self.pool = pool
self.mongo = mongo
self.logger = logger
self.ttl_s = ttl_s
self.missing_ttl_s = missing_ttl_s
self.metadata = metadata or {}
self.clock = MonotonicClock()
self._cache: dict[str, tuple[dict[str, Any] | None, float]] = {}
async def resolve(self, server_id: str, cmd: dict) -> dict[str, Any] | None:
"""Connection settings for `server_id`, or None when no source knows it.
Raises:
Exception: When MongoDB cannot be read (the caller reports the write as
failed).
"""
if cmd.get('url'):
return {
'url': cmd['url'],
'server_uri': cmd.get('server_uri'),
'use_security': cmd.get('use_security', False),
}
server_id = str(server_id)
pooled = self.pool.config_for(server_id)
if pooled is not None:
return pooled
server = await self._server(server_id)
return dict(server['connection']) if server is not None else None
async def write_options(self, server_id: str) -> dict[str, bool]:
"""`write_timestamp`/`write_quality` set on the server document (SIENTIAPDE-2202),
only the ones it sets: the gateway defaults apply to the others.
Read from MongoDB even when the read path has the connection open (`resolve`
skips MongoDB then), with the same cache. Never raises: when MongoDB cannot be
read, the gateway defaults apply and the write goes on.
"""
try:
server = await self._server(str(server_id))
except Exception as e:
self.logger.warning(
f'Write options of OPC server {server_id} not read, using the gateway defaults: {e}'
)
return {}
return dict(server['write_options']) if server is not None else {}
async def _server(self, server_id: str) -> dict[str, Any] | None:
now = self.clock.elapsed()
cached = self._cache.get(server_id)
if cached is not None:
server, cached_at = cached
if now - cached_at < (self.ttl_s if server is not None else self.missing_ttl_s):
return server
server = await self._lookup(server_id)
self._cache[server_id] = (server, now)
return server
async def _lookup(self, server_id: str) -> dict[str, Any] | None:
"""The connection settings and write options of the first document that knows
the server, or None."""
ids = {'$in': _id_forms(server_id)}
connector = await self.mongo.find_one(
CONNECTORS,
{'connector_id': ids, 'type': 'opc_ua', 'status': 'Active'},
self.metadata,
)
cfg = _connector_cfg(connector) if connector else None
if cfg is not None:
return {'connection': cfg, 'write_options': _write_options(connector)}
server = await self.mongo.find_one(OPC_SERVERS, {'id': ids}, self.metadata)
cfg = _opc_server_cfg(server) if server else None
if cfg is None:
self.logger.warning(
f'OPC server {server_id} not found in {CONNECTORS} nor {OPC_SERVERS}'
)
return None
return {'connection': cfg, 'write_options': _write_options(server)}