🚧 WIP
This commit is contained in:
+221
-14
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from opcda_diagnozer import __version__
|
||||
from opcda_diagnozer.config import ConfigError, load_config
|
||||
@@ -20,9 +21,21 @@ from opcda_diagnozer.opc.discovery import ServerInfo, list_servers
|
||||
|
||||
DEFAULT_CONFIG = "config.yaml"
|
||||
|
||||
|
||||
def _default_config_path() -> str:
|
||||
"""Where the config lives by default: CWD in source runs, exe dir when frozen.
|
||||
|
||||
A frozen exe must not rely on the CWD: a Windows service starts with
|
||||
``CWD = C:\\Windows\\System32``, so the relative name would silently resolve
|
||||
into System32. Frozen runs therefore default to ``config.yaml`` **next to
|
||||
the exe** — the deployment layout the Phase 3 service will use (SPEC §6.4).
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return str(Path(sys.executable).resolve().parent / DEFAULT_CONFIG)
|
||||
return DEFAULT_CONFIG
|
||||
|
||||
_NOT_IMPLEMENTED: dict[str, str] = {
|
||||
"run": "Phase 1 (core engine)",
|
||||
"probe": "Phase 1 (core engine)",
|
||||
"list-items": "superseded by list-devices / list-tags",
|
||||
"install": "Phase 3 (Windows service)",
|
||||
"uninstall": "Phase 3 (Windows service)",
|
||||
@@ -34,11 +47,30 @@ _NOT_IMPLEMENTED: dict[str, str] = {
|
||||
def _add_config_option(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--config", "-c",
|
||||
default=DEFAULT_CONFIG,
|
||||
default=_default_config_path(),
|
||||
help="path to the YAML config (default: %(default)s)",
|
||||
)
|
||||
|
||||
|
||||
def _add_server_overrides(parser: argparse.ArgumentParser) -> None:
|
||||
"""--server/--host override the config's opc section (SPECIFICATION §6).
|
||||
|
||||
They make one-shot CLI use possible without a config file — and let an
|
||||
ops engineer point a validation verb at a different server without
|
||||
touching the deployed config.yaml.
|
||||
"""
|
||||
parser.add_argument(
|
||||
"--server",
|
||||
metavar="PROGID",
|
||||
help="OPC DA server ProgID, overriding the config (e.g. Matrikon.OPC.Simulation.1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
metavar="HOST",
|
||||
help="remote DCOM host, overriding the config (omit for a local server)",
|
||||
)
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="opcda-diagnozer",
|
||||
@@ -53,8 +85,44 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
p = sub.add_parser("run", help="run in the foreground (console)")
|
||||
_add_config_option(p)
|
||||
|
||||
p = sub.add_parser("probe", help="connect once and print one read of every configured tag")
|
||||
p = sub.add_parser(
|
||||
"read",
|
||||
help="connect once and read the given tags (a quick validation tool)",
|
||||
)
|
||||
_add_config_option(p)
|
||||
_add_server_overrides(p)
|
||||
p.add_argument(
|
||||
"tags",
|
||||
nargs="*",
|
||||
metavar="TAG",
|
||||
help="tag ItemIDs to read (e.g. 'Bucket Brigade.Int1'); omitted -> the "
|
||||
"config's tags",
|
||||
)
|
||||
p.add_argument(
|
||||
"--source",
|
||||
choices=("device", "cache"),
|
||||
default="device",
|
||||
help="read from the device (round-trip to the PLC) or the server cache "
|
||||
"(default: %(default)s)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="emit one JSON object per tag (for scripts) instead of a table",
|
||||
)
|
||||
|
||||
p = sub.add_parser(
|
||||
"probe",
|
||||
help="connect once and print one read of every configured tag",
|
||||
)
|
||||
_add_config_option(p)
|
||||
_add_server_overrides(p)
|
||||
p.add_argument(
|
||||
"--source",
|
||||
choices=("device", "cache"),
|
||||
default="device",
|
||||
help="read from the device or the server cache (default: %(default)s)",
|
||||
)
|
||||
|
||||
p = sub.add_parser("check-config", help="validate the configuration and exit")
|
||||
_add_config_option(p)
|
||||
@@ -90,6 +158,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
help="list the devices (address-space branches) exposed by the configured server",
|
||||
)
|
||||
_add_config_option(p)
|
||||
_add_server_overrides(p)
|
||||
p.add_argument(
|
||||
"--depth",
|
||||
type=int,
|
||||
@@ -113,6 +182,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
help="list the tags (leaves) of one device, or every tag on the server",
|
||||
)
|
||||
_add_config_option(p)
|
||||
_add_server_overrides(p)
|
||||
p.add_argument(
|
||||
"device",
|
||||
nargs="*",
|
||||
@@ -211,19 +281,36 @@ def _cmd_list_servers(host: str | None, source: str, as_json: bool) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _connect_from_config(config_path: str):
|
||||
"""Load the config and connect to its configured server (Automation Wrapper).
|
||||
def _connect_from_config(config_path: str, server: str | None = None, host: str | None = None):
|
||||
"""Load the config (when needed) and connect; --server/--host override opc.
|
||||
|
||||
Overrides make one-shot CLI use possible without a config file: when
|
||||
``--server`` is given, a missing config file is not an error and the
|
||||
remaining settings fall back to their defaults. Without ``--server`` the
|
||||
config must exist (its ``opc`` section decides where to connect).
|
||||
|
||||
Returns the connected client; raises :class:`ConfigError` / :class:`OpcError`
|
||||
with actionable messages for the caller to print.
|
||||
"""
|
||||
from opcda_diagnozer.opc.automation import AutomationOpcClient
|
||||
|
||||
config = load_config(config_path)
|
||||
connect_timeout_s = 10.0 # OpcConfig default
|
||||
config_exists = Path(config_path).is_file()
|
||||
if config_exists:
|
||||
config = load_config(config_path)
|
||||
server = server or config.opc.server
|
||||
if host is None:
|
||||
host = config.opc.host
|
||||
connect_timeout_s = config.opc.connect_timeout_s
|
||||
elif server is None:
|
||||
raise ConfigError(
|
||||
f"config file not found: {config_path} - pass --server (and optionally "
|
||||
"--host) to connect without a config, or create the config first"
|
||||
)
|
||||
client = AutomationOpcClient(
|
||||
server=config.opc.server,
|
||||
host=config.opc.host,
|
||||
connect_timeout_s=config.opc.connect_timeout_s,
|
||||
server=server,
|
||||
host=host,
|
||||
connect_timeout_s=connect_timeout_s,
|
||||
)
|
||||
client.connect()
|
||||
return client
|
||||
@@ -257,11 +344,14 @@ def _print_devices(space, as_json: bool) -> None:
|
||||
print(f" {device.qualified_name:<{width}} ({', '.join(notes)})")
|
||||
|
||||
|
||||
def _cmd_list_devices(config_path: str, depth: int, include_counts: bool, as_json: bool) -> int:
|
||||
def _cmd_list_devices(
|
||||
config_path: str, server: str | None, host: str | None,
|
||||
depth: int, include_counts: bool, as_json: bool,
|
||||
) -> int:
|
||||
from opcda_diagnozer.opc.browse import list_devices
|
||||
|
||||
try:
|
||||
client = _connect_from_config(config_path)
|
||||
client = _connect_from_config(config_path, server, host)
|
||||
browser = None
|
||||
try:
|
||||
browser = client.create_browser()
|
||||
@@ -317,6 +407,8 @@ def _print_tags(tags, device_path: tuple[str, ...], recursive: bool, as_json: bo
|
||||
|
||||
def _cmd_list_tags(
|
||||
config_path: str,
|
||||
server: str | None,
|
||||
host: str | None,
|
||||
device: Sequence[str],
|
||||
recursive: bool,
|
||||
as_json: bool,
|
||||
@@ -327,7 +419,7 @@ def _cmd_list_tags(
|
||||
# explicit branch names (names may contain dots then).
|
||||
device_arg = device[0] if len(device) == 1 else list(device)
|
||||
try:
|
||||
client = _connect_from_config(config_path)
|
||||
client = _connect_from_config(config_path, server, host)
|
||||
browser = None
|
||||
try:
|
||||
browser = client.create_browser()
|
||||
@@ -353,6 +445,111 @@ def _cmd_list_tags(
|
||||
return 0
|
||||
|
||||
|
||||
def _print_read_results(results, as_json: bool) -> int:
|
||||
"""Human table / JSON-lines output for the read/probe verbs."""
|
||||
from opcda_diagnozer.quality import decode_quality
|
||||
|
||||
failures = 0
|
||||
if as_json:
|
||||
import json
|
||||
|
||||
for result in results:
|
||||
quality = decode_quality(result.quality_raw)
|
||||
payload = {
|
||||
"tag": result.tag,
|
||||
"value": result.value,
|
||||
"quality_raw": result.quality_raw,
|
||||
"quality": quality.name,
|
||||
"quality_class": quality.quality_class.value,
|
||||
"device_time_utc": (
|
||||
result.device_time.isoformat() if result.device_time else None
|
||||
),
|
||||
"error": result.error,
|
||||
}
|
||||
if result.error or quality.quality_class.value != "GOOD":
|
||||
failures += 1
|
||||
print(json.dumps(payload, ensure_ascii=False, default=str))
|
||||
return failures
|
||||
if not results:
|
||||
print("no tags to read")
|
||||
return 0
|
||||
width = max(len(r.tag) for r in results)
|
||||
print(f"{'tag':<{width}} value quality device time (UTC)")
|
||||
for result in results:
|
||||
quality = decode_quality(result.quality_raw)
|
||||
if result.error:
|
||||
failures += 1
|
||||
print(f" {result.tag:<{width}} ERROR: {result.error}")
|
||||
continue
|
||||
marker = "" if quality.quality_class.value == "GOOD" else " <-- " + quality.name
|
||||
if quality.quality_class.value != "GOOD":
|
||||
failures += 1
|
||||
time_str = (
|
||||
result.device_time.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
||||
if result.device_time
|
||||
else "-"
|
||||
)
|
||||
print(
|
||||
f" {result.tag:<{width}} {str(result.value):<30} {quality.raw_hex} {time_str}{marker}"
|
||||
)
|
||||
return failures
|
||||
|
||||
|
||||
def _cmd_read(
|
||||
config_path: str,
|
||||
server: str | None,
|
||||
host: str | None,
|
||||
tags: Sequence[str],
|
||||
source: str,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
"""One-shot read of explicit tags (or the config's tags) from the server."""
|
||||
from opcda_diagnozer.opc.base import OPCDataSource
|
||||
|
||||
requested = list(tags)
|
||||
try:
|
||||
# connect first: a missing config with no --server must fail here with
|
||||
# the actionable hint (before the tags load below can complain).
|
||||
client = _connect_from_config(config_path, server, host)
|
||||
if not requested:
|
||||
# no tags given -> use the config's tags (config is mandatory then)
|
||||
config = load_config(config_path)
|
||||
requested = [t.name for t in config.tags]
|
||||
except ConfigError as exc:
|
||||
print(f"read: FAILED\n {exc}", file=sys.stderr)
|
||||
return 2
|
||||
except OpcError as exc:
|
||||
print(f"read: FAILED\n {exc}", file=sys.stderr)
|
||||
return 2
|
||||
data_source = OPCDataSource.DEVICE if source == "device" else OPCDataSource.CACHE
|
||||
try:
|
||||
results = client.read(requested, source=data_source)
|
||||
except OpcError as exc:
|
||||
print(f"read: FAILED\n {exc}", file=sys.stderr)
|
||||
client.close()
|
||||
return 2
|
||||
finally:
|
||||
client.close()
|
||||
failures = _print_read_results(results, as_json)
|
||||
if failures:
|
||||
print(
|
||||
f"read: {failures} of {len(results)} tag(s) did not read GOOD",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_probe(
|
||||
config_path: str,
|
||||
server: str | None,
|
||||
host: str | None,
|
||||
source: str,
|
||||
) -> int:
|
||||
"""Connect once, read every configured tag once, print — the pre-deploy check."""
|
||||
return _cmd_read(config_path, server, host, (), source, as_json=False)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""CLI entry point (console script + ``python -m opcda_diagnozer``)."""
|
||||
args = _build_parser().parse_args(argv)
|
||||
@@ -361,9 +558,19 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
if args.verb == "list-servers":
|
||||
return _cmd_list_servers(args.host, args.source, args.json)
|
||||
if args.verb == "list-devices":
|
||||
return _cmd_list_devices(args.config, args.depth, not args.no_counts, args.json)
|
||||
return _cmd_list_devices(
|
||||
args.config, args.server, args.host, args.depth, not args.no_counts, args.json
|
||||
)
|
||||
if args.verb == "list-tags":
|
||||
return _cmd_list_tags(args.config, args.device, args.recursive, args.json)
|
||||
return _cmd_list_tags(
|
||||
args.config, args.server, args.host, args.device, args.recursive, args.json
|
||||
)
|
||||
if args.verb == "read":
|
||||
return _cmd_read(
|
||||
args.config, args.server, args.host, args.tags, args.source, args.json
|
||||
)
|
||||
if args.verb == "probe":
|
||||
return _cmd_probe(args.config, args.server, args.host, args.source)
|
||||
phase = _NOT_IMPLEMENTED[args.verb]
|
||||
print(
|
||||
f"{args.verb}: not implemented yet — planned for {phase} "
|
||||
|
||||
@@ -17,23 +17,31 @@ Matrikon.OPC.Simulation.1): ``Connect(progId)`` / ``Connect(progId, node)``,
|
||||
``CurrentPosition``; ``Organization`` 1 = hierarchical, 2 = flat. Bad branch
|
||||
names raise ``E_INVALIDARG``; ``MoveTo`` requires a *sequence* (a bare string
|
||||
is a pywin32 SAFEARRAY ``TypeError``).
|
||||
|
||||
**Reads** (verified live): the wrapper's ``SyncRead`` cannot be marshalled by
|
||||
pywin32 (SAFEARRAY ByRef out-params fail in every invocation shape), but
|
||||
``OPCItem.Read(Source)`` + the ``Value`` / ``Quality`` / ``TimeStamp``
|
||||
properties work — per-item reads with only scalar out-params. Data-source
|
||||
constants come from the wrapper typelib: ``OPCCache = 1``, ``OPCDevice = 2``.
|
||||
``TimeStamp`` is a tz-aware ``pywintypes.datetime`` (converted with
|
||||
``astimezone(timezone.utc)`` for the UTC event model, SPECIFICATION §6).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from opcda_diagnozer.opc.base import (
|
||||
Browser,
|
||||
OPCDataSource,
|
||||
OpcError,
|
||||
ReadResult,
|
||||
TagInfo,
|
||||
as_com_error,
|
||||
)
|
||||
|
||||
_NOT_IMPL = "Phase 1: Automation Wrapper adapter (docs/IMPLEMENTATION_PLAN.md §4.4)"
|
||||
|
||||
#: OPC_BROWSE organization constants (Automation Wrapper): 1 = hierarchical,
|
||||
#: 2 = flat (tags addressed by bare ItemID, no branches to walk).
|
||||
_ORGANIZATION_LABELS = {1: "hierarchical", 2: "flat"}
|
||||
@@ -120,9 +128,10 @@ class _AutomationBrowser:
|
||||
class AutomationOpcClient:
|
||||
"""``win32com``-based :class:`OpcClient`.
|
||||
|
||||
``connect`` / ``create_browser`` / ``close`` are live (they power the
|
||||
``list-devices`` / ``list-tags`` validation verbs); ``read`` lands with the
|
||||
Phase 1 core engine (IMPLEMENTATION_PLAN §4.4).
|
||||
``connect`` / ``create_browser`` / ``read`` / ``close`` are live: they
|
||||
power the ``list-devices`` / ``list-tags`` validation verbs and the
|
||||
one-shot ``read`` / ``probe`` CLI verbs. The Phase 1 poller builds on this
|
||||
same adapter (IMPLEMENTATION_PLAN §4.4).
|
||||
"""
|
||||
|
||||
def __init__(self, server: str, host: str | None = None, connect_timeout_s: float = 10.0) -> None:
|
||||
@@ -214,9 +223,92 @@ class AutomationOpcClient:
|
||||
self._com_initialized = False
|
||||
pythoncom.CoUninitialize()
|
||||
|
||||
def read(self, tags: Sequence[str]) -> list[ReadResult]:
|
||||
self._require_pywin32()
|
||||
raise NotImplementedError(_NOT_IMPL)
|
||||
def read(
|
||||
self, tags: Sequence[str], source: OPCDataSource = OPCDataSource.DEVICE
|
||||
) -> list[ReadResult]:
|
||||
"""Read the given tags from the connected server.
|
||||
|
||||
Uses one throwaway group with one item per tag and per-item
|
||||
``Item.Read(Source)`` (see the module docstring: the wrapper's
|
||||
``SyncRead`` cannot be marshalled by pywin32). A tag the server does
|
||||
not know yields a :class:`ReadResult` with ``error`` set and a
|
||||
``BAD_CONFIG_ERROR``-style quality of 0 — never an exception, matching
|
||||
the per-tag error contract of :class:`ReadResult` (IMPLEMENTATION_PLAN
|
||||
§4.3). The group (and its items) is removed afterwards so repeated
|
||||
calls never leak groups on the server.
|
||||
"""
|
||||
if self._opc is None:
|
||||
raise OpcError("AutomationOpcClient: read() before connect()")
|
||||
results: list[ReadResult] = []
|
||||
group = None
|
||||
items_collection = None
|
||||
live_items: list[tuple[str, Any]] = [] # (tag, item) in request order
|
||||
try:
|
||||
try:
|
||||
group = self._opc.OPCGroups.Add("DiagnozerRead")
|
||||
items_collection = group.OPCItems
|
||||
errors: list[str | None] = [None] * len(tags)
|
||||
for index, tag in enumerate(tags):
|
||||
try:
|
||||
item = items_collection.AddItem(tag, index + 1)
|
||||
live_items.append((tag, item))
|
||||
except Exception as exc: # noqa: BLE001 — per-tag error
|
||||
results.append(
|
||||
ReadResult(
|
||||
tag=tag,
|
||||
value=None,
|
||||
quality_raw=0,
|
||||
device_time=None,
|
||||
error=str(as_com_error(f"cannot add tag {tag!r}", exc)),
|
||||
)
|
||||
)
|
||||
for tag, item in live_items:
|
||||
try:
|
||||
item.Read(int(source))
|
||||
raw_ts = item.TimeStamp
|
||||
# pywintypes.datetime is a datetime subclass; normalize
|
||||
# to UTC for the event model (SPECIFICATION §6).
|
||||
device_time = (
|
||||
raw_ts.astimezone(timezone.utc) if raw_ts is not None else None
|
||||
)
|
||||
results.append(
|
||||
ReadResult(
|
||||
tag=tag,
|
||||
value=item.Value,
|
||||
quality_raw=int(item.Quality),
|
||||
device_time=device_time,
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — per-tag error
|
||||
results.append(
|
||||
ReadResult(
|
||||
tag=tag,
|
||||
value=None,
|
||||
quality_raw=0,
|
||||
device_time=None,
|
||||
error=str(as_com_error(f"read of tag {tag!r} failed", exc)),
|
||||
)
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — group-level failure
|
||||
raise as_com_error(
|
||||
f"could not read from server {self._server!r} "
|
||||
"(group/level failure)",
|
||||
exc,
|
||||
) from None
|
||||
finally:
|
||||
# Release every COM reference INSIDE the apartment, in reverse
|
||||
# creation order, before close()/CoUninitialize can see them.
|
||||
live_items.clear()
|
||||
items_collection = None
|
||||
if group is not None:
|
||||
try:
|
||||
group.Parent.OPCGroups.RemoveAll()
|
||||
except Exception: # noqa: BLE001 — best-effort cleanup
|
||||
pass
|
||||
group = None
|
||||
# preserve request order regardless of which tags failed to add
|
||||
by_tag = {r.tag: r for r in results}
|
||||
return [by_tag[tag] for tag in tags]
|
||||
|
||||
def close(self) -> None:
|
||||
self._release_and_uninit()
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import IntEnum
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@@ -29,6 +30,19 @@ class ReadResult:
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class OPCDataSource(IntEnum):
|
||||
"""Automation Wrapper data-source constants (OPCDataSource enum).
|
||||
|
||||
``CACHE`` reads the server's last-known value (fast, may be stale),
|
||||
``DEVICE`` forces a round-trip to the underlying device/controller — the
|
||||
one the diagnozer cares about (SPECIFICATION §10: the measured path is
|
||||
server → device, not server → cache).
|
||||
"""
|
||||
|
||||
CACHE = 1
|
||||
DEVICE = 2
|
||||
|
||||
|
||||
# Common HRESULTs the tool decodes and reports (README "DCOM troubleshooting").
|
||||
_HRESULT_NAMES: dict[int, str] = {
|
||||
0x800706BA: "RPC_S_SERVER_UNAVAILABLE — server process down, firewall, or DCOM activation refused",
|
||||
@@ -38,7 +52,8 @@ _HRESULT_NAMES: dict[int, str] = {
|
||||
0x80040154: "REGDB_E_CLASSNOTREG — wrong ProgID, or wrapper/server registered under a different bitness",
|
||||
0x80020009: "DISP_E_EXCEPTION — the server rejected the call (e.g. no such branch/tag)",
|
||||
0x80070057: "E_INVALIDARG — invalid argument (e.g. wrong branch or item name)",
|
||||
0xC0040007: "OPC_E_INVALIDITEMID — tag name wrong in config (surfaced by probe)",
|
||||
0xC0040007: "OPC_E_UNKNOWNITEMID — the server does not know this tag",
|
||||
0xC0040008: "OPC_E_INVALIDITEMID — tag name wrong in config (surfaced by probe)",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from opcda_diagnozer.opc.base import Browser, OpcError, ReadResult, TagInfo
|
||||
from opcda_diagnozer.opc.base import Browser, OPCDataSource, OpcError, ReadResult, TagInfo
|
||||
|
||||
#: The fake address space: branch path -> (sub-branches, leaf names).
|
||||
#: Mirrors the shape of a real SCADA server (Channel → Device → Tags).
|
||||
@@ -99,15 +99,19 @@ class FakeOpcClient:
|
||||
"""In-process :class:`OpcClient`; every read returns GOOD-quality zeros.
|
||||
|
||||
``space`` is injectable so tests can exercise flat namespaces, empty
|
||||
servers, and deep trees; the default mirrors a Matrikon-like hierarchy.
|
||||
servers, and deep trees; ``read_results`` scripts the next read() result
|
||||
(Phase 1 scenario engine seed, IMPLEMENTATION_PLAN §4.9).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
space: dict[tuple[str, ...], tuple[tuple[str, ...], tuple[str, ...]]] | None = None,
|
||||
read_results: list[ReadResult] | None = None,
|
||||
) -> None:
|
||||
self.connected = False
|
||||
self._space = FAKE_SPACE if space is None else space
|
||||
self._scripted_reads = read_results
|
||||
self.reads: list[tuple[tuple[str, ...], OPCDataSource]] = [] # call log
|
||||
|
||||
def connect(self) -> None:
|
||||
self.connected = True
|
||||
@@ -117,9 +121,27 @@ class FakeOpcClient:
|
||||
raise OpcError("FakeOpcClient: create_browser() before connect()")
|
||||
return _FakeBrowser(self._space)
|
||||
|
||||
def read(self, tags: Sequence[str]) -> list[ReadResult]:
|
||||
def read(
|
||||
self, tags: Sequence[str], source: OPCDataSource = OPCDataSource.DEVICE
|
||||
) -> list[ReadResult]:
|
||||
if not self.connected:
|
||||
raise OpcError("FakeOpcClient: read() before connect()")
|
||||
self.reads.append((tuple(tags), source))
|
||||
if self._scripted_reads is not None:
|
||||
scripted = {r.tag: r for r in self._scripted_reads}
|
||||
return [
|
||||
scripted.get(
|
||||
tag,
|
||||
ReadResult(
|
||||
tag=tag,
|
||||
value=None,
|
||||
quality_raw=0,
|
||||
device_time=None,
|
||||
error=f"no scripted result for {tag!r}",
|
||||
),
|
||||
)
|
||||
for tag in tags
|
||||
]
|
||||
now = datetime.now(timezone.utc)
|
||||
return [
|
||||
ReadResult(tag=tag, value=0, quality_raw=0xC0, device_time=now)
|
||||
|
||||
Reference in New Issue
Block a user