163 lines
9.4 KiB
Markdown
163 lines
9.4 KiB
Markdown
# 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. |