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
+31 -6
View File
@@ -27,7 +27,7 @@ opcda-diagnozer/
├── src/opcda_diagnozer/
│ ├── __init__.py
│ ├── __main__.py # python -m opcda_diagnozer
│ ├── main.py # CLI: run / install / uninstall / check-config / probe / list-devices / list-tags
│ ├── main.py # CLI: run / install / uninstall / check-config / probe / read / list-items
│ ├── config.py # pydantic-free typed config (dataclasses) + YAML load + validation
│ ├── events.py # DiagEvent, Severity, EventKind
│ ├── quality.py # OPC DA quality decode tables
@@ -44,6 +44,7 @@ opcda-diagnozer/
│ ├── report/
│ │ ├── base.py # Reporter protocol
│ │ ├── jsonl.py # rotating JSONL file
│ │ ├── textlog.py # plain-text anomaly log (human-readable, §9.1)
│ │ ├── eventlog.py # Windows Event Log (WARN+)
│ │ └── http.py # optional webhook
│ └── service.py # pywin32 ServiceFramework wrapper
@@ -82,7 +83,13 @@ Tasks:
1. `config.py` — YAML config (see `config.example.yaml` sketch in README), dataclass
model, strict validation, `check-config` messages that explain exactly what is wrong.
2. `quality.py` — decode tables (spec §7), `decode_quality(raw: int) -> QualityInfo`.
Includes the **device-link indicators** (spec §6.1 / D16): `devices:` — each entry a
human-readable device name plus its connection-indicator tag ItemID. Indicators are
added to the polled tag set automatically; `check-config` warns when an indicator tag
is also listed in `tags:` (it should not be) and fails on an empty device name.
2. `quality.py` — decode tables (spec §7), `decode_quality(raw: int) -> QualityInfo`,
plus the **clear-text status helper**: `status_text(raw) -> "NAME (0xNN)"` used by
every reporter (spec §7 closing rule — e.g. `BAD / communication failure (0x18)`).
3. `opc/base.py` — client abstraction:
```python
class OpcClient(Protocol):
@@ -99,6 +106,14 @@ Tasks:
6. `monitor/detectors.py` — D1–D15 as small pure functions/classes over
`PollOutcome` (list[ReadResult] + rtt_ms + error), using an injected clock for
testability. Rolling window (deque, size `window_polls`) for p95/streak logic.
**D16 device-link detector** (spec §6.1): per configured indicator — quality
not GOOD, a read error, or a GOOD read whose value is **not in the
configurable `ok_values` list** (default `[true]`; string-equality compare
of the rendered value, so inverted fault flags `[false, 0]` and string states
`["RUN"]` both work) ⇒ `DEVICE_LINK_DOWN` (CRITICAL) carrying the device name,
indicator tag, clear-text status and value; recovery (`DEVICE_LINK_UP`) when
it reads GOOD with an ok value again. Edge-level per device, so flapping
links don't spam (one event per transition).
7. `monitor/poller.py` — the single COM thread; watchdog logic for hang detection
(spec §10.4): a second thread timestamps poll starts/ends; on `hard_hang_s` the COM
thread is abandoned and rebuilt.
@@ -107,17 +122,26 @@ Tasks:
hangs, quality flips) → powers the entire unit-test suite without COM/hardware.
**Exit:** `opcda-diagnozer run` (foreground console) produces correct events against the
fake client; unit tests for all detectors.
fake client; unit tests for all detectors — including D16 edge semantics (false /
non-GOOD / read-error / misconfigured value / recovery) against scripted indicator reads.
## 5. Phase 2 — Reporters (≈1 day)
1. `report/jsonl.py` — rotation (size + backups), atomic-ish append, header line with
config digest for provenance.
2. `report/eventlog.py` — pywin32 `win32evtlog` usage; map severities; source
2. `report/textlog.py` — **plain-text anomaly log** (spec §9.1): `anomalies-YYYYMMDD.log`
in the same directory as the JSONL files, one human-readable line per detected
anomaly (`timestamp SEVERITY KIND message`) with clear-text status `NAME (0xNN)`.
Anomalies only — heartbeats/CONNECTED are filtered out (a small `is_anomaly(event)`
predicate on `EventKind`); recoveries (D2, `DEVICE_LINK_UP`, quality back to GOOD)
**are** logged, so the file reads as an incident narrative. Daily + size rotation
with `backups` kept, same scheme as the JSONL reporter. Every line corresponds to
a JSONL record (text = convenience view, JSONL = source of truth).
3. `report/eventlog.py` — pywin32 `win32evtlog` usage; map severities; source
registration via `install` step.
3. `report/http.py` — optional webhook with timeout + simple retry/drop on failure
4. `report/http.py` — optional webhook with timeout + simple retry/drop on failure
(never blocks the poll loop; queue with bound).
4. Heartbeat assembly (D15): uptime, reconnects, RTT percentiles, tags-bad counts.
5. Heartbeat assembly (D15): uptime, reconnects, RTT percentiles, tags-bad counts.
**Exit:** events visible in file + Event Log; heartbeat cadence verified.
@@ -150,6 +174,7 @@ Scenario matrix against a simulation server (and, when available, the real serve
| Quality oscillation | toggling sim items | D11 flapping |
| Stale/frozen tag | simulation server static item marked `expect_change` | D12 / D13 |
| Mass bad quality | all sim items to bad | D14 |
| **Device link down** | indicator tag flipped to `false` / set BAD (alias a bool sim item per device, e.g. `P5001.Connected` on Matrikon) | D16 `DEVICE_LINK_DOWN`, recovery line when restored |
| Config errors | bad ProgID, unknown tag | fast fail + actionable message; `OPC_E_INVALIDITEMID` surfaced at startup |
| Soak | 24 h healthy run | no leaks, heartbeats every 5 min, zero false positives |
+66
View File
@@ -99,9 +99,44 @@ diagnosis.
| D13 | Frozen value | `expect_change: true` tag with unchanged value+timestamp for `max_age_s` | WARN |
| D14 | Mass quality degradation | ≥ 50% of monitored tags BAD simultaneously while connection OK | CRITICAL (`CONTROL_LINK_DEGRADED` — points at OPC-server↔device link, not DCOM) |
| D15 | Heartbeat / summary | Periodic stats snapshot every `heartbeat_s` (300) | INFO |
| D16 | Device link down | A configured device-indicator tag reads `false`, or any quality other than GOOD, or a read error | CRITICAL per device (`DEVICE_LINK_DOWN`); D1/D2 do **not** imply device-link state |
All thresholds are configurable; severities are INFO / WARN / CRITICAL.
### 6.1 Device connection-indicator tags (D16)
The config names one or more tags as **device-link indicators**: values the
control system maintains that say whether the OPC server can reach the device.
The **normal (connected) reading is configurable per indicator** — conventions
differ per control system, so it is never hardcoded:
```yaml
devices:
- { name: "Conveyor 3", tag: "...Connected" } # any truthy: true/1
- { name: "Pump A", tag: "...PumpAFault", ok_values: [false, 0] } # inverted fault flag
- { name: "Boiler 2", tag: "...State", ok_values: ["RUN"] } # enum/string state
```
Default `ok_values: [true]` (boolean connected-flags are the common case).
Value comparison is by **string equality** on the read value rendered as text
(`"True"`, `"1"`, `"RUN"`), so type quirks across systems cannot cause false
positives; quality must also be GOOD. Semantics (all DOWN cases are logged as
`DEVICE_LINK_DOWN`):
| Indicator reading | Interpretation |
|-------------------|------------------|
| GOOD + value ∈ `ok_values` | Device connected — normal |
| GOOD + value ∉ `ok_values` | **Device link down / fault** — CRITICAL (works for inverted fault-flags: a fault `1` is not in `[false, 0]`) |
| Any quality other than GOOD (BAD/UNCERTAIN) | **Something is wrong with the device link** — CRITICAL |
| Read error (tag unreadable) | **Something is wrong with the device link** — CRITICAL |
Device-link state is per indicator tag: the config maps each indicator to a
human-readable device name for the log lines (see `config.example.yaml`).
Recovery (`DEVICE_LINK_UP`) is logged when the same indicator reads GOOD with
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.
## 7. OPC DA quality codes (reference)
OPC DA quality is a 16-bit field. Bits 7–6 of the low byte give the class:
@@ -126,6 +161,13 @@ decodes and names:
Remaining UNCERTAIN subcodes (DA 2.05a) are decoded by the same table in `quality.py`.
Every quality event carries the raw hex code **and** the decoded name.
**Clear-text status rule:** wherever a status or quality is reported — JSONL
fields, the text log, Event Log, console — it is written in clear text with
the raw code in parentheses: `BAD / communication failure (0x18)`,
`GOOD (0xC0)`. Nobody should have to decode hex codes by hand, but the raw
code stays for unambiguous correlation with vendor documentation and the
HRESULT table.
## 8. Event model
Every event is a structured record:
@@ -163,12 +205,36 @@ alongside and clearly labeled.
| Reporter | Purpose | Notes |
|----------|---------|-------|
| JSONL rotating file | Primary evidence trail | One JSON event per line; rotation by size; default dir `%ProgramData%\OPCDA-Diagnozer\logs` |
| **Plain-text anomaly log** | Human-readable evidence | One line per **detected anomaly** (not heartbeats): timestamp, kind, tag/device, message with clear-text status `NAME (0xNN)`; same rotation scheme as JSONL. See §9.1 for the line format |
| Windows Event Log | Ops visibility | WARN+ only; source `OPCDA-Diagnozer` |
| Console | Foreground/debug runs | Human-readable |
| HTTP webhook (optional) | Alert integration | POST one JSON event per request, optional bearer token |
Reporters are pluggable (`Reporter.emit(event)`); a heartbeat event every 5 minutes
proves liveness and enables baseline graphs even when everything is healthy.
The JSONL file stays the machine-readable source of truth; the text log is a
human-first convenience view of the same events — every text line corresponds
to a JSONL record.
### 9.1 Plain-text anomaly log format
File `anomalies-YYYYMMDD.log` (daily rotation, size-capped) in the same log
directory. One line per detected anomaly — anomalies only; heartbeats and
reconnect-OK events do not appear here. Clear text first, codes in parentheses:
```
2026-10-07 14:23:01.123 UTC WARN QUALITY_BAD tag 'Channel1.Device1.Counter' changed GOOD (0xC0) -> BAD / communication failure (0x18)
2026-10-07 14:24:31.500 UTC CRITICAL DEVICE_LINK_DOWN device 'Conveyor 3': link indicator 'Channel1.Device3.Connected' read false (GOOD)
2026-10-07 14:26:02.780 UTC CRITICAL DEVICE_LINK_DOWN device 'Conveyor 3': link indicator 'Channel1.Device3.Connected' quality BAD / not connected (0x08)
2026-10-07 14:27:10.001 UTC CRITICAL POLL_HANG read call hung > 5000 ms; COM thread abandoned and rebuilt
2026-10-07 14:30:00.000 UTC CRITICAL CONTROL_LINK_DEGRADED 12 of 14 monitored tags BAD while connection OK
```
Fields (pipe-free, fixed order): `timestamp SEVERITY KIND message`.
Timestamps are the local receive time in UTC (§8). The message carries the
clear-text status with the code between parentheses per §7. A matching
recovery (link back up, quality back to GOOD) is also logged as an anomaly
line, so the log reads as a continuous incident narrative.
## 10. Operational behavior