Compare commits

..

4 Commits

Author SHA1 Message Date
Joseph Doherty
fc0ce36308 Add Installed Inventory section to dev-environment.md tracking every v2 dev service, toolchain, credential, port, data location, and container volume stood up on this machine. Records what is actually running (not just planned) so future setup work and troubleshooting has a single source of truth. Four subsections: Host (machine identity, VM platform, CPU, OS features); Toolchain (.NET 10 SDK 10.0.201 + runtimes 10.0.5, WSL2 default v2 with docker-desktop distro Running, Docker Desktop 29.3.1 / engine 29.3.1, dotnet-ef CLI 10.0.6 — each row records install method and date); Services (SQL Server 2022 container otopcua-mssql at localhost:1433 with sa/OtOpcUaDev_2026! credentials and Docker named volume otopcua-mssql-data mounted at /var/opt/mssql, dev Galaxy, GLAuth at C:\publish\glauth\ on ports 3893/3894, plus rows for not-yet-standing services like OPC Foundation reference server / FOCAS stub / Modbus simulator / ab_server / Snap7 / TwinCAT XAR VM with target ports to stand up later); Connection strings for appsettings.Development.json (copy-paste-ready, flagged never-commit); Container management quick reference (start/stop/logs/shell/query/nuclear-reset); Credential rotation note.
Per decision #137 (dev env credentials documented openly in dev-environment.md; production uses Integrated Security / gMSA per decision #46 and never any value from this table). Section lives at the top of the doc immediately after Two Environment Tiers, so it's discoverable as the single source of truth for "what's actually running here right now".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 16:57:09 -04:00
Joseph Doherty
bf6741ba7f Doc — flesh out dev-environment.md inner-loop bootstrap with the explicit Windows install steps that surfaced when actually trying to stand up SQL Server on the local box: prereq winget commands per tool (.NET 10 SDK / .NET Framework 4.8 SDK + targeting pack / Git / PowerShell 7.4+); WSL2 install (UAC-elevated) as a separate sub-step before Docker Desktop; Docker Desktop install (UAC-elevated) followed by sign-out/sign-in for docker-users group membership; explicit post-install Docker Desktop config checklist (WSL 2 based engine = checked, Windows containers = NOT checked, WSL Integration enabled for Ubuntu) per decision #134; named volume otopcua-mssql-data:/var/opt/mssql on the SQL Server container so DB files survive container restart and docker rm; sqlcmd verification command using the new mssql-tools18 path that the 2022 image ships with; EF Core CLI install for use starting in Phase 1 Stream B; bumped step count from 8 → 10. Also adds a Troubleshooting subsection covering the seven most common Windows install snags (WSL distro not auto-installed needs -d Ubuntu; Docker PATH not refreshed needs new shell or sign-in; docker-users group membership needs sign-out/in; WSL 2 kernel update needs manual install on legacy systems; SA password complexity rules; Linux vs Windows containers mode mismatch; Hyper-V coexistence with Docker requires WSL 2 backend not Hyper-V backend per decision #134). Step 1 acceptance criteria gain "docker ps shows otopcua-mssql Up" and explicit note that steps 4a/4b need admin elevation (no silent admin-free path exists on Windows).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:54:52 -04:00
Joseph Doherty
980ea5190c Phase 1 Stream A — Core.Abstractions project + 11 capability interfaces + DriverTypeRegistry + interface-independence tests
New project src/ZB.MOM.WW.OtOpcUa.Core.Abstractions (.NET 10, BCL-only dependencies, GenerateDocumentationFile=true, TreatWarningsAsErrors=true) defining the contract surface every driver implements. Per docs/v2/plan.md decisions #4 (composable capability interfaces), #52 (streaming IAddressSpaceBuilder), #53 (capability discovery via `is` checks no flag enum), #54 (optional IRediscoverable sub-interface), #59 (Core.Abstractions internal-only for now design as if public).

Eleven capability interfaces:
- IDriver — required lifecycle / health / config-apply / memory-footprint accounting (per driver-stability.md Tier A/B allocation tracking)
- ITagDiscovery — discovers tags streaming to IAddressSpaceBuilder
- IReadable — on-demand reads idempotent for Polly retry
- IWritable — writes NOT auto-retried by default per decisions #44 + #45
- ISubscribable — data-change subscriptions covering both native (Galaxy MXAccess advisory, OPC UA monitored items, TwinCAT ADS) and driver-internal polled (Modbus, AB CIP, S7, FOCAS) mechanisms; OnDataChange callback regardless of source
- IAlarmSource — alarm events + acknowledge + AlarmSeverity enum mirroring acl-design.md NodePermissions alarm-severity values
- IHistoryProvider — HistoryReadRaw + HistoryReadProcessed with continuation points
- IRediscoverable — opt-in change-detection signal; static drivers don't implement
- IHostConnectivityProbe — generalized from Galaxy's GalaxyRuntimeProbeManager per plan §5a
- IDriverConfigEditor — Admin UI plug-point for per-driver custom config editors deferred to each driver's phase per decision #27
- IAddressSpaceBuilder — streaming builder API for driver-driven address-space construction

Plus DTOs: DriverDataType, SecurityClassification (mirroring v1 Galaxy model), DriverAttributeInfo (replaces Galaxy-specific GalaxyAttributeInfo per plan §5a), DriverHealth + DriverState, DataValueSnapshot (universal OPC UA quality + timestamp carrier per decision #13), HostConnectivityStatus + HostState + HostStatusChangedEventArgs, RediscoveryEventArgs, DataChangeEventArgs, AlarmEventArgs + AlarmAcknowledgeRequest + AlarmSeverity, WriteRequest + WriteResult, HistoryReadResult + HistoryAggregateType, ISubscriptionHandle + IAlarmSubscriptionHandle + IVariableHandle.

DriverTypeRegistry singleton with Register / Get / TryGet / All; thread-safe via Interlocked.Exchange snapshot replacement on registration; case-insensitive lookups; rejects duplicate registrations; rejects empty type names. DriverTypeMetadata record carries TypeName + AllowedNamespaceKinds (NamespaceKindCompatibility flags enum per decision #111) + per-config-tier JSON Schemas the validator checks at draft-publish time (decision #91).

Tests project tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests (xUnit v3 1.1.0 matching existing test projects). 24 tests covering: 1) interface independence reflection check (no references outside BCL/System; all public types in root namespace; every capability interface is public); 2) DriverTypeRegistry round-trip, case-insensitive lookups, KeyNotFoundException on unknown, null on TryGet of unknown, InvalidOperationException on duplicate registration (case-insensitive too), All() enumeration, NamespaceKindCompatibility bitmask combinations, ArgumentException on empty type names.

Build: 0 errors, 4 warnings (only pre-existing transitive package vulnerability + analyzer hints). Full test suite: 845 passing / 1 failing — strict improvement over Phase 0 baseline (821/1) by the 24 new Core.Abstractions tests; no regressions in any other test project.

