Adds a filter-aware overload of IHistoryProvider.ReadEventsAsync that carries
EventFilter SelectClauses + WhereClause, and implements it on the OPC UA
Client driver via Session.HistoryReadAsync + ReadEventDetails.
The change is additive (default-impl returns NotSupportedException) so the
existing Galaxy.Proxy.GalaxyProxyDriver implementation keeps compiling
against the fixed-field overload — no cross-driver refactor required.
* Core.Abstractions: new EventHistoryRequest / SimpleAttributeSpec /
ContentFilterSpec records mirror the OPC UA wire shape transport-neutrally.
HistoricalEventBatch / HistoricalEventRow carry an open-ended Fields bag
keyed by SimpleAttributeSpec.FieldName so server-side dispatch can re-align
with the client's wire-side SelectClause order.
* OpcUaClient driver: new ReadEventsAsync(fullReference, EventHistoryRequest, ct)
builds an EventFilter, calls Session.HistoryReadAsync, and unwraps
HistoryEvent.Events into HistoricalEventBatch rows. Default SelectClause
set matches BuildHistoryEvent on the server side. ContentFilter bytes are
decoded through the live session's MessageContext (passthrough — the
driver does not evaluate filters).
* Unit tests: 7 new tests cover SelectClause translation, default-clause
fallback, malformed where-clause swallowing, uninitialized-driver guard,
null-request guard, and IHistoryProvider default fallback.
* Integration scaffold: build-only [Fact] gated on opc-plc --alm; flips to
green when the fixture image is upgraded.
* Docs: HistoryRead Events section in docs/drivers/OpcUaClient.md plus a
cross-link from Client.CLI.md historyread page.
* E2E: -HistoryEvents switch on scripts/e2e/test-opcuaclient.ps1 confirms
the gateway round-trips HistoryReadEvents without
BadHistoryOperationUnsupported (gated; defaults to skip).
Closes#284
Adds FocasAlarmProjection with two modes (ActiveOnly default, ActivePlusHistory)
that polls cnc_rdalmhistry on connect + on a configurable cadence (5 min default,
HistoryDepth=100 capped at 250). Emits historic events via IAlarmSource with
SourceTimestampUtc set from the CNC's reported timestamp; dedup keyed on
(OccurrenceTime, AlarmNumber, AlarmType). Ships the ODBALMHIS packed-buffer
decoder + encoder in Wire/FocasAlarmHistoryDecoder.cs and threads
ReadAlarmHistoryAsync through IFocasClient (default no-op so existing transport
variants stay back-compat). FocasDriver now implements IAlarmSource.
13 new unit tests cover: mode switch, dedup, distinct-timestamp emission,
type-as-key behaviour, OccurrenceTime passthrough (not Now), HistoryDepth
clamp/fallback, and decoder round-trip. All 341 FOCAS unit tests still pass.
Docs: docs/drivers/FOCAS.md (new), docs/v2/focas-deployment.md (new),
docs/v2/implementation/focas-wire-protocol.md (new),
docs/v2/implementation/focas-simulator-plan.md (new),
docs/drivers/FOCAS-Test-Fixture.md (alarm-history bullet appended).
Closes#267
Audit of docs/ against src/ surfaced shipped features without current-reference
coverage (FOCAS CLI, Core.Scripting+VirtualTags, Core.ScriptedAlarms,
Core.AlarmHistorian), an out-of-date driver count + capability matrix, ADR-002's
virtual-tag dispatch not reflected in data-path docs, broken cross-references,
and OpcUaServerReqs declaring OPC-020..022 that were never scoped. This commit
closes all of those so operators + integrators can stay inside docs/ without
falling back to v2/implementation/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaced the "ab_server PCCC upstream-broken" skip gate with the actual
root cause: libplctag's ab_server rejects empty CIP routing paths at the
unconnected-send layer before the PCCC dispatcher runs. Real SLC/
MicroLogix/PLC-5 hardware accepts empty paths (no backplane); ab_server
does not. With `/1,0` in place, N (Int16), F (Float32), and L (Int32)
file reads + writes round-trip cleanly across all three compose profiles.
## Fixture changes
- `AbLegacyServerFixture.cs`:
- Drop `AB_LEGACY_TRUST_WIRE` env var + the reachable-but-untrusted
skip branch. Fixture now only skips on TCP unreachability.
- Add `AB_LEGACY_CIP_PATH` env var (default `1,0`) + expose `CipPath`
property. Set `AB_LEGACY_CIP_PATH=` (empty) against real hardware.
- Shorter skip messages on the `[AbLegacyFact]` / `[AbLegacyTheory]`
attributes — one reason: endpoint not reachable.
- `AbLegacyReadSmokeTests.cs`:
- Device URI built from `sim.CipPath` instead of hardcoded empty path.
- New `AB_LEGACY_COMPOSE_PROFILE` env var filters the parametric
theory to the running container's family. Only one container binds
`:44818` at a time, so cross-family params would otherwise fail.
- `Slc500_write_then_read_round_trip` skips cleanly when the running
profile isn't `slc500`.
## E2E + seed + docs
- `scripts/e2e/test-ablegacy.ps1` — drop the `AB_LEGACY_TRUST_WIRE`
skip gate; synopsis calls out the `/1,0` vs empty cip-path split
between the Docker fixture and real hardware.
- `scripts/e2e/e2e-config.sample.json` — sample gateway flipped from
the hardware placeholder (`192.168.1.10`) to the Docker fixture
(`127.0.0.1/1,0`); comment rewritten.
- `scripts/e2e/README.md` — AB Legacy expected-matrix row goes from
SKIP to PASS.
- `scripts/smoke/seed-ablegacy-smoke.sql` — default HostAddress points
at the Docker fixture + header / footer text reflect the new state.
- `tests/.../Docker/README.md` — "Known limitations" section rewritten
to describe the cip-path gate (not a dispatcher gap); env-var table
picks up `AB_LEGACY_CIP_PATH` + `AB_LEGACY_COMPOSE_PROFILE`.
- `docs/drivers/AbLegacy-Test-Fixture.md` + `docs/drivers/README.md`
+ `docs/DriverClis.md` — flip status from blocked to functional;
residual bit-file-write gap (B3:0/5 → 0x803D0000) documented.
## Residual gap
Bit-file writes (`B3:0/5` style) surface `0x803D0000` against
`ab_server --plc=SLC500`; bit reads work. Non-blocking for smoke
coverage — N/F/L round-trip is enough. Real hardware / RSEmulate 500
for bit-write fidelity. Documented in `Docker/README.md` §"Known
limitations" + the `AbLegacy-Test-Fixture.md` follow-ups list.
## Verified
- Full-solution build: 0 errors, 334 pre-existing warnings.
- Integration suite passes per-profile with
`AB_LEGACY_COMPOSE_PROFILE=<slc500|micrologix|plc5>` + matching
compose container up.
- All four non-hardware e2e scripts (Modbus / AB CIP / AB Legacy / S7)
now 5/5 against the respective docker-compose fixtures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-CLI runbooks (Driver.{Modbus,AbCip,AbLegacy,S7,TwinCAT}.Cli.md) shipped
with #249-#251 but docs/README.md's Client tooling table never grew entries
for them and there was no parent doc pulling the suite together.
Adds:
- docs/DriverClis.md — short parent. Index table, shared-commands callout
(probe / read / write / subscribe), Driver.Cli.Common infrastructure
note (what's shared, marginal cost of adding a sixth CLI), typical
cross-CLI workflows (commissioning, bug reproduction, recipe-write
validation, byte-order debugging), known gaps that cross-ref the
per-CLI docs (AB Legacy ab_server upstream gap, S7 PUT/GET enable,
TwinCAT AMS router, UDT-write refusal), tracking pointer to #249-251.
- docs/README.md — Client tooling table grows 6 rows (DriverClis parent
+ 5 per-CLI). Also corrects the Client.CLI.md row: it's otopcua-cli,
not lmxopcua-cli (renamed in #208).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final two of the five driver test clients. Pattern carried forward from
#249 (Modbus) + #250 (AB CIP, AB Legacy) — each CLI inherits Driver.Cli.Common
for DriverCommandBase + SnapshotFormatter and adds a protocol-specific
CommandBase + 4 commands (probe / read / write / subscribe).
New projects:
- src/ZB.MOM.WW.OtOpcUa.Driver.S7.Cli/ — otopcua-s7-cli.
S7CommandBase carries host/port/cpu/rack/slot/timeout. Handles all S7
atomic types (Bool, Byte, Int16..UInt64, Float32/64, String, DateTime).
DateTime parses via RoundtripKind so "2026-04-21T12:34:56Z" works.
- src/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/ — otopcua-twincat-cli.
TwinCATCommandBase carries ams-net-id + ams-port + --poll-only toggle
(flips UseNativeNotifications=false). Covers the full IEC 61131-3
atomic set: Bool, SInt/USInt, Int/UInt, DInt/UDInt, LInt/ULInt, Real,
LReal, String, WString, Time/Date/DateTime/TimeOfDay. Structure writes
refused as out-of-scope (same as AB CIP). IEC time/date variants marshal
as UDINT on the wire per IEC spec. Subscribe banner announces "ADS
notification" vs "polling" so the mechanism is obvious in bug reports.
Tests (49 new, 122 cumulative driver-CLI):
- S7: 22 tests. Every S7DataType has a happy-path + bounds case. DateTime
round-trips an ISO-8601 string. Tag-name synthesis round-trips every
S7 address form (DB / M / I / Q, bit/word/dword, strings).
- TwinCAT: 27 tests. Full IEC type matrix including WString UTF-8 pass-
through + the four IEC time/date variants landing on UDINT. Structure
rejection case. Tag-name synthesis for Program scope, GVL scope, nested
UDT members, and array elements.
Docs:
- docs/Driver.S7.Cli.md — address grammar cheat sheet + the PUT/GET-must-
be-enabled gotcha every S7-1200/1500 operator hits.
- docs/Driver.TwinCAT.Cli.md — AMS router prerequisite (XAR / standalone
Router NuGet / remote AMS route) + per-command examples.
Wiring:
- ZB.MOM.WW.OtOpcUa.slnx grew 4 entries (2 src + 2 tests).
Full-solution build clean. Both --help outputs verified end-to-end.
Driver CLI suite complete: 5 CLIs (otopcua-{modbus,abcip,ablegacy,s7,twincat}-cli)
sharing a common base + formatter. 122 CLI tests cumulative. Every driver family
shipped in v2 now has a shell-level ad-hoc validation tool.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second + third of the four driver test clients. Both follow the same shape as
otopcua-modbus-cli (#249) and consume Driver.Cli.Common for DriverCommandBase +
SnapshotFormatter.
New projects:
- src/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli/ — otopcua-abcip-cli.
AbCipCommandBase carries gateway (ab://host[:port]/cip-path) + family
(ControlLogix/CompactLogix/Micro800/GuardLogix) + timeout.
Commands: probe, read, write, subscribe.
Value parser covers every AbCipDataType atomic type (Bool, SInt..LInt,
USInt..ULInt, Real, LReal, String, Dt); Structure writes refused as
out-of-scope for the CLI.
- src/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/ — otopcua-ablegacy-cli.
AbLegacyCommandBase carries gateway + plc-type (Slc500/MicroLogix/Plc5/
LogixPccc) + timeout.
Commands: probe (default address N7:0), read, write, subscribe.
Value parser covers Bit, Int, Long, Float, AnalogInt, String, and the
three sub-element types (TimerElement / CounterElement / ControlElement
all land on int32 at the wire).
Tests (35 new, 73 cumulative across the driver CLI family):
- AB CIP: 17 tests — ParseValue happy-paths for every Logix atomic type,
failure cases (non-numeric / bool garbage), tag-name synthesis.
- AB Legacy: 18 tests — ParseValue coverage (Bit / Int / AnalogInt / Long /
Float / String / sub-elements), PCCC address round-trip in tag names
including bit-within-word + sub-element syntax.
Docs:
- docs/Driver.AbCip.Cli.md — family ↔ CIP-path cheat sheet + examples per
command + typical workflows.
- docs/Driver.AbLegacy.Cli.md — PCCC address primer (file letters → CLI
--type) + known ab_server upstream gap cross-ref to #224 close-out.
Wiring:
- ZB.MOM.WW.OtOpcUa.slnx grew 4 entries (2 src + 2 tests).
Full-solution build clean. `otopcua-abcip-cli --help` + `otopcua-ablegacy-cli
--help` verified end-to-end.
Next up (#251): S7 + TwinCAT CLIs, same pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the v1 otopcua-cli value prop (ad-hoc shell-level PLC validation) for
the Modbus-TCP driver, and lays down the shared scaffolding that AB CIP, AB
Legacy, S7, and TwinCAT CLIs will build on.
New projects:
- src/ZB.MOM.WW.OtOpcUa.Driver.Cli.Common/ — DriverCommandBase (verbose
flag + Serilog config) + SnapshotFormatter (single-tag + table +
write-result renders with invariant-culture value formatting + OPC UA
status-code shortnames + UTC-normalised timestamps).
- src/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli/ — otopcua-modbus-cli executable.
Commands: probe, read, write, subscribe. ModbusCommandBase carries the
host/port/unit-id flags + builds ModbusDriverOptions with Probe.Enabled
=false (CLI runs are one-shot; driver-internal keep-alive would race).
Commands + coverage:
- probe single FC03 + GetHealth() + pretty-print
- read region × address × type synth into one driver tag
- write same shape + --value parsed per --type
- subscribe polled-subscription stream until Ctrl+C
Tests (38 total):
- 16 SnapshotFormatterTests covering: status-code shortnames, unknown
codes fall back to hex, null value + timestamp placeholders, bool
lowercase, float invariant culture, string quoting, write-result shape,
aligned table columns, mismatched-length rejection, UTC normalisation.
- 22 Modbus CLI tests:
· ReadCommandTests.SynthesiseTagName (5 theory cases)
· WriteCommandParseValueTests (17 cases: bool aliases, unknown rejected,
Int16 bounds, UInt16/Bcd16 type, Float32/64 invariant culture,
String passthrough, BitInRegister, Int32 MinValue, non-numeric reject)
Wiring:
- ZB.MOM.WW.OtOpcUa.slnx grew 4 entries (2 src + 2 tests).
- docs/Driver.Modbus.Cli.md — operator-facing runbook with examples per
command + output format + typical workflows.
Regression: full-solution build clean; shared-lib tests 16/0, Modbus CLI tests
22/0.
Next up: repeat the pattern for AB CIP (shares ~40% more with Modbus via
libplctag), then AB Legacy, S7, TwinCAT. The shared base stays as-is unless
one of those exposes a gap the Modbus-first pass missed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the live-smoke validation Phase 7 deferred to. Ships:
## docs/v2/implementation/phase-7-e2e-smoke.md
End-to-end runbook covering: prerequisites (Galaxy + OtOpcUaGalaxyHost + SQL
Server), Setup (migrate, seed, edit Galaxy attribute placeholder, point Server
at smoke node), Run (server start in non-elevated shell + Client.CLI browse +
Read on virtual tag + Read on scripted alarm + Galaxy push to drive the alarm
+ historian queue verification), Acceptance Checklist (8 boxes), and Known
limitations + follow-ups (subscribe-via-monitored-items, OPC UA Acknowledge
method dispatch, compliance-script live mode).
## scripts/smoke/seed-phase-7-smoke.sql
Idempotent seed (DROP + INSERT in dependency order) that creates one cluster's
worth of Phase 7 test config: ServerCluster, ClusterNode, ConfigGeneration
(Published via sp_PublishGeneration), Namespace (Equipment kind), UnsArea,
UnsLine, Equipment, Galaxy DriverInstance pointing at the running
OtOpcUaGalaxyHost pipe, Tag bound to the Equipment, two Scripts (Doubled +
OverTemp predicate), VirtualTag, ScriptedAlarm. Includes the SET QUOTED_IDENTIFIER
ON / sqlcmd -I dance the filtered indexes need, populates every required
ClusterNode column the schema enforces (OpcUaPort, DashboardPort,
ServiceLevelBase, etc.), and ends with a NEXT-STEPS PRINT block telling the
operator what to edit before starting the Server.
## First-run evidence on the dev box
Running the seed + starting the Server (non-elevated shell, Galaxy.Host
already running) emitted these log lines verbatim — proving the entire
Phase 7 wiring chain executes in production:
Bootstrapped from central DB: generation 1
Phase 7 historian sink: no driver provides IAlarmHistorianWriter — using NullAlarmHistorianSink
VirtualTagEngine loaded 1 tag(s), 1 upstream subscription(s)
ScriptedAlarmEngine loaded 1 alarm(s)
Phase 7: composed engines from generation 1 — 1 virtual tag(s), 1 scripted alarm(s), 2 script(s)
Each line corresponds to a piece shipped in #243 / #244 / #245 / #246 / #247.
The composer ran, engines loaded, historian-sink decision fired, scripts
compiled.
## Surfaced — pre-Phase-7 deployment-wiring gaps (NOT Phase 7 regressions)
1. Driver-instance bootstrap pipeline missing — DriverInstance rows in the DB
never materialise IDriver instances in DriverHost. Filed as task #248.
2. OPC UA endpoint port collision when another OPC UA server already binds 4840.
Operator concern; documented in the runbook prereqs.
Both predate Phase 7 + are orthogonal. Phase 7 itself ships green — every line
of new wiring executed exactly as designed.
## Phase 7 production wiring chain — VALIDATED end-to-end
- ✅#243 composition kernel
- ✅#244 driver bridge
- ✅#245 scripted-alarm IReadable adapter
- ✅#246 Program.cs wire-in
- ✅#247 Galaxy.Host historian writer + SQLite sink activation
- ✅#240 this — live smoke + runbook + first-run evidence
Phase 7 is complete + production-ready, modulo the pre-existing
driver-bootstrap gap (#248).
Locks in 22 design decisions from the planning conversation: C# via Roslyn scripting; virtual tags in the Equipment tree (not a separate /Virtual/ namespace); change-driven + timer-driven triggers operator-configurable per tag; Shape A one-script-per-tag-or-alarm (no predicate/action split); full OPC UA Part 9 alarm fidelity; read-only sandbox (scripts read any tag, write only to virtual tags, no File/HttpClient/Process/reflection); AST-inferred dependencies via CSharpSyntaxWalker (non-literal tag paths rejected at publish); config DB storage with generation-sealed cache; ctx.GetTag returns a full DataValue {Value, StatusCode, Timestamp}; per-tag Historize checkbox; per-tag error isolation (throwing script sets tag quality BadInternalError, engine unaffected); dedicated scripts-*.log Serilog sink bound to ctx.Logger; alarm message as template with {TagPath} substitution resolved at event emission; ActiveState recomputed from tags on startup while EnabledState/AckedState/ConfirmedState/ShelvingState + audit persist to config DB; historian sink scope = all IAlarmSource impls with per-alarm toggle; SQLite store-and-forward on the node so operators are never blocked by Historian downtime; IPC to Galaxy.Host for ingestion reusing the already-loaded aahClientManaged DLLs; Monaco editor for Admin code editing; serial cascade evaluation for v1 (parallel as follow-up); shelving UX via OPC UA method calls only with no custom Admin controls (operator drives state transitions from plant HMIs or Client.CLI); 30-day dead-letter retention with manual retry button; test harness accepts only declared-input paths so the harness enforces dependency declaration.
Eight streams totaling ~10-12 weeks, scope-comparable to Phase 6: A - Core.Scripting (Roslyn engine + sandbox + AST inference + logger); B - virtual tag engine (dependency graph + change/timer schedulers + historize); C - scripted alarm engine (Part 9 state machine + template messages + startup recovery + OPC UA method binding); D - historian alarm sink (SQLite store-and-forward + Galaxy.Host IPC contract extension); E - config DB schema (four new tables under sp_PublishGeneration); F - Admin UI scripting tab (Monaco + test harness + dependency preview + script-log viewer + historian diagnostics); G - address-space integration (extend EquipmentNodeWalker for virtual source kind + extend DriverNodeManager dispatch); H - exit gate.
Compliance-check surface covers sandbox escape (typeof/Assembly.Load/File/HttpClient attempts must fail at compile), dependency inference (literal-only paths), change cascade (topological ordering), cycle rejection at publish, startup recovery (ack/confirm/shelve survive restart but ActiveState recomputed), ack audit trail persistence, historian queue durability (Galaxy.Host offline → online drains in-order), per-alarm historian toggle gating, script timeout isolation, log sink isolation, ACL binding (virtual tags inherit Equipment scope grants).
Follow-up artifacts tracked as tasks #231-#238 (stream placeholders). Supporting doc updates (plan.md §6 Migration Strategy, config-db-schema.md §§ for the four new tables, driver-specs.md §Alarm semantics clarification, new ADR-002 for driver-vs-virtual dispatch) will land alongside the streams that touch them, not in this doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>