# Changelog All notable changes to the OPC DA Diagnozer are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions bump per [SemVer](https://semver.org/). Dates are YYYY-MM-DD. ## [Unreleased] ### Added — Phase 3 Windows service - Service host (`service.py`): pywin32 `ServiceFramework` subclass runs the monitoring loop on its own monitor thread; clean SCM stop semantics — `STOP_PENDING` reported immediately, 30 s join-with-timeout, a hung COM poll is **abandoned** (thread never joined, daemon; spec §10.4); service start/stop recorded in the Windows Event Log (`PYS_SERVICE_STARTED/ STOPPED`). Verified in-process via the pywin32 debug host against the fake client: start → poll → stop in 0.1 s. - CLI verbs `install` / `uninstall` / `start` / `stop` / `status`: - `install` registers the exe **itself** as the service binary (no `pythonservice.exe`, no Python install on the diag host) with auto-start, validates the config **before** touching the SCM, records the config path in the service registry Parameters (startup never depends on the service CWD = System32), configures SCM crash recovery (restart on first+second failure, 5 s delay, 24 h counter reset — the `ChangeServiceConfig2` failure-actions dict shape verified against pywin32's SWIG parser), and registers the Windows Event Log source with its message file (completing the eventlog reporter's deferred step). - Errors map to actionable one-liners: elevation needed, service not installed, service not running, marked-for-delete — plus the raw Windows error code and `net helpmsg` pointer for anything else. - `status` prints the SCM state without needing admin. - SCM dispatch: when the SCM starts the exe (fixed `service` argv shape + CWD = System32, exactly what install records in the ImagePath), `main` hands the process to `servicemanager.Initialize` + `PrepareToHostSingle` + `StartServiceCtrlDispatcher`; any other invocation stays a CLI verb. - PyInstaller build script: hidden imports added for the service host's lazily-imported SCM modules (`win32service`, `win32serviceutil`, `servicemanager`, `win32evtlogutil`). ### Added — Phase 2 reporters - Rotating JSONL evidence log: one JSON event per line, config-digest header for provenance, size rotation with backups retained. - Plain-text anomaly log (spec §9.1): `anomalies-YYYYMMDD.log`, one line per detected anomaly in `timestamp SEVERITY KIND message` order with clear-text statuses; recoveries included so the file reads as an incident narrative. - Windows Event Log sink: severity-mapped (INFO/WARN/CRITICAL → Information/Warning/Error), WARN+ by default, delivery verified live. - Optional HTTP webhook (`report.http`): bounded queue + daemon sender thread, per-event POST with bearer auth; failures dropped and counted — never blocks the poll loop. - Reporter fan-out dispatcher: every sink isolated (one failing sink never kills the others or polling); the `run` verb wires all configured sinks. ### Fixed - Windows Event Log reporter initially lost every event silently: `win32evtlog.ReportEvent` takes **positional arguments only** (keyword calls raise `TypeError`, swallowed by the sink's own isolation) — now uses a `RegisterEventSource` handle with positional calls (live-verified). - HTTP webhook in injected-`post` mode raised into the poll loop on a failing POST; both modes now count-and-drop. ### Added — Phase 1 core engine (the continuous monitoring loop) - `run` verb: the foreground monitoring loop — coordinator + dedicated COM worker thread (the client is born on the worker; COM is per-thread), 1-second default polling of configured tags **and** device indicators, heartbeat (D15) every `heartbeat_s`, clean Ctrl+C stop. - Detection engine, all pure and fake-client tested: latency spike / sustained streak / rolling p95 (D4–D6), per-tag quality transitions, persistence and flapping (D8–D11), stale timestamp & frozen value (D12–D13), mass-quality degradation with latch (D14), device-link indicators (D16, edge-level DOWN/UP against configurable `ok_values`). - Hang watchdog (spec §10.4): polls exceeding `timeout_ms` raise the D7 soft-hang event and keep the thread; silence beyond `hard_hang_s` abandons the stuck COM thread (never joined — dead RPC channel) and rebuilds. - Connection state machine with exponential backoff (1 s → 60 s, reset on success) and reconnect accounting (D1/D2 with outage duration, D3 flapping). - Fake-client scenario engine: scripted polls and whole-call failures power the entire poller/detector test suite without COM. - Clear-text status rendering (`status_text`): `BAD / COMM FAILURE (0x18)` everywhere events are shown. ### Added — server discovery & browsing - `list-servers` verb: enumerates OPC DA servers locally or on a DCOM host via the Automation Wrapper (`GetOPCServers`), with a no-COM registry-scan fallback (`--source registry|automation|auto`), JSON output, and DA-version metadata. - Browsing of the connected server's address space, unified in the `list-items [all|branches|tags]` verb: `all` prints the full structure tree (branches with `/`, tags with `[ItemID: ...]`), `branches` the structure-only flat list, `tags` the flat ItemID list for config `tags:`. `--branch` (dotted path or repeated segments), `--depth N` (1 default, 0 = whole subtree), `--json`. Replaces the earlier split `list-devices`/`list-tags`. - `browse.max_depth` config key (default 32) as the address-space walk guard, validated by `check-config`; cycle-safe tree walk in `opc/browse.py`. - `SpaceNode` tree data model (branch/leaf) replacing the earlier device-centric `DeviceInfo`. ### Added — reading & CLI - `read` verb: one-shot read of explicit tags (or the config's tags) with a table or `--json`, `--source device|cache`, and a non-zero exit when any tag doesn't read GOOD — scriptable as a health check. - `probe` verb: connect once and read every configured tag once. - `--server` / `--host` overrides on every connecting verb; with `--server` no config file is needed at all. - Frozen-exe default config path: `config.yaml` next to the exe (a Windows service starts with `CWD = System32`), not the working directory. ### Added — monitoring design (D16) & config - `devices:` config section with `DeviceEntry(name, tag, ok_values)`: D16 device-link indicators. The normal (connected) reading is configurable per indicator (`ok_values`, default `[true]`) — inverted fault flags `[false, 0]` and string states `["RUN"]` are first-class. Strict validation: duplicate indicators and indicator/tag-list overlap are rejected; `check-config` prints the indicators. - Detection rules D16 (`DEVICE_LINK_DOWN`/`DEVICE_LINK_UP`) and the plain-text anomaly log reporter (`report/textlog.py` task) specified in the docs (SPECIFICATION §6.1, §9.1; IMPLEMENTATION_PLAN Phases 1–2) — implementation pending, see TODO.md. - Clear-text status rule (spec §7): every reporter writes `NAME (0xNN)` — e.g. `BAD / communication failure (0x18)`. ### Added — packaging - `scripts/build_exe.ps1`: PyInstaller build of the 32-bit console exe with hidden imports for the deferred COM modules (incl. `win32timezone` — see Fixed), embedded version metadata, a PE-bitness check, and smoke tests including a **frozen-read canary** against a live server. - `--version` and this CHANGELOG.md / TODO.md. ### Fixed - COM lifecycle: all COM references (browsers, groups, items) are released **inside** the apartment before `CoUninitialize`; retained exception tracebacks are cleared in `as_com_error` — both caused "Win32 exception occurred releasing IUnknown" noise and hard crashes at exit. - Frozen exe: every tag read failed with `SystemError: ... InvokeTypes ... exception set` because pywin32 lazily imports `win32timezone` during VARIANT-DATE conversion and PyInstaller never bundles it — now a hidden import, with the build canary guarding it. - Reads: per-tag failures now carry the underlying exception's own type and text (`_per_tag_error`) instead of a bare "failed"; a degenerate device timestamp (never-updated alias, DST math) can no longer fail a read — the timestamp is metadata and is dropped to `None`. - Matrikon browse quirk: servers may return an empty ItemID for special leaves (alias roots); the adapter falls back to the bare browse name. - HRESULT labels: `OPC_E_UNKNOWNITEMID` (0xC0040007) and `OPC_E_INVALIDITEMID` (0xC0040008) were swapped; now corrected everywhere. ### Verified - All CLI verbs live-tested against Matrikon OPC Simulation Server (`Matrikon.OPC.Simulation.1`): discovery, browse (all kinds/depths), reads (incl. alias tags after server-side alias repair), config-driven `max_depth`, and the frozen exe (onedir + onefile) including the read canary. - Test suite: 108 tests, all passing (pure logic + fake-client driven, runs on any OS; COM paths are live-smoke-tested on dev boxes only). ## [0.1.0] - 2026-10-07 ### Added - Initial project skeleton: typed YAML config with strict actionable validation (`check-config`), OPC DA quality decode tables (spec §7), event model skeletons (`DiagEvent`, D1–D15 `EventKind`s), client/protocol abstractions, and the fake in-process client powering cross-OS tests. - Phase 0 decisions recorded: 32-bit interpreter mandatory (Automation Wrapper is a 32-bit in-proc DLL), ruamel.yaml over PyYAML (no 32-bit cp314 wheels), COM imports import-guarded for cross-OS test runs.