Phase 1 entry-gate record (docs/v2/implementation/entry-gate-phase-1.md) documents the deviation: only Stream A executed in this continuation since Streams B-E need SQL Server / GLAuth / Galaxy infrastructure standup per dev-environment.md Step 1, which is currently TODO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:15:55 -04:00
Joseph Doherty
45ffa3e7d4 Merge phase-0-rename into v2
Phase 0 exit gate cleared. 284 files changed (720 insertions / 720 deletions — pure rename). Build clean (0 errors, 30 warnings, lower than baseline 167). Tests at strict improvement over baseline (821 passing / 1 failing vs baseline 820 / 2). 23 LmxOpcUa references retained per Phase 0 Out-of-Scope rules (runtime identifiers preserved for v1/v2 client trust during coexistence).

Implementation lead: Claude (Opus 4.7). Reviewer signoff: pending — being absorbed into v2 with the deferred service-install verification flagged for the deployment-side reviewer at the next field deployment milestone.

See docs/v2/implementation/exit-gate-phase-0.md for the full compliance checklist (6 PASS + 1 DEFERRED).
2026-04-17 14:03:06 -04:00
24 changed files with 1134 additions and 9 deletions

View File

@@ -1,5 +1,6 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
@@ -7,6 +8,7 @@
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests/ZB.MOM.WW.OtOpcUa.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>

View File

