This commit is contained in:
Jonas Bengtsson
2026-10-08 06:30:54 +02:00
parent 76b1cc5ccf
commit a1af8d68c9
12 changed files with 966 additions and 21 deletions
+102 -1
View File
@@ -38,6 +38,23 @@ class TagEntry:
expect_change: bool = False
@dataclass(frozen=True)
class DeviceEntry:
"""D16 device-link indicator: a tag saying if the device is reachable.
DOWN (``DEVICE_LINK_DOWN``, CRITICAL) when ``tag`` reads non-GOOD, errors,
or its GOOD value is **not** in ``ok_values`` — the normal (connected)
reading, configurable per indicator because conventions differ per
control system: connected-flags ``[true]`` (default), inverted fault-flags
``[false, 0]``, string states ``["RUN"]``. Compared by string equality on
the rendered value (SPECIFICATION §6.1).
"""
name: str
tag: str
ok_values: tuple[str, ...] = ("true",)
@dataclass(frozen=True)
class PollConfig:
interval_s: float = 1.0
@@ -100,6 +117,7 @@ class Config:
opc: OpcConfig
poll: PollConfig = field(default_factory=PollConfig)
tags: tuple[TagEntry, ...] = ()
devices: tuple[DeviceEntry, ...] = ()
latency: LatencyConfig = field(default_factory=LatencyConfig)
quality: QualityConfig = field(default_factory=QualityConfig)
stale: StaleConfig = field(default_factory=StaleConfig)
@@ -107,8 +125,41 @@ class Config:
report: ReportConfig = field(default_factory=ReportConfig)
_ALLOWED_TOP_LEVEL = {"opc", "poll", "tags", "latency", "quality", "stale", "browse", "report"}
_ALLOWED_TOP_LEVEL = {"opc", "poll", "tags", "devices", "latency", "quality", "stale", "browse", "report"}
_TAG_KEYS = {"name", "expect_change"}
_DEVICE_KEYS = {"name", "tag", "ok_values"}
def _normalize_ok_values(raw: Any, where: str) -> tuple[str, ...]:
"""ok_values as a tuple of rendered strings ("true", "1", "RUN").
The D16 comparison is string equality on the value rendered as text, so
booleans/ints/strings from YAML all work uniformly: `[true]` -> `("true",)`,
`[false, 0]` -> `("false", "0")`.
"""
if raw is None:
return ("true",)
if isinstance(raw, (list, tuple)):
if not raw:
raise ConfigError(f"{where}: ok_values must not be empty - set the normal (connected) value(s)")
values = []
for item in raw:
if isinstance(item, bool):
values.append("true" if item else "false")
elif isinstance(item, (int, float, str)):
values.append(str(item).strip())
else:
raise ConfigError(
f"{where}: ok_values entries must be scalars (bool/int/str), got {type(item).__name__}"
)
cleaned = tuple(v for v in values if v)
if not cleaned:
raise ConfigError(f"{where}: ok_values must not be empty - set the normal (connected) value(s)")
return cleaned
# a single scalar is shorthand for a one-element list
if isinstance(raw, (bool, int, float, str)):
return _normalize_ok_values([raw], where)
raise ConfigError(f"{where}: ok_values must be a list of scalars, got {type(raw).__name__}")
def _build_section(cls: type[Any], data: Any, where: str) -> Any:
@@ -169,6 +220,37 @@ def _build_tags(data: Any) -> tuple[TagEntry, ...]:
return tuple(entries)
def _build_devices(data: Any) -> tuple[DeviceEntry, ...]:
"""Parse the devices: section (D16 link indicators) with actionable errors."""
if data is None:
return ()
if not isinstance(data, list):
raise ConfigError(f"devices: must be a list, got {type(data).__name__}")
entries: list[DeviceEntry] = []
for i, item in enumerate(data):
if not isinstance(item, dict):
raise ConfigError(
f"devices[{i}]: must be a mapping with 'name' and 'tag'"
)
unknown = sorted(set(item) - _DEVICE_KEYS)
if unknown:
raise ConfigError(
f"devices[{i}]: unknown key(s) {unknown}; accepted keys: {sorted(_DEVICE_KEYS)}"
)
name = item.get("name")
tag = item.get("tag")
if not isinstance(name, str) or not name.strip():
raise ConfigError(f"devices[{i}]: 'name' is required and must be a non-empty string")
if not isinstance(tag, str) or not tag.strip():
raise ConfigError(
f"devices[{i}]: 'tag' is required and must be the connection-indicator "
"tag ItemID (see list-items)"
)
ok_values = _normalize_ok_values(item.get("ok_values"), f"devices[{i}]")
entries.append(DeviceEntry(name=name, tag=tag, ok_values=ok_values))
return tuple(entries)
def _validate(config: Config) -> None:
"""Cross-field validation; each message names the offending key."""
if not config.opc.server or not config.opc.server.strip():
@@ -186,6 +268,24 @@ def _validate(config: Config) -> None:
duplicates = sorted({n for n in names if names.count(n) > 1})
if duplicates:
raise ConfigError(f"tags: duplicate tag name(s) {duplicates}")
device_names = [d.name for d in config.devices]
duplicate_devices = sorted({n for n in device_names if device_names.count(n) > 1})
if duplicate_devices:
raise ConfigError(f"devices: duplicate device name(s) {duplicate_devices}")
indicator_tags = [d.tag for d in config.devices]
duplicate_indicators = sorted({t for t in indicator_tags if indicator_tags.count(t) > 1})
if duplicate_indicators:
raise ConfigError(
f"devices: duplicate indicator tag(s) {duplicate_indicators} - one device per indicator"
)
# An indicator doubles as a monitored tag; listing it in tags: too would double-poll
# it and double-report its quality transitions (D8 AND D16 fire).
overlap = sorted(set(indicator_tags) & set(names))
if overlap:
raise ConfigError(
f"tags: {overlap} also appear as device indicators - remove them from "
"tags: (devices: entries are polled automatically)"
)
if config.poll.interval_s <= 0:
raise ConfigError(f"poll.interval_s: must be > 0, got {config.poll.interval_s}")
if config.poll.timeout_ms <= 0:
@@ -253,6 +353,7 @@ def load_config(path: str | Path) -> Config:
opc=_build_section(OpcConfig, data.get("opc"), "opc"),
poll=_build_section(PollConfig, data.get("poll"), "poll"),
tags=_build_tags(data.get("tags")),
devices=_build_devices(data.get("devices")),
latency=_build_section(LatencyConfig, data.get("latency"), "latency"),
quality=_build_section(QualityConfig, data.get("quality"), "quality"),
stale=_build_section(StaleConfig, data.get("stale"), "stale"),