From ca0bb0d0487dc82caba6f5ca69ca44990a223d32 Mon Sep 17 00:00:00 2001 From: Jonas Bengtsson Date: Thu, 8 Oct 2026 08:12:04 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.example.yaml | 4 ++++ docs/SPECIFICATION.md | 6 ++++++ src/opcda_diagnozer/config.py | 6 ++++-- src/opcda_diagnozer/main.py | 2 ++ tests/test_config.py | 27 ++++++++++++++++++++++++--- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index a4f9d85..96e719f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -23,6 +23,10 @@ tags: # convention: inverted fault flags -> [false, 0], string states -> ["RUN"]. # Compared by string equality on the rendered value ("True"/"1"/"RUN"). # Do NOT also list these under tags: - they are polled automatically. +# +# tags: and devices: are each optional (devices-only monitors just the links; +# tags-only runs classic surveillance) — but both empty is rejected: +# the tool would read nothing. devices: - { name: "Conveyor 3", tag: "Channel1.Device3.Connected" } # ok: true/1 - { name: "Conveyor 4", tag: "Channel1.Device4.Connected", ok_values: [true, 1] } diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index 6631e23..b6e25f0 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -137,6 +137,12 @@ an ok value again. A value that is neither in `ok_values` nor a recognized fault shape still logs DOWN — the normal state is explicit, and everything else is "not normal" by definition. +**Section optionality:** `tags:` and `devices:` are each optional in the +config — a devices-only setup monitors just the links, a tags-only setup +runs classic quality/latency surveillance. Both empty (or absent) is a +validation error: the tool would read nothing and every heartbeat would +report `tags_total: 0`. + ## 7. OPC DA quality codes (reference) OPC DA quality is a 16-bit field. Bits 7–6 of the low byte give the class: diff --git a/src/opcda_diagnozer/config.py b/src/opcda_diagnozer/config.py index c0f1a6a..f72e5aa 100644 --- a/src/opcda_diagnozer/config.py +++ b/src/opcda_diagnozer/config.py @@ -276,9 +276,11 @@ def _validate(config: Config) -> None: ) if config.opc.connect_timeout_s <= 0: raise ConfigError(f"opc.connect_timeout_s: must be > 0, got {config.opc.connect_timeout_s}") - if not config.tags: + if not config.tags and not config.devices: raise ConfigError( - "tags: at least one tag is required — without tags there is nothing to monitor" + "tags: nothing to monitor - define at least one tag or device " + "indicator (devices: entries alone are fine, tags: entries alone " + "are fine; both empty means the tool would read nothing)" ) names = [t.name for t in config.tags] duplicates = sorted({n for n in names if names.count(n) > 1}) diff --git a/src/opcda_diagnozer/main.py b/src/opcda_diagnozer/main.py index 99b07ac..9214699 100644 --- a/src/opcda_diagnozer/main.py +++ b/src/opcda_diagnozer/main.py @@ -225,6 +225,8 @@ def _cmd_check_config(config_path: str) -> int: print(f" devices: {len(config.devices)} link indicator(s):") for device in config.devices: print(f" {device.name} <- {device.tag}") + if not config.tags and config.devices: + print(" note: no surveillance tags - monitoring device links only") print(f" poll: every {config.poll.interval_s} s, hang flagged after {config.poll.timeout_ms} ms") return 0 diff --git a/tests/test_config.py b/tests/test_config.py index 1739ba0..1f46302 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -60,12 +60,33 @@ def test_unknown_top_level_section_rejected(tmp_path: Path) -> None: load_config(p) -def test_empty_tags_rejected(tmp_path: Path) -> None: - p = _write(tmp_path, "opc:\n server: X\ntags: []\n") - with pytest.raises(ConfigError, match="at least one tag"): +def test_both_tags_and_devices_empty_rejected(tmp_path: Path) -> None: + """tags: and devices: are each optional, but their union must not be empty.""" + p = _write(tmp_path, "opc:\n server: X\ntags: []\ndevices: []\n") + with pytest.raises(ConfigError, match="nothing to monitor"): load_config(p) +def test_no_tags_section_with_devices_is_allowed(tmp_path: Path) -> None: + """Devices-only config: link monitoring without extra surveillance tags.""" + p = _write( + tmp_path, + "opc:\n server: X\n" + "devices:\n - { name: 'Pump A', tag: Ind.Tag }\n", + ) + config = load_config(p) + assert config.tags == () + assert [d.name for d in config.devices] == ["Pump A"] + + +def test_tags_only_still_allowed(tmp_path: Path) -> None: + """The classic setup (no device indicators) stays valid.""" + p = _write(tmp_path, "opc:\n server: X\ntags:\n - name: t\n") + config = load_config(p) + assert len(config.tags) == 1 + assert config.devices == () + + def test_duplicate_tags_rejected(tmp_path: Path) -> None: p = _write(tmp_path, "opc:\n server: X\ntags:\n - name: a\n - name: a\n") with pytest.raises(ConfigError, match="duplicate"):