@@ -22,6 +22,102 @@ Per decision #99:
The tier split keeps developer onboarding fast (no Docker required for first build) while concentrating the heavy simulator setup on one machine the team maintains.
## Installed Inventory — This Machine
Running record of every v2 dev service stood up on this developer machine. Updated on every install / config change. Credentials here are **dev-only** per decision #137 — production uses Integrated Security / gMSA per decision #46 and never any value in this table.
**Last updated**: 2026-04-17
### Host
| Attribute | Value |
|-----------|-------|
| Machine name | `DESKTOP-6JL3KKO` |
| User | `dohertj2` (member of local Administrators + `docker-users`) |
| VM platform | VMware (`VMware20,1`), nested virtualization enabled |
| CPU | Intel Xeon E5-2697 v4 @ 2.30GHz (3 vCPUs) |
| OS | Windows (WSL2 + Hyper-V Platform features installed) |
### Toolchain
| Tool | Version | Location | Install method |
|------|---------|----------|----------------|
| .NET SDK | 10.0.201 | `C:\Program Files\dotnet\sdk\` | Pre-installed |
| .NET AspNetCore runtime | 10.0.5 | `C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App\` | Pre-installed |
| .NET NETCore runtime | 10.0.5 | `C:\Program Files\dotnet\shared\Microsoft.NETCore.App\` | Pre-installed |
| .NET WindowsDesktop runtime | 10.0.5 | `C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App\` | Pre-installed |
| .NET Framework 4.8 SDK | — | Pending (needed for Phase 2 Galaxy.Host; not yet required) | — |
| Git | Pre-installed | Standard | — |
| PowerShell 7 | Pre-installed | Standard | — |
| winget | v1.28.220 | Standard Windows feature | — |
| WSL | Default v2, distro `docker-desktop` `STATE Running` | — | `wsl --install --no-launch` (2026-04-17) |
| Docker Desktop | 29.3.1 (engine) / Docker Desktop 4.68.0 (app) | Standard | `winget install --id Docker.DockerDesktop` (2026-04-17) |
| `dotnet-ef` CLI | 10.0.6 | `%USERPROFILE%\.dotnet\tools\dotnet-ef.exe` | `dotnet tool install --global dotnet-ef --version 10.0.*` (2026-04-17) |
### Services
| Service | Container / Process | Version | Host:Port | Credentials (dev-only) | Data location | Status |
|---------|---------------------|---------|-----------|------------------------|---------------|--------|
| **Central config DB** | Docker container `otopcua-mssql` (image `mcr.microsoft.com/mssql/server:2022-latest`) | 16.0.4250.1 (RTM-CU24-GDR, KB5083252) | `localhost:1433` | User `sa` / Password `OtOpcUaDev_2026!` | Docker named volume `otopcua-mssql-data` (mounted at `/var/opt/mssql` inside container) | ✅ Running |
| Dev Galaxy (AVEVA System Platform) | Local install on this dev box | v1 baseline | Local COM via MXAccess | Windows Auth | Galaxy repository DB `ZB` on local SQL Server (separate instance from `otopcua-mssql` — legacy v1 Galaxy DB, not related to v2 config DB) | ✅ Available (per CLAUDE.md) |
| GLAuth (LDAP) | Local install at `C:\publish\glauth\` | v1 baseline | `localhost:3893` (LDAP) / `3894` (LDAPS) | Bind DN `cn=admin,dc=otopcua,dc=local` / password in `glauth-otopcua.cfg` | `C:\publish\glauth\` | Pending — v2 test users + groups config not yet seeded (Phase 1 Stream E task) |
| OPC Foundation reference server | Not yet built | — | `localhost:62541` (target) | `user1` / `password1` (reference-server defaults) | — | Pending (needed for Phase 5 OPC UA Client driver testing) |
| FOCAS TCP stub | Not yet built | — | `localhost:8193` (target) | n/a | — | Pending (built in Phase 5) |
| Modbus simulator (`oitc/modbus-server`) | — | — | `localhost:502` (target) | n/a | — | Pending (needed for Phase 3 Modbus driver; moves to integration host per two-tier model) |
| libplctag `ab_server` | — | — | `localhost:44818` (target) | n/a | — | Pending (Phase 3/4 AB CIP and AB Legacy drivers) |
| Snap7 Server | — | — | `localhost:102` (target) | n/a | — | Pending (Phase 4 S7 driver) |
| TwinCAT XAR VM | — | — | `localhost:48898` (ADS) (target) | TwinCAT default route creds | — | Pending — runs in Hyper-V VM, not on this dev box (per decision #135) |
### Connection strings for `appsettings.Development.json`
Copy-paste-ready. **Never commit these to the repo** — they go in `appsettings.Development.json` (gitignored per the standard .NET convention) or in user-scoped dotnet secrets.
```jsonc
{
"ConfigDatabase": {
"ConnectionString": "Server=localhost,1433;Database=OtOpcUaConfig_Dev;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=true;Encrypt=false;"
},
"Authentication": {
"Ldap": {
"Host": "localhost",
"Port": 3893,
"UseLdaps": false,
"BindDn": "cn=admin,dc=otopcua,dc=local",
"BindPassword": "<see glauth-otopcua.cfg — pending seeding>"
}
}
}
```
For xUnit test fixtures that need a throwaway DB per test run, build connection strings with `Database=OtOpcUaConfig_Test_{timestamp}` to avoid cross-run pollution.
### Container management quick reference
```powershell
# Start / stop the SQL Server container (survives reboots via Docker Desktop auto-start)
docker stop otopcua-mssql
docker start otopcua-mssql
# Logs (useful for diagnosing startup failures or login issues)
docker logs otopcua-mssql --tail 50
# Shell into the container (rarely needed; sqlcmd is the usual tool)
docker exec -it otopcua-mssql bash
# Query via sqlcmd inside the container (Git Bash needs MSYS_NO_PATHCONV=1 to avoid path mangling)
MSYS_NO_PATHCONV=1 docker exec otopcua-mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "OtOpcUaDev_2026!" -C -Q "SELECT @@VERSION"
# Nuclear reset: drop the container + volume (destroys all DB data)
docker stop otopcua-mssql
docker rm otopcua-mssql
docker volume rm otopcua-mssql-data
# …then re-run the docker run command from Bootstrap Step 6
```
### Credential rotation
Dev credentials in this inventory are convenience defaults, not secrets. Change them at will per developer — just update this doc + each developer's `appsettings.Development.json`. There is no shared secret store for dev.
## Resource Inventory
### A. Always-required (every developer + integration host)
@@ -108,25 +204,102 @@ The tier split keeps developer onboarding fast (no Docker required for first bui
## Bootstrap Order — Inner-loop Developer Machine
Order matters because some installs have prerequisites. ~3060 min total on a fresh machine.
Order matters because some installs have prerequisites and several need admin elevation (UAC). ~6090 min total on a fresh Windows machine, including reboots.
**Admin elevation appears at**: WSL2 install (step 4a), Docker Desktop install (step 4b), and any `wsl --install -d` call. winget will prompt UAC interactively when these run; accept it. There is no fully-silent admin-free install path on Windows for Docker Desktop's prerequisites.
1. **Install .NET 10 SDK** (https://dotnet.microsoft.com/) — required to build anything
```powershell
winget install --id Microsoft.DotNet.SDK.10 --accept-package-agreements --accept-source-agreements
```
2. **Install .NET Framework 4.8 SDK + targeting pack** — only needed when starting Phase 2 (Galaxy.Host); skip for Phase 01 if not yet there
```powershell
winget install --id Microsoft.DotNet.Framework.DeveloperPack_4 --accept-package-agreements --accept-source-agreements
```
3. **Install Git + PowerShell 7.4+**
4. **Clone repos**:
```powershell
winget install --id Git.Git --accept-package-agreements --accept-source-agreements
winget install --id Microsoft.PowerShell --accept-package-agreements --accept-source-agreements
```
4. **Install Docker Desktop** (with WSL2 backend per decision #134, leaves Hyper-V free for the future TwinCAT XAR VM):
**4a. Enable WSL2** — UAC required:
```powershell
wsl --install
```
Reboot when prompted. After reboot, the default Ubuntu distro launches and asks for a username/password — set them (these are WSL-internal, not used for Docker auth).
Verify after reboot:
```powershell
wsl --status
wsl --list --verbose
```
Expected: `Default Version: 2`, at least one distro (typically `Ubuntu`) with `STATE Running` or `Stopped`.
**4b. Install Docker Desktop** — UAC required:
```powershell
winget install --id Docker.DockerDesktop --accept-package-agreements --accept-source-agreements
```
The installer adds you to the `docker-users` Windows group. **Sign out and back in** (or reboot) so the group membership takes effect.
**4c. Configure Docker Desktop** — open it once after sign-in:
- **Settings → General**: confirm "Use the WSL 2 based engine" is **checked** (decision #134 — coexists with future Hyper-V VMs)
- **Settings → General**: confirm "Use Windows containers" is **NOT checked** (we use Linux containers for `mcr.microsoft.com/mssql/server`, `oitc/modbus-server`, etc.)
- **Settings → Resources → WSL Integration**: enable for the default Ubuntu distro
- (Optional, large fleets) **Settings → Resources → Advanced**: bump CPU / RAM allocation if you have headroom
Verify:
```powershell
docker --version
docker ps
```
Expected: version reported, `docker ps` returns an empty table (no containers running yet, but the daemon is reachable).
5. **Clone repos**:
```powershell
git clone https://gitea.dohertylan.com/dohertj2/lmxopcua.git
git clone https://gitea.dohertylan.com/dohertj2/scadalink-design.git
git clone https://gitea.dohertylan.com/dohertj2/3yearplan.git
```
5. **Install SQL Server 2022 dev edition** (local install) OR start the Docker container (see Resource B):
6. **Start SQL Server** (Linux container; runs in the WSL2 backend):
```powershell
docker run --name otopcua-mssql -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=OtOpcUaDev_2026!" `
-p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest
docker run --name otopcua-mssql `
-e "ACCEPT_EULA=Y" `
-e "MSSQL_SA_PASSWORD=OtOpcUaDev_2026!" `
-p 1433:1433 `
-v otopcua-mssql-data:/var/opt/mssql `
-d mcr.microsoft.com/mssql/server:2022-latest
```
6. **Install GLAuth** at `C:\publish\glauth\` per existing CLAUDE.md instructions; populate `glauth-otopcua.cfg` with the test users + groups (template in `docs/v2/dev-environment-glauth-config.md` — to be added in the setup task)
7. **Run `dotnet restore`** in the `lmxopcua` repo
8. **Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx`** (post-Phase-0) or `ZB.MOM.WW.LmxOpcUa.slnx` (pre-Phase-0) — verifies the toolchain
The `-v otopcua-mssql-data:/var/opt/mssql` named volume preserves database files across container restarts and `docker rm` — drop it only if you want a strictly throwaway instance.
Verify:
```powershell
docker ps --filter name=otopcua-mssql
docker exec -it otopcua-mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "OtOpcUaDev_2026!" -C -Q "SELECT @@VERSION"
```
Expected: container `STATUS Up`, `SELECT @@VERSION` returns `Microsoft SQL Server 2022 (...)`.
To stop / start later:
```powershell
docker stop otopcua-mssql
docker start otopcua-mssql
```
7. **Install GLAuth** at `C:\publish\glauth\` per existing CLAUDE.md instructions; populate `glauth-otopcua.cfg` with the test users + groups (template in `docs/v2/dev-environment-glauth-config.md` — to be added in the setup task)
8. **Install EF Core CLI** (used to apply migrations against the SQL Server container starting in Phase 1 Stream B):
```powershell
dotnet tool install --global dotnet-ef --version 10.0.*
```
9. **Run `dotnet restore`** in the `lmxopcua` repo
10. **Run `dotnet build ZB.MOM.WW.OtOpcUa.slnx`** (post-Phase-0) or `ZB.MOM.WW.LmxOpcUa.slnx` (pre-Phase-0) — verifies the toolchain
9. **Run `dotnet test`** with the inner-loop filter — should pass on a fresh machine
## Bootstrap Order — Integration Host
@@ -213,11 +386,22 @@ Seeds are idempotent (re-runnable) and gitignored where they contain credentials
### Step 1 — Inner-loop dev environment (each developer, ~1 day with documentation)
**Owner**: developer
**Prerequisite**: Bootstrap order steps 19 above
**Prerequisite**: Bootstrap order steps 110 above (note: steps 4a, 4b, and any later `wsl --install -d` call require admin elevation / UAC interaction — there is no fully-silent admin-free install path on Windows for Docker Desktop's prerequisites)
**Acceptance**:
- `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes
- A test that touches the central config DB succeeds (proves SQL Server reachable)
- A test that authenticates against GLAuth succeeds (proves LDAP reachable)
- `docker ps --filter name=otopcua-mssql` shows the SQL Server container `STATUS Up`
### Troubleshooting (common Windows install snags)
- **`wsl --install` says "Windows Subsystem for Linux has no installed distributions"** after first reboot — open a fresh PowerShell and run `wsl --install -d Ubuntu` (the `-d` form forces a distro install if the prereq-only install ran first).
- **Docker Desktop install completes but `docker --version` reports "command not found"** — `PATH` doesn't pick up the new Docker shims until a new shell is opened. Open a fresh PowerShell, or sign out/in, and retry.
- **`docker ps` reports "permission denied" or "Cannot connect to the Docker daemon"** — your user account isn't in the `docker-users` group yet. Sign out and back in (group membership is loaded at login). Verify with `whoami /groups | findstr docker-users`.
- **Docker Desktop refuses to start with "WSL 2 installation is incomplete"** — open the WSL2 kernel update from https://aka.ms/wsl2kernel, install, then restart Docker Desktop. (Modern `wsl --install` ships the kernel automatically; this is mostly a legacy problem.)
- **SQL Server container starts but immediately exits** — SA password complexity. The default `OtOpcUaDev_2026!` meets the requirement (≥8 chars, upper + lower + digit + symbol); if you change it, keep complexity. Check `docker logs otopcua-mssql` for the exact failure.
- **`docker run` fails with "image platform does not match host platform"** — your Docker is configured for Windows containers. Switch to Linux containers in Docker Desktop tray menu ("Switch to Linux containers"), or recheck Settings → General per step 4c.
- **Hyper-V conflict when later setting up TwinCAT XAR VM** — confirm Docker Desktop is on the **WSL 2 backend**, not Hyper-V backend. The two coexist only when Docker uses WSL 2.
### Step 2 — Integration host (one-time, ~1 week)

