🚧 WIP
This commit is contained in:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user