View File

@@ -0,0 +1,56 @@
# Phase 1 — Entry Gate Record
**Phase**: 1 — Configuration project + Core.Abstractions + Admin scaffold
**Branch**: `phase-1-configuration`
**Date**: 2026-04-17
**Implementation lead**: Claude (executing on behalf of dohertj2)
## Entry conditions
| Check | Required | Actual | Pass |
|-------|----------|--------|------|
| Phase 0 exit gate cleared | Rename complete, all v1 tests pass under OtOpcUa names | Phase 0 merged to `v2` at commit `45ffa3e` | ✅ |
| `v2` branch is clean | Clean | Clean post-merge | ✅ |
| Phase 0 PR merged | — | Merged via `--no-ff` to v2 | ✅ |
| SQL Server 2019+ instance available | For development | NOT YET AVAILABLE — see deviation below | ⚠️ |
| LDAP/GLAuth dev instance available | For Admin auth integration testing | Existing v1 GLAuth at `C:\publish\glauth\` | ✅ |
| ScadaLink CentralUI source accessible | For parity reference | `C:\Users\dohertj2\Desktop\scadalink-design\` per memory | ✅ |
| Phase 1-relevant design docs reviewed | All read by impl lead | ✅ Read in preceding sessions | ✅ |
| Decisions read | #1142 covered cumulatively | ✅ | ✅ |
## Deviation: SQL Server dev instance not yet stood up
The Phase 1 entry gate requires a SQL Server 2019+ dev instance for the `Configuration` project's EF Core migrations + tests. This is per `dev-environment.md` Step 1, which is currently TODO.
**Decision**: proceed with **Stream A only** (Core.Abstractions) in this continuation. Stream A has zero infrastructure dependencies — it's a `.NET 10` project with BCL-only references defining capability interfaces and DTOs. Streams B (Configuration), C (Core), D (Server), and E (Admin) all have infrastructure dependencies (SQL Server, GLAuth, Galaxy) and require the dev environment standup to be productive.
The SQL Server standup is a one-line `docker run` per `dev-environment.md` §"Bootstrap Order — Inner-loop Developer Machine" step 5. It can happen in parallel with subsequent Stream A work but is not a blocker for Stream A itself.
**This continuation will execute only Stream A.** Streams BE require their own continuations after the dev environment is stood up.
## Phase 1 work scope (for reference)
Per `phase-1-configuration-and-admin-scaffold.md`:
| Stream | Scope | Status this continuation |
|--------|-------|--------------------------|
| **A. Core.Abstractions** | 11 capability interfaces + DTOs + DriverTypeRegistry | ▶ EXECUTING |
| B. Configuration | EF Core schema, stored procs, LiteDB cache, generation-diff applier | DEFERRED — needs SQL Server |
| C. Core | `LmxNodeManager → GenericDriverNodeManager` rename, `IAddressSpaceBuilder`, driver hosting | DEFERRED — depends on Stream A + needs Galaxy |
| D. Server | `Microsoft.Extensions.Hosting` host, credential-bound bootstrap | DEFERRED — depends on Stream B |
| E. Admin | Blazor Server scaffold mirroring ScadaLink | DEFERRED — depends on Stream B |
## Baseline metrics (carried from Phase 0 exit)
- **Total tests**: 822 (pass + fail)
- **Pass count**: 821 (improved from baseline 820 — one flaky test happened to pass at Phase 0 exit)
- **Fail count**: 1 (the second pre-existing failure may flap; either 1 or 2 failures is consistent with baseline)
- **Build warnings**: 30 (lower than original baseline 167)
- **Build errors**: 0
Phase 1 must not introduce new failures or new errors against this baseline.
## Signoff
Implementation lead: Claude (Opus 4.7) — 2026-04-17
Reviewer: pending — Stream A PR will require a second reviewer per overview.md exit-gate rules

View File

@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver-agnostic value snapshot returned by <see cref="IReadable"/> and pushed
/// by <see cref="ISubscribable.OnDataChange"/>. Mirrors the OPC UA <c>DataValue</c>
/// shape so the node-manager can pass through quality, source timestamp, and
/// server timestamp without translation.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #13 — every driver maps to the same
/// OPC UA StatusCode space; this DTO is the universal carrier.
/// </remarks>
/// <param name="Value">The raw value; null when <see cref="StatusCode"/> indicates Bad.</param>
/// <param name="StatusCode">OPC UA status code (numeric value matches the OPC UA spec).</param>
/// <param name="SourceTimestampUtc">Driver-side timestamp when the value was sampled at the source. Null if unavailable.</param>
/// <param name="ServerTimestampUtc">Driver-side timestamp when the driver received / processed the value.</param>
public sealed record DataValueSnapshot(
object? Value,
uint StatusCode,
DateTime? SourceTimestampUtc,
DateTime ServerTimestampUtc);

View File

@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver-agnostic per-attribute (tag) descriptor used by the generic node-manager
/// to build OPC UA address-space variables. Every driver maps its native attribute
/// metadata into this DTO during discovery.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> §5a (LmxNodeManager reusability) — <c>DriverAttributeInfo</c>
/// replaces the v1 Galaxy-specific <c>GalaxyAttributeInfo</c> in the generic node-manager
/// so the same node-manager class works against every driver.
/// </remarks>
/// <param name="FullName">
/// Driver-side full reference for read/write addressing
/// (e.g. for Galaxy: <c>"DelmiaReceiver_001.DownloadPath"</c>).
/// </param>
/// <param name="DriverDataType">Driver-agnostic data type; maps to OPC UA built-in type at build time.</param>
/// <param name="IsArray">True when this attribute is a 1-D array.</param>
/// <param name="ArrayDim">Declared array length when <see cref="IsArray"/> is true; null otherwise.</param>
/// <param name="SecurityClass">Write-authorization tier for this attribute.</param>
/// <param name="IsHistorized">True when this attribute is expected to feed historian / HistoryRead.</param>
public sealed record DriverAttributeInfo(
string FullName,
DriverDataType DriverDataType,
bool IsArray,
uint? ArrayDim,
SecurityClassification SecurityClass,
bool IsHistorized);

View File

@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver-agnostic data type for an attribute or signal.
/// Maps to OPC UA built-in types at the address-space build layer.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/driver-specs.md</c> driver DataType columns, every driver maps its
/// native types into this enumeration. Mirrors the OPC UA built-in type set commonly
/// seen across Modbus / S7 / AB CIP / TwinCAT / FANUC / Galaxy.
/// </remarks>
public enum DriverDataType
{
Boolean,
Int16,
Int32,
Int64,
UInt16,
UInt32,
UInt64,
Float32,
Float64,
String,
DateTime,
/// <summary>Galaxy-style attribute reference encoded as an OPC UA String.</summary>
Reference,
}

View File

@@ -0,0 +1,38 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Health snapshot a driver returns to the Core. Drives the status dashboard,
/// ServiceLevel computation, and Bad-quality fan-out decisions.
/// </summary>
/// <param name="State">Current driver-instance state.</param>
/// <param name="LastSuccessfulRead">Timestamp of the most recent successful equipment read; null if never.</param>
/// <param name="LastError">Most recent error message; null when state is Healthy.</param>
public sealed record DriverHealth(
DriverState State,
DateTime? LastSuccessfulRead,
string? LastError);
/// <summary>Driver-instance lifecycle state.</summary>
public enum DriverState
{
/// <summary>Driver has not been initialized yet.</summary>
Unknown,
/// <summary>Driver is in the middle of <see cref="IDriver.InitializeAsync"/> or <see cref="IDriver.ReinitializeAsync"/>.</summary>
Initializing,
/// <summary>Driver is connected and serving data.</summary>
Healthy,
/// <summary>Driver is connected but reporting degraded data (e.g. some equipment unreachable, some tags Bad).</summary>
Degraded,
/// <summary>Driver lost connection to its data source; reconnecting in the background.</summary>
Reconnecting,
/// <summary>
/// Driver hit an unrecoverable error and stopped trying.
/// Operator must reinitialize via Admin UI; nodes report Bad quality.
/// </summary>
Faulted,
}

View File

@@ -0,0 +1,94 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Process-singleton registry of driver types known to this OtOpcUa instance.
/// Per-driver assemblies register their type metadata at startup; the Core uses
/// the registry to validate <c>DriverInstance.DriverType</c> values from the central config DB.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decisions #91 (JSON content validation in Admin app, not SQL CLR)
/// and #111 (driver type → namespace kind mapping enforced by sp_ValidateDraft).
/// The registry is the source of truth for both checks.
///
/// Thread-safety: registration happens at startup (single thread); lookups happen on every
/// config-apply (multi-threaded). The internal dictionary is replaced atomically via
/// <see cref="System.Threading.Interlocked"/> on register; readers see a stable snapshot.
/// </remarks>
public sealed class DriverTypeRegistry
{
private IReadOnlyDictionary<string, DriverTypeMetadata> _types =
new Dictionary<string, DriverTypeMetadata>(StringComparer.OrdinalIgnoreCase);
/// <summary>Register a driver type. Throws if the type name is already registered.</summary>
public void Register(DriverTypeMetadata metadata)
{
ArgumentNullException.ThrowIfNull(metadata);
var snapshot = _types;
if (snapshot.ContainsKey(metadata.TypeName))
{
throw new InvalidOperationException(
$"Driver type '{metadata.TypeName}' is already registered. " +
$"Each driver type may be registered only once per process.");
}
var next = new Dictionary<string, DriverTypeMetadata>(snapshot, StringComparer.OrdinalIgnoreCase)
{
[metadata.TypeName] = metadata,
};
Interlocked.Exchange(ref _types, next);
}
/// <summary>Look up a driver type by name. Throws if unknown.</summary>
public DriverTypeMetadata Get(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
if (_types.TryGetValue(driverType, out var metadata))
return metadata;
throw new KeyNotFoundException(
$"Driver type '{driverType}' is not registered. " +
$"Known types: {string.Join(", ", _types.Keys)}.");
}
/// <summary>Try to look up a driver type by name. Returns null if unknown (no exception).</summary>
public DriverTypeMetadata? TryGet(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
return _types.GetValueOrDefault(driverType);
}
/// <summary>Snapshot of all registered driver types.</summary>
public IReadOnlyCollection<DriverTypeMetadata> All() => _types.Values.ToList();
}
/// <summary>Per-driver-type metadata used by the Core, validator, and Admin UI.</summary>
/// <param name="TypeName">Driver type name (matches <c>DriverInstance.DriverType</c> column values).</param>
/// <param name="AllowedNamespaceKinds">Which namespace kinds this driver type may be bound to.</param>
/// <param name="DriverConfigJsonSchema">JSON Schema (Draft 2020-12) the driver's <c>DriverConfig</c> column must validate against.</param>
/// <param name="DeviceConfigJsonSchema">JSON Schema for <c>DeviceConfig</c> (multi-device drivers); null if the driver has no device layer.</param>
/// <param name="TagConfigJsonSchema">JSON Schema for <c>TagConfig</c>; required for every driver since every driver has tags.</param>
public sealed record DriverTypeMetadata(
string TypeName,
NamespaceKindCompatibility AllowedNamespaceKinds,
string DriverConfigJsonSchema,
string? DeviceConfigJsonSchema,
string TagConfigJsonSchema);
/// <summary>Bitmask of namespace kinds a driver type may populate. Per decision #111.</summary>
[Flags]
public enum NamespaceKindCompatibility
{
/// <summary>Driver does not populate any namespace (invalid; should never appear in registry).</summary>
None = 0,
/// <summary>Driver may populate Equipment-kind namespaces (UNS path, Equipment rows).</summary>
Equipment = 1,
/// <summary>Driver may populate SystemPlatform-kind namespaces (Galaxy hierarchy, FolderPath).</summary>
SystemPlatform = 2,
/// <summary>Driver may populate the future Simulated namespace (replay driver — not in v2.0).</summary>
Simulated = 4,
}

View File

@@ -0,0 +1,45 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Streaming builder API a driver uses to register OPC UA nodes during discovery.
/// Core owns the tree; driver streams <c>AddFolder</c> / <c>AddVariable</c> calls
/// as it discovers nodes — no buffering of the whole tree.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #52 — drivers register nodes via this builder
/// rather than returning a tree object. Supports incremental / large address spaces
/// without forcing the driver to buffer the whole tree.
/// </remarks>
public interface IAddressSpaceBuilder
{
/// <summary>
/// Add a folder node. Returns a child builder scoped to inside this folder, so subsequent
/// calls on the child place nodes under it.
/// </summary>
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent).</param>
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
IAddressSpaceBuilder Folder(string browseName, string displayName);
/// <summary>
/// Add a variable node corresponding to a tag. Driver-side full reference + data-type
/// metadata come from the <see cref="DriverAttributeInfo"/> DTO.
/// </summary>
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent folder).</param>
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
/// <param name="attributeInfo">Driver-side metadata for the variable.</param>
IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo);
/// <summary>
/// Add a property to the current node (folder or variable). Properties are static metadata
/// read once at build time (e.g. OPC 40010 Identification fields per the schemas-repo
/// <c>_base</c> equipment-class template).
/// </summary>
void AddProperty(string browseName, DriverDataType dataType, object? value);
}
/// <summary>Opaque handle for a registered variable. Used by Core for subscription routing.</summary>
public interface IVariableHandle
{
/// <summary>Driver-side full reference for read/write addressing.</summary>
string FullReference { get; }
}

View File

@@ -0,0 +1,54 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver capability for alarm events. Optional — only drivers whose backends expose
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
/// (CNC alarms), OPC UA Client (A&amp;C events from upstream server).
/// </summary>
public interface IAlarmSource
{
/// <summary>
/// Subscribe to alarm events for a node-set (typically: a folder or equipment subtree).
/// The driver fires <see cref="OnAlarmEvent"/> for every alarm transition.
/// </summary>
Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
IReadOnlyList<string> sourceNodeIds,
CancellationToken cancellationToken);
/// <summary>Cancel an alarm subscription returned by <see cref="SubscribeAlarmsAsync"/>.</summary>
Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken);
/// <summary>Acknowledge one or more active alarms by source node ID + condition ID.</summary>
Task AcknowledgeAsync(
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
CancellationToken cancellationToken);
/// <summary>Server-pushed alarm transition (raise / clear / change).</summary>
event EventHandler<AlarmEventArgs>? OnAlarmEvent;
}
/// <summary>Opaque alarm-subscription identity returned by <see cref="IAlarmSource.SubscribeAlarmsAsync"/>.</summary>
public interface IAlarmSubscriptionHandle
{
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
string DiagnosticId { get; }
}
/// <summary>One alarm acknowledgement in a batch.</summary>
public sealed record AlarmAcknowledgeRequest(
string SourceNodeId,
string ConditionId,
string? Comment);
/// <summary>Event payload for <see cref="IAlarmSource.OnAlarmEvent"/>.</summary>
public sealed record AlarmEventArgs(
IAlarmSubscriptionHandle SubscriptionHandle,
string SourceNodeId,
string ConditionId,
string AlarmType,
string Message,
AlarmSeverity Severity,
DateTime SourceTimestampUtc);
/// <summary>Mirrors the <c>NodePermissions</c> alarm-severity enum in <c>docs/v2/acl-design.md</c>.</summary>
public enum AlarmSeverity { Low, Medium, High, Critical }

View File

@@ -0,0 +1,60 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Required capability for every driver instance. Owns lifecycle, metadata, health.
/// Other capabilities (<see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IAlarmSource"/>,
/// <see cref="IHistoryProvider"/>, <see cref="IRediscoverable"/>,
/// <see cref="IHostConnectivityProbe"/>) are composable — a driver implements only what its
/// backend actually supports.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decisions #4 (composable capability interfaces) and #53
/// (capability discovery via <c>is</c> checks — no redundant flag enum).
/// </remarks>
public interface IDriver
{
/// <summary>Stable logical ID of this driver instance, sourced from the central config DB.</summary>
string DriverInstanceId { get; }
/// <summary>Driver type name (e.g. "Galaxy", "ModbusTcp", "AbCip"). Matches <c>DriverInstance.DriverType</c>.</summary>
string DriverType { get; }
/// <summary>Initialize the driver from its <c>DriverConfig</c> JSON; open connections; prepare for first use.</summary>
Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
/// <summary>
/// Apply a config change in place without tearing down the driver process.
/// Used by <c>IGenerationApplier</c> when only this driver's config changed in the new generation.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/driver-stability.md</c> §"In-process only (Tier A/B)" — Reinitialize is the
/// only Core-initiated recovery path for in-process drivers; if it fails, the driver instance
/// is marked Faulted and its nodes go Bad quality, but the server process keeps running.
/// </remarks>
Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
Task ShutdownAsync(CancellationToken cancellationToken);
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.</summary>
DriverHealth GetHealth();
/// <summary>
/// Approximate driver-attributable footprint in bytes (caches, queues, symbol tables).
/// Polled every 30s by Core; on cache-budget breach, Core asks the driver to flush via
/// <see cref="FlushOptionalCachesAsync"/>.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/driver-stability.md</c> §"In-process only (Tier A/B) — driver-instance
/// allocation tracking". Tier C drivers (process-isolated) report through the same
/// interface but the cache-flush is internal to their host.
/// </remarks>
long GetMemoryFootprint();
/// <summary>
/// Drop optional caches (symbol cache, browse cache, etc.) to bring footprint back below budget.
/// Required-for-correctness state must NOT be flushed.
/// </summary>
Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,30 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Optional plug-point a driver implements to provide a custom Admin UI editor for its
/// <c>DriverConfig</c> JSON. Drivers that don't implement this fall back to the generic
/// JSON editor with schema-driven validation against the registered JSON schema.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #27 — driver-specific config editors are deferred
/// to each driver's implementation phase; v2.0 ships with the generic JSON editor as the
/// default. This interface is the future plug-point so phase-specific editors can land
/// incrementally.
///
/// The actual UI rendering happens in the Admin Blazor Server app (see
/// <c>docs/v2/admin-ui.md</c>). This interface in <c>Core.Abstractions</c> is the
/// contract between the driver and the Admin app — the Admin app discovers
/// implementations and slots them into the Driver Detail screen.
/// </remarks>
public interface IDriverConfigEditor
{
/// <summary>Driver type name this editor handles (e.g. "Galaxy", "ModbusTcp").</summary>
string DriverType { get; }
/// <summary>
/// Type of the Razor component (must derive from <c>ComponentBase</c> in the Admin app's
/// `Components/Shared/` folder) that renders the editor. Returned as <c>Type</c> so the
/// <c>Core.Abstractions</c> project doesn't need a Blazor reference.
/// </summary>
Type EditorComponentType { get; }
}

View File

@@ -0,0 +1,50 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver capability for historical-data reads (OPC UA HistoryRead). Optional —
/// only drivers whose backends carry historian data implement this. Currently:
/// Galaxy (Wonderware Historian via the optional plugin), OPC UA Client (forward
/// to upstream server).
/// </summary>
public interface IHistoryProvider
{
/// <summary>
/// Read raw historical samples for a single attribute over a time range.
/// The Core wraps this with continuation-point handling.
/// </summary>
Task<HistoryReadResult> ReadRawAsync(
string fullReference,
DateTime startUtc,
DateTime endUtc,
uint maxValuesPerNode,
CancellationToken cancellationToken);
/// <summary>
/// Read processed (aggregated) samples — interval-bucketed average / min / max / etc.
/// Optional — drivers that only support raw history can throw <see cref="NotSupportedException"/>.
/// </summary>
Task<HistoryReadResult> ReadProcessedAsync(
string fullReference,
DateTime startUtc,
DateTime endUtc,
TimeSpan interval,
HistoryAggregateType aggregate,
CancellationToken cancellationToken);
}
/// <summary>Result of a HistoryRead call.</summary>
/// <param name="Samples">Returned samples in chronological order.</param>
/// <param name="ContinuationPoint">Opaque token for the next call when more samples are available; null when complete.</param>
public sealed record HistoryReadResult(
IReadOnlyList<DataValueSnapshot> Samples,
byte[]? ContinuationPoint);
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
public enum HistoryAggregateType
{
Average,
Minimum,
Maximum,
Total,
Count,
}

View File

@@ -0,0 +1,41 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Optional driver capability for per-host connectivity reporting. Currently used by
/// the Galaxy driver (Platform / AppEngine ScanState) but generalized so future drivers
/// with multi-host topology (e.g. an OPC UA Client gateway proxying multiple upstream
/// servers) can opt in.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> §5a — the Galaxy driver's <c>GalaxyRuntimeProbeManager</c>
/// becomes <c>IHostConnectivityProbe</c> after the v2 refactor.
/// </remarks>
public interface IHostConnectivityProbe
{
/// <summary>
/// Snapshot of host-level connectivity. The Core uses this to drive Bad-quality
/// fan-out scoped to the affected host's subtree (not the whole driver namespace).
/// </summary>
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
/// <summary>Fired when a host transitions Running ↔ Stopped (or similar lifecycle change).</summary>
event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
}
/// <summary>Per-host connectivity snapshot.</summary>
/// <param name="HostName">Driver-side host identifier (e.g. for Galaxy: Platform or AppEngine name).</param>
/// <param name="State">Current state.</param>
/// <param name="LastChangedUtc">Timestamp of the last state transition.</param>
public sealed record HostConnectivityStatus(
string HostName,
HostState State,
DateTime LastChangedUtc);
/// <summary>Event payload for <see cref="IHostConnectivityProbe.OnHostStatusChanged"/>.</summary>
public sealed record HostStatusChangedEventArgs(
string HostName,
HostState OldState,
HostState NewState);
/// <summary>Host lifecycle state. Generalization of Galaxy's Platform/Engine ScanState.</summary>
public enum HostState { Unknown, Running, Stopped, Faulted }

View File

@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver capability for on-demand reads. Required for any driver whose nodes are
/// readable from OPC UA clients (essentially all of them — every committed v2 driver
/// implements this).
/// </summary>
/// <remarks>
/// Reads are idempotent — Polly retry pipelines can safely retry on transient failures
/// (per <c>docs/v2/plan.md</c> decisions #34 and #44).
/// </remarks>
public interface IReadable
{
/// <summary>
/// Read a batch of attributes by their full driver-side reference.
/// Returns one snapshot per requested reference, in the same order.
/// </summary>
/// <remarks>
/// Per-reference failures should be reported via the snapshot's <see cref="DataValueSnapshot.StatusCode"/>
/// (Bad-coded), not as exceptions. The whole call should throw only if the driver itself is unreachable.
/// </remarks>
Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,29 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Optional driver capability — drivers whose backend has a native change signal
/// (Galaxy <c>time_of_last_deploy</c>, OPC UA server change notifications, TwinCAT
/// symbol-version-changed) implement this to tell Core when to re-run discovery.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decision #54 — static drivers (Modbus, S7, etc. whose tags
/// only change via a published config generation) don't implement <c>IRediscoverable</c>.
/// The Core just sees absence of the interface and skips change-detection wiring for that driver.
/// </remarks>
public interface IRediscoverable
{
/// <summary>
/// Fired when the driver's backend signals that the address space may have changed.
/// The Core's response is to re-run <see cref="ITagDiscovery.DiscoverAsync"/> and
/// diff the result against the current address space.
/// </summary>
event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
}
/// <summary>Event payload for <see cref="IRediscoverable.OnRediscoveryNeeded"/>.</summary>
/// <param name="Reason">Driver-supplied reason string for the diagnostic log (e.g. "Galaxy time_of_last_deploy advanced", "TwinCAT symbol-version-changed 0x0702").</param>
/// <param name="ScopeHint">
/// Optional hint about which subtree changed. Null means "the whole address space may have changed".
/// A non-null value (e.g. a folder path) lets the Core scope the rebuild surgically.
/// </param>
public sealed record RediscoveryEventArgs(string Reason, string? ScopeHint);

View File

@@ -0,0 +1,47 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver capability for data-change subscriptions — covers both native subscriptions
/// (Galaxy MXAccess advisory, OPC UA monitored items, TwinCAT ADS notifications) and
/// driver-internal polled subscriptions (Modbus, AB CIP, S7, FOCAS). The driver owns
/// its polling loop where applicable; the Core just sees <see cref="OnDataChange"/>
/// callbacks regardless of mechanism.
/// </summary>
public interface ISubscribable
{
/// <summary>
/// Subscribe to data changes for a batch of attributes.
/// The driver MAY fire <see cref="OnDataChange"/> immediately with the current value
/// (initial-data callback per OPC UA convention) and again on every change.
/// </summary>
/// <returns>An opaque subscription handle the caller passes to <see cref="UnsubscribeAsync"/>.</returns>
Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences,
TimeSpan publishingInterval,
CancellationToken cancellationToken);
/// <summary>Cancel a subscription returned by <see cref="SubscribeAsync"/>.</summary>
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
/// <summary>
/// Server-pushed data-change notification. Fires whenever a subscribed attribute changes,
/// and (per OPC UA convention) on subscription establishment for current values.
/// </summary>
event EventHandler<DataChangeEventArgs>? OnDataChange;
}
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync"/>.</summary>
public interface ISubscriptionHandle
{
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
string DiagnosticId { get; }
}
/// <summary>Event payload for <see cref="ISubscribable.OnDataChange"/>.</summary>
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync"/> call.</param>
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
/// <param name="Snapshot">New value + quality + timestamps.</param>
public sealed record DataChangeEventArgs(
ISubscriptionHandle SubscriptionHandle,
string FullReference,
DataValueSnapshot Snapshot);

View File

@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver capability for discovering tags and hierarchy from the backend.
/// Streams discovered nodes into <see cref="IAddressSpaceBuilder"/> rather than
/// buffering the entire tree (decision #52 — supports incremental / large address spaces).
/// </summary>
public interface ITagDiscovery
{
/// <summary>
/// Discover the driver's tag set and stream nodes to the builder.
/// The driver decides ordering (root → leaf typically) and may yield as many calls as needed.
/// </summary>
Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,34 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Driver capability for on-demand writes. Optional — read-only drivers (a hypothetical
/// historian-only adapter, for example) can omit this.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/plan.md</c> decisions #44 + #45 — <b>writes are NOT auto-retried by default</b>.
/// A timeout may fire after the device already accepted the command; replaying non-idempotent
/// field actions (pulses, alarm acks, recipe steps, counter increments) can cause duplicate
/// operations. Per-tag opt-in via <c>Tag.WriteIdempotent = true</c> in the central config DB
/// enables retry; otherwise the OPC UA client decides whether to re-issue.
/// </remarks>
public interface IWritable
{
/// <summary>
/// Write a batch of values to the driver. Returns one status per requested write,
/// in the same order.
/// </summary>
/// <param name="writes">Pairs of full reference + value to write.</param>
/// <param name="cancellationToken">Cancellation token; the driver should abort the batch if cancelled.</param>
Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
CancellationToken cancellationToken);
}
/// <summary>One write request in a batch.</summary>
/// <param name="FullReference">Driver-side full reference (matches <see cref="DriverAttributeInfo.FullName"/>).</param>
/// <param name="Value">Value to write; type must be compatible with the attribute's <see cref="DriverDataType"/>.</param>
public sealed record WriteRequest(string FullReference, object? Value);
/// <summary>Result of one write in a batch.</summary>
/// <param name="StatusCode">OPC UA status code (numeric value matches the OPC UA spec).</param>
public sealed record WriteResult(uint StatusCode);

View File

@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Security classification for write authorization on a tag, mirroring
/// the v1 Galaxy SecurityClassification model documented in <c>docs/DataTypeMapping.md</c>.
/// Generalized so non-Galaxy drivers can declare per-tag write protection levels.
/// </summary>
/// <remarks>
/// Maps to <c>NodePermissions</c> write tiers in <c>docs/v2/acl-design.md</c>:
/// FreeAccess + Operate require <c>WriteOperate</c>; Tune requires <c>WriteTune</c>;
/// Configure requires <c>WriteConfigure</c>; SecuredWrite + VerifiedWrite + ViewOnly
/// are read-only from OPC UA (v1 behavior preserved).
/// </remarks>
public enum SecurityClassification
{
FreeAccess = 0,
Operate = 1,
SecuredWrite = 2,
VerifiedWrite = 3,
Tune = 4,
Configure = 5,
ViewOnly = 6,
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,107 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
public sealed class DriverTypeRegistryTests
{
private static DriverTypeMetadata SampleMetadata(
string typeName = "Modbus",
NamespaceKindCompatibility allowed = NamespaceKindCompatibility.Equipment) =>
new(typeName, allowed,
DriverConfigJsonSchema: "{\"type\": \"object\"}",
DeviceConfigJsonSchema: "{\"type\": \"object\"}",
TagConfigJsonSchema: "{\"type\": \"object\"}");
[Fact]
public void Register_ThenGet_RoundTrips()
{
var registry = new DriverTypeRegistry();
var metadata = SampleMetadata();
registry.Register(metadata);
registry.Get("Modbus").ShouldBe(metadata);
}
[Fact]
public void Get_IsCaseInsensitive()
{
var registry = new DriverTypeRegistry();
registry.Register(SampleMetadata("Galaxy"));
registry.Get("galaxy").ShouldNotBeNull();
registry.Get("GALAXY").ShouldNotBeNull();
}
[Fact]
public void Get_UnknownType_Throws()
{
var registry = new DriverTypeRegistry();
registry.Register(SampleMetadata("Modbus"));
Should.Throw<KeyNotFoundException>(() => registry.Get("UnregisteredType"));
}
[Fact]
public void TryGet_UnknownType_ReturnsNull()
{
var registry = new DriverTypeRegistry();
registry.Register(SampleMetadata("Modbus"));
registry.TryGet("UnregisteredType").ShouldBeNull();
}
[Fact]
public void Register_DuplicateType_Throws()
{
var registry = new DriverTypeRegistry();
registry.Register(SampleMetadata("Modbus"));
Should.Throw<InvalidOperationException>(() => registry.Register(SampleMetadata("Modbus")));
}
[Fact]
public void Register_DuplicateTypeIsCaseInsensitive()
{
var registry = new DriverTypeRegistry();
registry.Register(SampleMetadata("Modbus"));
Should.Throw<InvalidOperationException>(() => registry.Register(SampleMetadata("modbus")));
}
[Fact]
public void All_ReturnsRegisteredTypes()
{
var registry = new DriverTypeRegistry();
registry.Register(SampleMetadata("Modbus"));
registry.Register(SampleMetadata("S7"));
registry.Register(SampleMetadata("Galaxy", NamespaceKindCompatibility.SystemPlatform));
var all = registry.All();
all.Count.ShouldBe(3);
all.Select(m => m.TypeName).ShouldBe(new[] { "Modbus", "S7", "Galaxy" }, ignoreOrder: true);
}
[Fact]
public void NamespaceKindCompatibility_FlagsAreBitmask()
{
// Per decision #111 — driver types like OpcUaClient may be valid for multiple namespace kinds.
var both = NamespaceKindCompatibility.Equipment | NamespaceKindCompatibility.SystemPlatform;
both.HasFlag(NamespaceKindCompatibility.Equipment).ShouldBeTrue();
both.HasFlag(NamespaceKindCompatibility.SystemPlatform).ShouldBeTrue();
both.HasFlag(NamespaceKindCompatibility.Simulated).ShouldBeFalse();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Get_RejectsEmptyTypeName(string? typeName)
{
var registry = new DriverTypeRegistry();
Should.Throw<ArgumentException>(() => registry.Get(typeName!));
}
}

View File

@@ -0,0 +1,71 @@
using System.Reflection;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Asserts that <c>Core.Abstractions</c> stays a true contract project — it must not depend on
/// any implementation type, any other OtOpcUa project, or anything beyond BCL + System types.
/// Per <c>docs/v2/plan.md</c> decision #59 (Core.Abstractions internal-only for now; design as
/// if public to minimize churn later).
/// </summary>
public sealed class InterfaceIndependenceTests
{
private static readonly Assembly Assembly = typeof(IDriver).Assembly;
[Fact]
public void Assembly_HasNoReferencesOutsideBcl()
{
// Allowed reference assembly name prefixes — BCL + the assembly itself.
var allowed = new[]
{
"System",
"Microsoft.Win32",
"netstandard",
"mscorlib",
"ZB.MOM.WW.OtOpcUa.Core.Abstractions",
};
var referenced = Assembly.GetReferencedAssemblies();
var disallowed = referenced
.Where(r => !allowed.Any(a => r.Name!.StartsWith(a, StringComparison.Ordinal)))
.ToList();
disallowed.ShouldBeEmpty(
$"Core.Abstractions must reference only BCL/System assemblies. " +
$"Found disallowed references: {string.Join(", ", disallowed.Select(a => a.Name))}");
}
[Fact]
public void AllPublicTypes_LiveInRootNamespace()
{
// Per the decision-#59 "design as if public" rule — no nested sub-namespaces; one flat surface.
var publicTypes = Assembly.GetExportedTypes();
var nonRoot = publicTypes
.Where(t => t.Namespace != "ZB.MOM.WW.OtOpcUa.Core.Abstractions")
.ToList();
nonRoot.ShouldBeEmpty(
$"Core.Abstractions should expose all public types in the root namespace. " +
$"Found types in other namespaces: {string.Join(", ", nonRoot.Select(t => $"{t.FullName}"))}");
}
[Theory]
[InlineData(typeof(IDriver))]
[InlineData(typeof(ITagDiscovery))]
[InlineData(typeof(IReadable))]
[InlineData(typeof(IWritable))]
[InlineData(typeof(ISubscribable))]
[InlineData(typeof(IAlarmSource))]
[InlineData(typeof(IHistoryProvider))]
[InlineData(typeof(IRediscoverable))]
[InlineData(typeof(IHostConnectivityProbe))]
[InlineData(typeof(IDriverConfigEditor))]
[InlineData(typeof(IAddressSpaceBuilder))]
public void EveryCapabilityInterface_IsPublic(Type type)
{
type.IsPublic.ShouldBeTrue($"{type.Name} must be public — drivers in separate assemblies implement it.");
type.IsInterface.ShouldBeTrue($"{type.Name} must be an interface, not a class.");
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>