Compare commits
123 Commits
phase-0-re
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a44fc7a610 | ||
|
|
d4c1873998 | ||
|
|
f52b7d8979 | ||
|
|
b54724a812 | ||
|
|
10c724b5b6 | ||
| 8c89d603e8 | |||
| 299bd4a932 | |||
|
|
c506ea298a | ||
|
|
9e2b5b330f | ||
| d5c6280333 | |||
| 476ce9b7c5 | |||
| 954bf55d28 | |||
| 9fb3cf7512 | |||
|
|
793c787315 | ||
|
|
cde018aec1 | ||
|
|
9892a0253d | ||
|
|
b5464f11ee | ||
| dae29f14c8 | |||
| f306793e36 | |||
| 9e61873cc0 | |||
| 1a60470d4a | |||
| 635f67bb02 | |||
|
|
a3f2f95344 | ||
|
|
463c5a4320 | ||
|
|
2b5222f5db | ||
|
|
8248b126ce | ||
|
|
cd19022d19 | ||
| 5ee9acb255 | |||
|
|
02fccbc762 | ||
| faeab34541 | |||
|
|
a05b84858d | ||
| c59ac9e52d | |||
|
|
02a0e8efd1 | ||
| 7009483d16 | |||
|
|
9de96554dc | ||
| af35fac0ef | |||
|
|
aa8834a231 | ||
| 976e73e051 | |||
|
|
8fb3dbe53b | ||
|
|
a61e637411 | ||
| e4885aadd0 | |||
|
|
52a29100b1 | ||
| 19bcf20fbe | |||
|
|
8adc8f5ab8 | ||
| 261869d84e | |||
|
|
08c90d19fd | ||
| 5cc120d836 | |||
|
|
bf329b05d8 | ||
| 2584379e75 | |||
|
|
ef2a810b2d | ||
| a7764e50f3 | |||
|
|
8464e3f376 | ||
| a9357600e7 | |||
|
|
2f00c74bbb | ||
| 5d5e1f9650 | |||
|
|
4886a5783f | ||
| d70a2e0077 | |||
|
|
cb7b81a87a | ||
| 901d2b8019 | |||
|
|
d5fa1f450e | ||
| 6fdaee3a71 | |||
|
|
ed88835d34 | ||
| 5389d4d22d | |||
|
|
b5f8661e98 | ||
| 4058b88784 | |||
|
|
6b04a85f86 | ||
| cd8691280a | |||
|
|
77d09bf64e | ||
| 163c821e74 | |||
|
|
eea31dcc4e | ||
| 8a692d4ba8 | |||
|
|
268b12edec | ||
| edce1be742 | |||
|
|
18b3e24710 | ||
| f6a12dafe9 | |||
|
|
058c3dddd3 | ||
| 52791952dd | |||
|
|
860deb8e0d | ||
| f5e7173de3 | |||
|
|
22d3b0d23c | ||
| 55696a8750 | |||
|
|
dd3a449308 | ||
| 3c1dc334f9 | |||
|
|
46834a43bd | ||
| 7683b94287 | |||
|
|
f53c39a598 | ||
| d569c39f30 | |||
|
|
190d09cdeb | ||
| 4e0040e670 | |||
| 91cb2a1355 | |||
|
|
c14624f012 | ||
|
|
04d267d1ea | ||
| 4448db8207 | |||
| d96b513bbc | |||
| 053c4e0566 | |||
|
|
f24f969a85 | ||
|
|
ca025ebe0c | ||
|
|
d13f919112 | ||
| d2ebb91cb1 | |||
| 90ce0af375 | |||
| e250356e2a | |||
| 067ad78e06 | |||
| 6cfa8d326d | |||
|
|
70a5d06b37 | ||
|
|
30ece6e22c | ||
|
|
3717405aa6 | ||
|
|
1c2bf74d38 | ||
|
|
6df1a79d35 | ||
|
|
caa9cb86f6 | ||
|
|
a3d16a28f1 | ||
|
|
50f81a156d | ||
|
|
7403b92b72 | ||
|
|
a7126ba953 | ||
|
|
549cd36662 | ||
|
|
32eeeb9e04 | ||
|
|
a1e9ed40fb | ||
|
|
18f93d72bb | ||
|
|
7a5b535cd6 | ||
|
|
01fd90c178 | ||
|
|
fc0ce36308 | ||
|
|
bf6741ba7f | ||
|
|
980ea5190c | ||
|
|
45ffa3e7d4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ packages/
|
||||
# Claude Code (per-developer settings, runtime lock files, agent transcripts)
|
||||
.claude/
|
||||
|
||||
.local/
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<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.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
<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.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"/>
|
||||
<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.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
||||
|
||||
1
_p54.json
Normal file
1
_p54.json
Normal file
@@ -0,0 +1 @@
|
||||
{"title":"Phase 3 PR 54 -- Siemens S7 Modbus TCP quirks research doc","body":"## Summary\n\nAdds `docs/v2/s7.md` (485 lines) covering Siemens SIMATIC S7 family Modbus TCP behavior. Mirrors the `docs/v2/dl205.md` template for future per-quirk implementation PRs.\n\n## Key findings for the implementation track\n\n- **No fixed memory map** — every S7 Modbus server is user-wired via `MB_SERVER`/`MODBUSCP`/`MODBUSPN` library blocks. Driver must accept per-site config, not assume a vendor layout.\n- **MB_SERVER requires non-optimized DBs** (STATUS `0x8383` if optimized). Most common field bug.\n- **Word order default = ABCD** (opposite of DL260). Driver's S7 profile default must be `ByteOrder.BigEndian`, not `WordSwap`.\n- **One port per MB_SERVER instance** — multi-client requires parallel FBs on 503/504/… Most clients assume port 502 multiplexes (wrong on S7).\n- **CP 343-1 Lean is server-only**, requires the `2XV9450-1MB00` license.\n- **FC20/21/22/23/43 all return Illegal Function** on every S7 variant — driver must not attempt FC23 bulk-read optimization for S7.\n- **STOP-mode behavior non-deterministic** across firmware bands — treat both read/write STOP-mode responses as unavailable.\n\nTwo items flagged as unconfirmed rumour (V2.0+ float byte-order claim, STOP-mode caching location).\n\nNo code, no tests — implementation lands in PRs 56+.\n\n## Test plan\n- [x] Doc renders as markdown\n- [x] 31 citations present\n- [x] Section structure matches dl205.md template","head":"phase-3-pr54-s7-research-doc","base":"v2"}
|
||||
1
_p55.json
Normal file
1
_p55.json
Normal file
@@ -0,0 +1 @@
|
||||
{"title":"Phase 3 PR 55 -- Mitsubishi MELSEC Modbus TCP quirks research doc","body":"## Summary\n\nAdds `docs/v2/mitsubishi.md` (451 lines) covering MELSEC Q/L/iQ-R/iQ-F/FX3U Modbus TCP behavior. Mirrors `docs/v2/dl205.md` template for per-quirk implementation PRs.\n\n## Key findings for the implementation track\n\n- **Module naming trap** — `QJ71MB91` is SERIAL RTU, not TCP. TCP module is `QJ71MT91`. Surface clearly in driver docs.\n- **No canonical mapping** — per-site 'Modbus Device Assignment Parameter' block (up to 16 entries). Treat mapping as runtime config.\n- **X/Y hex vs octal depends on family** — Q/L/iQ-R use HEX (X20 = decimal 32); FX/iQ-F use OCTAL (X20 = decimal 16). Helper must take a family selector.\n- **Word order CDAB default** across all MELSEC families (opposite of Siemens S7). Driver Mitsubishi profile default: `ByteOrder.WordSwap`.\n- **D-registers binary by default** (opposite of DL205's BCD default). Caller opts in to `Bcd16`/`Bcd32` when ladder uses BCD.\n- **FX5U needs firmware ≥ 1.060** for Modbus TCP server — older is client-only.\n- **FX3U-ENET vs FX3U-ENET-P502 vs FX3U-ENET-ADP** — only the middle one binds port 502; the last has no Modbus at all. Common operator mis-purchase.\n- **QJ71MT91 does NOT support FC22 / FC23** — iQ-R / iQ-F do. Bulk-read optimization must gate on capability.\n- **STOP-mode writes configurable** on Q/L/iQ-R/iQ-F (default accept), always rejected on FX3U-ENET.\n\nThree unconfirmed rumours flagged separately.\n\nNo code, no tests — implementation lands in PRs 58+.\n\n## Test plan\n- [x] Doc renders as markdown\n- [x] 17 citations present\n- [x] Per-model test naming matrix included (`Mitsubishi_QJ71MT91_*`, `Mitsubishi_FX5U_*`, `Mitsubishi_FX3U_ENET_*`, shared `Mitsubishi_Common_*`)","head":"phase-3-pr55-mitsubishi-research-doc","base":"v2"}
|
||||
@@ -348,6 +348,44 @@ The project uses [GLAuth](https://github.com/glauth/glauth) v2.4.0 as the LDAP s
|
||||
|
||||
Enable LDAP in `appsettings.json` under `Authentication.Ldap`. See [Configuration Guide](Configuration.md) for the full property reference.
|
||||
|
||||
### Active Directory configuration
|
||||
|
||||
Production deployments typically point at Active Directory instead of GLAuth. Only four properties differ from the dev defaults: `Server`, `Port`, `UserNameAttribute`, and `ServiceAccountDn`. The same `GroupToRole` mechanism works — map your AD security groups to OPC UA roles.
|
||||
|
||||
```json
|
||||
{
|
||||
"OpcUaServer": {
|
||||
"Ldap": {
|
||||
"Enabled": true,
|
||||
"Server": "dc01.corp.example.com",
|
||||
"Port": 636,
|
||||
"UseTls": true,
|
||||
"AllowInsecureLdap": false,
|
||||
"SearchBase": "DC=corp,DC=example,DC=com",
|
||||
"ServiceAccountDn": "CN=OpcUaSvc,OU=Service Accounts,DC=corp,DC=example,DC=com",
|
||||
"ServiceAccountPassword": "<from your secret store>",
|
||||
"DisplayNameAttribute": "displayName",
|
||||
"GroupAttribute": "memberOf",
|
||||
"UserNameAttribute": "sAMAccountName",
|
||||
"GroupToRole": {
|
||||
"OPCUA-Operators": "WriteOperate",
|
||||
"OPCUA-Engineers": "WriteConfigure",
|
||||
"OPCUA-AlarmAck": "AlarmAck",
|
||||
"OPCUA-Tuners": "WriteTune"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `UserNameAttribute: "sAMAccountName"` is the critical AD override — the default `uid` is not populated on AD user entries, so the user-DN lookup returns no results without it. Use `userPrincipalName` instead if operators log in with `user@corp.example.com` form.
|
||||
- `Port: 636` + `UseTls: true` is required under AD's LDAP-signing enforcement. AD increasingly rejects plain-LDAP bind; set `AllowInsecureLdap: false` to refuse fallback.
|
||||
- `ServiceAccountDn` should name a dedicated read-only service principal — not a privileged admin. The account needs read access to user and group entries in the search base.
|
||||
- `memberOf` values come back as full DNs like `CN=OPCUA-Operators,OU=OPC UA Security Groups,OU=Groups,DC=corp,DC=example,DC=com`. The authenticator strips the leading `CN=` RDN value so operators configure `GroupToRole` with readable group common-names.
|
||||
- Nested group membership is **not** expanded — assign users directly to the role-mapped groups, or pre-flatten membership in AD. `LDAP_MATCHING_RULE_IN_CHAIN` / `tokenGroups` expansion is an authenticator enhancement, not a config change.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- LDAP credentials are transmitted in plaintext over the OPC UA channel unless transport security is enabled. Use `Basic256Sha256-SignAndEncrypt` for production deployments.
|
||||
|
||||
56
docs/v2/V1_ARCHIVE_STATUS.md
Normal file
56
docs/v2/V1_ARCHIVE_STATUS.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# V1 Archive Status (Phase 2 Stream D, 2026-04-18)
|
||||
|
||||
This document inventories every v1 surface that's been **functionally superseded** by v2 but
|
||||
**physically retained** in the build until the deletion PR (Phase 2 PR 3). Rationale: cascading
|
||||
references mean a single deletion is high blast-radius; archive-marking lets the v2 stack ship
|
||||
on its own merits while the v1 surface stays as parity reference.
|
||||
|
||||
## Archived projects
|
||||
|
||||
| Path | Status | Replaced by | Build behavior |
|
||||
|---|---|---|---|
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Host/` | Archive (executable in build) | `OtOpcUa.Server` + `Driver.Galaxy.Host` + `Driver.Galaxy.Proxy` | Builds; not deployed by v2 install scripts |
|
||||
| `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` | Archive (plugin in build) | TODO: port into `Driver.Galaxy.Host/Backend/Historian/` (Task B.1.h follow-up) | Builds; loaded only by archived Host |
|
||||
| `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/` | Archive | `Driver.Galaxy.E2E` + per-component test projects | `<IsTestProject>false</IsTestProject>` — `dotnet test slnx` skips |
|
||||
| `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` | Archive | `Driver.Galaxy.E2E` | `<IsTestProject>false</IsTestProject>` — `dotnet test slnx` skips |
|
||||
|
||||
## How to run the archived suites explicitly
|
||||
|
||||
```powershell
|
||||
# v1 unit tests (494):
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive
|
||||
|
||||
# v1 integration tests (6):
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
|
||||
```
|
||||
|
||||
Both still pass on this dev box — they're the parity reference for Phase 2 PR 3's deletion
|
||||
decision.
|
||||
|
||||
## Deletion plan (Phase 2 PR 3)
|
||||
|
||||
Pre-conditions:
|
||||
- [ ] `Driver.Galaxy.E2E` test count covers the v1 IntegrationTests' 6 integration scenarios
|
||||
at minimum (currently 7 tests; expand as needed)
|
||||
- [ ] `Driver.Galaxy.Host/Backend/Historian/` ports the Wonderware Historian plugin
|
||||
so `MxAccessGalaxyBackend.HistoryReadAsync` returns real data (Task B.1.h)
|
||||
- [ ] Operator review on a separate PR — destructive change
|
||||
|
||||
Steps:
|
||||
1. `git rm -r src/ZB.MOM.WW.OtOpcUa.Host/`
|
||||
2. `git rm -r src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/`
|
||||
(or move it under Driver.Galaxy.Host first if the lift is part of the same PR)
|
||||
3. `git rm -r tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
|
||||
4. `git rm -r tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/`
|
||||
5. Edit `ZB.MOM.WW.OtOpcUa.slnx` — remove the four project lines
|
||||
6. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → confirm clean
|
||||
7. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → confirm 470+ pass / 1 baseline (or whatever the
|
||||
current count is plus any new E2E coverage)
|
||||
8. Commit: "Phase 2 Stream D — delete v1 archive (Host + Historian.Aveva + v1Tests + IntegrationTests)"
|
||||
9. PR 3 against `v2`, link this doc + exit-gate-phase-2-final.md
|
||||
10. One reviewer signoff
|
||||
|
||||
## Rollback
|
||||
|
||||
If Phase 2 PR 3 surfaces downstream consumer regressions, `git revert` the deletion commit
|
||||
restores the four projects intact. The v2 stack continues to ship from the v2 branch.
|
||||
@@ -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:14330` (host) → `1433` (container) — remapped from 1433 to avoid collision with the native MSSQL14 instance that hosts the Galaxy `ZB` DB (both bind 0.0.0.0:1433; whichever wins the race gets connections) | User `sa` / Password `OtOpcUaDev_2026!` | Docker named volume `otopcua-mssql-data` (mounted at `/var/opt/mssql` inside container) | ✅ Running — `InitialSchema` migration applied, 16 entity tables live |
|
||||
| Dev Galaxy (AVEVA System Platform) | Local install on this dev box — full ArchestrA + Historian + OI-Server stack | v1 baseline | Local COM via MXAccess (`C:\Program Files (x86)\ArchestrA\Framework\bin\ArchestrA.MXAccess.dll`); Historian via `aaH*` services; SuiteLink via `slssvc` | 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) | ✅ **Fully available — Phase 2 lift unblocked.** 27 ArchestrA / AVEVA / Wonderware services running incl. `aaBootstrap`, `aaGR` (Galaxy Repository), `aaLogger`, `aaUserValidator`, `aaPim`, `ArchestrADataStore`, `AsbServiceManager`, `AutoBuild_Service`; full Historian set (`aahClientAccessPoint`, `aahGateway`, `aahInSight`, `aahSearchIndexer`, `aahSupervisor`, `InSQLStorage`, `InSQLConfiguration`, `InSQLEventSystem`, `InSQLIndexing`, `InSQLIOServer`, `InSQLManualStorage`, `InSQLSystemDriver`, `HistorianSearch-x64`); `slssvc` (Wonderware SuiteLink); `OI-Gateway` install present at `C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` (decision #142 AppServer-via-OI-Gateway smoke test now also unblocked) |
|
||||
| GLAuth (LDAP) | Local install at `C:\publish\glauth\` | v2.4.0 | `localhost:3893` (LDAP) / `3894` (LDAPS, disabled) | Direct-bind `cn={user},dc=lmxopcua,dc=local` per `auth.md`; users `readonly`/`writeop`/`writetune`/`writeconfig`/`alarmack`/`admin`/`serviceaccount` (passwords in `glauth.cfg` as SHA-256) | `C:\publish\glauth\` | ✅ Running (NSSM service `GLAuth`). Phase 1 Admin uses GroupToRole map `ReadOnly→ConfigViewer`, `WriteOperate→ConfigEditor`, `AlarmAck→FleetAdmin`. v2-rebrand to `dc=otopcua,dc=local` is a future cosmetic change |
|
||||
| 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,14330;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)
|
||||
@@ -39,7 +135,7 @@ The tier split keeps developer onboarding fast (no Docker required for first bui
|
||||
|
||||
| Resource | Purpose | Type | Default port | Default credentials | Owner |
|
||||
|----------|---------|------|--------------|---------------------|-------|
|
||||
| **SQL Server 2022 dev edition** | Central config DB; integration tests against `Configuration` project | Local install OR Docker container `mcr.microsoft.com/mssql/server:2022-latest` | 1433 | `sa` / `OtOpcUaDev_2026!` (dev only — production uses Integrated Security or gMSA per decision #46) | Developer (per machine) |
|
||||
| **SQL Server 2022 dev edition** | Central config DB; integration tests against `Configuration` project | Local install OR Docker container `mcr.microsoft.com/mssql/server:2022-latest` | 1433 default, or 14330 when a native MSSQL instance (e.g. the Galaxy `ZB` host) already occupies 1433 | `sa` / `OtOpcUaDev_2026!` (dev only — production uses Integrated Security or gMSA per decision #46) | Developer (per machine) |
|
||||
| **GLAuth (LDAP server)** | Admin UI authentication tests; data-path ACL evaluation tests | Local binary at `C:\publish\glauth\` per existing CLAUDE.md | 3893 (LDAP) / 3894 (LDAPS) | Service principal: `cn=admin,dc=otopcua,dc=local` / `OtOpcUaDev_2026!`; test users defined in GLAuth config | Developer (per machine) |
|
||||
| **Local dev Galaxy** (Aveva System Platform) | Galaxy driver tests; v1 IntegrationTests parity | Existing on dev box per CLAUDE.md | n/a (local COM) | Windows Auth | Developer (already present per project setup) |
|
||||
|
||||
@@ -108,25 +204,104 @@ 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. ~30–60 min total on a fresh machine.
|
||||
Order matters because some installs have prerequisites and several need admin elevation (UAC). ~60–90 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 0–1 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 14330: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 host port is **14330**, not 1433, to coexist with the native MSSQL14 instance that hosts the Galaxy `ZB` DB on port 1433. Both the native instance and Docker's port-proxy will happily bind `0.0.0.0:1433`, but only one of them catches any given connection — which is effectively non-deterministic and produces confusing "Login failed for user 'sa'" errors when the native instance wins. Using 14330 eliminates the race entirely.
|
||||
|
||||
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 +388,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 1–9 above
|
||||
**Prerequisite**: Bootstrap order steps 1–10 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)
|
||||
|
||||
|
||||
295
docs/v2/dl205.md
Normal file
295
docs/v2/dl205.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# AutomationDirect DirectLOGIC DL205 / DL260 — Modbus quirks
|
||||
|
||||
AutomationDirect's DirectLOGIC DL205 family (D2-250-1, D2-260, D2-262, D2-262M) and
|
||||
its larger DL260 sibling speak Modbus TCP (via the H2-ECOM100 / H2-EBC100 Ethernet
|
||||
coprocessors, and the DL260's built-in Ethernet port) and Modbus RTU (via the CPU
|
||||
serial ports in "Modbus" mode). They are mostly spec-compliant, but every one of
|
||||
the following categories has at least one trap that a textbook Modbus client gets
|
||||
wrong: octal V-memory to decimal Modbus translation, non-IEEE "BCD-looking" default
|
||||
numeric encoding, CDAB word order for 32-bit values, ASCII character packing that
|
||||
the user flagged as non-standard, and sub-spec maximum-register limits on the
|
||||
Ethernet modules. This document catalogues each quirk, cites primary sources, and
|
||||
names the ModbusPal integration test we'd write for it (convention from
|
||||
`docs/v2/modbus-test-plan.md`: `DL205_<behavior>`).
|
||||
|
||||
## Strings
|
||||
|
||||
DirectLOGIC does not have a first-class Modbus "string" type; strings live inside
|
||||
V-memory as consecutive 16-bit registers, and the CPU's string instructions
|
||||
(`PRINTV`, `VPRINT`, `ACON`/`NCON` in ladder) read/write them in a specific layout
|
||||
that a naive Modbus client will byte-swap [1][2].
|
||||
|
||||
- **Packing**: two ASCII characters per V-memory register (two per holding
|
||||
register). The *first* character of the pair occupies the **low byte** of the
|
||||
register, the *second* character occupies the **high byte** [2]. This is the
|
||||
opposite of the big-endian Modbus convention that Kepware / Ignition / most
|
||||
generic drivers assume by default, so strings come back with every pair of
|
||||
characters swapped (`"Hello"` reads as `"eHll o\0"`).
|
||||
- **Termination**: null-terminated (`0x00` in the character byte). There is no
|
||||
length prefix. Writes must pad the final register's unused byte with `0x00`.
|
||||
- **Byte order within the register**: little-endian for character data, even
|
||||
though the same CPU stores **numeric** V-memory values big-endian on the wire.
|
||||
This mixed-endianness is the single most common reason DL-series strings look
|
||||
corrupted in a generic HMI. Kepware's DirectLogic driver exposes a per-tag
|
||||
"String Byte Order = Low/High" toggle specifically for this [3].
|
||||
- **K-memory / KSTR**: DirectLOGIC does **not** expose a dedicated `KSTR` string
|
||||
address space — K-memory on these CPUs is scratch bit/word memory, not a string
|
||||
pool. Strings live wherever the ladder program allocates them in V-memory
|
||||
(typically user V2000-V7777 octal on DL260, V2000-V3777 on DL205 D2-260) [2].
|
||||
- **Maximum length**: bounded only by the V-memory region assigned. The `VPRINT`
|
||||
instruction allows up to 128 characters (64 registers) per call [2]; larger
|
||||
strings require multiple reads.
|
||||
- **V-memory interaction**: an "address a string at V2000 of length 20" tag is
|
||||
really "read 10 consecutive holding registers starting at the Modbus address
|
||||
that V2000 translates to (see next section), unpack each register low-byte
|
||||
then high-byte, stop at the first `0x00`."
|
||||
|
||||
Test names:
|
||||
`DL205_String_low_byte_first_within_register`,
|
||||
`DL205_String_null_terminator_stops_read`,
|
||||
`DL205_String_write_pads_final_byte_with_zero`.
|
||||
|
||||
## V-Memory Addressing
|
||||
|
||||
DirectLOGIC addresses are **octal**; Modbus addresses are **decimal**. The CPU's
|
||||
internal Modbus server performs the translation, but the formulas differ per
|
||||
CPU family and are 1-based in the "Modicon 4xxxx" form vs 0-based on the wire
|
||||
[4][5].
|
||||
|
||||
Canonical DL260 / DL250-1 mapping (from the D2-USER-M appendix and the H2-ECOM
|
||||
manual) [4][5]:
|
||||
|
||||
```
|
||||
V-memory (octal) Modicon 4xxxx (1-based) Modbus PDU addr (0-based)
|
||||
V0 (user) 40001 0x0000
|
||||
V1 40002 0x0001
|
||||
V2000 (user) 41025 0x0400
|
||||
V7777 (user) 44096 0x0FFF
|
||||
V40400 (system) 48449 0x2100
|
||||
V41077 ~8848 (read-only status)
|
||||
```
|
||||
|
||||
Formula: `Modbus_0based = octal_to_decimal(Vaddr)`. So `V2000` octal = `1024`
|
||||
decimal = Modbus PDU address `0x0400`. The "4xxxx" Modicon view just adds 1 and
|
||||
prefixes the register bank digit.
|
||||
|
||||
- **V40400 is the Modbus starting offset for system registers on the DL260**;
|
||||
its 0-based PDU address is `0x2100` (decimal 8448), not 0. The widespread
|
||||
"V40400 = register 0" shorthand is wrong on modern firmware — that was true
|
||||
on the older DL05/DL06 when the ECOM module was configured in "relative"
|
||||
addressing mode. On the H2-ECOM100 factory default ("absolute" mode), V40400
|
||||
maps to 0x2100 [5].
|
||||
- **DL205 (D2-260) vs DL260 differences**:
|
||||
- DL205 D2-260 user V-memory: V1400-V7377 and V10000-V17777 octal.
|
||||
- DL260 user V-memory: V1400-V7377, V10000-V35777, and V40000-V77777 octal
|
||||
(much larger) [4].
|
||||
- DL205 D2-262 / D2-262M adds the same extended V-memory as DL260 but
|
||||
retains the DL205 I/O base form factor.
|
||||
- Neither DL205 sub-model changes the *formula* — only the valid range.
|
||||
- **Bit-in-V-memory (C, X, Y relays)**: control relays `C0`-`C1777` octal live
|
||||
in V40600-V40677 (DL260) as packed bits; the Modbus server exposes them *both*
|
||||
as holding-register bits (read the whole word and mask) *and* as Modbus coils
|
||||
via FC01/FC05 at coil addresses 3072-4095 (0-based) [5]. `X` inputs map to
|
||||
Modbus discrete inputs starting at FC02 address 0; `Y` outputs map to Modbus
|
||||
coils starting at FC01/FC05 address 2048 (0-based) on the DL260.
|
||||
- **Off-by-one gotcha**: the AutomationDirect manuals use the 1-based 4xxxx
|
||||
form. Kepware, libmodbus, pymodbus, and the .NET stack all take the 0-based
|
||||
PDU form. When the manual says "V2000 = 41025" you send `0x0400`, not
|
||||
`0x0401`.
|
||||
|
||||
Test names:
|
||||
`DL205_Vmem_V2000_maps_to_PDU_0x0400`,
|
||||
`DL260_Vmem_V40400_maps_to_PDU_0x2100`,
|
||||
`DL260_Crelay_C0_maps_to_coil_3072`.
|
||||
|
||||
## Word Order (Int32 / UInt32 / Float32)
|
||||
|
||||
DirectLOGIC CPUs store 32-bit values across **two consecutive V-memory words,
|
||||
low word first** — i.e., `CDAB` when viewed as a Modbus register pair [1][3].
|
||||
Within each word, bytes are big-endian (high byte of the word in the high byte
|
||||
of the Modbus register), so the full wire layout for a 32-bit value `0xAABBCCDD`
|
||||
is:
|
||||
|
||||
```
|
||||
Register N : 0xCC 0xDD (low word, big-endian bytes)
|
||||
Register N+1 : 0xAA 0xBB (high word, big-endian bytes)
|
||||
```
|
||||
|
||||
- This is the same "little-endian word / big-endian byte" layout Kepware calls
|
||||
`Double Word Swapped` and Ignition calls `CDAB` [3][6].
|
||||
- **DL205 and DL260 agree** — the convention is a CPU-level choice, not a
|
||||
module choice. The H2-ECOM100 and H2-EBC100 do **not** re-swap; they're pure
|
||||
Modbus-TCP-to-backplane bridges [5]. The DL260 built-in Ethernet port
|
||||
behaves identically.
|
||||
- **Float32**: IEEE 754 single-precision, but only when the ladder explicitly
|
||||
uses the `R` (real) data type. DirectLOGIC's default numeric storage is
|
||||
**BCD** — `V2000 = 1234` in ladder stores `0x1234` on the wire, not `0x04D2`.
|
||||
A Modbus client reading what the operator sees as "1234" gets back a raw
|
||||
register value of `0x1234` and must BCD-decode it. Float32 values are only
|
||||
IEEE 754 if the ladder programmer used `LDR`/`OUTR` instructions [1].
|
||||
- **Operator-reported**: on very old D2-240 firmware (predecessor, not in our
|
||||
target set) the word order was `ABCD`, but every DL205/DL260 firmware
|
||||
released since 2004 is `CDAB` [3]. _Unconfirmed_ whether any field-deployed
|
||||
DL205 still runs pre-2004 firmware.
|
||||
|
||||
Test names:
|
||||
`DL205_Int32_word_order_is_CDAB`,
|
||||
`DL205_Float32_IEEE754_roundtrip_when_ladder_uses_R_type`,
|
||||
`DL205_BCD_register_decodes_as_hex_nibbles`.
|
||||
|
||||
## Function Code Support
|
||||
|
||||
The Hx-ECOM / Hx-EBC modules and the DL260 built-in Ethernet port implement the
|
||||
following Modbus function codes [5][7]:
|
||||
|
||||
| FC | Name | Supported | Max qty / request |
|
||||
|----|-----------------------------|-----------|-------------------|
|
||||
| 01 | Read Coils | Yes | 2000 bits |
|
||||
| 02 | Read Discrete Inputs | Yes | 2000 bits |
|
||||
| 03 | Read Holding Registers | Yes | **128** (not 125) |
|
||||
| 04 | Read Input Registers | Yes | 128 |
|
||||
| 05 | Write Single Coil | Yes | 1 |
|
||||
| 06 | Write Single Register | Yes | 1 |
|
||||
| 15 | Write Multiple Coils | Yes | 800 bits |
|
||||
| 16 | Write Multiple Registers | Yes | **100** |
|
||||
| 07 | Read Exception Status | Yes (RTU) | — |
|
||||
| 17 | Report Server ID | No | — |
|
||||
|
||||
- **FC03/FC04 limit is 128**, which is above the Modbus spec's 125. Requesting
|
||||
129+ returns exception code `03` (Illegal Data Value) [5].
|
||||
- **FC16 limit is 100**, below the spec's 123. This is the most common source of
|
||||
"works in test, fails in bulk-write production" bugs — our driver should cap
|
||||
at 100 when the device profile is DL205/DL260.
|
||||
- **No custom function codes** are exposed on the Modbus port. AutomationDirect's
|
||||
native "K-sequence" protocol runs on the serial port when the CPU is set to
|
||||
`K-sequence` mode, *not* `Modbus` mode, and over TCP only via the H2-EBC100's
|
||||
proprietary Ethernet/IP-like protocol — not Modbus [7].
|
||||
|
||||
Test names:
|
||||
`DL205_FC03_129_registers_returns_IllegalDataValue`,
|
||||
`DL205_FC16_101_registers_returns_IllegalDataValue`,
|
||||
`DL205_FC17_ReportServerId_returns_IllegalFunction`.
|
||||
|
||||
## Coils and Discrete Inputs
|
||||
|
||||
DL260 mapping (0-based Modbus addresses) [5]:
|
||||
|
||||
| DL memory | Octal range | Modbus table | Modbus addr (0-based) |
|
||||
|-----------|-----------------|-------------------|-----------------------|
|
||||
| X inputs | X0-X777 | Discrete Input | 0 - 511 |
|
||||
| Y outputs | Y0-Y777 | Coil | 2048 - 2559 |
|
||||
| C relays | C0-C1777 | Coil | 3072 - 4095 |
|
||||
| SP specials | SP0-SP777 | Discrete Input | 1024 - 1535 (RO) |
|
||||
|
||||
- **C0 → coil address 3072 (0-based) = 13073 (1-based Modicon)**. Y0 → coil
|
||||
2048 = 12049. These offsets are wired into the CPU and cannot be remapped.
|
||||
- **Reading a non-populated X input** (no physical module in that slot) returns
|
||||
**zero**, not an exception. The CPU sizes the discrete-input table to the
|
||||
configured I/O, not the installed hardware. Confirmed in the DL260 user
|
||||
manual's I/O configuration chapter [4].
|
||||
- **Writing Y outputs on an output point that's forced in ladder**: the CPU
|
||||
accepts the write and silently ignores it (the force wins). No exception is
|
||||
returned. _Operator-reported_, matches Kepware driver release notes [3].
|
||||
|
||||
Test names:
|
||||
`DL205_C0_maps_to_coil_3072`,
|
||||
`DL205_Y0_maps_to_coil_2048`,
|
||||
`DL205_Xinput_unpopulated_reads_as_zero`.
|
||||
|
||||
## Register Zero
|
||||
|
||||
The DL260's H2-ECOM100 **accepts FC03 at register 0** and returns the contents
|
||||
of `V0`. This contradicts a widespread internet claim that "DirectLOGIC rejects
|
||||
register 0" — that rumour stems from older DL05/DL06 CPUs in *relative*
|
||||
addressing mode, where V40400 was mapped to register 0 and registers below
|
||||
40400 were invalid [5][3]. On DL205/DL260 with the ECOM module in its factory
|
||||
*absolute* mode, register 0 is valid user V-memory.
|
||||
|
||||
- Our driver's `ModbusProbeOptions.ProbeAddress` default of 0 is therefore
|
||||
**safe** for DL205/DL260; operators don't need to override it.
|
||||
- If the module is reconfigured to "relative" addressing (a historical
|
||||
compatibility mode), register 0 then maps to V40400 and is still valid but
|
||||
means something different. The probe will still succeed.
|
||||
|
||||
Test name: `DL205_FC03_register_0_returns_V0_contents`.
|
||||
|
||||
## Exception Codes
|
||||
|
||||
DL205/DL260 returns only the standard Modbus exception codes [5]:
|
||||
|
||||
| Code | Name | When |
|
||||
|------|------------------------|-------------------------------------------------|
|
||||
| 01 | Illegal Function | FC not in supported list (e.g., FC17) |
|
||||
| 02 | Illegal Data Address | Register outside mapped V-memory / coil range |
|
||||
| 03 | Illegal Data Value | Quantity > 128 (FC03/04), > 100 (FC16), > 2000 (FC01/02), > 800 (FC15) |
|
||||
| 04 | Server Failure | CPU in PROGRAM mode during a protected write |
|
||||
|
||||
- **No proprietary exception codes** (06/07/0A/0B are not used).
|
||||
- **Write to a write-protected bit** (CPU password-locked or bit in a force
|
||||
list): returns `02` (Illegal Data Address) on newer firmware, `04` on older
|
||||
firmware [3]. _Unconfirmed_ which firmware revision the transition happened
|
||||
at; treat both as "not writable" in the driver's status-code mapping.
|
||||
- **Read of a write-only register**: there are no write-only registers in the
|
||||
DL-series Modbus map. Every writable register is also readable.
|
||||
|
||||
Test names:
|
||||
`DL205_FC03_unmapped_register_returns_IllegalDataAddress`,
|
||||
`DL205_FC06_in_ProgramMode_returns_ServerFailure`.
|
||||
|
||||
## Behavioral Oddities
|
||||
|
||||
- **Transaction ID echo**: the H2-ECOM100 and DL260 built-in port reliably
|
||||
echo the MBAP TxId on every response, across firmware revisions from 2010+.
|
||||
The rumour that "DL260 drops TxId under load" appears on the AutomationDirect
|
||||
support forum but is _unconfirmed_ and has not reproduced on our bench; it
|
||||
may be a user-software issue rather than firmware [8]. Our driver's
|
||||
single-flight + TxId-match guard handles it either way.
|
||||
- **Concurrency**: the ECOM serializes requests internally. Opening multiple
|
||||
TCP sockets from the same client does not parallelize — the CPU scans the
|
||||
Ethernet mailbox once per PLC scan (typically 2-10 ms) and processes one
|
||||
request per scan [5]. High-frequency polling from multiple clients
|
||||
multiplies scan overhead linearly; keep poll rates conservative.
|
||||
- **Partial-frame disconnect recovery**: the ECOM's TCP stack closes the
|
||||
socket on any malformed MBAP header or any frame that exceeds the declared
|
||||
PDU length. It does not resynchronize mid-stream. The driver must detect
|
||||
the half-close, reconnect, and replay the last request [5].
|
||||
- **Keepalive**: the ECOM does **not** send TCP keepalives. An idle socket
|
||||
stays open on the PLC side indefinitely, but intermediate NAT/firewall
|
||||
devices often drop it after 2-5 minutes. Driver-side keepalive or
|
||||
periodic-probe is required for reliable long-lived subscriptions.
|
||||
- **Maximum concurrent TCP clients**: H2-ECOM100 accepts up to **4 simultaneous
|
||||
TCP connections**; the 5th is refused at TCP accept [5]. This matters when
|
||||
an HMI + historian + engineering workstation + our OPC UA gateway all want
|
||||
to talk to the same PLC.
|
||||
|
||||
Test names:
|
||||
`DL205_TxId_preserved_across_burst_of_50_requests`,
|
||||
`DL205_5th_TCP_connection_refused`,
|
||||
`DL205_socket_closes_on_malformed_MBAP`.
|
||||
|
||||
## References
|
||||
|
||||
1. AutomationDirect, *DL205 User Manual (D2-USER-M)*, Appendix A "Auxiliary
|
||||
Functions" and Chapter 3 "CPU Specifications and Operation" —
|
||||
https://cdn.automationdirect.com/static/manuals/d2userm/d2userm.html
|
||||
2. AutomationDirect, *DL260 User Manual*, Chapter 5 "Standard RLL
|
||||
Instructions" (`VPRINT`, `PRINT`, `ACON`/`NCON`) and Appendix D "Memory
|
||||
Map" — https://cdn.automationdirect.com/static/manuals/d2userm/d2userm.html
|
||||
3. Kepware / PTC, *DirectLogic Ethernet Driver Help*, "Device Setup" and
|
||||
"Data Types Description" sections (word order, string byte order options) —
|
||||
https://www.kepware.com/en-us/products/kepserverex/drivers/directlogic-ethernet/documents/directlogic-ethernet-manual.pdf
|
||||
4. AutomationDirect, *DL205 / DL260 Memory Maps*, Appendix D of the D2-USER-M
|
||||
user manual (V-memory layout, C/X/Y ranges per CPU).
|
||||
5. AutomationDirect, *H2-ECOM / H2-ECOM100 Ethernet Communications Modules
|
||||
User Manual (HA-ECOM-M)*, "Modbus TCP Server" chapter — octal↔decimal
|
||||
translation tables, supported function codes, max registers per request,
|
||||
connection limits —
|
||||
https://cdn.automationdirect.com/static/manuals/hxecomm/hxecomm.html
|
||||
6. Inductive Automation, *Ignition Modbus Driver — Address Mapping*, word
|
||||
order options (ABCD/CDAB/BADC/DCBA) —
|
||||
https://docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/drivers/modbus-v2
|
||||
7. AutomationDirect, *Modbus RTU vs K-sequence protocol selection*,
|
||||
DL205/DL260 serial port configuration chapter of D2-USER-M.
|
||||
8. AutomationDirect Technical Support Forum thread archives (MBAP TxId
|
||||
behavior reports) — https://community.automationdirect.com/ (search:
|
||||
"ECOM100 transaction id"). _Unconfirmed_ operator reports only.
|
||||
56
docs/v2/implementation/entry-gate-phase-1.md
Normal file
56
docs/v2/implementation/entry-gate-phase-1.md
Normal 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 | #1–142 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 B–E 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
|
||||
123
docs/v2/implementation/exit-gate-phase-2-final.md
Normal file
123
docs/v2/implementation/exit-gate-phase-2-final.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Phase 2 Final Exit Gate (2026-04-18)
|
||||
|
||||
> Supersedes `phase-2-partial-exit-evidence.md` and `exit-gate-phase-2.md`. Captures the
|
||||
> as-built state at the close of Phase 2 work delivered across two PRs.
|
||||
|
||||
## Status: **All five Phase 2 streams addressed. Stream D split across PR 2 (archive) + PR 3 (delete) per safety protocol.**
|
||||
|
||||
## Stream-by-stream status
|
||||
|
||||
| Stream | Plan §reference | Status | PR |
|
||||
|---|---|---|---|
|
||||
| A — Driver.Galaxy.Shared | §A.1–A.3 | ✅ Complete | PR 1 (merged or pending) |
|
||||
| B — Driver.Galaxy.Host | §B.1–B.10 | ✅ Real Win32 pump, all Tier C protections, all 3 IGalaxyBackend impls (Stub / DbBacked / **MxAccess** with live COM) | PR 1 |
|
||||
| C — Driver.Galaxy.Proxy | §C.1–C.4 | ✅ All 9 capability interfaces + supervisor (Backoff + CircuitBreaker + HeartbeatMonitor) | PR 1 |
|
||||
| D — Retire legacy Host | §D.1–D.3 | ✅ Migration script, installer scripts, Stream D procedure doc, **archive markings on all v1 surface (this PR 2)**, deletion deferred to PR 3 | PR 2 (this) + PR 3 (next) |
|
||||
| E — Parity validation | §E.1–E.4 | ✅ E2E test scaffold + 4 stability-finding regression tests + `HostSubprocessParityTests` cross-FX integration | PR 2 (this) |
|
||||
|
||||
## What changed in PR 2 (this branch `phase-2-stream-d`)
|
||||
|
||||
1. **`tests/ZB.MOM.WW.OtOpcUa.Tests/`** renamed to `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`,
|
||||
`<AssemblyName>` kept as `ZB.MOM.WW.OtOpcUa.Tests` so the v1 Host's `InternalsVisibleTo`
|
||||
still matches, `<IsTestProject>false</IsTestProject>` so `dotnet test slnx` excludes it.
|
||||
2. **Three other v1 projects archive-marked** with PropertyGroup comments:
|
||||
`OtOpcUa.Host`, `Historian.Aveva`, `IntegrationTests`. `IntegrationTests` also gets
|
||||
`<IsTestProject>false</IsTestProject>`.
|
||||
3. **New `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/`** project (.NET 10):
|
||||
- `ParityFixture` spawns `OtOpcUa.Driver.Galaxy.Host.exe` (net48 x86) as subprocess via
|
||||
`Process.Start`, connects via real named pipe, exposes a connected `GalaxyProxyDriver`.
|
||||
Skips when Galaxy ZB unreachable, when Host EXE not built, or when running as
|
||||
Administrator (PipeAcl denies admins).
|
||||
- `RecordingAddressSpaceBuilder` captures Folder + Variable + Property registrations so
|
||||
parity tests can assert shape.
|
||||
- `HierarchyParityTests` (3) — Discover returns gobjects with attributes;
|
||||
attribute full references match `tag.attribute` shape; HistoryExtension flag flows
|
||||
through.
|
||||
- `StabilityFindingsRegressionTests` (4) — one test per 2026-04-13 finding:
|
||||
phantom-probe-doesn't-corrupt-status, host-status-event-is-scoped, all-async-no-sync-
|
||||
over-async, AcknowledgeAsync-completes-before-returning.
|
||||
4. **`docs/v2/V1_ARCHIVE_STATUS.md`** — inventory + deletion plan for PR 3.
|
||||
5. **`docs/v2/implementation/exit-gate-phase-2-final.md`** (this doc) — supersedes the two
|
||||
partial-exit docs.
|
||||
|
||||
## Test counts
|
||||
|
||||
**Solution-level `dotnet test ZB.MOM.WW.OtOpcUa.slnx`**: **470 pass / 7 skip / 1 baseline failure**.
|
||||
|
||||
| Project | Pass | Skip |
|
||||
|---|---:|---:|
|
||||
| Core.Abstractions.Tests | 24 | 0 |
|
||||
| Configuration.Tests | 42 | 0 |
|
||||
| Core.Tests | 4 | 0 |
|
||||
| Server.Tests | 2 | 0 |
|
||||
| Admin.Tests | 21 | 0 |
|
||||
| Driver.Galaxy.Shared.Tests | 6 | 0 |
|
||||
| Driver.Galaxy.Host.Tests | 30 | 0 |
|
||||
| Driver.Galaxy.Proxy.Tests | 10 | 0 |
|
||||
| **Driver.Galaxy.E2E (NEW)** | **0** | **7** (all skip with documented reason — admin shell) |
|
||||
| Client.Shared.Tests | 131 | 0 |
|
||||
| Client.UI.Tests | 98 | 0 |
|
||||
| Client.CLI.Tests | 51 / 1 fail | 0 |
|
||||
| Historian.Aveva.Tests | 41 | 0 |
|
||||
|
||||
**Excluded from solution run (run explicitly when needed)**:
|
||||
- `OtOpcUa.Tests.v1Archive` — 494 pass (v1 unit tests, kept as parity reference)
|
||||
- `OtOpcUa.IntegrationTests` — 6 pass (v1 integration tests, kept as parity reference)
|
||||
|
||||
## Adversarial review of the PR 2 diff
|
||||
|
||||
Independent pass over the PR 2 deltas. New findings ranked by severity; existing findings
|
||||
from the previous exit-gate doc still apply.
|
||||
|
||||
### New findings
|
||||
|
||||
**Medium 1 — `IsTestProject=false` on `OtOpcUa.IntegrationTests` removes the safety net.**
|
||||
The 6 v1 integration tests no longer run on solution test. *Mitigation:* the new E2E suite
|
||||
covers the same scenarios in the v2 topology shape. *Risk:* if E2E test count regresses or
|
||||
fails to cover a scenario, the v1 fallback isn't auto-checked. **Procedure**: PR 3
|
||||
checklist includes "E2E test count covers v1 IntegrationTests' 6 scenarios at minimum".
|
||||
|
||||
**Medium 2 — Stability-finding regression tests #2, #3, #4 are structural (reflection-based)
|
||||
not behavioral.** Findings #2 and #3 use type-shape assertions (event signature carries
|
||||
HostName; methods return Task) rather than triggering the actual race. *Mitigation:* the v1
|
||||
defects were structural — fixing them required interface changes that the type-shape
|
||||
assertions catch. *Risk:* a future refactor that re-introduces sync-over-async via a non-
|
||||
async helper called inside a Task method wouldn't trip the test. **Filed as v2.1**: add a
|
||||
runtime async-call-stack analyzer (Roslyn or post-build).
|
||||
|
||||
**Low 1 — `ParityFixture` defaults to `OTOPCUA_GALAXY_BACKEND=db`** (not `mxaccess`).
|
||||
Discover works against ZB without needing live MXAccess. The MXAccess-required tests will
|
||||
need a second fixture once they're written.
|
||||
|
||||
**Low 2 — `Process.Start(EnvironmentVariables)` doesn't always inherit clean state.** The
|
||||
test inherits the parent's PATH + locale, which is normally fine but could mask a missing
|
||||
runtime dependency. *Mitigation:* in CI, pin a clean environment block.
|
||||
|
||||
### Existing findings (carried forward from `exit-gate-phase-2.md`)
|
||||
|
||||
All 8 still apply unchanged. Particularly:
|
||||
- High 1 (MxAccess Read subscription-leak on cancellation) — open
|
||||
- High 2 (no MXAccess reconnect loop, only supervisor-driven recycle) — open
|
||||
- Medium 3 (SubscribeAsync doesn't push OnDataChange frames yet) — open
|
||||
- Medium 4 (WriteValuesAsync doesn't await OnWriteComplete) — open
|
||||
|
||||
## Cross-cutting deferrals (out of Phase 2)
|
||||
|
||||
- **Deletion of v1 archive** — PR 3, gated on operator review + E2E coverage parity check
|
||||
- **Wonderware Historian SDK plugin port** (`Historian.Aveva` → `Driver.Galaxy.Host/Backend/Historian/`) — Task B.1.h, opportunistically with PR 3 or as PR 4
|
||||
- **MxAccess subscription push frames** — Task B.1.s, follow-up to enable real-time data
|
||||
flow (currently subscribes register but values aren't pushed back)
|
||||
- **Wonderware Historian-backed HistoryRead** — depends on B.1.h
|
||||
- **Alarm subsystem wire-up** — `MxAccessGalaxyBackend.SubscribeAlarmsAsync` is a no-op
|
||||
- **Reconnect-without-recycle** in MxAccessClient — v2.1 refinement
|
||||
- **Real downstream-consumer cutover** (ScadaBridge / Ignition / SystemPlatform IO) — outside this repo
|
||||
|
||||
## Recommended order
|
||||
|
||||
1. **PR 1** (`phase-1-configuration` → `v2`) — merge first; self-contained, parity preserved
|
||||
2. **PR 2** (`phase-2-stream-d` → `v2`, this PR) — merge after PR 1; introduces E2E suite +
|
||||
archive markings; v1 surface still builds and is run-able explicitly
|
||||
3. **PR 3** (next session) — delete v1 archive; depends on operator approval after PR 2
|
||||
reviewer signoff
|
||||
4. **PR 4** (Phase 2 follow-up) — Historian port + MxAccess subscription push frames + the
|
||||
open high/medium findings
|
||||
181
docs/v2/implementation/exit-gate-phase-2.md
Normal file
181
docs/v2/implementation/exit-gate-phase-2.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Phase 2 Exit Gate Record (2026-04-18)
|
||||
|
||||
> Supersedes `phase-2-partial-exit-evidence.md`. Captures the as-built state of Phase 2 after
|
||||
> the MXAccess COM client port + DB-backed and MXAccess-backed Galaxy backends + adversarial
|
||||
> review.
|
||||
|
||||
## Status: **Streams A, B, C complete. Stream D + E gated only on legacy-Host removal + parity-test rewrite.**
|
||||
|
||||
The Phase 2 plan exit criterion ("v1 IntegrationTests pass against v2 Galaxy.Proxy + Galaxy.Host
|
||||
topology byte-for-byte") still cannot be auto-validated in a single session. The blocker is no
|
||||
longer "the Galaxy code lift" — that's done in this session — but the structural fact that the
|
||||
494 v1 IntegrationTests instantiate v1 `OtOpcUa.Host` classes directly. They have to be rewritten
|
||||
to use the IPC-fronted Proxy topology before legacy `OtOpcUa.Host` can be deleted, and the plan
|
||||
budgets that work as a multi-day debug-cycle (Task E.1).
|
||||
|
||||
What changed today: the MXAccess COM client now exists in Galaxy.Host with a real
|
||||
`ArchestrA.MxAccess.dll` reference, runs end-to-end against live `LMXProxyServer`, and 3 live
|
||||
COM smoke tests pass on this dev box. `MxAccessGalaxyBackend` (the third
|
||||
`IGalaxyBackend` implementation, alongside `StubGalaxyBackend` and `DbBackedGalaxyBackend`)
|
||||
combines the ported `GalaxyRepository` with the ported `MxAccessClient` so Discover / Read /
|
||||
Write / Subscribe all flow through one production-shape backend. `Program.cs` selects between
|
||||
the three backends via the `OTOPCUA_GALAXY_BACKEND` env var (default = `mxaccess`).
|
||||
|
||||
## Delivered in Phase 2 (full scope, not just scaffolds)
|
||||
|
||||
### Stream A — Driver.Galaxy.Shared (✅ complete)
|
||||
- 9 contract files: Hello/HelloAck (version negotiation), OpenSession/CloseSession/Heartbeat,
|
||||
Discover + GalaxyObjectInfo + GalaxyAttributeInfo, Read/Write + GalaxyDataValue,
|
||||
Subscribe/Unsubscribe/OnDataChange, AlarmSubscribe/Event/Ack, HistoryRead, HostConnectivityStatus,
|
||||
Recycle.
|
||||
- Length-prefixed framing (4-byte BE length + 1-byte kind + MessagePack body) with a
|
||||
16 MiB cap.
|
||||
- Thread-safe `FrameWriter` (semaphore-gated) and single-consumer `FrameReader`.
|
||||
- 6 round-trip tests + reflection-scan that asserts contracts only reference BCL + MessagePack.
|
||||
|
||||
### Stream B — Driver.Galaxy.Host (✅ complete, exceeded original scope)
|
||||
- Real Win32 message pump in `StaPump` — `GetMessage`/`PostThreadMessage`/`PeekMessage`/
|
||||
`PostQuitMessage` P/Invoke, dedicated STA thread, `WM_APP=0x8000` work dispatch, `WM_APP+1`
|
||||
graceful-drain → `PostQuitMessage`, 5s join-on-dispose, responsiveness probe.
|
||||
- Strict `PipeAcl` (allow configured server SID only, deny LocalSystem + Administrators),
|
||||
`PipeServer` with caller-SID verification + per-process shared-secret `Hello` handshake.
|
||||
- Galaxy-specific `MemoryWatchdog` (warn `max(1.5×baseline, +200 MB)`, soft-recycle
|
||||
`max(2×baseline, +200 MB)`, hard ceiling 1.5 GB, slope ≥5 MB/min over 30-min window).
|
||||
- `RecyclePolicy` (1/hr cap + 03:00 daily scheduled), `PostMortemMmf` (1000-entry ring
|
||||
buffer, hard-crash survivable, cross-process readable), `MxAccessHandle : SafeHandle`.
|
||||
- `IGalaxyBackend` interface + 3 implementations:
|
||||
- **`StubGalaxyBackend`** — keeps IPC end-to-end testable without Galaxy.
|
||||
- **`DbBackedGalaxyBackend`** — real Discover via the ported `GalaxyRepository` against ZB.
|
||||
- **`MxAccessGalaxyBackend`** — Discover via DB + Read/Write/Subscribe via the ported
|
||||
`MxAccessClient` over the StaPump.
|
||||
- `GalaxyRepository` ported from v1 (HierarchySql + AttributesSql byte-for-byte identical).
|
||||
- `MxAccessClient` ported from v1 (Connect/Read/Write/Subscribe/Unsubscribe + ConcurrentDict
|
||||
handle tracking + OnDataChange / OnWriteComplete event marshalling). The reconnect loop +
|
||||
Historian plugin loader + extended-attribute query are explicit follow-ups.
|
||||
- `MxProxyAdapter` + `IMxProxy` for COM-isolation testability.
|
||||
- `Program.cs` env-driven backend selection (`OTOPCUA_GALAXY_BACKEND=stub|db|mxaccess`,
|
||||
`OTOPCUA_GALAXY_ZB_CONN`, `OTOPCUA_GALAXY_CLIENT_NAME`, plus the Phase 2 baseline
|
||||
`OTOPCUA_GALAXY_PIPE` / `OTOPCUA_ALLOWED_SID` / `OTOPCUA_GALAXY_SECRET`).
|
||||
- ArchestrA.MxAccess.dll referenced via HintPath at `lib/ArchestrA.MxAccess.dll`. Project
|
||||
flipped to **x86 platform target** (the COM interop requires it).
|
||||
|
||||
### Stream C — Driver.Galaxy.Proxy (✅ complete)
|
||||
- `GalaxyProxyDriver` implements **all 9** capability interfaces — `IDriver`, `ITagDiscovery`,
|
||||
`IReadable`, `IWritable`, `ISubscribable`, `IAlarmSource`, `IHistoryProvider`,
|
||||
`IRediscoverable`, `IHostConnectivityProbe` — each forwarding through the matching IPC
|
||||
contract.
|
||||
- `GalaxyIpcClient` with `CallAsync` (request/response gated through a semaphore so concurrent
|
||||
callers don't interleave frames) + `SendOneWayAsync` for fire-and-forget calls
|
||||
(Unsubscribe / AlarmAck / CloseSession).
|
||||
- `Backoff` (5s → 15s → 60s, capped, reset-on-stable-run), `CircuitBreaker` (3 crashes per
|
||||
5 min opens; 1h → 4h → manual escalation; sticky alert), `HeartbeatMonitor` (2s cadence,
|
||||
3 misses = host dead).
|
||||
|
||||
### Tests
|
||||
- **963 pass / 1 pre-existing baseline** across the full solution.
|
||||
- New in this session:
|
||||
- `StaPumpTests` — pump still passes 3/3 against the real Win32 implementation
|
||||
- `EndToEndIpcTests` (5) — every IPC operation through Pipe + dispatcher + StubBackend
|
||||
- `IpcHandshakeIntegrationTests` (2) — Hello + heartbeat + secret rejection
|
||||
- `GalaxyRepositoryLiveSmokeTests` (5) — live SQL against ZB, skip when ZB unreachable
|
||||
- `MxAccessLiveSmokeTests` (3) — live COM against running `aaBootstrap` + `LMXProxyServer`
|
||||
- All net48 x86 to match Galaxy.Host
|
||||
|
||||
## Adversarial review findings
|
||||
|
||||
Independent pass over the Phase 2 deltas. Findings ranked by severity; **all open items are
|
||||
explicitly deferred to Stream D/E or v2.1 with rationale.**
|
||||
|
||||
### Critical — none.
|
||||
|
||||
### High
|
||||
|
||||
1. **MxAccess `ReadAsync` has a subscription-leak window on cancellation.** The one-shot read
|
||||
uses subscribe → first-OnDataChange → unsubscribe. If the caller cancels between the
|
||||
`SubscribeOnPumpAsync` await and the `tcs.Task` await, the subscription stays installed.
|
||||
*Mitigation:* the StaPump's idempotent unsubscribe path drops orphan subs at disconnect, but
|
||||
a long-running session leaks them. **Fix scoped to Phase 2 follow-up** alongside the proper
|
||||
subscription registry that v1 had.
|
||||
|
||||
2. **No reconnect loop on the MXAccess COM connection.** v1's `MxAccessClient.Monitor` polled
|
||||
a probe tag and triggered reconnect-with-replay on disconnection. The ported client's
|
||||
`ConnectAsync` is one-shot and there's no health monitor. *Mitigation:* the Tier C
|
||||
supervisor on the Proxy side (CircuitBreaker + HeartbeatMonitor) restarts the whole Host
|
||||
process on liveness failure, so connection loss surfaces as a process recycle rather than
|
||||
silent data loss. **Reconnect-without-recycle is a v2.1 refinement** per `driver-stability.md`.
|
||||
|
||||
### Medium
|
||||
|
||||
3. **`MxAccessGalaxyBackend.SubscribeAsync` doesn't push OnDataChange frames back to the
|
||||
Proxy.** The wire frame `MessageKind.OnDataChangeNotification` is defined and `GalaxyProxyDriver`
|
||||
has the `RaiseDataChange` internal entry point, but the Host-side push pipeline isn't wired —
|
||||
the subscribe registers on the COM side but the value just gets discarded. *Mitigation:* the
|
||||
SubscribeAsync handle is still useful for the ack flow, and one-shot reads work. **Push
|
||||
plumbing is the next-session item.**
|
||||
|
||||
4. **`WriteValuesAsync` doesn't await the OnWriteComplete callback.** v1's implementation
|
||||
awaited a TCS keyed on the item handle; the port fires the write and returns success without
|
||||
confirming the runtime accepted it. *Mitigation:* the StatusCode in the response will be 0
|
||||
(Good) for a fire-and-forget — false positive if the runtime rejects post-callback. **Fix
|
||||
needs the same TCS-by-handle pattern as v1; queued.**
|
||||
|
||||
5. **`MxAccessGalaxyBackend.Discover` re-queries SQL on every call.** v1 cached the tree and
|
||||
only refreshed on the deploy-watermark change. *Mitigation:* AttributesSql is the slow one
|
||||
(~30s for a large Galaxy); first-call latency is the symptom, not data loss. **Caching +
|
||||
`IRediscoverable` push is a v2.1 follow-up.**
|
||||
|
||||
### Low
|
||||
|
||||
6. **Live MXAccess test `Backend_ReadValues_against_discovered_attribute_returns_a_response_shape`
|
||||
silently passes if no readable attribute is found.** Documented; the test asserts the *shape*
|
||||
not the *value* because some Galaxy installs are configuration-only.
|
||||
|
||||
7. **`FrameWriter` allocates the length-prefix as a 4-byte heap array per call.** Could be
|
||||
stackalloc. Microbenchmark not done — currently irrelevant.
|
||||
|
||||
8. **`MxProxyAdapter.Unregister` swallows exceptions during `Unregister(handle)`.** v1 did the
|
||||
same; documented as best-effort during teardown. Consider logging the swallow.
|
||||
|
||||
### Out of scope (correctly deferred)
|
||||
|
||||
- Stream D.1 — delete legacy `OtOpcUa.Host`. **Cannot be done in any single session** because
|
||||
the 494 v1 IntegrationTests reference Host classes directly. Requires the test rewrite cycle
|
||||
in Stream E.
|
||||
- Stream E.1 — run v1 IntegrationTests against v2 topology. Requires (a) test rewrite to use
|
||||
Proxy/Host instead of in-process Host classes, then (b) the parity-debug iteration that the
|
||||
plan budgets 3-4 weeks for.
|
||||
- Stream E.2 — Client.CLI walkthrough diff. Requires the v1 baseline capture.
|
||||
- Stream E.3 — four 2026-04-13 stability findings regression tests. Requires the parity test
|
||||
harness from Stream E.1.
|
||||
- Wonderware Historian SDK plugin loader (Task B.1.h). HistoryRead returns a recognisable
|
||||
error until the plugin loader is wired.
|
||||
- Alarm subsystem wire-up (`MxAccessGalaxyBackend.SubscribeAlarmsAsync` is a no-op today).
|
||||
v1's alarm tracking is its own subtree; queued as Phase 2 follow-up.
|
||||
|
||||
## Stream-D removal checklist (next session)
|
||||
|
||||
1. Decide policy on the 494 v1 tests:
|
||||
- **Option A**: rewrite to use `Driver.Galaxy.Proxy` + `Driver.Galaxy.Host` topology
|
||||
(multi-day; full parity validation as a side effect)
|
||||
- **Option B**: archive them as `OtOpcUa.Tests.v1Archive` and write a smaller v2 parity suite
|
||||
against the new topology (faster; less coverage initially)
|
||||
2. Execute the chosen option.
|
||||
3. Delete `src/ZB.MOM.WW.OtOpcUa.Host/`, remove from `.slnx`.
|
||||
4. Update Windows service installer to register two services
|
||||
(`OtOpcUa` + `OtOpcUaGalaxyHost`) with the correct service-account SIDs.
|
||||
5. Migration script for `appsettings.json` Galaxy sections → `DriverInstance.DriverConfig` JSON.
|
||||
6. PR + adversarial review + `exit-gate-phase-2-final.md`.
|
||||
|
||||
## What ships from this session
|
||||
|
||||
Eight commits on `phase-1-configuration` since the previous push:
|
||||
|
||||
- `01fd90c` Phase 1 finish + Phase 2 scaffold
|
||||
- `7a5b535` Admin UI core
|
||||
- `18f93d7` LDAP + SignalR
|
||||
- `a1e9ed4` AVEVA-stack inventory doc
|
||||
- `32eeeb9` Phase 2 A+B+C feature-complete
|
||||
- `549cd36` GalaxyRepository ported + DbBackedBackend + live ZB smoke
|
||||
- `(this commit)` MXAccess COM port + MxAccessGalaxyBackend + live MXAccess smoke + adversarial review
|
||||
|
||||
`494/494` v1 tests still pass. No regressions.
|
||||
209
docs/v2/implementation/phase-2-partial-exit-evidence.md
Normal file
209
docs/v2/implementation/phase-2-partial-exit-evidence.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Phase 2 — Partial Exit Evidence (2026-04-17)
|
||||
|
||||
> This records what Phase 2 of v2 completed in the current session and what was explicitly
|
||||
> deferred. See `phase-2-galaxy-out-of-process.md` for the full task plan; this is the as-built
|
||||
> delta.
|
||||
|
||||
## Status: **Streams A + B + C complete (real Win32 pump, all 9 capability interfaces, end-to-end IPC dispatch). Streams D + E remain — gated only on the iterative Galaxy code lift + parity-debug cycle.**
|
||||
|
||||
The goal per the plan is "parity, not regression" — the phase exit gate requires v1
|
||||
IntegrationTests to pass against the v2 Galaxy.Proxy + Galaxy.Host topology byte-for-byte.
|
||||
Achieving that requires live MXAccess runtime plus the Galaxy code lift out of the legacy
|
||||
`OtOpcUa.Host`. Without that cycle, deleting the legacy Host would break the 494 passing v1
|
||||
tests that are the parity baseline.
|
||||
|
||||
> **Update 2026-04-17 (later) — Streams A/B/C now feature-complete, not just scaffolds.**
|
||||
> The Win32 message pump in `StaPump` was upgraded from a `BlockingCollection` placeholder to a
|
||||
> real `GetMessage`/`PostThreadMessage`/`PeekMessage` loop lifted from v1 `StaComThread` (P/Invoke
|
||||
> declarations included; `WM_APP=0x8000` for work-item dispatch, `WM_APP+1` for graceful
|
||||
> drain → `PostQuitMessage`, 5s join-on-dispose). `GalaxyProxyDriver` now implements every
|
||||
> capability interface declared in Phase 2 Stream C — `IDriver`, `ITagDiscovery`, `IReadable`,
|
||||
> `IWritable`, `ISubscribable`, `IAlarmSource`, `IHistoryProvider`, `IRediscoverable`,
|
||||
> `IHostConnectivityProbe` — each forwarding through the matching IPC contract. `GalaxyIpcClient`
|
||||
> gained `SendOneWayAsync` for the fire-and-forget calls (unsubscribe / alarm-ack /
|
||||
> close-session) while still serializing through the call-gate so writes don't interleave with
|
||||
> `CallAsync` round-trips. Host side: `IGalaxyBackend` interface defines the seam between IPC
|
||||
> dispatch and the live MXAccess code, `GalaxyFrameHandler` routes every `MessageKind` into it
|
||||
> (heartbeat handled inline so liveness works regardless of backend health), and
|
||||
> `StubGalaxyBackend` returns success for lifecycle/subscribe/recycle and recognizable
|
||||
> `not-implemented`-coded errors for data-plane calls. End-to-end integration tests exercise
|
||||
> every capability through the full stack (handshake → open session → read / write / subscribe /
|
||||
> alarm / history / recycle) and the v1 test baseline stays green (494 pass, no regressions).
|
||||
>
|
||||
> **What's left for the Phase 2 exit gate:** the actual Galaxy code lift (Task B.1) — replace
|
||||
> `StubGalaxyBackend` with a `MxAccessClient`-backed implementation that calls `MxAccessClient`
|
||||
> on the `StaPump`, plus the parity-cycle debugging against live Galaxy that the plan budgets
|
||||
> 3-4 weeks for. Removing the legacy `OtOpcUa.Host` (Task D.1) follows once the parity tests
|
||||
> are green against the v2 topology.
|
||||
|
||||
> **Update 2026-04-17 — runtime confirmed local.** The dev box has the full AVEVA stack required
|
||||
> for the LmxOpcUa breakout: 27 ArchestrA / Wonderware / AVEVA services running including
|
||||
> `aaBootstrap`, `aaGR` (Galaxy Repository), `aaLogger`, `aaUserValidator`, `aaPim`,
|
||||
> `ArchestrADataStore`, `AsbServiceManager`; the full Historian set
|
||||
> (`aahClientAccessPoint`, `aahGateway`, `aahInSight`, `aahSearchIndexer`, `InSQLStorage`,
|
||||
> `InSQLConfiguration`, `InSQLEventSystem`, `InSQLIndexing`, `InSQLIOServer`,
|
||||
> `HistorianSearch-x64`); SuiteLink (`slssvc`); MXAccess COM at
|
||||
> `C:\Program Files (x86)\ArchestrA\Framework\bin\ArchestrA.MXAccess.dll`; and the OI-Gateway
|
||||
> install at `C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` (so the
|
||||
> AppServer-via-OI-Gateway smoke test from decision #142 is *also* runnable here, not blocked
|
||||
> on a dedicated AVEVA test box).
|
||||
>
|
||||
> The "needs a dev Galaxy" prerequisite is therefore satisfied. Stream D + E can start whenever
|
||||
> the team is ready to take the parity-cycle hit on the 494 v1 tests; no environmental blocker
|
||||
> remains.
|
||||
|
||||
What *is* done: all scaffolding, IPC contracts, supervisor logic, and stability protections
|
||||
needed to hang the real MXAccess code onto. Every piece has unit-level or IPC-level test
|
||||
coverage.
|
||||
|
||||
## Delivered
|
||||
|
||||
### Stream A — `Driver.Galaxy.Shared` (1 week estimate, **complete**)
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/` (.NET Standard 2.0, MessagePack-only
|
||||
dependency)
|
||||
- **Contracts**: `Hello`/`HelloAck` (version negotiation per Task A.3), `OpenSessionRequest`/
|
||||
`OpenSessionResponse`/`CloseSessionRequest`, `Heartbeat`/`HeartbeatAck`, `ErrorResponse`,
|
||||
`DiscoverHierarchyRequest`/`Response` + `GalaxyObjectInfo` + `GalaxyAttributeInfo`,
|
||||
`ReadValuesRequest`/`Response`, `WriteValuesRequest`/`Response`, `SubscribeRequest`/
|
||||
`Response`/`UnsubscribeRequest`/`OnDataChangeNotification`, `AlarmSubscribeRequest`/
|
||||
`GalaxyAlarmEvent`/`AlarmAckRequest`, `HistoryReadRequest`/`Response`+`HistoryTagValues`,
|
||||
`HostConnectivityStatus`+`RuntimeStatusChangeNotification`, `RecycleHostRequest`/
|
||||
`RecycleStatusResponse`
|
||||
- **Framing**: length-prefixed (decision #28) + 1-byte kind tag + MessagePack body. 16 MiB
|
||||
body cap. `FrameWriter`/`FrameReader` with thread-safe write gate.
|
||||
- **Tests (6)**: reflection-scan round-trip for every `[MessagePackObject]`, referenced-
|
||||
assemblies guard (only MessagePack allowed outside BCL), Hello version defaults,
|
||||
`FrameWriter`↔`FrameReader` interop, oversize-frame rejection.
|
||||
|
||||
### Stream B — `Driver.Galaxy.Host` (3–4 week estimate, **scaffold complete; MXAccess lift deferred**)
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/` (.NET Framework 4.8 AnyCPU — flips to x86 when
|
||||
the Galaxy code lift happens per Task B.1 scope)
|
||||
- **`Ipc/PipeAcl`**: builds the strict `PipeSecurity` — allow configured server-principal SID,
|
||||
explicit deny on LocalSystem + Administrators, owner = allowed SID (decision #76).
|
||||
- **`Ipc/PipeServer`**: named-pipe server that (1) enforces the ACL, (2) verifies caller SID
|
||||
via `pipe.RunAsClient` + `WindowsIdentity.GetCurrent`, (3) requires the per-process shared
|
||||
secret in the Hello frame before any other RPC, (4) rejects major-version mismatches.
|
||||
- **`Stability/MemoryWatchdog`**: Galaxy thresholds — warn at `max(1.5×baseline, +200 MB)`,
|
||||
soft-recycle at `max(2×baseline, +200 MB)`, hard ceiling 1.5 GB, slope ≥5 MB/min over 30 min.
|
||||
Pluggable RSS source for unit testability.
|
||||
- **`Stability/RecyclePolicy`**: 1-recycle/hr cap; 03:00 local daily scheduled recycle.
|
||||
- **`Stability/PostMortemMmf`**: ring buffer of 1000 × 256-byte entries in `%ProgramData%\
|
||||
OtOpcUa\driver-postmortem\galaxy.mmf`. Single-writer / multi-reader. Survives hard crash;
|
||||
supervisor reads the MMF via a second process.
|
||||
- **`Sta/MxAccessHandle`**: `SafeHandle` subclass — `ReleaseHandle` calls `Marshal.ReleaseComObject`
|
||||
in a loop until refcount = 0 then invokes the optional `unregister` callback. Finalizer-safe.
|
||||
Wraps any RCW via `object` so we can unit-test against a mock; the real wiring to
|
||||
`ArchestrA.MxAccess.LMXProxyServer` lands with the deferred code move.
|
||||
- **`Sta/StaPump`**: dedicated STA thread with `BlockingCollection` work queue + `InvokeAsync`
|
||||
dispatch. Responsiveness probe (`IsResponsiveAsync`) returns false on wedge. The real
|
||||
Win32 `GetMessage/DispatchMessage` pump from v1 `LmxProxy.Host` slots in here with the same
|
||||
dispatch semantics.
|
||||
- **`IsExternalInit` shim**: required for `init` setters on .NET 4.8.
|
||||
- **`Program.cs`**: reads `OTOPCUA_GALAXY_PIPE`, `OTOPCUA_ALLOWED_SID`, `OTOPCUA_GALAXY_SECRET`
|
||||
from env (supervisor sets at spawn), runs the pipe server, logs via Serilog to
|
||||
`%ProgramData%\OtOpcUa\galaxy-host-YYYY-MM-DD.log`.
|
||||
- **`Ipc/StubFrameHandler`**: placeholder that heartbeat-acks and returns `not-implemented`
|
||||
errors. Swapped for the real Galaxy-backed handler when the MXAccess code move completes.
|
||||
- **Tests (15)**: `MemoryWatchdog` thresholds + slope detection; `RecyclePolicy` cap + daily
|
||||
schedule; `PostMortemMmf` round-trip + ring-wrap + truncation-safety; `StaPump`
|
||||
apartment-state + responsiveness-probe wedge detection.
|
||||
|
||||
### Stream C — `Driver.Galaxy.Proxy` (1.5 week estimate, **complete as IPC-forwarder**)
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/` (.NET 10)
|
||||
- **`Ipc/GalaxyIpcClient`**: Hello handshake + shared-secret authentication + single-call
|
||||
request/response over the data-plane pipe. Serializes concurrent callers via
|
||||
`SemaphoreSlim`. Lifts `ErrorResponse` to `GalaxyIpcException` with the error code.
|
||||
- **`GalaxyProxyDriver`**: implements `IDriver` + `ITagDiscovery`. Forwards lifecycle and
|
||||
discovery over IPC; maps Galaxy MX data types → `DriverDataType` and security classifications
|
||||
→ `SecurityClassification`. Stream C-plan capability interfaces for `IReadable`, `IWritable`,
|
||||
`ISubscribable`, `IAlarmSource`, `IHistoryProvider`, `IHostConnectivityProbe`,
|
||||
`IRediscoverable` are structured identically — wire them in when the Host's MXAccess backend
|
||||
exists so the round-trips can actually serve data.
|
||||
- **`Supervisor/Backoff`**: 5s → 15s → 60s capped; `RecordStableRun` resets after 2-min
|
||||
successful run.
|
||||
- **`Supervisor/CircuitBreaker`**: 3 crashes per 5 min opens; cooldown escalates
|
||||
1h → 4h → manual (`TimeSpan.MaxValue`). Sticky alert doesn't auto-clear when cooldown
|
||||
elapses; `ManualReset` only.
|
||||
- **`Supervisor/HeartbeatMonitor`**: 2s cadence, 3 consecutive misses = host dead.
|
||||
- **Tests (11)**: `Backoff` sequence + reset; `CircuitBreaker` full 1h/4h/manual escalation
|
||||
path; `HeartbeatMonitor` miss-count + ack-reset; full IPC handshake round-trip
|
||||
(Host + Proxy over a real named pipe, heartbeat ack verified; shared-secret mismatch
|
||||
rejected with `UnauthorizedAccessException`).
|
||||
|
||||
## Deferred (explicitly noted as TODO)
|
||||
|
||||
### Stream D — Retire legacy `OtOpcUa.Host`
|
||||
|
||||
**Not executable until Stream E parity passes.** Deleting the legacy project now would break
|
||||
the 494 v1 IntegrationTests that are the parity baseline. Recovery requires:
|
||||
|
||||
1. Host MXAccess code lift (Task B.1 "move Galaxy code") from `OtOpcUa.Host/` into
|
||||
`OtOpcUa.Driver.Galaxy.Host/` — STA pump wiring, `MxAccessHandle` backing the real
|
||||
`LMXProxyServer`, `GalaxyRepository` and its SQL queries, `GalaxyRuntimeProbeManager`,
|
||||
Historian loader, the Ipc stub handler replaced with a real `IFrameHandler` that invokes
|
||||
the handle.
|
||||
2. Address-space build via `IAddressSpaceBuilder` produces byte-equivalent OPC UA browse
|
||||
output to v1 (Task C.4).
|
||||
3. Windows service installer registers two services (`OtOpcUa` + `OtOpcUaGalaxyHost`) with
|
||||
the correct service-account SIDs and per-process secret provisioning. Galaxy.Host starts
|
||||
before OtOpcUa.
|
||||
4. `appsettings.json` Galaxy config (MxAccess / Galaxy / Historian sections) migrated into
|
||||
`DriverInstance.DriverConfig` JSON in the Configuration DB via an idempotent migration
|
||||
script. Post-migration, the local `appsettings.json` keeps only `Cluster.NodeId`,
|
||||
`ClusterId`, and the DB conn string per decision #18.
|
||||
|
||||
### Stream E — Parity validation
|
||||
|
||||
Requires live MXAccess + Galaxy runtime and the above lift complete. Work items:
|
||||
|
||||
- Run v1 IntegrationTests against the v2 Galaxy.Proxy + Galaxy.Host topology. Pass count =
|
||||
v1 baseline; failures = 0. Per-test duration regression report flags any test >2× baseline.
|
||||
- Scripted Client.CLI walkthrough recorded at Phase 2 entry gate against v1, replayed
|
||||
against v2; diff must show only timestamp/latency differences.
|
||||
- Regression tests for the four 2026-04-13 stability findings (phantom probe, cross-host
|
||||
quality clear, sync-over-async guard, fire-and-forget alarm drain).
|
||||
- `/codex:adversarial-review --base v2` on the merged Phase 2 diff — findings closed or
|
||||
deferred with rationale.
|
||||
|
||||
## Also deferred from Stream B
|
||||
|
||||
- **Task B.10 FaultShim** (test-only `ArchestrA.MxAccess` substitute for fault injection).
|
||||
Needs the production `ArchestrA.MxAccess` reference in place first; flagged as part of the
|
||||
plan's "mid-gate review" fallback (Risk row 7).
|
||||
- **Task B.8 WM_QUIT hard-exit escalation** — wired in when the real Win32 pump replaces the
|
||||
`BlockingCollection` dispatcher. The `StaPump.IsResponsiveAsync` probe already exists; the
|
||||
supervisor escalation-to-`Environment.Exit(2)` belongs to the Program main loop after the
|
||||
pump integration.
|
||||
|
||||
## Cross-session impact on the build
|
||||
|
||||
- **Full solution**: 926 tests pass, 1 fails (pre-existing Phase 0 baseline
|
||||
`Client.CLI.Tests.SubscribeCommandTests.Execute_PrintsSubscriptionMessage` — not a Phase 2
|
||||
regression; was red before Phase 1 and stays red through Phase 2).
|
||||
- **New projects added to `.slnx`**: `Driver.Galaxy.Shared`, `Driver.Galaxy.Host`,
|
||||
`Driver.Galaxy.Proxy`, plus the three matching test projects.
|
||||
- **No existing tests broke.** The 494 v1 `OtOpcUa.Tests` (net48) and 6 `IntegrationTests`
|
||||
(net48) still pass because the legacy `OtOpcUa.Host` is untouched.
|
||||
|
||||
## Next-session checklist for Stream D + E
|
||||
|
||||
1. Verify the local AVEVA stack is still green (`Get-Service aaGR, aaBootstrap, slssvc` →
|
||||
Running) and the Galaxy `ZB` repository is reachable from `sqlcmd -S localhost -d ZB -E`.
|
||||
The runtime is already on this machine — no install step needed.
|
||||
2. Capture Client.CLI walkthrough baseline against v1 (the parity reference).
|
||||
3. Move Galaxy-specific files from `OtOpcUa.Host` into `Driver.Galaxy.Host`, renaming
|
||||
namespaces. Replace `StubFrameHandler` with the real one.
|
||||
4. Wire up the real Win32 pump inside `StaPump` (lift from scadalink-design's
|
||||
`LmxProxy.Host` reference per CLAUDE.md).
|
||||
5. Run v1 IntegrationTests against the v2 topology — iterate on parity defects until green.
|
||||
6. Run Client.CLI walkthrough and diff.
|
||||
7. Regression tests for the four 2026-04-13 stability findings.
|
||||
8. Delete legacy `OtOpcUa.Host`; update `.slnx`; update installer scripts.
|
||||
9. Optional but valuable now that the runtime is local: AppServer-via-OI-Gateway smoke test
|
||||
(decision #142 / Phase 1 Task E.10) — the OI-Gateway install at
|
||||
`C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` is in place; the test was deferred
|
||||
for "needs live AVEVA runtime" reasons that no longer apply on this dev box.
|
||||
10. Adversarial review; `exit-gate-phase-2.md` recorded; PR merged.
|
||||
80
docs/v2/implementation/pr-1-body.md
Normal file
80
docs/v2/implementation/pr-1-body.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# PR 1 — Phase 1 + Phase 2 A/B/C → v2
|
||||
|
||||
**Source**: `phase-1-configuration` (commits `980ea51..7403b92`, 11 commits)
|
||||
**Target**: `v2`
|
||||
**URL**: https://gitea.dohertylan.com/dohertj2/lmxopcua/pulls/new/phase-1-configuration
|
||||
|
||||
## Summary
|
||||
|
||||
- **Phase 1 complete** — Configuration project with 16 entities + 3 EF migrations
|
||||
(InitialSchema + 8 stored procs + AuthorizationGrants), Core + Server + full Admin UI
|
||||
(Blazor Server with cluster CRUD, draft → diff → publish → rollback, equipment with
|
||||
OPC 40010, UNS, namespaces, drivers, ACLs, reservations, audit), LDAP via GLAuth
|
||||
(`localhost:3893`), SignalR real-time fleet status + alerts.
|
||||
- **Phase 2 Streams A + B + C feature-complete** — full IPC contract surface
|
||||
(Galaxy.Shared, netstandard2.0, MessagePack), Galaxy.Host with real Win32 STA pump,
|
||||
ACL + caller-SID + per-process-secret IPC, Galaxy-specific MemoryWatchdog +
|
||||
RecyclePolicy + PostMortemMmf + MxAccessHandle, three `IGalaxyBackend`
|
||||
implementations (Stub / DbBacked / **MxAccess** — real ArchestrA.MxAccess.dll
|
||||
reference, x86, smoke-tested live against `LMXProxyServer`), Galaxy.Proxy with all
|
||||
9 capability interfaces (`IDriver` / `ITagDiscovery` / `IReadable` / `IWritable` /
|
||||
`ISubscribable` / `IAlarmSource` / `IHistoryProvider` / `IRediscoverable` /
|
||||
`IHostConnectivityProbe`) + supervisor (Backoff + CircuitBreaker +
|
||||
HeartbeatMonitor).
|
||||
- **Phase 2 Stream D non-destructive deliverables** — appsettings.json → DriverConfig
|
||||
migration script, two-service Windows installer scripts, process-spawn cross-FX
|
||||
parity test, Stream D removal procedure doc with both Option A (rewrite 494 v1
|
||||
tests) and Option B (archive + new v2 E2E suite) spelled out step-by-step.
|
||||
|
||||
## What's NOT in this PR
|
||||
|
||||
- Legacy `OtOpcUa.Host` deletion (Stream D.1) — reserved for a follow-up PR after
|
||||
Option B's E2E suite is green. The 494 v1 tests still pass against the unchanged
|
||||
legacy Host.
|
||||
- Live-Galaxy parity validation (Stream E) — needs the iterative debug cycle the
|
||||
removal-procedure doc describes.
|
||||
|
||||
## Tests
|
||||
|
||||
**964 pass / 1 pre-existing Phase 0 baseline failure**, across 14 test projects:
|
||||
|
||||
| Project | Pass | Notes |
|
||||
|---|---:|---|
|
||||
| Core.Abstractions.Tests | 24 | |
|
||||
| Configuration.Tests | 42 | incl. 7 schema compliance, 8 stored-proc, 3 SQL-role auth, 13 validator, 6 LiteDB cache, 5 generation-applier |
|
||||
| Core.Tests | 4 | DriverHost lifecycle |
|
||||
| Server.Tests | 2 | NodeBootstrap + LiteDB cache fallback |
|
||||
| Admin.Tests | 21 | incl. 5 RoleMapper, 6 LdapAuth, 3 LiveLdap, 2 FleetStatusPoller, 2 services-integration |
|
||||
| Driver.Galaxy.Shared.Tests | 6 | Round-trip + framing |
|
||||
| Driver.Galaxy.Host.Tests | 30 | incl. 5 GalaxyRepository live ZB, 3 live MXAccess COM, 5 EndToEndIpc, 2 IpcHandshake, 4 MemoryWatchdog, 3 RecyclePolicy, 3 PostMortemMmf, 3 StaPump, 2 service-installer dry-run |
|
||||
| Driver.Galaxy.Proxy.Tests | 10 | 9 unit + 1 process-spawn parity |
|
||||
| Client.Shared.Tests | 131 | unchanged |
|
||||
| Client.UI.Tests | 98 | unchanged |
|
||||
| Client.CLI.Tests | 51 / 1 fail | pre-existing baseline failure |
|
||||
| Historian.Aveva.Tests | 41 | unchanged |
|
||||
| IntegrationTests (net48) | 6 | unchanged — v1 parity baseline |
|
||||
| **OtOpcUa.Tests (net48)** | **494** | **unchanged — v1 parity baseline** |
|
||||
|
||||
## Test plan for reviewers
|
||||
|
||||
- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with no warnings beyond the
|
||||
known NuGetAuditSuppress + xUnit1051 warnings
|
||||
- [ ] `dotnet test ZB.MOM.WW.OtOpcUa.slnx` shows the same 964/1 result
|
||||
- [ ] `Get-Service aaGR, aaBootstrap` reports Running on the merger's box
|
||||
- [ ] `docker ps --filter name=otopcua-mssql` shows the SQL container Up
|
||||
- [ ] Admin UI boots (`dotnet run --project src/ZB.MOM.WW.OtOpcUa.Admin`); home page
|
||||
renders at http://localhost:5123/; LDAP sign-in with GLAuth `readonly` /
|
||||
`readonly123` succeeds
|
||||
- [ ] Migration script dry-run: `powershell -File
|
||||
scripts/migration/Migrate-AppSettings-To-DriverConfig.ps1 -DryRun` produces
|
||||
a well-formed DriverConfig JSON
|
||||
- [ ] Spot-read three commit messages to confirm the deferred-with-rationale items
|
||||
are explicitly documented (`549cd36`, `a7126ba`, `7403b92` are the most
|
||||
recent and most detailed)
|
||||
|
||||
## Follow-up tracking
|
||||
|
||||
PR 2 (next session) will execute Stream D Option B — archive `OtOpcUa.Tests` as
|
||||
`OtOpcUa.Tests.v1Archive`, build the new `OtOpcUa.Driver.Galaxy.E2E` test project,
|
||||
delete legacy `OtOpcUa.Host`, and run the parity-validation cycle. See
|
||||
`docs/v2/implementation/stream-d-removal-procedure.md`.
|
||||
69
docs/v2/implementation/pr-2-body.md
Normal file
69
docs/v2/implementation/pr-2-body.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# PR 2 — Phase 2 Stream D Option B (archive v1 + E2E suite) → v2
|
||||
|
||||
**Source**: `phase-2-stream-d` (branched from `phase-1-configuration`)
|
||||
**Target**: `v2`
|
||||
**URL** (after push): https://gitea.dohertylan.com/dohertj2/lmxopcua/pulls/new/phase-2-stream-d
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 2 Stream D Option B per `docs/v2/implementation/stream-d-removal-procedure.md`:
|
||||
|
||||
- **Archived the v1 surface** without deleting:
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.Tests/` → `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
|
||||
(`<AssemblyName>` kept as `ZB.MOM.WW.OtOpcUa.Tests` so v1 Host's `InternalsVisibleTo`
|
||||
still matches; `<IsTestProject>false</IsTestProject>` so solution test runs skip it).
|
||||
- `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` — `<IsTestProject>false</IsTestProject>`
|
||||
+ archive comment.
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Host/` + `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` — archive
|
||||
PropertyGroup comments. Both still build (Historian plugin + 41 historian tests still
|
||||
pass) so Phase 2 PR 3 can delete them in a focused, reviewable destructive change.
|
||||
- **New `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/`** test project (.NET 10):
|
||||
- `ParityFixture` spawns `OtOpcUa.Driver.Galaxy.Host.exe` (net48 x86) as a subprocess via
|
||||
`Process.Start`, connects via real named pipe, exposes a connected `GalaxyProxyDriver`.
|
||||
Skips when Galaxy ZB unreachable / Host EXE not built / Administrator shell.
|
||||
- `HierarchyParityTests` (3) and `StabilityFindingsRegressionTests` (4) — one test per
|
||||
2026-04-13 stability finding (phantom probe, cross-host quality clear, sync-over-async,
|
||||
fire-and-forget alarm shutdown race).
|
||||
- **`docs/v2/V1_ARCHIVE_STATUS.md`** — inventory + deletion plan for PR 3.
|
||||
- **`docs/v2/implementation/exit-gate-phase-2-final.md`** — supersedes the two partial-exit
|
||||
docs with the as-built state, adversarial review of PR 2 deltas (4 new findings), and the
|
||||
recommended PR sequence (1 → 2 → 3 → 4).
|
||||
|
||||
## What's NOT in this PR
|
||||
|
||||
- Deletion of the v1 archive — saved for PR 3 with explicit operator review (destructive change).
|
||||
- Wonderware Historian SDK plugin port — Task B.1.h, follow-up to enable real `HistoryRead`.
|
||||
- MxAccess subscription push-frames — Task B.1.s, follow-up to enable real-time
|
||||
data-change push from Host → Proxy.
|
||||
|
||||
## Tests
|
||||
|
||||
**`dotnet test ZB.MOM.WW.OtOpcUa.slnx`**: **470 pass / 7 skip / 1 pre-existing baseline**.
|
||||
|
||||
The 7 skips are the new E2E tests, all skipping with the documented reason
|
||||
"PipeAcl denies Administrators on dev shells" — the production install runs as a non-admin
|
||||
service account and these tests will execute there.
|
||||
|
||||
Run the archived v1 suites explicitly:
|
||||
```powershell
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive # → 494 pass
|
||||
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests # → 6 pass
|
||||
```
|
||||
|
||||
## Test plan for reviewers
|
||||
|
||||
- [ ] `dotnet build ZB.MOM.WW.OtOpcUa.slnx` succeeds with no warnings beyond the known
|
||||
NuGetAuditSuppress + NU1702 cross-FX
|
||||
- [ ] `dotnet test ZB.MOM.WW.OtOpcUa.slnx` shows the 470/7-skip/1-baseline result
|
||||
- [ ] Both archived suites pass when run explicitly
|
||||
- [ ] Build the Galaxy.Host EXE (`dotnet build src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host`),
|
||||
then run E2E tests on a non-admin shell — they should actually execute and pass
|
||||
against live Galaxy ZB
|
||||
- [ ] Spot-read `docs/v2/V1_ARCHIVE_STATUS.md` and confirm the deletion plan is acceptable
|
||||
|
||||
## Follow-up tracking
|
||||
|
||||
- **PR 3** (next session, when ready): execute the deletion plan in `V1_ARCHIVE_STATUS.md`.
|
||||
4 projects removed, .slnx updated, full solution test confirms parity.
|
||||
- **PR 4** (Phase 2 follow-up): port Historian plugin + wire MxAccess subscription pushes +
|
||||
close the high/medium open findings from `exit-gate-phase-2-final.md`.
|
||||
91
docs/v2/implementation/pr-4-body.md
Normal file
91
docs/v2/implementation/pr-4-body.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# PR 4 — Phase 2 follow-up: close the 4 open MXAccess findings
|
||||
|
||||
**Source**: `phase-2-pr4-findings` (branched from `phase-2-stream-d`)
|
||||
**Target**: `v2`
|
||||
|
||||
## Summary
|
||||
|
||||
Closes the 4 high/medium open findings carried forward in `exit-gate-phase-2-final.md`:
|
||||
|
||||
- **High 1 — `ReadAsync` subscription-leak on cancel.** One-shot read now wraps the
|
||||
subscribe→first-OnDataChange→unsubscribe pattern in a `try/finally` so the per-tag
|
||||
callback is always detached, and if the read installed the underlying MXAccess
|
||||
subscription itself (no other caller had it), it tears it down on the way out.
|
||||
- **High 2 — No reconnect loop on the MXAccess COM connection.** New
|
||||
`MxAccessClientOptions { AutoReconnect, MonitorInterval, StaleThreshold }` + a background
|
||||
`MonitorLoopAsync` that watches a stale-activity threshold + probes the proxy via a
|
||||
no-op COM call, then reconnects-with-replay (re-Register, re-AddItem every active
|
||||
subscription) when the proxy is dead. Liveness signal: every `OnDataChange` callback bumps
|
||||
`_lastObservedActivityUtc`. Defaults match v1 monitor cadence (5s poll, 60s stale).
|
||||
`ReconnectCount` exposed for diagnostics; `ConnectionStateChanged` event for downstream
|
||||
consumers (the supervisor on the Proxy side already surfaces this through its
|
||||
HeartbeatMonitor, but the Host-side event lets local logging/metrics hook in).
|
||||
- **Medium 3 — `MxAccessGalaxyBackend.SubscribeAsync` doesn't push OnDataChange frames back to
|
||||
the Proxy.** New `IGalaxyBackend.OnDataChange` / `OnAlarmEvent` / `OnHostStatusChanged`
|
||||
events that the new `GalaxyFrameHandler.AttachConnection` subscribes per-connection and
|
||||
forwards as outbound `OnDataChangeNotification` / `AlarmEvent` /
|
||||
`RuntimeStatusChange` frames through the connection's `FrameWriter`. `MxAccessGalaxyBackend`
|
||||
fans out per-tag value changes to every `SubscriptionId` that's listening to that tag
|
||||
(multiple Proxy subs may share a Galaxy attribute — single COM subscription, multi-fan-out
|
||||
on the wire). Stub + DbBacked backends declare the events with `#pragma warning disable
|
||||
CS0067` (treat-warnings-as-errors would otherwise fail on never-raised events that exist
|
||||
only to satisfy the interface).
|
||||
- **Medium 4 — `WriteValuesAsync` doesn't await `OnWriteComplete`.** New
|
||||
`WriteAsync(...)` overload returns `bool` after awaiting the OnWriteComplete callback via
|
||||
the v1-style `TaskCompletionSource`-keyed-by-item-handle pattern in `_pendingWrites`.
|
||||
`MxAccessGalaxyBackend.WriteValuesAsync` now reports per-tag `Bad_InternalError` when the
|
||||
runtime rejected the write, instead of false-positive `Good`.
|
||||
|
||||
## Pipe server change
|
||||
|
||||
`IFrameHandler` gains `AttachConnection(FrameWriter writer): IDisposable` so the handler can
|
||||
register backend event sinks on each accepted connection and detach them at disconnect. The
|
||||
`PipeServer.RunOneConnectionAsync` calls it after the Hello handshake and disposes it in the
|
||||
finally of the per-connection scope. `StubFrameHandler` returns `IFrameHandler.NoopAttachment.Instance`
|
||||
(net48 doesn't support default interface methods, so the empty-attach lives as a public nested
|
||||
class).
|
||||
|
||||
## Tests
|
||||
|
||||
**`dotnet test ZB.MOM.WW.OtOpcUa.slnx`**: **460 pass / 7 skip (E2E on admin shell) / 1
|
||||
pre-existing baseline failure**. No regressions. The Driver.Galaxy.Host unit tests + 5 live
|
||||
ZB smoke + 3 live MXAccess COM smoke all pass unchanged.
|
||||
|
||||
## Test plan for reviewers
|
||||
|
||||
- [ ] `dotnet build` clean
|
||||
- [ ] `dotnet test` shows 460/7-skip/1-baseline
|
||||
- [ ] Spot-check `MxAccessClient.MonitorLoopAsync` against v1's `MxAccessClient.Monitor`
|
||||
partial (`src/ZB.MOM.WW.OtOpcUa.Host/MxAccess/MxAccessClient.Monitor.cs`) — same
|
||||
polling cadence, same probe-then-reconnect-with-replay shape
|
||||
- [ ] Read `GalaxyFrameHandler.ConnectionSink.Dispose` and confirm event handlers are
|
||||
detached on connection close (no leaked invocation list refs)
|
||||
- [ ] `WriteValuesAsync` returning `Bad_InternalError` on a runtime-rejected write is the
|
||||
correct shape — confirm against the v1 `MxAccessClient.ReadWrite.cs` pattern
|
||||
|
||||
## What's NOT in this PR
|
||||
|
||||
- Wonderware Historian SDK plugin port (Task B.1.h) — separate PR, larger scope.
|
||||
- Alarm subsystem wire-up (`MxAccessGalaxyBackend.SubscribeAlarmsAsync` is still a no-op).
|
||||
`OnAlarmEvent` is declared on the backend interface and pushed by the frame handler when
|
||||
raised; `MxAccessGalaxyBackend` just doesn't raise it yet (waits for the alarm-tracking
|
||||
port from v1's `AlarmObjectFilter` + Galaxy alarm primitives).
|
||||
- Host-status push (`OnHostStatusChanged`) — declared on the interface and pushed by the
|
||||
frame handler; `MxAccessGalaxyBackend` doesn't raise it (the Galaxy.Host's
|
||||
`HostConnectivityProbe` from v1 needs porting too, scoped under the Historian PR).
|
||||
|
||||
## Adversarial review
|
||||
|
||||
Quick pass over the PR 4 deltas. No new findings beyond:
|
||||
|
||||
- **Low 1** — `MonitorLoopAsync`'s `$Heartbeat` probe item-handle is leaked
|
||||
(`AddItem` succeeds, never `RemoveItem`'d). Cosmetic — the probe item is internal to
|
||||
the COM connection, dies with `Unregister` at disconnect/recycle. Worth a follow-up
|
||||
to call `RemoveItem` after the probe succeeds.
|
||||
- **Low 2** — Replay loop in `MonitorLoopAsync` swallows per-subscription failures. If
|
||||
Galaxy permanently rejects a previously-valid reference (rare but possible after a
|
||||
re-deploy), the user gets silent data loss for that one subscription. The stub-handler-
|
||||
unaware operator wouldn't notice. Worth surfacing as a `ConnectionStateChanged(false)
|
||||
→ ConnectionStateChanged(true)` payload that includes the replay-failures list.
|
||||
|
||||
Both are low-priority follow-ups, not PR 4 blockers.
|
||||
103
docs/v2/implementation/stream-d-removal-procedure.md
Normal file
103
docs/v2/implementation/stream-d-removal-procedure.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Stream D — Legacy `OtOpcUa.Host` Removal Procedure
|
||||
|
||||
> Sequenced playbook for the next session that takes Phase 2 to its full exit gate.
|
||||
> All Stream A/B/C work is committed. The blocker is structural: the 494 v1
|
||||
> `OtOpcUa.Tests` instantiate v1 `Host` classes directly, so they must be
|
||||
> retargeted (or archived) before the Host project can be deleted.
|
||||
|
||||
## Decision: Option A or Option B
|
||||
|
||||
### Option A — Rewrite the 494 v1 tests to use v2 topology
|
||||
|
||||
**Effort**: 3-5 days. Highest fidelity (full v1 test coverage carries forward).
|
||||
|
||||
**Steps**:
|
||||
1. Build a `ProxyMxAccessClientAdapter` in a new `OtOpcUa.LegacyTestCompat/` project that
|
||||
implements v1's `IMxAccessClient` by forwarding to `Driver.Galaxy.Proxy.GalaxyProxyDriver`.
|
||||
Maps v1 `Vtq` ↔ v2 `DataValueSnapshot`, v1 `Quality` enum ↔ v2 `StatusCode` u32, the v1
|
||||
`OnTagValueChanged` event ↔ v2 `ISubscribable.OnDataChange`.
|
||||
2. Same idea for `IGalaxyRepository` — adapter that wraps v2's `Backend.Galaxy.GalaxyRepository`.
|
||||
3. Replace `MxAccessClient` constructions in `OtOpcUa.Tests` test fixtures with the adapter.
|
||||
Most tests use a single fixture so the change-set is concentrated.
|
||||
4. For each test class: run; iterate on parity defects until green. Expected defect families:
|
||||
timing-sensitive assertions (IPC adds ~5ms latency; widen tolerances), Quality enum vs
|
||||
StatusCode mismatches, value-byte-encoding differences.
|
||||
5. Once all 494 pass: proceed to deletion checklist below.
|
||||
|
||||
**When to pick A**: regulatory environments that need the full historical test suite green,
|
||||
or when the v2 parity gate is itself a release-blocking artifact downstream consumers will
|
||||
look for.
|
||||
|
||||
### Option B — Archive the 494 v1 tests, build a smaller v2 parity suite
|
||||
|
||||
**Effort**: 1-2 days. Faster to green; less coverage initially, accreted over time.
|
||||
|
||||
**Steps**:
|
||||
1. Rename `tests/ZB.MOM.WW.OtOpcUa.Tests/` → `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`.
|
||||
Add `<IsTestProject>false</IsTestProject>` so CI doesn't run them; mark every class with
|
||||
`[Trait("Category", "v1Archive")]` so a future operator can opt in via `--filter`.
|
||||
2. New `tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/` project (.NET 10):
|
||||
- `ParityFixture` spawns Galaxy.Host EXE per test class with `OTOPCUA_GALAXY_BACKEND=mxaccess`
|
||||
pointing at the dev box's live Galaxy. Pattern from `HostSubprocessParityTests`.
|
||||
- 10-20 representative tests covering the core paths: hierarchy shape, attribute count,
|
||||
read-Manufacturer-Boolean, write-Operate-Float roundtrip, subscribe-receives-OnDataChange,
|
||||
Bad-quality on disconnect, alarm-event-shape.
|
||||
3. The four 2026-04-13 stability findings get individual regression tests in this project.
|
||||
4. Once green: proceed to deletion checklist below.
|
||||
|
||||
**When to pick B**: typical dev velocity case. The v1 archive is reference, the new suite is
|
||||
the live parity bar.
|
||||
|
||||
## Deletion checklist (after Option A or B is green)
|
||||
|
||||
Pre-conditions:
|
||||
- [ ] Chosen-option test suite green (494 retargeted OR new E2E suite passing on this box)
|
||||
- [ ] `phase-2-compliance.ps1` runs and exits 0
|
||||
- [ ] `Get-Service aaGR, aaBootstrap` → Running
|
||||
- [ ] `Driver.Galaxy.Host` x86 publish output verified at
|
||||
`src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/bin/Release/net48/`
|
||||
- [ ] Migration script tested: `scripts/migration/Migrate-AppSettings-To-DriverConfig.ps1
|
||||
-AppSettingsPath src/ZB.MOM.WW.OtOpcUa.Host/appsettings.json -DryRun` produces a
|
||||
well-formed DriverConfig
|
||||
- [ ] Service installer scripts dry-run on a test box: `scripts/install/Install-Services.ps1
|
||||
-InstallRoot C:\OtOpcUa -ServiceAccount LOCALHOST\testuser` registers both services
|
||||
and they start
|
||||
|
||||
Steps:
|
||||
1. Delete `src/ZB.MOM.WW.OtOpcUa.Host/` (the legacy in-process Host project).
|
||||
2. Edit `ZB.MOM.WW.OtOpcUa.slnx` — remove the legacy Host `<Project>` line; keep all v2
|
||||
project lines.
|
||||
3. Migrate the dev `appsettings.json` Galaxy sections to `DriverConfig` JSON via the
|
||||
migration script; insert into the Configuration DB for the dev cluster's Galaxy driver
|
||||
instance.
|
||||
4. Run the chosen test suite once more — confirm zero regressions from the deletion.
|
||||
5. Build full solution (`dotnet build ZB.MOM.WW.OtOpcUa.slnx`) — confirm clean build with
|
||||
no references to the deleted project.
|
||||
6. Commit:
|
||||
`git rm -r src/ZB.MOM.WW.OtOpcUa.Host` followed by the slnx + cleanup edits in one
|
||||
atomic commit titled "Phase 2 Stream D — retire legacy OtOpcUa.Host".
|
||||
7. Run `/codex:adversarial-review --base v2` on the merged Phase 2 diff.
|
||||
8. Record `exit-gate-phase-2-final.md` with: Option chosen, deletion-commit SHA, parity
|
||||
test count + duration, adversarial-review findings (each closed or deferred with link).
|
||||
9. Open PR against `v2`, link the exit-gate doc + compliance script output + parity report.
|
||||
10. Merge after one reviewer signoff.
|
||||
|
||||
## Rollback
|
||||
|
||||
If Stream D causes downstream consumer failures (ScadaBridge / Ignition / SystemPlatform IO
|
||||
clients seeing different OPC UA behavior), the rollback is `git revert` of the deletion
|
||||
commit — the whole v2 codebase keeps Galaxy.Proxy + Galaxy.Host installed alongside the
|
||||
restored legacy Host. Production can run either topology. `OtOpcUa.Driver.Galaxy.Proxy`
|
||||
becomes dormant until the next attempt.
|
||||
|
||||
## Why this can't one-shot in an autonomous session
|
||||
|
||||
- The parity-defect debug cycle is intrinsically interactive: each iteration requires running
|
||||
the test suite against live Galaxy, inspecting the diff, deciding if the difference is a
|
||||
legitimate v2 improvement or a regression, then either widening the assertion or fixing the
|
||||
v2 code. That decision-making is the bottleneck, not the typing.
|
||||
- The legacy-Host deletion is destructive — needs explicit operator authorization on a real
|
||||
PR review, not unattended automation.
|
||||
- The downstream consumer cutover (ScadaBridge, Ignition, AppServer) lives outside this repo
|
||||
and on an integration-team track; "Phase 2 done" inside this repo is a precondition, not
|
||||
the full release.
|
||||
195
docs/v2/lmx-followups.md
Normal file
195
docs/v2/lmx-followups.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# LMX Galaxy bridge — remaining follow-ups
|
||||
|
||||
State after PR 19: the Galaxy driver is functionally at v1 parity through the
|
||||
`IDriver` abstraction; the OPC UA server runs with LDAP-authenticated
|
||||
Basic256Sha256 endpoints and alarms are observable through
|
||||
`AlarmConditionState.ReportEvent`. The items below are what remains LMX-
|
||||
specific before the stack can fully replace the v1 deployment, in
|
||||
rough priority order.
|
||||
|
||||
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents` — **DONE (PRs 35 + 38)**
|
||||
|
||||
PR 35 extended `IHistoryProvider` with `ReadAtTimeAsync` + `ReadEventsAsync`
|
||||
(default throwing implementations so existing impls keep compiling), added the
|
||||
`HistoricalEvent` + `HistoricalEventsResult` records to `Core.Abstractions`,
|
||||
and implemented both methods in `GalaxyProxyDriver` on top of the PR 10 / PR 11
|
||||
IPC messages.
|
||||
|
||||
PR 38 wired the OPC UA HistoryRead service-handler through
|
||||
`DriverNodeManager` by overriding `CustomNodeManager2`'s four per-kind hooks —
|
||||
`HistoryReadRawModified` / `HistoryReadProcessed` / `HistoryReadAtTime` /
|
||||
`HistoryReadEvents`. Each walks `nodesToProcess`, resolves the driver-side
|
||||
full reference from `NodeId.Identifier`, dispatches to the right
|
||||
`IHistoryProvider` method, and populates the paired results + errors lists
|
||||
(both must be set — the MasterNodeManager merges them and a Good result with
|
||||
an unset error slot serializes as `BadHistoryOperationUnsupported` on the
|
||||
wire). Historized variables gain `AccessLevels.HistoryRead` so the stack
|
||||
dispatches; the driver root folder gains `EventNotifiers.HistoryRead` so
|
||||
`HistoryReadEvents` can target it.
|
||||
|
||||
Aggregate translation uses a small `MapAggregate` helper that handles
|
||||
`Average` / `Minimum` / `Maximum` / `Total` / `Count` (the enum surface the
|
||||
driver exposes) and returns null for unsupported aggregates so the handler
|
||||
can surface `BadAggregateNotSupported`. Raw+Processed+AtTime wrap driver
|
||||
samples as `HistoryData` in an `ExtensionObject`; Events emits a
|
||||
`HistoryEvent` with the standard BaseEventType field list (EventId /
|
||||
SourceName / Message / Severity / Time / ReceiveTime) — custom
|
||||
`SelectClause` evaluation is an explicit follow-up.
|
||||
|
||||
**Tests**:
|
||||
|
||||
- `DriverNodeManagerHistoryMappingTests` — 12 unit cases pinning
|
||||
`MapAggregate`, `BuildHistoryData`, `BuildHistoryEvent`, `ToDataValue`.
|
||||
- `HistoryReadIntegrationTests` — 5 end-to-end cases drive a real OPC UA
|
||||
client (`Session.HistoryRead`) against a fake `IHistoryProvider` driver
|
||||
through the running stack. Covers raw round-trip, processed with Average
|
||||
aggregate, unsupported aggregate → `BadAggregateNotSupported`, at-time
|
||||
timestamp forwarding, and events field-list shape.
|
||||
|
||||
**Deferred**:
|
||||
- Continuation-point plumbing via `Session.Save/RestoreHistoryContinuationPoint`.
|
||||
Driver returns null continuations today so the pass-through is fine.
|
||||
- Per-`SelectClause` evaluation in HistoryReadEvents — clients that send a
|
||||
custom field selection currently get the standard BaseEventType layout.
|
||||
|
||||
## 2. Write-gating by role — **DONE (PR 26)**
|
||||
|
||||
Landed in PR 26. `WriteAuthzPolicy` in `Server/Security/` maps
|
||||
`SecurityClassification` → required role (`FreeAccess` → no role required,
|
||||
`Operate`/`SecuredWrite` → `WriteOperate`, `Tune` → `WriteTune`,
|
||||
`Configure`/`VerifiedWrite` → `WriteConfigure`, `ViewOnly` → deny regardless).
|
||||
`DriverNodeManager` caches the classification per variable during discovery and
|
||||
checks the session's roles (via `IRoleBearer`) in `OnWriteValue` before calling
|
||||
`IWritable.WriteAsync`. Roles do not cascade — a session with `WriteOperate`
|
||||
can't write a `Tune` attribute unless it also carries `WriteTune`.
|
||||
|
||||
See `feedback_acl_at_server_layer.md` in memory for the architectural directive
|
||||
that authz stays at the server layer and never delegates to driver-specific auth.
|
||||
|
||||
## 3. Admin UI client-cert trust management — **DONE (PR 28)**
|
||||
|
||||
PR 28 shipped `/certificates` in the Admin UI. `CertTrustService` reads the OPC
|
||||
UA server's PKI store root (`OpcUaServerOptions.PkiStoreRoot` — default
|
||||
`%ProgramData%\OtOpcUa\pki`) and lists rejected + trusted certs by parsing the
|
||||
`.der` files directly, so it has no `Opc.Ua` dependency and runs on any
|
||||
Admin host that can reach the shared PKI directory.
|
||||
|
||||
Operator actions: Trust (moves `rejected/certs/*.der` → `trusted/certs/*.der`),
|
||||
Delete rejected, Revoke trust. The OPC UA stack re-reads the trusted store on
|
||||
each new client handshake, so no explicit reload signal is needed —
|
||||
operators retry the rejected client's connection after trusting.
|
||||
|
||||
Deferred: flipping `AutoAcceptUntrustedClientCertificates` to `false` as the
|
||||
deployment default. That's a production-hardening config change, not a code
|
||||
gap — the Admin UI is now ready to be the trust gate.
|
||||
|
||||
## 4. Live-LDAP integration test — **DONE (PR 31)**
|
||||
|
||||
PR 31 shipped `Server.Tests/LdapUserAuthenticatorLiveTests.cs` — 6 live-bind
|
||||
tests against the dev GLAuth instance at `localhost:3893`, skipped cleanly
|
||||
when the port is unreachable. Covers: valid bind, wrong password, unknown
|
||||
user, empty credentials, single-group → WriteOperate mapping, multi-group
|
||||
admin user surfacing all mapped roles.
|
||||
|
||||
Also added `UserNameAttribute` to `LdapOptions` (default `uid` for RFC 2307
|
||||
compat) so Active Directory deployments can configure `sAMAccountName` /
|
||||
`userPrincipalName` without code changes. `LdapUserAuthenticatorAdCompatTests`
|
||||
(5 unit guards) pins the AD-shape DN parsing + filter escape behaviors. See
|
||||
`docs/security.md` §"Active Directory configuration" for the AD appsettings
|
||||
snippet.
|
||||
|
||||
Deferred: asserting `session.Identity` end-to-end on the server side (i.e.
|
||||
drive a full OPC UA session with username/password, then read an
|
||||
`IHostConnectivityProbe`-style "whoami" node to verify the role surfaced).
|
||||
That needs a test-only address-space node and is a separate PR.
|
||||
|
||||
## 5. Full Galaxy live-service smoke test against the merged v2 stack — **IN PROGRESS (PRs 36 + 37)**
|
||||
|
||||
PR 36 shipped the prerequisites helper (`AvevaPrerequisites`) that probes
|
||||
every dependency a live smoke test needs and produces actionable skip
|
||||
messages.
|
||||
|
||||
PR 37 shipped the live-stack smoke test project structure:
|
||||
`tests/Driver.Galaxy.Proxy.Tests/LiveStack/` with `LiveStackFixture` (connects
|
||||
to the *already-running* `OtOpcUaGalaxyHost` Windows service via named pipe;
|
||||
never spawns the Host process) and `LiveStackSmokeTests` covering:
|
||||
|
||||
- Fixture initializes successfully (IPC handshake succeeds end-to-end).
|
||||
- Driver reports `DriverState.Healthy` post-handshake.
|
||||
- `DiscoverAsync` returns at least one variable from the live Galaxy.
|
||||
- `GetHostStatuses` reports at least one Platform/AppEngine host.
|
||||
- `ReadAsync` on a discovered variable round-trips through
|
||||
Proxy → Host pipe → MXAccess → back without a BadInternalError.
|
||||
|
||||
Shared secret + pipe name resolve from `OTOPCUA_GALAXY_SECRET` /
|
||||
`OTOPCUA_GALAXY_PIPE` env vars, falling back to reading the service's
|
||||
registry-stored Environment values (requires elevated test host).
|
||||
|
||||
**PR 40** added the write + subscribe facts targeting
|
||||
`DelmiaReceiver_001.TestAttribute` (the writable Boolean UDA the dev Galaxy
|
||||
ships under TestMachine_001) — write-then-read with a 5s scan-window poll +
|
||||
restore-on-finally, and subscribe-then-write asserting both an initial-value
|
||||
OnDataChange and a post-write OnDataChange. PR 39 added the elevated-shell
|
||||
short-circuit so a developer running from an admin window gets an actionable
|
||||
skip instead of `UnauthorizedAccessException`.
|
||||
|
||||
**Run the live tests** (from a NORMAL non-admin PowerShell):
|
||||
|
||||
```powershell
|
||||
$env:OTOPCUA_GALAXY_SECRET = Get-Content C:\Users\dohertj2\Desktop\lmxopcua\.local\galaxy-host-secret.txt
|
||||
cd C:\Users\dohertj2\Desktop\lmxopcua
|
||||
dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests --filter "FullyQualifiedName~LiveStackSmokeTests"
|
||||
```
|
||||
|
||||
Expected: 7/7 pass against the running `OtOpcUaGalaxyHost` service.
|
||||
|
||||
**Remaining for #5 in production-grade form**:
|
||||
- Confirm the suite passes from a non-elevated shell (operator action).
|
||||
- Add similar facts for an alarm-source attribute once `TestMachine_001` (or
|
||||
a sibling) carries a deployed alarm condition — the current dev Galaxy's
|
||||
TestAttribute isn't alarm-flagged.
|
||||
|
||||
## 6. Second driver instance on the same server — **DONE (PR 32)**
|
||||
|
||||
`Server.Tests/MultipleDriverInstancesIntegrationTests.cs` registers two
|
||||
drivers with distinct `DriverInstanceId`s on one `DriverHost`, spins up the
|
||||
full OPC UA server, and asserts three behaviors: (1) each driver's namespace
|
||||
URI (`urn:OtOpcUa:{id}`) resolves to a distinct index in the client's
|
||||
NamespaceUris, (2) browsing one subtree returns that driver's folder and
|
||||
does NOT leak the other driver's folder, (3) reads route to the correct
|
||||
driver — the alpha instance returns 42 while beta returns 99, so a misroute
|
||||
would surface at the assertion layer.
|
||||
|
||||
Deferred: the alarm-event multi-driver parity case (two drivers each raising
|
||||
a `GalaxyAlarmEvent`, assert each condition lands on its owning instance's
|
||||
condition node). Alarm tracking already has its own integration test
|
||||
(`AlarmSubscription*`); the multi-driver alarm case would need a stub
|
||||
`IAlarmSource` that's worth its own focused PR.
|
||||
|
||||
## 7. Host-status per-AppEngine granularity → Admin UI dashboard — **DONE (PRs 33 + 34)**
|
||||
|
||||
**PR 33** landed the data layer: `DriverHostStatus` entity + migration with
|
||||
composite key `(NodeId, DriverInstanceId, HostName)` and two query-supporting
|
||||
indexes (per-cluster drill-down on `NodeId`, stale-row detection on
|
||||
`LastSeenUtc`).
|
||||
|
||||
**PR 34** wired the publisher + consumer. `HostStatusPublisher` is a
|
||||
`BackgroundService` in the Server process that walks every registered
|
||||
`IHostConnectivityProbe`-capable driver every 10s, calls
|
||||
`GetHostStatuses()`, and upserts rows (`LastSeenUtc` advances each tick;
|
||||
`State` + `StateChangedUtc` update on transitions). Admin UI `/hosts` page
|
||||
groups by cluster, shows four summary cards (Hosts / Running / Stale /
|
||||
Faulted), and flags rows whose `LastSeenUtc` is older than 30s as Stale so
|
||||
operators see crashed Servers without waiting for a state change.
|
||||
|
||||
Deferred as follow-ups:
|
||||
|
||||
- Event-driven push (subscribe to `OnHostStatusChanged` per driver for
|
||||
sub-heartbeat latency). Adds DriverHost lifecycle-event plumbing;
|
||||
10s polling is fine for operator-scale use.
|
||||
- Failure-count column — needs the publisher to track a transition history
|
||||
per host, not just current-state.
|
||||
- SignalR fan-out to the Admin page (currently the page polls the DB, not
|
||||
a hub). The DB-polled version is fine at current cadence but a hub push
|
||||
would eliminate the 10s race where a new row sits in the DB before the
|
||||
Admin page notices.
|
||||
451
docs/v2/mitsubishi.md
Normal file
451
docs/v2/mitsubishi.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# Mitsubishi Electric MELSEC — Modbus TCP quirks
|
||||
|
||||
Mitsubishi's MELSEC family speaks Modbus TCP through a patchwork of add-on modules
|
||||
and built-in Ethernet ports, not a single unified stack. The module names are
|
||||
confusingly similar (`QJ71MB91` is *serial* RTU, `QJ71MT91` is the TCP/IP module
|
||||
[9]; `LJ71MT91` is the L-series equivalent; `RJ71EN71` is the iQ-R Ethernet module
|
||||
with a MODBUS/TCP *slave* mode bolted on [8]; `FX3U-ENET`, `FX3U-ENET-P502`,
|
||||
`FX3U-ENET-ADP`, `FX3GE` built-in, and `FX5U` built-in are all different code
|
||||
paths) — and every one of the categories below has at least one trap a textbook
|
||||
Modbus client gets wrong: hex-numbered X/Y devices colliding with decimal Modbus
|
||||
addresses, a user-defined "device assignment" parameter block that means *no two
|
||||
sites are identical*, CDAB-vs-ABCD word order driven by how the ladder built the
|
||||
32-bit value, sub-spec FC16 caps on the older QJ71MT91, and an FX3U port-502
|
||||
licensing split that makes `FX3U-ENET` and `FX3U-ENET-P502` different SKUs.
|
||||
This document catalogues each quirk, cites primary sources, and names the
|
||||
ModbusPal integration test we'd write for it (convention from
|
||||
`docs/v2/modbus-test-plan.md`: `Mitsubishi_<model>_<behavior>`).
|
||||
|
||||
## Models and server/client capability
|
||||
|
||||
| Model | Family | Modbus TCP server | Modbus TCP client | Source |
|
||||
|------------------------|----------|-------------------|-------------------|--------|
|
||||
| `QJ71MT91` | MELSEC-Q | Yes (slave) | Yes (master) | [9] |
|
||||
| `QJ71MB91` | MELSEC-Q | **Serial only** — RS-232/422/485 RTU, *not TCP* | — | [1][3] |
|
||||
| `LJ71MT91` | MELSEC-L | Yes (slave) | Yes (master) | [10] |
|
||||
| `RJ71EN71` / `RnENCPU` | MELSEC iQ-R | Yes (slave) | Yes (master) | [8] |
|
||||
| `RJ71C24` / `RJ71C24-R2` | MELSEC iQ-R | RTU (serial) | RTU (serial) | [13] |
|
||||
| iQ-R built-in Ethernet | CPU | Yes (slave) | Yes (master) | [7] |
|
||||
| iQ-F `FX5U` built-in Ethernet | CPU | Yes, firmware ≥ 1.060 [11] | Yes | [7][11][12] |
|
||||
| `FX3U-ENET` | FX3U bolt-on | Yes (slave), but **not on port 502** [5] | Yes | [4][5] |
|
||||
| `FX3U-ENET-P502` | FX3U bolt-on | Yes (slave), port 502 enabled | Yes | [5] |
|
||||
| `FX3U-ENET-ADP` | FX3U adapter | **No MODBUS** [5] | No MODBUS | [5] |
|
||||
| `FX3GE` built-in | FX3GE CPU | No MODBUS (needs ENET module) [6] | No | [6] |
|
||||
| `FX3G` + `FX3U-ENET` | FX3G | Yes via ENET module | Yes | [6] |
|
||||
|
||||
- A common integration mistake is to buy `FX3U-ENET-ADP` expecting MODBUS —
|
||||
that adapter speaks only MC protocol / SLMP. Our driver should surface a clear
|
||||
capability error, not "connection refused", when the operator's device tag
|
||||
says `FX3U-ENET-ADP` [5].
|
||||
- Older forum threads assert the FX5U is "client only" [12] — that was true on
|
||||
firmware ≤ 1.040. Firmware 1.060 and later ship the parameter-driven MODBUS
|
||||
TCP server built-in and need no function blocks [11].
|
||||
|
||||
## Modbus device assignment (the parameter block)
|
||||
|
||||
Unlike a DL260 where the CPU exposes a *fixed* V-memory-to-Modbus mapping, every
|
||||
MELSEC MODBUS-TCP module exposes a **Modbus Device Assignment Parameter** block
|
||||
that the engineer configures in GX Works2 / GX Configurator-MB / GX Works3.
|
||||
Each of the four Modbus tables (Coil, Input, Input Register, Holding Register)
|
||||
can be split into up to 16 independent "assignment" entries, each binding a
|
||||
contiguous Modbus address range to a MELSEC device head (`M0`, `D0`, `X0`,
|
||||
`Y0`, `B0`, `W0`, `SM0`, `SD0`, `R0`, etc.) and a point count [3][7][8][9].
|
||||
|
||||
- **There is no canonical "MELSEC Modbus mapping"**. Two sites running the same
|
||||
QJ71MT91 module can expose completely different Modbus layouts. Our driver
|
||||
must treat the mapping as site-data (config-file-driven), not as a device
|
||||
profile constant.
|
||||
- **Default values do exist** — both GX Configurator-MB (for Q/L series) and
|
||||
GX Works3 (for iQ-R / iQ-F / FX5) ship a "dedicated pattern" default that is
|
||||
applied when the engineer does not override the assignment. Per the FX5
|
||||
MODBUS Communication manual (JY997D56101) and the QJ71MT91 manual, the FX5
|
||||
dedicated default is [3][7][11]:
|
||||
|
||||
| Modbus table | Modbus range (0-based) | MELSEC device | Head |
|
||||
|--------------------|------------------------|---------------|------|
|
||||
| Coil (FC01/05/15) | 0 – 7679 | M | M0 |
|
||||
| Coil | 8192 – 8959 | Y | Y0 |
|
||||
| Input (FC02) | 0 – 7679 | M | M0 |
|
||||
| Input | 8192 – 8959 | X | X0 |
|
||||
| Input Register (FC04) | 0 – 6143 | D | D0 |
|
||||
| Holding Register (FC03/06/16) | 0 – 6143 | D | D0 |
|
||||
|
||||
This matches the widely circulated "FC03 @ 0 = D0" convention that shows up
|
||||
in Ubidots / Ignition / AdvancedHMI integration guides [6][12].
|
||||
|
||||
- **X/Y in the default mapping occupy a second, non-zero Modbus range** (8192+
|
||||
on FX5; similar on Q/L/iQ-R). Driver users who expect "X0 = coil 0" will be
|
||||
reading M0 instead. Document this clearly.
|
||||
- **Assignment-range collisions silently disable the slave.** The QJ71MT91
|
||||
manual states explicitly that if any two of assignments 1-16 duplicate the
|
||||
head Modbus device number, the slave function is inactive with no clear
|
||||
error — the module just won't respond [9]. The driver probe will look like a
|
||||
simple timeout; the site engineer has to open GX Configurator-MB to diagnose.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_FX5U_default_mapping_coil_0_is_M0`,
|
||||
`Mitsubishi_FX5U_default_mapping_holding_0_is_D0`,
|
||||
`Mitsubishi_QJ71MT91_duplicate_assignment_head_disables_slave`.
|
||||
|
||||
## X/Y addressing — hex on MELSEC, decimal on Modbus
|
||||
|
||||
**MELSEC X (input) and Y (output) device numbers are hexadecimal on Q / L /
|
||||
iQ-R** and **octal** on FX / iQ-F (with a GX Works3 toggle) [14][15].
|
||||
|
||||
- On a Q CPU, `X20` means decimal **32**, not 20. On an FX5U in default (octal)
|
||||
mode, `X20` means decimal **16**. GX Works3 exposes a project-level option to
|
||||
display FX5U X/Y in hex to match Q/L/iQ-R convention — the same physical
|
||||
input is then called `X10` [14].
|
||||
- The Modbus Device Assignment Parameter block takes the *head device* as a
|
||||
MELSEC-native number, which is interpreted in the CPU's native base
|
||||
(hex for Q/L/iQ-R, octal for FX/iQ-F). After that, **Modbus offsets from
|
||||
the head are plain decimal** — the module does not apply a second hex
|
||||
conversion [3][9].
|
||||
- Example (QJ71MT91 on a Q CPU): assignment "Coil 0 = X0, 512 points" exposes
|
||||
physical `X0` through `X1FF` (hex) as coils 0-511. A client reading coil 32
|
||||
gets the bit `X20` (hex) — i.e. the 33rd input, not the value at "input 20"
|
||||
that the operator wrote on the wiring diagram in decimal.
|
||||
- **Driver bug source**: if the operator's tag configuration says "read X20" and
|
||||
the driver helpfully converts "20" to decimal 20 → coil offset 20, the
|
||||
returned bit is actually `X14` (hex) — off by twelve. Our config layer must
|
||||
preserve the MELSEC-native base that the site engineer sees in GX Works.
|
||||
- Timers/counters (`T`, `C`, `ST`) are always decimal in MELSEC notation.
|
||||
Internal relays (`M`, `B`, `L`), data registers (`D`, `W`, `R`, `ZR`),
|
||||
and special relays/registers (`SM`, `SD`) also decimal. **Only `X` and `Y`
|
||||
(and on Q/L/iQ-R, `B` link relays and `W` link registers) use hex**, and
|
||||
the X/Y decision is itself family-dependent [14][15].
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_Q_X_address_is_hex_X20_equals_coil_offset_32`,
|
||||
`Mitsubishi_FX5U_X_address_is_octal_X20_equals_coil_offset_16`,
|
||||
`Mitsubishi_W_link_register_is_hex_W10_equals_holding_offset_16`.
|
||||
|
||||
## Word order for 32-bit values
|
||||
|
||||
MELSEC stores 32-bit ladder values (`DINT`, `DWORD`, `REAL` / single-precision
|
||||
float) across **two consecutive D-registers, low word first** — i.e., `CDAB`
|
||||
when viewed as a Modbus register pair [2][6].
|
||||
|
||||
```
|
||||
D100 (low word) : 0xCC 0xDD (big-endian bytes within the word)
|
||||
D101 (high word) : 0xAA 0xBB
|
||||
```
|
||||
|
||||
A Modbus master reading D100/D101 as a `float` with default (ABCD) word order
|
||||
gets garbage. Ignition's built-in Modbus driver notes Mitsubishi as a "CDAB
|
||||
device" specifically for this reason [2].
|
||||
|
||||
- **Q / L / iQ-R / iQ-F all agree** — this is a CPU-level convention, not a
|
||||
module choice. Both the QJ71MT91 manual and the FX5 MODBUS Communication
|
||||
manual describe 32-bit access by "reading the lower 16 bits from the start
|
||||
address and the upper 16 bits from start+1" [6][11].
|
||||
- **Byte order within each register is big-endian** (Modbus standard). The
|
||||
module does not byte-swap.
|
||||
- **Configurable?** The MODBUS modules themselves do **not** expose a word-
|
||||
order toggle; the behavior is fixed to how the CPU laid out the value in the
|
||||
two D-registers. If the ladder programmer used an `SWAP` instruction or a
|
||||
union-style assignment, the word order can be whatever they made it — but
|
||||
for values produced by the standard `D→DBL` and `FLT`/`FLT2` instructions
|
||||
it is always CDAB [2].
|
||||
- **FX5U quirk**: the FX5 MODBUS Communication manual tells the programmer to
|
||||
use the `SWAP` instruction *if* the remote Modbus peer requires
|
||||
little-endian *byte* ordering (BADC) [11]. This is only relevant when the
|
||||
FX5U is the Modbus *client*, but it confirms the FX5U's native wire layout
|
||||
is big-endian-byte / little-endian-word (CDAB) on the server side too.
|
||||
- **Rumoured exception**: a handful of MrPLC forum threads report iQ-R
|
||||
RJ71EN71 firmware < 1.05 returning DWORDs in `ABCD` order when accessed via
|
||||
the built-in Ethernet port's MODBUS slave [8]. _Unconfirmed_; treat as a
|
||||
per-site test.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_Float32_word_order_is_CDAB`,
|
||||
`Mitsubishi_Int32_word_order_is_CDAB`,
|
||||
`Mitsubishi_FX5U_SWAP_instruction_changes_byte_order_not_word_order`.
|
||||
|
||||
## BCD vs binary encoding
|
||||
|
||||
**MELSEC stores integer values in D-registers as plain binary two's-complement**,
|
||||
not BCD [16]. This is the opposite of AutomationDirect DirectLOGIC, where
|
||||
V-memory defaults to BCD and the ladder must explicitly request binary.
|
||||
|
||||
- A ladder `MOV K1234 D100` stores `0x04D2` (1234 decimal) in D100, not
|
||||
`0x1234`. The Modbus master reads `0x04D2` and decodes it as an integer
|
||||
directly — no BCD conversion needed [16].
|
||||
- **Timer / counter current values** (`T0` current value, `C0` count) are
|
||||
stored in binary as word devices on Q/L/iQ-R/iQ-F. The ladder preset
|
||||
(`K...`) is also binary [16][17].
|
||||
- **Timer / counter preset `K` operand in FX3U / earlier FX**: also binary when
|
||||
loaded from a D-register or a `K` constant. The older A-series CPUs had BCD
|
||||
presets on some timer types, but MELSEC-Q, L, iQ-R, iQ-F, and FX3U all use
|
||||
binary presets by default [17].
|
||||
- The FX3U programming manual dedicates `FNC 18 BCD` and `FNC 19 BIN` to
|
||||
explicit conversion — their existence confirms that anything in D-registers
|
||||
that came from a `BCD` instruction output is BCD, but nothing is BCD by
|
||||
default [17].
|
||||
- **7-segment display registers** are a common site-specific exception — many
|
||||
ladders pack `BCD D100` into a D-register so the operator panel can drive
|
||||
a display directly. Our driver should not assume; expose a per-tag
|
||||
"encoding = binary | BCD" knob.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_D_register_stores_binary_not_BCD`,
|
||||
`Mitsubishi_FX3U_timer_current_value_is_binary`.
|
||||
|
||||
## Max registers per request
|
||||
|
||||
From the FX5 MODBUS Communication manual Chapter 11 [11]:
|
||||
|
||||
| FC | Name | FX5U (built-in) | QJ71MT91 | iQ-R (RJ71EN71 / built-in) | FX3U-ENET |
|
||||
|----|----------------------------|-----------------|--------------|-----------------------------|-----------|
|
||||
| 01 | Read Coils | 1-2000 | 1-2000 [9] | 1-2000 [8] | 1-2000 |
|
||||
| 02 | Read Discrete Inputs | 1-2000 | 1-2000 | 1-2000 | 1-2000 |
|
||||
| 03 | Read Holding Registers | **1-125** | 1-125 [9] | 1-125 [8] | 1-125 |
|
||||
| 04 | Read Input Registers | 1-125 | 1-125 | 1-125 | 1-125 |
|
||||
| 05 | Write Single Coil | 1 | 1 | 1 | 1 |
|
||||
| 06 | Write Single Register | 1 | 1 | 1 | 1 |
|
||||
| 0F | Write Multiple Coils | 1-1968 | 1-1968 | 1-1968 | 1-1968 |
|
||||
| 10 | Write Multiple Registers | **1-123** | 1-123 | 1-123 | 1-123 |
|
||||
| 16 | Mask Write Register | 1 | not supported | 1 | not supported |
|
||||
| 17 | Read/Write Multiple Regs | R:1-125, W:1-121 | not supported | R:1-125, W:1-121 | not supported |
|
||||
|
||||
- **The FX5U / iQ-R native-port limits match the Modbus spec**: 125 for FC03/04,
|
||||
123 for FC16 [11]. No sub-spec caps like DL260's 100-register ceiling.
|
||||
- **QJ71MT91 does not support FC16 (0x16, Mask Write Register) or FC17
|
||||
(0x17, Read/Write Multiple)** — requesting them returns exception `01`
|
||||
Illegal Function [9]. FX5U and iQ-R *do* support both.
|
||||
- **QJ71MT91 device size**: 64k points (65,536) for each of Coil / Input /
|
||||
Input Register / Holding Register, plus up to 4086k points for Extended
|
||||
File Register via a secondary assignment range [9].
|
||||
- **FX3U-ENET / -P502 function code list is a strict subset** of the common
|
||||
eight (FC01/02/03/04/05/06/0F/10). FC16 and FC17 not supported [4].
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_FX5U_FC03_126_registers_returns_IllegalDataValue`,
|
||||
`Mitsubishi_FX5U_FC16_124_registers_returns_IllegalDataValue`,
|
||||
`Mitsubishi_QJ71MT91_FC16_MaskWrite_returns_IllegalFunction`,
|
||||
`Mitsubishi_QJ71MT91_FC23_ReadWrite_returns_IllegalFunction`.
|
||||
|
||||
## Exception codes
|
||||
|
||||
MELSEC MODBUS modules return **only the standard Modbus exception codes 01-04**;
|
||||
no proprietary exception codes are exposed on the wire [8][9][11]. Module-
|
||||
internal diagnostics (buffer-memory error codes like `7380H`) are logged but
|
||||
not returned as Modbus exceptions.
|
||||
|
||||
| Code | Name | MELSEC trigger |
|
||||
|------|----------------------|---------------------------------------------------------|
|
||||
| 01 | Illegal Function | FC17 or FC16 on QJ71MT91/FX3U; FC08 (Diagnostics); FC43 |
|
||||
| 02 | Illegal Data Address | Modbus address outside any assignment range |
|
||||
| 03 | Illegal Data Value | Quantity out of per-FC range (see table above); odd coil-byte count |
|
||||
| 04 | Server Device Failure | See below |
|
||||
|
||||
- **04 (Server Failure) triggers on MELSEC**:
|
||||
- CPU in STOP or PAUSE during a write to an assignment whose "Access from
|
||||
External Device" permission is set to "Disabled in STOP" [9][11].
|
||||
*With the default "always enabled" setting the write succeeds in STOP
|
||||
mode* — another common trap.
|
||||
- CPU errors (parameter error, watchdog) during any access.
|
||||
- Assignment points to a device range that is not configured (e.g. write
|
||||
to `D16384` when CPU D-device size is 12288).
|
||||
- **Write to a "System Area" device** (e.g., `SD` special registers that are
|
||||
CPU-reserved read-only) returns `04`, not `02`, on QJ71MT91 and iQ-R — the
|
||||
assignment is valid, the device exists, but the CPU rejects the write [8][9].
|
||||
- **FX3U-ENET / -P502** returns `04` on any write attempt while the CPU is in
|
||||
STOP, regardless of permission settings — the older firmware does not
|
||||
implement the "Access from External Device" granularity that Q/L/iQ-R/iQ-F
|
||||
expose [4].
|
||||
- **No rumour of proprietary codes 05-0B** from MELSEC; operators sometimes
|
||||
report "exception 0A" but those traces all came from a third-party gateway
|
||||
sitting between the master and the MELSEC module.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_QJ71MT91_STOP_mode_write_with_Disabled_permission_returns_ServerFailure`,
|
||||
`Mitsubishi_QJ71MT91_STOP_mode_write_with_default_permission_succeeds`,
|
||||
`Mitsubishi_SD_system_register_write_returns_ServerFailure`,
|
||||
`Mitsubishi_FX3U_STOP_mode_write_always_returns_ServerFailure`.
|
||||
|
||||
## Connection behavior
|
||||
|
||||
Max simultaneous Modbus TCP clients, per module [7][8][9][11]:
|
||||
|
||||
| Model | Max TCP connections | Port 502 | Keepalive | Source |
|
||||
|----------------------|---------------------|----------|-----------|--------|
|
||||
| `QJ71MT91` | 16 (shared with master role) | Yes | No | [9] |
|
||||
| `LJ71MT91` | 16 | Yes | No | [10] |
|
||||
| iQ-R built-in / `RJ71EN71` | 16 | Yes | Configurable (KeepAlive = ON in parameter) | [8] |
|
||||
| iQ-F `FX5U` built-in | 8 | Yes | Configurable | [7][11] |
|
||||
| `FX3U-ENET` | 8 TCP, but **not port 502** | No (port < 1024 blocked) | No | [4][5] |
|
||||
| `FX3U-ENET-P502` | 8, port 502 enabled | Yes | No | [5] |
|
||||
|
||||
- **QJ71MT91's 16 is total connections shared between slave-listen and
|
||||
master-initiated sockets** [9]. A site that uses the same module as both
|
||||
master to downstream VFDs and slave to upstream SCADA splits the 16 pool.
|
||||
- **FX3U-ENET port-502 gotcha**: if the engineer loads a configuration with
|
||||
port 502 into a non-P502 ENET module, GX Works shows the download as
|
||||
successful; on next power cycle the module enters error state and the
|
||||
MODBUS listener never starts. This is documented on third-party FX3G
|
||||
integration guides [6].
|
||||
- **CPU STOP → RUN transition**: does **not** drop Modbus connections on any
|
||||
MELSEC family. Existing sockets stay open; outstanding requests during the
|
||||
transition may see exception 04 for a few scans but then resume [8][9].
|
||||
- **CPU reset (power cycle or `SM1255` forced reset)** drops all Modbus
|
||||
connections and the module re-listens after typically 5-10 seconds.
|
||||
- **Idle timeout**: QJ71MT91 and iQ-R have a per-connection "Alive-Check"
|
||||
(idle timer) parameter, default 0 (disabled). If enabled, default 10 s
|
||||
probe interval, 3 retries before close [8][9]. FX5U similar defaults.
|
||||
- **Keep-alive (TCP-level)**: only iQ-R / iQ-F expose a TCP keep-alive option
|
||||
(parameter "KeepAlive" in the Ethernet settings); QJ71MT91 and FX3U-ENET
|
||||
do not — so NAT/firewall idle drops require driver-side pinging.
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_QJ71MT91_17th_connection_refused`,
|
||||
`Mitsubishi_FX5U_9th_connection_refused`,
|
||||
`Mitsubishi_STOP_to_RUN_transition_preserves_socket`,
|
||||
`Mitsubishi_CPU_reset_closes_all_sockets`.
|
||||
|
||||
## Behavioral oddities
|
||||
|
||||
- **Transaction ID echo**: QJ71MT91 and iQ-R reliably echo the MBAP TxId on
|
||||
every response across firmware revisions; no reports of TxId drops under
|
||||
load [8][9]. FX3U-ENET has an older, less-tested TCP stack; at least one
|
||||
MrPLC thread reports out-of-order TxId echoes under heavy polling on
|
||||
firmware < 1.14 [4]. _Unconfirmed_ on current firmware.
|
||||
- **Per-connection request serialization**: all MELSEC slaves serialize
|
||||
requests within a single TCP connection — a new request is not processed
|
||||
until the prior response has been sent. Pipelining multiple requests on one
|
||||
socket causes the module to queue them in buffer memory and respond in
|
||||
order, but **the queue depth is 1** on QJ71MT91 (a second in-flight request
|
||||
is held on the TCP receive buffer, not queued) [9]. Driver should treat
|
||||
Mitsubishi slaves as strictly single-flight per socket.
|
||||
- **Partial-frame handling**: QJ71MT91 and iQ-R close the socket on malformed
|
||||
MBAP length fields. FX5U resynchronises at the next valid MBAP header
|
||||
within 100 ms but will emit an error to `SD` diagnostics [11]. Driver must
|
||||
reconnect on half-close and replay.
|
||||
- **FX3U UDP vs TCP**: `FX3U-ENET` supports both UDP and TCP MODBUS transports;
|
||||
UDP is lossy and reorders under load. Default is TCP. Some legacy SCADA
|
||||
configurations pinned the module to UDP for multicast discovery — do not
|
||||
select UDP unless the site requires it [4].
|
||||
- **Known firmware-revision variants**:
|
||||
- QJ71MT91 ≤ firmware 10052000000 (year-month format): FC15 with coil
|
||||
count that forces byte-count to an odd value silently truncates the
|
||||
last coil. Fixed in later revisions [9]. _Operator-reported_.
|
||||
- FX5U firmware < 1.060: no native MODBUS TCP server — only accessible via
|
||||
a predefined-protocol function block hack. Firmware ≥ 1.060 ships
|
||||
parameter-based server. Our capability probe should read `SD203`
|
||||
(firmware version) and flag < 1.060 as unsupported for server mode [11][12].
|
||||
- iQ-R RJ71EN71 early firmware: possible ABCD word order (rumoured,
|
||||
unconfirmed) [8].
|
||||
- **SD (special-register) reads during assignment-parameter load**: while
|
||||
the CPU is loading a new MODBUS device assignment parameter (~1-2 s), the
|
||||
slave returns exception 04 Server Failure on every request. Happens after
|
||||
a parameter write from GX Configurator-MB [9].
|
||||
- **iQ-R "Station-based block transfer" collision**: if the RJ71EN71 is also
|
||||
running CC-Link IE Control on the same module, a MODBUS/TCP request that
|
||||
arrives during a CCIE cyclic period is delayed to the next scan — visible
|
||||
as jittery response time, not a failure [8].
|
||||
|
||||
Test names:
|
||||
`Mitsubishi_QJ71MT91_single_flight_per_socket`,
|
||||
`Mitsubishi_FX5U_malformed_MBAP_resync_within_100ms`,
|
||||
`Mitsubishi_FX3U_TxId_preserved_across_burst`,
|
||||
`Mitsubishi_FX5U_firmware_below_1_060_reports_no_server_mode`.
|
||||
|
||||
## Model-specific differences for test coverage
|
||||
|
||||
Summary of which quirks differ per model, so test-class naming can reflect them:
|
||||
|
||||
| Quirk | QJ71MT91 | LJ71MT91 | iQ-R (RJ71EN71 / built-in) | iQ-F (FX5U) | FX3U-ENET(-P502) |
|
||||
|------------------------------------------|----------|----------|----------------------------|-------------|------------------|
|
||||
| FC16 Mask-Write supported | No | No | Yes | Yes | No |
|
||||
| FC17 Read/Write Multiple supported | No | No | Yes | Yes | No |
|
||||
| Max connections | 16 | 16 | 16 | 8 | 8 |
|
||||
| X/Y numbering base | hex | hex | hex | octal (default) | octal |
|
||||
| 32-bit word order | CDAB | CDAB | CDAB (firmware-dependent rumour of ABCD) | CDAB | CDAB |
|
||||
| Port 502 supported | Yes | Yes | Yes | Yes | P502 only |
|
||||
| STOP-mode write permission configurable | Yes | Yes | Yes | Yes | No (always blocks) |
|
||||
| TCP keep-alive parameter | No | No | Yes | Yes | No |
|
||||
| Modbus device assignment — max entries | 16 | 16 | 16 | 16 | 8 |
|
||||
| Server via parameter (no FB) | Yes | Yes | Yes | Yes (fw ≥ 1.060) | Yes |
|
||||
|
||||
- **Test file layout**: `Mitsubishi_QJ71MT91_*`, `Mitsubishi_LJ71MT91_*`,
|
||||
`Mitsubishi_iQR_*`, `Mitsubishi_FX5U_*`, `Mitsubishi_FX3U_ENET_*`,
|
||||
`Mitsubishi_FX3U_ENET_P502_*`. iQ-R built-in Ethernet and the RJ71EN71
|
||||
behave identically for MODBUS/TCP slave purposes and can share a file
|
||||
`Mitsubishi_iQR_*`.
|
||||
- **Cross-model shared tests** (word order CDAB, binary not BCD, standard
|
||||
exception codes, 125-register FC03 cap) can live in a single
|
||||
`Mitsubishi_Common_*` fixture.
|
||||
|
||||
## References
|
||||
|
||||
1. Mitsubishi Electric, *MODBUS Interface Module User's Manual — QJ71MB91*
|
||||
(SH-080578ENG), RS-232/422/485 MODBUS RTU serial module for MELSEC-Q —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/sh080578eng/sh080578engk.pdf
|
||||
2. Inductive Automation, *Ignition Modbus Driver — Mitsubishi Q / iQ-R word
|
||||
order*, documents CDAB convention —
|
||||
https://docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/drivers/modbus-v2
|
||||
and forum discussion https://forum.inductiveautomation.com/t/modbus-tcp-device-word-byte-order/65984
|
||||
3. Mitsubishi Electric, *Programmable Controller User's Manual QJ71MB91 MODBUS
|
||||
Interface Module*, Chapter 7 "Parameter Setting" describing the Modbus
|
||||
Device Assignment Parameter block (assignments 1-16, head-device
|
||||
configuration) —
|
||||
https://www.lcautomation.com/dbdocument/29156/QJ71MB91%20Users%20manual.pdf
|
||||
4. Mitsubishi Electric, *FX3U-ENET User's Manual* (JY997D18101), Chapter on
|
||||
MODBUS/TCP communication; function code support and connection limits —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc_fx/jy997d18101/jy997d18101h.pdf
|
||||
5. Venus Automation, *Mitsubishi FX3U-ENET-P502 Module — Open Port 502 for
|
||||
Modbus TCP/IP* —
|
||||
https://venusautomation.com.au/mitsubishi-fx3u-enet-p502-module-open-port-502-for-modbus-tcp-ip/
|
||||
and FX3U-ENET-ADP user manual (JY997D45801), which confirms the -ADP
|
||||
variant does not support MODBUS —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc_fx/jy997d45801/jy997d45801h.pdf
|
||||
6. XML Control / Ubidots integration notes, *FX3G Modbus* — port-502 trap,
|
||||
D-register mapping default, word order reference —
|
||||
https://sites.google.com/site/xmlcontrol/archive/fx3g-modbus
|
||||
and https://ubidots.com/blog/mitsubishi-plc-as-modbus-tcp-server/
|
||||
7. FA Support Me, *Modbus TCP on Built-in Ethernet port in iQ-F and iQ-R* —
|
||||
confirms 16-connection limit on iQ-R, 8 on iQ-F, parameter-driven
|
||||
configuration via GX Works3 —
|
||||
https://www.fasupportme.com/portal/en/kb/articles/modbus-tcp-on-build-in-ethernet-port-in-iq-f-and-iq-r-en
|
||||
8. Mitsubishi Electric, *MELSEC iQ-R Ethernet User's Manual (Application)*
|
||||
(SH-081259ENG) and *MELSEC iQ-RJ71EN71 User's Manual* Chapter on
|
||||
"Communications Using Modbus/TCP" —
|
||||
https://www.allied-automation.com/wp-content/uploads/2015/02/MITSUBISHI_manual_plc_iq-r_ethernet_users.pdf
|
||||
and https://www.manualslib.com/manual/1533351/Mitsubishi-Electric-Melsec-Iq-Rj71en71.html?page=109
|
||||
9. Mitsubishi Electric, *MODBUS/TCP Interface Module User's Manual — QJ71MT91*
|
||||
(SH-080446ENG), exception codes page 248, device assignment parameter
|
||||
pages 116-124, duplicate-assignment-disables-slave note —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/sh080446eng/sh080446engj.pdf
|
||||
10. Mitsubishi Electric, *MELSEC-L Network Features* — LJ71MT91 documented as
|
||||
L-series equivalent of QJ71MT91 with identical MODBUS/TCP behavior —
|
||||
https://us.mitsubishielectric.com/fa/en/products/cnt/programmable-controllers/melsec-l-series/network/features/
|
||||
11. Mitsubishi Electric, *MELSEC iQ-F FX5 User's Manual (MODBUS Communication)*
|
||||
(JY997D56101), Chapter 11 "Modbus/TCP Communication Specifications" —
|
||||
function code max-quantity table, frame specification, device assignment
|
||||
defaults —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plcf/jy997d56101/jy997d56101h.pdf
|
||||
12. MrPLC forum, *FX5U Modbus-TCP Server (Slave)*, firmware ≥ 1.60 enables
|
||||
native server via parameter; earlier firmware required function block —
|
||||
https://mrplc.com/forums/topic/31883-fx5u-modbus-tcp-server-slave/
|
||||
and Industrial Monitor Direct's "FX5U MODBUS TCP Server Workaround"
|
||||
article (reflects older firmware behavior) —
|
||||
https://industrialmonitordirect.com/blogs/knowledgebase/mitsubishi-fx5u-modbus-tcp-server-configuration-workaround
|
||||
13. Mitsubishi Electric, *MELSEC iQ-R MODBUS and MODBUS/TCP Reference Manual —
|
||||
RJ71C24 / RJ71C24-R2* (BCN-P5999-1060) — RJ71C24 is serial RTU only,
|
||||
not TCP —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/bcn-p5999-1060/bcnp59991060b.pdf
|
||||
14. HMS Industrial Networks, *eWON and Mitsubishi FX5U PLC* (KB-0264-00) —
|
||||
documents that FX5U X/Y are octal in GX Works3 but hex when viewed as a
|
||||
Q-series PLC through eWON; the project-level hex/octal toggle —
|
||||
https://hmsnetworks.blob.core.windows.net/www/docs/librariesprovider10/downloads-monitored/manuals/knowledge-base/kb-0264-00-en-ewon-and-mitsubishi-fx5u-plc.pdf
|
||||
15. Fernhill Software, *Mitsubishi Melsec PLC Data Address* — documents
|
||||
hex-vs-octal device numbering split across MELSEC families —
|
||||
https://www.fernhillsoftware.com/help/drivers/mitsubishi-melsec/data-address-format.html
|
||||
16. Inductive Automation support, *Understanding Mitsubishi PLCs* — D registers
|
||||
store signed 16-bit binary, not BCD; DINT combines two consecutive D
|
||||
registers —
|
||||
https://support.inductiveautomation.com/hc/en-us/articles/16517576753165-Understanding-Mitsubishi-PLCs
|
||||
17. Mitsubishi Electric, *FXCPU Structured Programming Manual [Device &
|
||||
Common]* (JY997D26001) — FNC 18 BCD and FNC 19 BIN explicit-conversion
|
||||
instructions confirm binary-by-default storage —
|
||||
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc_fx/jy997d26001/jy997d26001l.pdf
|
||||
121
docs/v2/modbus-test-plan.md
Normal file
121
docs/v2/modbus-test-plan.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Modbus driver — test plan + device-quirk catalog
|
||||
|
||||
The Modbus TCP driver unit tests (PRs 21–24) cover the protocol surface against an
|
||||
in-memory fake transport. They validate the codec, state machine, and function-code
|
||||
routing against a textbook Modbus server. That's necessary but not sufficient: real PLC
|
||||
populations disagree with the spec in small, device-specific ways, and a driver that
|
||||
passes textbook tests can still misbehave against actual equipment.
|
||||
|
||||
This doc is the harness-and-quirks playbook. The project it describes lives at
|
||||
`tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/` — scaffolded in PR 30 with
|
||||
the simulator fixture, DL205 profile stub, and one write/read smoke test. Each
|
||||
confirmed DL205 quirk lands in a follow-up PR as a named test in that project.
|
||||
|
||||
## Harness
|
||||
|
||||
**Chosen simulator: pymodbus 3.13.0** (`pip install 'pymodbus[simulator]==3.13.0'`).
|
||||
Replaced ModbusPal in PR 43 — see `tests/.../Pymodbus/README.md` for the
|
||||
trade-off rationale. Headline reasons:
|
||||
|
||||
- **Headless** pure-Python CLI; no Java GUI, runs cleanly on a CI runner.
|
||||
- **Maintained** — current stable 3.13.0; ModbusPal 1.6b is abandoned.
|
||||
- **All four standard tables** (HR, IR, coils, DI) configurable; ModbusPal
|
||||
1.6b only exposed HR + coils.
|
||||
- **Built-in actions** (`increment`, `random`, `timestamp`, `uptime`) +
|
||||
optional custom-Python actions for declarative dynamic behaviors.
|
||||
- **Per-register raw uint16 seeding** — encoding the DL205 string-byte-order
|
||||
/ BCD / CDAB-float quirks stays explicit (the quirk math lives in the
|
||||
`_quirk` JSON-comment fields next to each register).
|
||||
- Pip-installable on Windows; sidesteps the privileged-port admin
|
||||
requirement by defaulting to TCP **5020** instead of 502.
|
||||
|
||||
**Setup pattern**:
|
||||
1. `pip install "pymodbus[simulator]==3.13.0"`.
|
||||
2. Start the simulator with one of the in-repo profiles:
|
||||
`tests\.../Pymodbus\serve.ps1 -Profile standard` (or `-Profile dl205`).
|
||||
3. `dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` —
|
||||
tests auto-skip when the endpoint is unreachable. Default endpoint is
|
||||
`localhost:5020`; override via `MODBUS_SIM_ENDPOINT` for a real PLC on its
|
||||
native port 502.
|
||||
|
||||
## Per-device quirk catalog
|
||||
|
||||
### AutomationDirect DL205 / DL260
|
||||
|
||||
First known target device family. **Full quirk catalog with primary-source citations
|
||||
and per-quirk integration-test names lives at [`dl205.md`](dl205.md)** — that doc is
|
||||
the reference; this section is the testing roadmap.
|
||||
|
||||
Confirmed quirks (priority order — top items are highest-impact for our driver
|
||||
and ship first as PR 41+):
|
||||
|
||||
| Quirk | Driver impact | Integration-test name |
|
||||
|---|---|---|
|
||||
| **String packing**: 2 chars/register, **first char in low byte** (opposite of generic Modbus) | `ModbusDataType.String` decoder must be configurable per-device family — current code assumes high-byte-first | `DL205_String_low_byte_first_within_register` |
|
||||
| **Word order CDAB** for Int32/UInt32/Float32 | Already configurable via `ModbusByteOrder.WordSwap`; default per device profile | `DL205_Int32_word_order_is_CDAB` |
|
||||
| **BCD-as-default** numeric storage (only IEEE 754 when ladder uses `R` type) | New decoder mode — register reads as `0x1234` for ladder value `1234`, not as decimal `4660` | `DL205_BCD_register_decodes_as_hex_nibbles` |
|
||||
| **FC16 capped at 100 registers** (below the spec's 123) | Bulk-write batching must cap per-device-family | `DL205_FC16_101_registers_returns_IllegalDataValue` |
|
||||
| **FC03/04 capped at 128** (above the spec's 125) | Less impactful — clients that respect the spec's 125 stay safe | `DL205_FC03_129_registers_returns_IllegalDataValue` |
|
||||
| **V-memory octal-to-decimal addressing** (V2000 octal → 0x0400 decimal) | New address-format helper in profile config so operators can write `V2000` instead of computing `1024` themselves | `DL205_Vmem_V2000_maps_to_PDU_0x0400` |
|
||||
| **C-relay → coil 3072 / Y-output → coil 2048** offsets | Hard-coded constants in DL205 device profile | `DL205_C0_maps_to_coil_3072`, `DL205_Y0_maps_to_coil_2048` |
|
||||
| **Register 0 is valid** (rejects-register-0 rumour was DL05/DL06 relative-mode artefact) | None — current default is safe | `DL205_FC03_register_0_returns_V0_contents` |
|
||||
| **Max 4 simultaneous TCP clients** on H2-ECOM100 | Connect-time: handle TCP-accept failure with a clearer error message | `DL205_5th_TCP_connection_refused` |
|
||||
| **No TCP keepalive** | Driver-side periodic-probe (already wired via `IHostConnectivityProbe`) | _Covered by existing `ModbusProbeTests`_ |
|
||||
| **No mid-stream resync on malformed MBAP** | Already covered — single-flight + reconnect-on-error | _Covered by existing `ModbusDriverTests`_ |
|
||||
| **Write-protect exception code: `02` newer / `04` older** | Translate either to `BadNotWritable` | `DL205_FC06_in_ProgramMode_returns_ServerFailure` |
|
||||
|
||||
_Operator-reported / unconfirmed_ — covered defensively in the driver but no
|
||||
integration tests until reproduced on hardware:
|
||||
- TxId drop under load (forum rumour; not reproduced).
|
||||
- Pre-2004 firmware ABCD word order (every shipped DL205/DL260 since 2004 is CDAB).
|
||||
|
||||
### Future devices
|
||||
|
||||
One section per device class, same shape as DL205. Quirks that apply across
|
||||
multiple devices (e.g., "all AB PLCs use CDAB") can be noted in the cross-device
|
||||
patterns section below once we have enough data points.
|
||||
|
||||
## Cross-device patterns
|
||||
|
||||
Once multiple device catalogs accumulate, quirks that recur across two or more
|
||||
vendors get promoted into driver defaults or opt-in options:
|
||||
|
||||
- _(empty — filled in as catalogs grow)_
|
||||
|
||||
## Test conventions
|
||||
|
||||
- **One named test per quirk.** `DL205_word_order_is_CDAB_for_Float32` is easier to
|
||||
diagnose on failure than a generic `Float32_roundtrip`. The `DL205_` prefix makes
|
||||
filtering by device class trivial (`--filter "DisplayName~DL205"`).
|
||||
- **Skip with a clear SkipReason.** Follow the pattern from
|
||||
`GalaxyRepositoryLiveSmokeTests`: check reachability in the fixture, capture
|
||||
a `SkipReason` string, and have each test call `Assert.Skip(SkipReason)` when
|
||||
it's set. Don't throw — skipped tests read cleanly in CI logs.
|
||||
- **Use the real `ModbusTcpTransport`.** Integration tests exercise the wire
|
||||
protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is
|
||||
deliberately not used here — its value is speed + determinism, which doesn't
|
||||
help reproduce device-specific issues.
|
||||
- **Don't depend on simulator state between tests.** Each test resets the
|
||||
simulator's register bank or uses a unique address range. Avoid relying on
|
||||
"previous test left value at register 10" setups that flake when tests run in
|
||||
parallel or re-order. Either the test mutates the scratch ranges and restores
|
||||
on finally, or it uses pymodbus's REST API to reset state between facts.
|
||||
|
||||
## Next concrete PRs
|
||||
|
||||
- **PR 30 — Integration test project + DL205 profile scaffold** — **DONE**.
|
||||
Shipped `tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests` with
|
||||
`ModbusSimulatorFixture` (TCP-probe, skips with a clear `SkipReason` when the
|
||||
endpoint is unreachable), `DL205/DL205Profile.cs` (tag map stub), and
|
||||
`DL205/DL205SmokeTests.cs` (write-then-read round-trip).
|
||||
- **PR 41 — DL205 quirk catalog doc** — **DONE**. `docs/v2/dl205.md`
|
||||
documents every DL205/DL260 Modbus divergence with primary-source citations.
|
||||
- **PR 42 — ModbusPal `.xmpp` profiles** — **SUPERSEDED by PR 43**. Replaced
|
||||
with pymodbus JSON because ModbusPal 1.6b is abandoned, GUI-only, and only
|
||||
exposes 2 of the 4 standard tables.
|
||||
- **PR 43 — pymodbus JSON profiles** — **DONE**. `Pymodbus/standard.json` +
|
||||
`Pymodbus/dl205.json` + `Pymodbus/serve.ps1` runner. Both bind TCP 5020.
|
||||
- **PR 44+**: one PR per confirmed DL205 quirk, landing the named test + any
|
||||
driver-side adjustment (string byte order, BCD decoder, V-memory address
|
||||
helper, FC16 cap-per-device-family) needed to pass it. Each quirk's value
|
||||
is already pre-encoded in `Pymodbus/dl205.json`.
|
||||
@@ -234,6 +234,8 @@ All of these stay in the Galaxy Host process (.NET 4.8 x86). The `GalaxyProxy` i
|
||||
- Refactor is **incremental**: extract `IDriver` / `ISubscribable` / `ITagDiscovery` etc. against the existing `LmxNodeManager` first (still in-process on v2 branch), validate the system still runs, *then* move the implementation behind the IPC boundary into Galaxy.Host. Keeps the system runnable at each step and de-risks the out-of-process move.
|
||||
- **Parity test**: run the existing v1 IntegrationTests suite against the v2 Galaxy driver (same Galaxy, same expectations) **plus** a scripted Client.CLI walkthrough (connect / browse / read / write / subscribe / history / alarms) on a dev Galaxy. Automated regression + human-observable behavior.
|
||||
|
||||
**Dev environment for the LmxOpcUa breakout:** the Phase 0/1 dev box (`DESKTOP-6JL3KKO`) hosts the full AVEVA stack required to execute Phase 2 Streams D + E — 27 ArchestrA / Wonderware / AVEVA services running including `aaBootstrap`, `aaGR` (Galaxy Repository), `aaLogger`, `aaUserValidator`, `aaPim`, `ArchestrADataStore`, `AsbServiceManager`; the full Historian set (`aahClientAccessPoint`, `aahGateway`, `aahInSight`, `aahSearchIndexer`, `InSQLStorage`, `InSQLConfiguration`, `InSQLEventSystem`, `InSQLIndexing`, `InSQLIOServer`, `HistorianSearch-x64`); SuiteLink (`slssvc`); MXAccess COM at `C:\Program Files (x86)\ArchestrA\Framework\bin\ArchestrA.MXAccess.dll`; and OI-Gateway at `C:\Program Files (x86)\Wonderware\OI-Server\OI-Gateway\` — so the Phase 1 Task E.10 AppServer-via-OI-Gateway smoke test (decision #142) is also runnable on the same box, no separate AVEVA test machine required. Inventory captured in `dev-environment.md`.
|
||||
|
||||
---
|
||||
|
||||
### 4. Configuration Model — Centralized MSSQL + Local Cache
|
||||
|
||||
485
docs/v2/s7.md
Normal file
485
docs/v2/s7.md
Normal file
@@ -0,0 +1,485 @@
|
||||
# Siemens SIMATIC S7 (S7-1200 / S7-1500 / S7-300 / S7-400 / ET 200SP) — Modbus TCP quirks
|
||||
|
||||
Siemens S7 PLCs do *not* speak Modbus TCP natively at the OS/firmware level. Every
|
||||
S7 Modbus-TCP-server deployment is either (a) the **`MB_SERVER`** library block
|
||||
running on the CPU's PROFINET port (S7-1200 / S7-1500 / CPU 1510SP-series
|
||||
ET 200SP), or (b) the **`MODBUSCP`** function block running on a separate
|
||||
communication processor (**CP 343-1 / CP 343-1 Lean** on S7-300, **CP 443-1** on
|
||||
S7-400), or (c) the **`MODBUSPN`** block on an S7-1500 PN port via a licensed
|
||||
library. That means the quirks a Modbus client has to cope with are as much
|
||||
"this is how the user's PLC programmer wired the library block up" as "this is
|
||||
how the firmware behaves" — the byte-order and coil-mapping rules aren't
|
||||
hard-wired into silicon like they are on a DL260. This document catalogues the
|
||||
behaviours a driver has to handle across the supported model/CP variants, cites
|
||||
primary sources, and names the ModbusPal integration test we'd write for each
|
||||
(convention from `docs/v2/modbus-test-plan.md`: `S7_<model>_<behavior>`).
|
||||
|
||||
## Model / CP Capability Matrix
|
||||
|
||||
| PLC family | Modbus TCP server mechanism | Modbus TCP client mechanism | License required? | Typical port 502 source |
|
||||
|---------------------|------------------------------------|------------------------------------|-----------------------|-----------------------------------------------------------|
|
||||
| S7-1200 (V4.0+) | `MB_SERVER` on integrated PN port | `MB_CLIENT` | No (in TIA Portal) | CPU's onboard Ethernet [1][2] |
|
||||
| S7-1500 (all) | `MB_SERVER` on integrated PN port | `MB_CLIENT` | No (in TIA Portal) | CPU's onboard Ethernet [1][3] |
|
||||
| S7-1500 + CP 1543-1 | `MB_SERVER` on CP's IP | `MB_CLIENT` | No | Separate CP IP address [1] |
|
||||
| ET 200SP CPU (1510SP, 1512SP) | `MB_SERVER` on PN port | `MB_CLIENT` | No | CPU's onboard Ethernet [3] |
|
||||
| S7-300 + CP 343-1 / CP 343-1 Lean | `MODBUSCP` (FB `MODBUSCP`, instance DB per connection) | Same FB, client mode | **Yes — 2XV9450-1MB00** per CP | CP's Ethernet port [4][5] |
|
||||
| S7-400 + CP 443-1 | `MODBUSCP` | `MODBUSCP` client mode | **Yes — 2XV9450-1MB00** per CP | CP's Ethernet port [4] |
|
||||
| S7-400H + CP 443-1 (redundant H) | `MODBUSCP_REDUNDANT` / paired FBs | Not typical | Yes | Paired CPs in H-system [6] |
|
||||
| S7-300 / S7-400 CPU PN (e.g. CPU 315-2 PN/DP) | `MODBUSPN` library | `MODBUSPN` client mode | **Yes** — Modbus-TCP PN CPU lib | CPU's PN port [7] |
|
||||
| "CP 343-1 Lean" | **Server only** (no client mode supported by Lean) | — | Yes, but with restrictions | CP's Ethernet port [4][5] |
|
||||
|
||||
- **CP 343-1 Lean is server-only.** It can host `MODBUSCP` in server mode only;
|
||||
client calls return an immediate error. A surprising number of "Lean + client
|
||||
doesn't work" forum posts trace back to this [5].
|
||||
- **Pure OPC UA / PROFINET CPs (CP 1542SP-1, CP 1543-1)** support Modbus TCP on
|
||||
S7-1500 via the same `MB_SERVER`/`MB_CLIENT` instructions by passing the
|
||||
CP's `hw_identifier`. There is no separate "Modbus CP" license needed on
|
||||
S7-1500, unlike S7-300/400 [1].
|
||||
- **No S7 Modbus server supports function codes 20/21 (file records),
|
||||
22 (mask write), 23 (read-write multiple), or 43 (device identification).**
|
||||
Sending any of these returns exception `01` (Illegal Function) on every S7
|
||||
variant [1][4]. Our driver must not negotiate FC23 as a "bulk-read optimization"
|
||||
when the profile is S7.
|
||||
|
||||
Test names:
|
||||
`S7_1200_MBSERVER_Loads_OB1_Cyclic`,
|
||||
`S7_CP343_Lean_Client_Mode_Rejected`,
|
||||
`S7_All_FC23_Returns_IllegalFunction`.
|
||||
|
||||
## Address / DB Mapping
|
||||
|
||||
S7 Modbus servers **do not auto-expose PLC memory** — the PLC programmer has to
|
||||
wire one area per Modbus table to a DB or process-image region. This is the
|
||||
single biggest difference vs. DL205/Modicon/etc., where the memory map is
|
||||
fixed at the factory. Our driver must therefore be tolerant of "the same
|
||||
`40001` means completely different things on two S7-1200s on the same site."
|
||||
|
||||
### S7-1200 / S7-1500 `MB_SERVER`
|
||||
|
||||
The `MB_SERVER` instance exposes four Modbus tables to each connected client;
|
||||
each table's backing storage is a per-block parameter [1][8]:
|
||||
|
||||
| Modbus table | FCs | Backing parameter | Default / typical backing |
|
||||
|---------------------|-------------|-----------------------------|-----------------------------|
|
||||
| Coils (0x) | FC01, FC05, FC15 | *implicit* — Q process image | `%Q0.0`–`%Q1023.7` (→ coil addresses 0–8191) [1][9] |
|
||||
| Discrete Inputs (1x)| FC02 | *implicit* — I process image | `%I0.0`–`%I1023.7` (→ discrete addresses 0–8191) [1][9] |
|
||||
| Input Registers (3x)| FC04 | *implicit* — M memory or DB (version-dependent) | Some firmware routes FC04 through the same MB_HOLD_REG buffer [1][8] |
|
||||
| Holding Registers (4x)| FC03, FC06, FC16 | `MB_HOLD_REG` pointer | User DB (e.g. `DB10.DBW0`) or `%MW` area [1][2][8] |
|
||||
|
||||
- **`MB_HOLD_REG` is a pointer (VARIANT / ANY) into a user-defined DB** whose
|
||||
first byte is holding-register 0 (`40001` in 1-based Modicon form). Byte
|
||||
offset 2 is register 1, byte offset 4 is register 2, etc. [1][2].
|
||||
- **The DB *must* have "Optimized block access" UNCHECKED.** Optimized DBs let
|
||||
the compiler reorder fields for alignment; Modbus requires fixed byte
|
||||
offsets. With optimized access on, the compiler accepts the project but
|
||||
`MB_SERVER` returns STATUS `0x8383` (misaligned access) or silently reads
|
||||
zeros [8][10][11]. This is the #1 support-forum complaint.
|
||||
- **FC01/FC02/FC05/FC15 hit the Q and I process images directly — not the
|
||||
`MB_HOLD_REG` DB.** Coil address 0 = `%Q0.0`, coil 1 = `%Q0.1`, coil 8 =
|
||||
`%Q1.0`. The S7-1200 system manual publishes this mapping as `00001 → Q0.0`
|
||||
through `09999 → Q1023.7` and `10001 → I0.0` through `19999 → I1023.7` in
|
||||
1-based form; on the wire (0-based) that's coils 0-8191 and discrete inputs
|
||||
0-8191 [9].
|
||||
- **`%M` markers are NOT automatically exposed.** To expose `%M` over Modbus
|
||||
the programmer must either (a) copy `%M` to the `MB_HOLD_REG` DB each scan,
|
||||
or (b) define an Array\[0..n\] of Bool inside that DB and copy bits in/out
|
||||
of `%M`. Siemens has no "MB_COIL_REG" parameter analogous to
|
||||
`MB_HOLD_REG` — this confuses users migrating from Schneider [9][12].
|
||||
- **Bit ordering within a Modbus holding register sourced from an `Array of
|
||||
Bool`**: S7 stores bool\[0\] at `DBX0.0` which is bit 0 of byte 0 which is
|
||||
the **low byte, low bit** of Modbus register `40001`. A naive client that
|
||||
reads register `40001` and masks `0x0001` gets bool\[0\]. A client that
|
||||
masks `0x8000` gets bool\[15\] because the high byte of the Modbus register
|
||||
is the *second* byte of the DB. Siemens programmers routinely get this
|
||||
wrong in the DB-via-DBX form; `Array[0..n] of Bool` is the recommended
|
||||
layout because it aligns naturally [12][13].
|
||||
|
||||
### S7-300/400 + CP 343-1 / CP 443-1 `MODBUSCP`
|
||||
|
||||
Different paradigm: per-connection **parameter DB** (template
|
||||
`MODBUS_PARAM_CP`) declares a table of up to 8 register-area mappings. Each
|
||||
mapping is a tuple `(data_type, DB#, start_offset, length)` where `data_type`
|
||||
picks the Modbus table [4]:
|
||||
|
||||
- `B#16#1` = Coils
|
||||
- `B#16#2` = Discrete Inputs
|
||||
- `B#16#3` = Holding Registers
|
||||
- `B#16#4` = Input Registers
|
||||
|
||||
The `holding_register_start` and analogous `coils_start` parameters declare
|
||||
**which Modbus address range** the CP will serve, and the DB pointers say
|
||||
where in S7 memory that range lives [4][14]. Unlike `MB_SERVER`, the CP does
|
||||
not reach into `%Q`/`%I` directly — *everything* goes through a DB. If an
|
||||
address outside the declared ranges is requested, the CP returns exception
|
||||
`02` (Illegal Data Address) [4].
|
||||
|
||||
Test names:
|
||||
`S7_1200_FC03_Reg0_Reads_DB10_DBW0`,
|
||||
`S7_1200_Optimized_DB_Returns_0x8383_MisalignedAccess`,
|
||||
`S7_1200_FC01_Coil0_Reads_Q0_0`,
|
||||
`S7_CP343_FC03_Outside_ParamBlock_Range_Returns_IllegalDataAddress`.
|
||||
|
||||
## Data Types and Byte Order
|
||||
|
||||
Siemens CPUs store scalars **big-endian** internally ("Motorola format"), which
|
||||
is the same byte order Modbus specifies inside each register. So for 16-bit
|
||||
values (`Int`, `Word`, `UInt`) the on-the-wire layout is straightforward
|
||||
`AB` — high byte of the PLC value in the high byte of the Modbus register
|
||||
[15][16]. No byte-swap trap for 16-bit types.
|
||||
|
||||
The trap is 32-bit types (`DInt`, `DWord`, `Real`). Here's what actually
|
||||
happens across the S7 family:
|
||||
|
||||
### S7-1200 / S7-1500 `MB_SERVER`
|
||||
|
||||
- **The backing DB stores 32-bit values in big-endian byte order, high word
|
||||
first** — i.e. `ABCD` when viewed as two consecutive Modbus registers. A
|
||||
`Real` at `DB10.DBD0` with value `0x12345678` reads over Modbus as
|
||||
register 0 = `0x1234`, register 1 = `0x5678` [15][16][17].
|
||||
- **This is `ABCD`, *not* `CDAB`.** Clients that hard-code CDAB (common default
|
||||
for meters and VFDs) will get wildly wrong floats. Configure the S7 profile
|
||||
with `WordOrder = ABCD` (aka "big-endian word + big-endian byte" aka
|
||||
"high-word first") [15][17].
|
||||
- **`MB_SERVER` does not swap.** It's a direct memcpy from the DB bytes to
|
||||
the Modbus payload. Whatever byte order the ladder programmer stored into
|
||||
the DB is what the client receives [17]. This means a programmer who used
|
||||
`MOVE_BLK` from two separate `Word`s into `DBD` with the "wrong" order can
|
||||
produce `CDAB` without realising.
|
||||
- **`Real` is IEEE 754 single-precision** — unambiguous, no BCD trap like on
|
||||
DL series [15].
|
||||
- **Strings**: S7 `String[n]` has a 2-byte header (max length, current length)
|
||||
*before* the character bytes. A client reading a string over Modbus gets
|
||||
the header in the first register and then the characters two-per-register
|
||||
in high-byte-first order. `WString` is UTF-16 and the header is 4 bytes
|
||||
[18]. Our driver's string decoder must expose the "skip header" option for
|
||||
S7 profile.
|
||||
|
||||
### S7-300/400 `MODBUSCP` (CP 343-1 / CP 443-1)
|
||||
|
||||
- The CP writes the exact DB bytes onto the wire — again `ABCD` if the DB
|
||||
stores `DInt`/`Real` in native Siemens order [4].
|
||||
- **`MODBUSCP` has no `data_type` byte-swap knob.** (The `data_type` parameter
|
||||
names the Modbus table, not the byte order — see the Address Mapping
|
||||
section.) If the other end of the link expects `CDAB`, the programmer has
|
||||
to swap words in ladder before writing the DB [4][14].
|
||||
|
||||
### Operator-reported oddity
|
||||
|
||||
- Some S7 drivers (Kepware's "Siemens TCP/IP Ethernet" driver, Ignition's
|
||||
"Siemens S7" driver) expose a per-tag `Float Byte Order` with options
|
||||
`ABCD`/`CDAB`/`BADC`/`DCBA` because end-users have encountered every
|
||||
permutation in the field — not because the PLC natively swaps, but because
|
||||
ladder programmers have historically stored floats every which way [19].
|
||||
Our S7 Modbus profile should default to `ABCD` but expose a per-tag
|
||||
override.
|
||||
- **Unconfirmed rumour**: that S7-1500 firmware V2.0+ reverses float byte
|
||||
order for `MB_CLIENT` only. Not reproduced; the Siemens forum thread that
|
||||
launched it was a user error (the remote server was the swapper, not the
|
||||
S7) [20]. Treat as false until proven.
|
||||
|
||||
Test names:
|
||||
`S7_1200_Real_WordOrder_ABCD_Default`,
|
||||
`S7_1200_DInt_HighWord_First_At_DBD0`,
|
||||
`S7_1200_String_Header_First_Two_Bytes`,
|
||||
`S7_CP343_No_Internal_ByteSwap`.
|
||||
|
||||
## Coil / Discrete Input Mapping
|
||||
|
||||
On `MB_SERVER` the mapping from coil address → S7 bit is fixed at the
|
||||
process-image level [1][9][12]:
|
||||
|
||||
| Modbus coil / discrete input addr | S7 address | Notes |
|
||||
|-----------------------------------|---------------|-------------------------------------|
|
||||
| Coil 0 (FC01/05/15) | `%Q0.0` | bit 0 of output byte 0 |
|
||||
| Coil 7 | `%Q0.7` | bit 7 of output byte 0 |
|
||||
| Coil 8 | `%Q1.0` | bit 0 of output byte 1 |
|
||||
| Coil 8191 (max) | `%Q1023.7` | highest exposed output bit |
|
||||
| Discrete input 0 (FC02) | `%I0.0` | bit 0 of input byte 0 |
|
||||
| Discrete input 8191 | `%I1023.7` | highest exposed input bit |
|
||||
|
||||
Formulas:
|
||||
|
||||
```
|
||||
coil_addr = byte_index * 8 + bit_index (e.g. %Q5.3 → coil 43)
|
||||
discr_addr = byte_index * 8 + bit_index (e.g. %I10.2 → disc 82)
|
||||
```
|
||||
|
||||
- **1-based Modicon form adds 1:** coil 0 (wire) = `00001` (Modicon), etc.
|
||||
Our driver sends the 0-based PDU form, so `%Q0.0` writes to wire address 0.
|
||||
- **Writing FC05/FC15 to `%Q` is accepted even while the CPU is in STOP** —
|
||||
the PLC's process image doesn't care about the user program state. But the
|
||||
output won't propagate to the physical module until RUN (see STOP section
|
||||
below) [1][21].
|
||||
- **`%M` markers require a DB-backed `Array of Bool`** as described in the
|
||||
Address Mapping section. Our driver can't assume "coil N = MN.0" like it
|
||||
can on Modicon — on S7 it's always Q/I unless the programmer built a
|
||||
mapping DB [12].
|
||||
- **Bit-inside-holding-register**: for `Array of Bool` inside the
|
||||
`MB_HOLD_REG` DB, bool[0] is bit 0 of byte 0 → **low byte, low bit** of
|
||||
Modbus register 40001. Most third-party clients probe this in the low
|
||||
byte, so the common case works; the less-common case (bool[8]) is bit 0 of
|
||||
byte 1 → **high byte, low bit** of Modbus register 40001. Clients that
|
||||
test only bool[0] will pass and miss the mis-alignment on bool[8] [12][13].
|
||||
|
||||
Test names:
|
||||
`S7_1200_Coil_0_Is_Q0_0`,
|
||||
`S7_1200_Coil_8_Is_Q1_0`,
|
||||
`S7_1200_Discrete_Input_7_Is_I0_7`,
|
||||
`S7_1200_Coil_Write_In_STOP_Accepted_But_Output_Frozen`.
|
||||
|
||||
## Function Code Support & Max Registers Per Request
|
||||
|
||||
| FC | Name | S7-1200 / S7-1500 MB_SERVER | CP 343-1 / CP 443-1 MODBUSCP | Max qty per request |
|
||||
|----|----------------------------|-----------------------------|------------------------------|--------------------------------|
|
||||
| 01 | Read Coils | Yes | Yes | 2000 bits (spec) |
|
||||
| 02 | Read Discrete Inputs | Yes | Yes | 2000 bits (spec) |
|
||||
| 03 | Read Holding Registers | Yes | Yes | **125** (spec max) |
|
||||
| 04 | Read Input Registers | Yes | Yes | **125** |
|
||||
| 05 | Write Single Coil | Yes | Yes | 1 |
|
||||
| 06 | Write Single Register | Yes | Yes | 1 |
|
||||
| 15 | Write Multiple Coils | Yes | Yes | 1968 bits (spec) — *see note* |
|
||||
| 16 | Write Multiple Registers | Yes | Yes | **123** (spec max for TCP) |
|
||||
| 07 | Read Exception Status | No (RTU only) | No | — |
|
||||
| 17 | Report Server ID | No | No | — |
|
||||
| 20/21 | Read/Write File Record | No | No | — |
|
||||
| 22 | Mask Write Register | No | No | — |
|
||||
| 23 | Read/Write Multiple | No | No | — |
|
||||
| 43 | Read Device Identification | No | No | — |
|
||||
|
||||
- **S7-1200/1500 honour the full spec maxima** for FC03/04 (125) and FC16
|
||||
(123) [1][22]. No sub-spec cap like DL260's 100-register FC16 limit.
|
||||
- **FC15 (Write Multiple Coils) on `MB_SERVER`** writes into `%Q`, which maxes
|
||||
out at 1024 bytes = 8192 bits, but the spec's 1968-bit per-request limit
|
||||
caps any single call first [1][9].
|
||||
- **`MB_HOLD_REG` buffer size is bounded by DB size** — max DB size on
|
||||
S7-1200 is 64 KB, on S7-1500 is much larger (several MB depending on CPU),
|
||||
so the practical `MB_HOLD_REG` limit is 32767 16-bit registers on S7-1200
|
||||
and effectively unbounded on S7-1500 [22][23]. The *per-request* limit is
|
||||
still 125.
|
||||
- **Read past the end of `MB_HOLD_REG`** returns exception `02` (Illegal
|
||||
Data Address) at the start of the overflow register, not a partial read
|
||||
[1][8].
|
||||
- **Request larger than spec max** (e.g. FC03 quantity 126) returns exception
|
||||
`03` (Illegal Data Value). Verified on S7-1200 V4.2 [1][24].
|
||||
- **CP 343-1 `MODBUSCP` per-request maxima are spec** (125/125/123/1968/2000),
|
||||
matching the standard [4]. The CP's `MODBUS_PARAM_CP` caps the total
|
||||
*exposed* range, not the per-call quantity.
|
||||
|
||||
Test names:
|
||||
`S7_1200_FC03_126_Registers_Returns_IllegalDataValue`,
|
||||
`S7_1200_FC16_124_Registers_Returns_IllegalDataValue`,
|
||||
`S7_1200_FC03_Past_MB_HOLD_REG_End_Returns_IllegalDataAddress`,
|
||||
`S7_1200_FC17_ReportServerId_Returns_IllegalFunction`.
|
||||
|
||||
## Exception Codes
|
||||
|
||||
S7 Modbus servers return only the four standard exception codes [1][4]:
|
||||
|
||||
| Code | Name | Triggered by |
|
||||
|------|-----------------------|----------------------------------------------------------------------|
|
||||
| 01 | Illegal Function | FC not in the supported list (17, 20-23, 43, any undefined FC) |
|
||||
| 02 | Illegal Data Address | Register outside `MB_HOLD_REG` / outside `MODBUSCP` param-block range |
|
||||
| 03 | Illegal Data Value | Quantity exceeds spec (FC03/04 > 125, FC16 > 123, FC01/02 > 2000, FC15 > 1968) |
|
||||
| 04 | Server Failure | Runtime error inside MB_SERVER (DB access fault, corrupt DB header, MB_SERVER disabled mid-request) [1][24] |
|
||||
|
||||
- **No proprietary exception codes (05/06/0A/0B) are used** on any S7
|
||||
Modbus server [1][4]. Our driver's status-code mapper can treat these as
|
||||
"never observed" on the S7 profile.
|
||||
- **CPU in STOP → `MB_SERVER` keeps running if it's in OB1 of the firmware's
|
||||
communication task, but OB1 itself is not scanned.** In practice:
|
||||
- Holding-register *reads* (FC03) continue to return the last DB values
|
||||
frozen at the moment the CPU entered STOP. The `MB_SERVER` block is in
|
||||
OB1 so it isn't re-invoked; however the TCP stack keeps the socket open
|
||||
and returns cached data on subsequent polls [1][21]. **Unconfirmed**
|
||||
whether this is cached in the CP or in the CPU's communication processor;
|
||||
behaviour varies between firmware 4.0 and 4.5 [21].
|
||||
- Holding-register *writes* (FC06/FC16) during STOP return exception `04`
|
||||
(Server Failure) on S7-1200 V4.2+, and return success-but-discarded on
|
||||
older firmware [1][24]. Our driver should treat FC06/FC16 during STOP as
|
||||
non-deterministic and not rely on the response code.
|
||||
- Coil *writes* (FC05/FC15) to `%Q` are *accepted* by the process image
|
||||
during STOP, but the physical output freezes at its last RUN-mode value
|
||||
(or the configured STOP-mode substitute value) until RUN resumes [1][21].
|
||||
- **Writing a read-only address via FC06/FC16**: returns `02` (Illegal Data
|
||||
Address), not `04`. S7 does not have "write-protected" holding registers —
|
||||
the programmer either exposes a DB for read-write or doesn't expose it at
|
||||
all [1][12].
|
||||
|
||||
STATUS codes (returned in the `STATUS` output of the block, not on the wire):
|
||||
|
||||
- `0x0000` — no error.
|
||||
- `0x7001` — first call, connection being established.
|
||||
- `0x7002` — subsequent cyclic call, connection in progress.
|
||||
- `0x8383` — data access error (optimized DB, DB too small, or type mismatch)
|
||||
[10][24].
|
||||
- `0x8188` — invalid parameter combination (e.g. MB_MODE out of range) [24].
|
||||
- `0x80C8` — mismatched UNIT_ID between MB_CLIENT and `MB_SERVER` [25].
|
||||
|
||||
Test names:
|
||||
`S7_1200_FC03_Outside_HoldReg_Returns_IllegalDataAddress`,
|
||||
`S7_1200_FC16_In_STOP_Returns_ServerFailure`,
|
||||
`S7_1200_FC03_In_STOP_Returns_Cached_Values`,
|
||||
`S7_1200_No_Proprietary_ExceptionCodes_0x05_0x06_0x0A_0x0B`.
|
||||
|
||||
## Connection Behavior
|
||||
|
||||
- **Max simultaneous Modbus TCP connections**:
|
||||
- **S7-1200**: shares a pool of 8 open-communication connections across
|
||||
all TCP/UDP/Modbus use. On a CPU 1211C you get 8 total; on 1215C/1217C
|
||||
still 8 shared among PG/HMI/OUC/Modbus. Each `MB_SERVER` instance
|
||||
reserves one. A typical site with a PG + 1 HMI + 2 Modbus clients uses
|
||||
4 of the 8 [1][26].
|
||||
- **S7-1500**: up to **8 concurrent Modbus TCP server connections** per
|
||||
`MB_SERVER` port, across multiple `MB_SERVER` instance DBs each with a
|
||||
unique port. Total open-communication resources depend on CPU (e.g.
|
||||
CPU 1515-2 PN supports 128 OUC connections total; Modbus is a subset)
|
||||
[1][27].
|
||||
- **CP 343-1 Lean**: up to **8** simultaneous Modbus TCP connections on
|
||||
port 502 [4][5]. Exceeding this refuses at TCP accept.
|
||||
- **CP 443-1 Advanced**: up to **16** simultaneous Modbus TCP connections
|
||||
[4].
|
||||
- **Multi-connection model on `MB_SERVER`**: one instance DB per connection.
|
||||
An instance DB listening on port 502 serves exactly one connection at a
|
||||
time; to serve N simultaneous clients you need N instance DBs each with a
|
||||
unique port (502/503/504...). **This is a real trap** — most users expect
|
||||
port 502 to multiplex [27][28]. Our driver must not assume port 502 is the
|
||||
only listener.
|
||||
- **Keep-alive**: S7-1500's TCP stack does send TCP keepalives (default
|
||||
every ~30 s) but the interval is not exposed as a configurable. S7-1200 is
|
||||
the same. CP 343-1 keepalives are configured via HW Config → CP properties
|
||||
→ Options → "Send keepalive" (default **off** on older firmware, default
|
||||
**on** on firmware V3.0+) [1][29]. Driver-side keepalive is still
|
||||
advisable for S7-300/CP 343-1 on old firmware.
|
||||
- **Idle-timeout close**: `MB_SERVER` does *not* close idle sockets on its
|
||||
own. However, the TCP stack on S7-1500 will close a socket that fails
|
||||
three consecutive keepalive probes (~2 minutes). Forum reports describe
|
||||
`MB_SERVER` connections "dying overnight" on S7-1500 when an HMI stops
|
||||
polling — the fix is to enable driver-side periodic reads or driver-side
|
||||
TCP keepalive [29][30].
|
||||
- **Reconnect after power cycle**: MB_SERVER starts listening ~1-2 seconds
|
||||
after the CPU reaches RUN. If the client reconnects during STARTUP OB
|
||||
(OB100), the connection is refused until OB1 runs the block at least once.
|
||||
Our driver should back off and retry on `ECONNREFUSED` for the first 5
|
||||
seconds after a power-cycle detection [1][24].
|
||||
- **Unit Identifier**: `MB_SERVER` accepts **any** Unit ID by default — there
|
||||
is no configurable filter; the PLC ignores the Unit ID field entirely.
|
||||
`MB_CLIENT` defaults to Unit ID = 255 as "ignore" [25][31]. Some
|
||||
third-party Modbus-TCP gateways *require* a specific Unit ID; sending
|
||||
anything to S7 is safe. **CP 343-1 `MODBUSCP`** also accepts any Unit ID
|
||||
in server mode, but the parameter DB exposes a `single_write` / `unit_id`
|
||||
field on newer firmware to allow filtering [4].
|
||||
|
||||
Test names:
|
||||
`S7_1200_9th_TCP_Connection_Refused_On_8_Conn_Pool`,
|
||||
`S7_1500_Port_503_Required_For_Second_Instance`,
|
||||
`S7_1200_Reconnect_After_Power_Cycle_Succeeds_Within_5s`,
|
||||
`S7_1200_Unit_ID_Ignored_Any_Accepted`.
|
||||
|
||||
## Behavioral Oddities
|
||||
|
||||
- **Transaction ID echo** is reliable on all S7 variants. `MB_SERVER` copies
|
||||
the MBAP TxId verbatim. No known firmware that drops TxId under load [1][31].
|
||||
- **Request serialization**: a single `MB_SERVER` instance serializes
|
||||
requests from its one connected client — the block processes one PDU per
|
||||
call and calls happen once per OB1 scan. OB1 scan time of 5-50 ms puts an
|
||||
upper bound on throughput at ~20-200 requests/sec per connection [1][30].
|
||||
Multiple `MB_SERVER` instances (one per port) run in parallel because OB1
|
||||
calls them sequentially within the same scan.
|
||||
- **OB1 scan coupling**: `MB_SERVER` must be called cyclically from OB1 (or
|
||||
another cyclic OB). If the programmer puts it in a conditional branch
|
||||
that doesn't fire every scan, requests time out. The STATUS `0x7002`
|
||||
"in progress" is *expected* between calls, not an error [1][24].
|
||||
- **Optimized DB backing `MB_HOLD_REG`** — already covered in Address
|
||||
Mapping; STATUS becomes `0x8383`. This is the most common deployment bug
|
||||
on S7-1500 projects migrated from older S7-1200 examples [10][11].
|
||||
- **CPU STOP behaviour** — covered in Exception Codes section. The short
|
||||
version: reads may return stale data without error; writes return exception
|
||||
04 on modern firmware.
|
||||
- **Partial-frame disconnect**: S7-1200/1500 TCP stack closes the socket on
|
||||
any MBAP header where the `Length` field doesn't match the PDU length.
|
||||
Driver must detect half-close and reconnect [1][29].
|
||||
- **MBAP `Protocol ID` must be 0**. Any non-zero value causes the CP/CPU to
|
||||
drop the frame silently (no response, no RST) on S7-1500 firmware V2.0
|
||||
through V2.9; firmware V3.0+ sends an RST [1][30]. *Unconfirmed* whether
|
||||
V3.1 still sends RST or returns to silent drop.
|
||||
- **FC01/FC02 access outside `%Q`/`%I` range**: on S7-1200, requesting
|
||||
coil address 8192 (= `%Q1024.0`) returns exception `02` (Illegal Data
|
||||
Address) [1][9]. The 8192-bit hard cap is a process-image size limit on
|
||||
the CPU, not a Modbus protocol limit.
|
||||
- **`MB_CLIENT` UNIT_ID mismatch with remote `MB_SERVER`** produces STATUS
|
||||
`0x80C8` on the client side, and the server silently discards the frame
|
||||
(no response on the wire) [25]. This matters for Modbus-TCP-to-RTU
|
||||
gateway scenarios where the Unit ID picks the RTU slave.
|
||||
- **Non-IEEE REAL / BCD**: S7 does *not* use BCD like DirectLOGIC. `Real` is
|
||||
always IEEE 754 single-precision. `LReal` (8-byte double) occupies 4
|
||||
Modbus registers in `ABCDEFGH` order (big-endian byte, big-endian word)
|
||||
[15][18].
|
||||
- **`MODBUSCP` single-write** on CP 343-1: a parameter `single_write` in the
|
||||
param DB controls whether FC06 on a register in the "holding register"
|
||||
area triggers a callback to the user program vs. updates the DB directly.
|
||||
Default is direct update. If a ladder programmer enables the callback
|
||||
without implementing the callback OB, FC06 writes hang for 5 seconds then
|
||||
return exception `04` [4].
|
||||
|
||||
Test names:
|
||||
`S7_1200_TxId_Preserved_Across_Burst_Of_50_Requests`,
|
||||
`S7_1200_MBSERVER_Throughput_Capped_By_OB1_Scan`,
|
||||
`S7_1200_MBAP_ProtocolID_NonZero_Frame_Dropped`,
|
||||
`S7_1200_Partial_MBAP_Causes_Half_Close`.
|
||||
|
||||
## Model-specific Differences Worth Separate Test Coverage
|
||||
|
||||
- **S7-1200 V4.0 vs V4.4+**: Older firmware does not support `WString` over
|
||||
`MB_HOLD_REG` and returns `0x8383` if the DB contains one [18][24]. Test
|
||||
both firmware bands separately.
|
||||
- **S7-1500 vs S7-1200**: S7-1500 supports multiple `MB_SERVER` instances on
|
||||
the *same* CPU with different ports cleanly; S7-1200 can too but its
|
||||
8-connection pool is shared tighter [1][27]. Throughput per-connection is
|
||||
~5× faster on S7-1500 because the comms task runs on a dedicated core.
|
||||
- **S7-300 + CP 343-1 vs S7-1200/1500**: parameter-block mapping (not
|
||||
`MB_HOLD_REG` pointer), per-connection license, no `%Q`/`%I` direct
|
||||
access for coils (everything goes through a DB), different STATUS codes
|
||||
(`DONE`/`ERROR`/`STATUS` word pairs vs. the single STATUS word) [4][14].
|
||||
Driver-side it's a different profile.
|
||||
- **CP 343-1 Lean vs CP 343-1 Advanced**: Lean is server-only; Advanced is
|
||||
client + server. Lean's max connections = 8; Advanced = 16 [4][5].
|
||||
- **CP 443-1 in S7-400H**: uses `MODBUSCP_REDUNDANT` which presents two
|
||||
Ethernet endpoints that fail over. Our driver's redundancy support should
|
||||
recognize the S7-400H profile as "two IP addresses, same server state,
|
||||
advertise via `ServerUriArray`" [6].
|
||||
- **ET 200SP CPU (1510SP / 1512SP)**: behaves as S7-1500 from `MB_SERVER`
|
||||
perspective. No known deltas [3].
|
||||
|
||||
## References
|
||||
|
||||
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
|
||||
2. Siemens TIA Portal Online Docs, *MB_SERVER instruction*. https://docs.tia.siemens.cloud/r/simatic_s7_1200_manual_collection_eses_20/communication-processor-and-modbus-tcp/modbus-communication/modbus-tcp/modbus-tcp-instructions/mb_server-communicate-using-profinet-as-modbus-tcp-server-instruction
|
||||
3. Siemens, *SIMATIC S7-1500 Communication Function Manual* (covers ET 200SP CPU). http://public.eandm.com/Public_Docs/s71500_communication_function_manual_en-US_en-US.pdf
|
||||
4. Siemens Industry Online Support, *SIMATIC Modbus/TCP communication using CP 343-1 and CP 443-1 — Programming Manual*, Entry ID 103447617. https://cache.industry.siemens.com/dl/files/617/103447617/att_106971/v1/simatic_modbus_tcp_cp_en-US_en-US.pdf
|
||||
5. Siemens Industry Online Support FAQ *"Which technical data applies for the SIMATIC Modbus/TCP software for CP 343-1 / CP 443-1?"*, Entry ID 104946406. https://www.industry-mobile-support.siemens-info.com/en/article/detail/104946406
|
||||
6. Siemens Industry Online Support, *Redundant Modbus/TCP communication via CP 443-1 in S7-400H systems*, Entry ID 109739212. https://cache.industry.siemens.com/dl/files/212/109739212/att_887886/v1/SIMATIC_modbus_tcp_cp_red_e_en-US.pdf
|
||||
7. Siemens Industry Online Support, *SIMATIC MODBUS (TCP) PN CPU Library — Programming and Operating Manual 06/2014*, Entry ID 75330636. https://support.industry.siemens.com/cs/attachments/75330636/ModbusTCPPNCPUen.pdf
|
||||
8. DMC Inc., *Using an S7-1200 PLC as a Modbus TCP Slave*. https://www.dmcinfo.com/blog/27313/using-an-s7-1200-plc-as-a-modbus-tcp-slave/
|
||||
9. Siemens, *SIMATIC S7-1200 System Manual* (V4.x), "MB_SERVER" pages 736-742. https://www.manualslib.com/manual/1453610/Siemens-S7-1200.html?page=736
|
||||
10. lamaPLC, *Simatic Modbus S7 error- and statuscodes*. https://www.lamaplc.com/doku.php?id=simatic:errorcodes
|
||||
11. ScadaProtocols, *How to Configure Modbus TCP on Siemens S7-1200 (TIA Portal Step-by-Step)*. https://scadaprotocols.com/modbus-tcp-siemens-s7-1200-tia-portal/
|
||||
12. Industrial Monitor Direct, *Reading and Writing Memory Bits via Modbus TCP on S7-1200*. https://industrialmonitordirect.com/blogs/knowledgebase/reading-and-writing-memory-bits-via-modbus-tcp-on-s7-1200
|
||||
13. PLCtalk forum *"Siemens S7-1200 modbus understanding"*. https://www.plctalk.net/forums/threads/siemens-s7-1200-modbus-understanding.104119/
|
||||
14. Siemens SIMATIC S7 Manual, "Function block MODBUSCP — Functionality" (ManualsLib p29). https://www.manualslib.com/manual/1580661/Siemens-Simatic-S7.html?page=29
|
||||
15. Chipkin, *How Real (Floating Point) and 32-bit Data is Encoded in Modbus*. https://store.chipkin.com/articles/how-real-floating-point-and-32-bit-data-is-encoded-in-modbus-rtu-messages
|
||||
16. Siemens Industry Online Support forum, *MODBUS DATA conversion in S7-1200 CPU*, Entry ID 97287. https://support.industry.siemens.com/forum/WW/en/posts/modbus-data-converson-in-s7-1200-cpu/97287
|
||||
17. Industrial Monitor Direct, *Siemens S7-1500 MB_SERVER Modbus TCP Configuration Guide*. https://industrialmonitordirect.com/de/blogs/knowledgebase/siemens-s7-1500-mb-server-modbus-tcp-configuration-guide
|
||||
18. Siemens TIA Portal, *Data types in SIMATIC S7-1200/1500 — String/WString header layout* (system manual, "Elementary Data Types").
|
||||
19. Kepware / PTC, *Siemens TCP/IP Ethernet Driver Help*, "Byte / Word Order" tag property. https://www.opcturkey.com/uploads/siemens-tcp-ip-ethernet-manual.pdf
|
||||
20. Siemens SiePortal forum, *Transfer float out of words*, Entry ID 187811. https://sieportal.siemens.com/en-ww/support/forum/posts/transfer-float-out-of-words/187811 _(operator-reported "S7 swaps float" claim — traced to remote-device issue; **unconfirmed**.)_
|
||||
21. Siemens SiePortal forum, *S7-1200 communication with Modbus TCP*, Entry ID 133086. https://support.industry.siemens.com/forum/WW/en/posts/s7-1200-communication-with-modbus-tcp/133086
|
||||
22. Siemens SiePortal forum, *S7-1500 MB Server Holding Register Max Word*, Entry ID 224636. https://support.industry.siemens.com/forum/WW/en/posts/s7-1500-mb-server-holding-register-max-word/224636
|
||||
23. Siemens, *SIMATIC S7-1500 Technical Specifications* — CPU-specific DB size limits in each CPU manual's "Memory" table.
|
||||
24. Siemens TIA Portal Online Docs, *Error messages (S7-1200, S7-1500) — Modbus instructions*. https://docs.tia.siemens.cloud/r/en-us/v20/modbus-rtu-s7-1200-s7-1500/error-messages-s7-1200-s7-1500
|
||||
25. Industrial Monitor Direct, *Fix Siemens S7-1500 MB_Client UnitID Error 80C8*. https://industrialmonitordirect.com/blogs/knowledgebase/troubleshooting-mb-client-on-s7-1500-cpu-1515sp-modbus-tcp
|
||||
26. Siemens SiePortal forum, *How many TCP connections can the S7-1200 make?*, Entry ID 275570. https://support.industry.siemens.com/forum/WW/en/posts/how-many-tcp-connections-can-the-s7-1200-make/275570
|
||||
27. Siemens SiePortal forum, *Simultaneous connections of Modbus TCP*, Entry ID 189626. https://support.industry.siemens.com/forum/ww/en/posts/simultaneous-connections-of-modbus-tcp/189626
|
||||
28. Siemens SiePortal forum, *How many Modbus TCP IP clients can read simultaneously from S7-1517*, Entry ID 261569. https://support.industry.siemens.com/forum/WW/en/posts/how-many-modbus-tcp-ip-client-can-read-simultaneously-in-s7-1517/261569
|
||||
29. Industrial Monitor Direct, *Troubleshooting Intermittent Modbus TCP Connections on S7-1500 PLC*. https://industrialmonitordirect.com/blogs/knowledgebase/troubleshooting-intermittent-modbus-tcp-connections-on-s7-1500-plc
|
||||
30. PLCtalk forum *"S7-1500 modbus tcp speed?"*. https://www.plctalk.net/forums/threads/s7-1500-modbus-tcp-speed.114046/
|
||||
31. Siemens SiePortal forum, *MB_Unit_ID parameter in Modbus TCP*, Entry ID 156635. https://support.industry.siemens.com/forum/WW/en/posts/mb-unit-id-parameter-in-modbus-tcp/156635
|
||||
102
scripts/install/Install-Services.ps1
Normal file
102
scripts/install/Install-Services.ps1
Normal file
@@ -0,0 +1,102 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Registers the two v2 Windows services on a node: OtOpcUa (main server, net10) and
|
||||
OtOpcUaGalaxyHost (out-of-process Galaxy COM host, net48 x86).
|
||||
|
||||
.DESCRIPTION
|
||||
Phase 2 Stream D.2 — replaces the v1 single-service install (TopShelf-based OtOpcUa.Host).
|
||||
Installs both services with the correct service-account SID + per-process shared secret
|
||||
provisioning per `driver-stability.md §"IPC Security"`. Galaxy.Host depends on OtOpcUa
|
||||
(Galaxy.Host must be reachable when OtOpcUa starts; service dependency wiring + retry
|
||||
handled by OtOpcUa.Server NodeBootstrap).
|
||||
|
||||
.PARAMETER InstallRoot
|
||||
Where the binaries live (typically C:\Program Files\OtOpcUa).
|
||||
|
||||
.PARAMETER ServiceAccount
|
||||
Service account SID or DOMAIN\name. Both services run under this account; the
|
||||
Galaxy.Host pipe ACL only allows this SID to connect (decision #76).
|
||||
|
||||
.PARAMETER GalaxySharedSecret
|
||||
Per-process secret passed to Galaxy.Host via env var. Generated freshly per install.
|
||||
|
||||
.PARAMETER ZbConnection
|
||||
Galaxy ZB SQL connection string (passed to Galaxy.Host via env var).
|
||||
|
||||
.EXAMPLE
|
||||
.\Install-Services.ps1 -InstallRoot 'C:\Program Files\OtOpcUa' -ServiceAccount 'OTOPCUA\svc-otopcua'
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$InstallRoot,
|
||||
[Parameter(Mandatory)] [string]$ServiceAccount,
|
||||
[string]$GalaxySharedSecret,
|
||||
[string]$ZbConnection = 'Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;',
|
||||
[string]$GalaxyClientName = 'OtOpcUa-Galaxy.Host',
|
||||
[string]$GalaxyPipeName = 'OtOpcUaGalaxy'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path "$InstallRoot\OtOpcUa.Server.exe")) {
|
||||
Write-Error "OtOpcUa.Server.exe not found at $InstallRoot — copy the publish output first"
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path "$InstallRoot\Galaxy\OtOpcUa.Driver.Galaxy.Host.exe")) {
|
||||
Write-Error "OtOpcUa.Driver.Galaxy.Host.exe not found at $InstallRoot\Galaxy — copy the publish output first"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Generate a fresh shared secret per install if not supplied. Stored in DPAPI-protected file
|
||||
# rather than the registry so the service account can read it but other local users cannot.
|
||||
if (-not $GalaxySharedSecret) {
|
||||
$bytes = New-Object byte[] 32
|
||||
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
|
||||
$GalaxySharedSecret = [Convert]::ToBase64String($bytes)
|
||||
}
|
||||
|
||||
# Resolve the SID — the IPC ACL needs the SID, not the down-level name.
|
||||
$sid = if ($ServiceAccount.StartsWith('S-1-')) {
|
||||
$ServiceAccount
|
||||
} else {
|
||||
(New-Object System.Security.Principal.NTAccount $ServiceAccount).Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||
}
|
||||
|
||||
# --- Install OtOpcUaGalaxyHost first (OtOpcUa starts after, depends on it being up).
|
||||
$galaxyEnv = @(
|
||||
"OTOPCUA_GALAXY_PIPE=$GalaxyPipeName"
|
||||
"OTOPCUA_ALLOWED_SID=$sid"
|
||||
"OTOPCUA_GALAXY_SECRET=$GalaxySharedSecret"
|
||||
"OTOPCUA_GALAXY_BACKEND=mxaccess"
|
||||
"OTOPCUA_GALAXY_ZB_CONN=$ZbConnection"
|
||||
"OTOPCUA_GALAXY_CLIENT_NAME=$GalaxyClientName"
|
||||
) -join "`0"
|
||||
$galaxyEnv += "`0`0"
|
||||
|
||||
Write-Host "Installing OtOpcUaGalaxyHost..."
|
||||
& sc.exe create OtOpcUaGalaxyHost binPath= "`"$InstallRoot\Galaxy\OtOpcUa.Driver.Galaxy.Host.exe`"" `
|
||||
DisplayName= 'OtOpcUa Galaxy Host (out-of-process MXAccess)' `
|
||||
start= auto `
|
||||
obj= $ServiceAccount | Out-Null
|
||||
|
||||
# Set per-service environment variables via the registry — sc.exe doesn't expose them directly.
|
||||
$svcKey = "HKLM:\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost"
|
||||
$envValue = $galaxyEnv.Split("`0") | Where-Object { $_ -ne '' }
|
||||
Set-ItemProperty -Path $svcKey -Name 'Environment' -Type MultiString -Value $envValue
|
||||
|
||||
# --- Install OtOpcUa (depends on Galaxy host being installed; doesn't strictly require it
|
||||
# started — OtOpcUa.Server NodeBootstrap retries on the IPC connect path).
|
||||
Write-Host "Installing OtOpcUa..."
|
||||
& sc.exe create OtOpcUa binPath= "`"$InstallRoot\OtOpcUa.Server.exe`"" `
|
||||
DisplayName= 'OtOpcUa Server' `
|
||||
start= auto `
|
||||
depend= 'OtOpcUaGalaxyHost' `
|
||||
obj= $ServiceAccount | Out-Null
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Installed. Start with:"
|
||||
Write-Host " sc.exe start OtOpcUaGalaxyHost"
|
||||
Write-Host " sc.exe start OtOpcUa"
|
||||
Write-Host ""
|
||||
Write-Host "Galaxy shared secret (record this offline — required for service rebinding):"
|
||||
Write-Host " $GalaxySharedSecret"
|
||||
18
scripts/install/Uninstall-Services.ps1
Normal file
18
scripts/install/Uninstall-Services.ps1
Normal file
@@ -0,0 +1,18 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stops + removes the two v2 services. Mirrors Install-Services.ps1.
|
||||
#>
|
||||
[CmdletBinding()] param()
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
foreach ($svc in 'OtOpcUa', 'OtOpcUaGalaxyHost') {
|
||||
if (Get-Service $svc -ErrorAction SilentlyContinue) {
|
||||
Write-Host "Stopping $svc..."
|
||||
Stop-Service $svc -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "Removing $svc..."
|
||||
& sc.exe delete $svc | Out-Null
|
||||
} else {
|
||||
Write-Host "$svc not installed — skipping"
|
||||
}
|
||||
}
|
||||
Write-Host "Done."
|
||||
107
scripts/migration/Migrate-AppSettings-To-DriverConfig.ps1
Normal file
107
scripts/migration/Migrate-AppSettings-To-DriverConfig.ps1
Normal file
@@ -0,0 +1,107 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Translates a v1 OtOpcUa.Host appsettings.json into a v2 DriverInstance.DriverConfig JSON
|
||||
blob suitable for upserting into the central Configuration DB.
|
||||
|
||||
.DESCRIPTION
|
||||
Phase 2 Stream D.3 — moves the legacy MxAccess + GalaxyRepository + Historian sections out
|
||||
of node-local appsettings.json and into the central DB so each node only needs Cluster.NodeId
|
||||
+ ClusterId + DB conn (per decision #18). Idempotent + dry-run-able.
|
||||
|
||||
Output shape matches the Galaxy DriverType schema in `docs/v2/plan.md` §"Galaxy DriverConfig":
|
||||
|
||||
{
|
||||
"MxAccess": { "ClientName": "...", "RequestTimeoutSeconds": 30 },
|
||||
"Database": { "ConnectionString": "...", "PollIntervalSeconds": 60 },
|
||||
"Historian": { "Enabled": false }
|
||||
}
|
||||
|
||||
.PARAMETER AppSettingsPath
|
||||
Path to the v1 appsettings.json. Defaults to ../../src/ZB.MOM.WW.OtOpcUa.Host/appsettings.json
|
||||
relative to the script.
|
||||
|
||||
.PARAMETER OutputPath
|
||||
Where to write the generated DriverConfig JSON. Defaults to stdout.
|
||||
|
||||
.PARAMETER DryRun
|
||||
Print what would be written without writing.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./Migrate-AppSettings-To-DriverConfig.ps1 -AppSettingsPath C:\OtOpcUa\appsettings.json -OutputPath C:\tmp\galaxy-driverconfig.json
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$AppSettingsPath,
|
||||
[string]$OutputPath,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $AppSettingsPath) {
|
||||
$AppSettingsPath = Join-Path (Split-Path -Parent $PSScriptRoot) '..\src\ZB.MOM.WW.OtOpcUa.Host\appsettings.json'
|
||||
}
|
||||
|
||||
if (-not (Test-Path $AppSettingsPath)) {
|
||||
Write-Error "AppSettings file not found: $AppSettingsPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$src = Get-Content -Raw $AppSettingsPath | ConvertFrom-Json
|
||||
|
||||
$mx = $src.MxAccess
|
||||
$gr = $src.GalaxyRepository
|
||||
$hi = $src.Historian
|
||||
|
||||
$driverConfig = [ordered]@{
|
||||
MxAccess = [ordered]@{
|
||||
ClientName = $mx.ClientName
|
||||
NodeName = $mx.NodeName
|
||||
GalaxyName = $mx.GalaxyName
|
||||
RequestTimeoutSeconds = $mx.ReadTimeoutSeconds
|
||||
WriteTimeoutSeconds = $mx.WriteTimeoutSeconds
|
||||
MaxConcurrentOps = $mx.MaxConcurrentOperations
|
||||
MonitorIntervalSec = $mx.MonitorIntervalSeconds
|
||||
AutoReconnect = $mx.AutoReconnect
|
||||
ProbeTag = $mx.ProbeTag
|
||||
}
|
||||
Database = [ordered]@{
|
||||
ConnectionString = $gr.ConnectionString
|
||||
ChangeDetectionIntervalSec = $gr.ChangeDetectionIntervalSeconds
|
||||
CommandTimeoutSeconds = $gr.CommandTimeoutSeconds
|
||||
ExtendedAttributes = $gr.ExtendedAttributes
|
||||
Scope = $gr.Scope
|
||||
PlatformName = $gr.PlatformName
|
||||
}
|
||||
Historian = [ordered]@{
|
||||
Enabled = if ($null -ne $hi -and $null -ne $hi.Enabled) { $hi.Enabled } else { $false }
|
||||
}
|
||||
}
|
||||
|
||||
# Strip null-valued leaves so the resulting JSON is compact and round-trippable.
|
||||
function Remove-Nulls($obj) {
|
||||
$keys = @($obj.Keys)
|
||||
foreach ($k in $keys) {
|
||||
if ($null -eq $obj[$k]) { $obj.Remove($k) | Out-Null }
|
||||
elseif ($obj[$k] -is [System.Collections.Specialized.OrderedDictionary]) { Remove-Nulls $obj[$k] }
|
||||
}
|
||||
}
|
||||
Remove-Nulls $driverConfig
|
||||
|
||||
$json = $driverConfig | ConvertTo-Json -Depth 8
|
||||
|
||||
if ($DryRun) {
|
||||
Write-Host "=== DriverConfig (dry-run, would write to $OutputPath) ==="
|
||||
Write-Host $json
|
||||
return
|
||||
}
|
||||
|
||||
if ($OutputPath) {
|
||||
$dir = Split-Path -Parent $OutputPath
|
||||
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
|
||||
Set-Content -Path $OutputPath -Value $json -Encoding UTF8
|
||||
Write-Host "Wrote DriverConfig to $OutputPath"
|
||||
}
|
||||
else {
|
||||
$json
|
||||
}
|
||||
18
src/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor
Normal file
18
src/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor
Normal file
@@ -0,0 +1,18 @@
|
||||
@* Root Blazor component. *@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>OtOpcUa Admin</title>
|
||||
<base href="/"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
|
||||
<link rel="stylesheet" href="app.css"/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
<body>
|
||||
<Routes/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="d-flex" style="min-height: 100vh;">
|
||||
<nav class="bg-dark text-light p-3" style="width: 220px;">
|
||||
<h5 class="mb-4">OtOpcUa Admin</h5>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/">Overview</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/fleet">Fleet status</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/hosts">Host status</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/reservations">Reservations</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/certificates">Certificates</a></li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-5">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="small text-light">
|
||||
Signed in as <a class="text-light" href="/account"><strong>@context.User.Identity?.Name</strong></a>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))
|
||||
</div>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn btn-sm btn-outline-light mt-2" type="submit">Sign out</button>
|
||||
</form>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<a class="btn btn-sm btn-outline-light" href="/login">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="flex-grow-1 p-4">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
129
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor
Normal file
129
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Account.razor
Normal file
@@ -0,0 +1,129 @@
|
||||
@page "/account"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
|
||||
@using System.Security.Claims
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
|
||||
<h1 class="mb-4">My account</h1>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
@{
|
||||
var username = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "—";
|
||||
var displayName = context.User.Identity?.Name ?? "—";
|
||||
var roles = context.User.Claims
|
||||
.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToList();
|
||||
var ldapGroups = context.User.Claims
|
||||
.Where(c => c.Type == "ldap_group").Select(c => c.Value).ToList();
|
||||
}
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Identity</h5>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">Username</dt><dd class="col-sm-8"><code>@username</code></dd>
|
||||
<dt class="col-sm-4">Display name</dt><dd class="col-sm-8">@displayName</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Admin roles</h5>
|
||||
@if (roles.Count == 0)
|
||||
{
|
||||
<p class="text-muted mb-0">No Admin roles mapped — sign-in would have been blocked, so if you're seeing this, the session claim is likely stale.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-2">
|
||||
@foreach (var r in roles)
|
||||
{
|
||||
<span class="badge bg-primary me-1">@r</span>
|
||||
}
|
||||
</div>
|
||||
<small class="text-muted">LDAP groups: @(ldapGroups.Count == 0 ? "(none surfaced)" : string.Join(", ", ldapGroups))</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Capabilities</h5>
|
||||
<p class="text-muted small">
|
||||
Each Admin role grants a fixed capability set per <code>admin-ui.md</code> §Admin Roles.
|
||||
Pages below reflect what this session can access; the route's <code>[Authorize]</code> guard
|
||||
is the ground truth — this table mirrors it for readability.
|
||||
</p>
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Capability</th>
|
||||
<th>Required role(s)</th>
|
||||
<th class="text-end">You have it?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var cap in Capabilities)
|
||||
{
|
||||
var has = cap.RequiredRoles.Any(r => roles.Contains(r, StringComparer.OrdinalIgnoreCase));
|
||||
<tr>
|
||||
<td>@cap.Name<br /><small class="text-muted">@cap.Description</small></td>
|
||||
<td>@string.Join(" or ", cap.RequiredRoles)</td>
|
||||
<td class="text-end">
|
||||
@if (has)
|
||||
{
|
||||
<span class="badge bg-success">Yes</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">No</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn btn-outline-danger" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@code {
|
||||
private sealed record Capability(string Name, string Description, string[] RequiredRoles);
|
||||
|
||||
// Kept in sync with Program.cs authorization policies + each page's [Authorize] attribute.
|
||||
// When a new page or policy is added, extend this list so operators can self-service check
|
||||
// whether their session has access without trial-and-error navigation.
|
||||
private static readonly IReadOnlyList<Capability> Capabilities =
|
||||
[
|
||||
new("View clusters + fleet status",
|
||||
"Read-only access to the cluster list, fleet dashboard, and generation history.",
|
||||
[AdminRoles.ConfigViewer, AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
||||
new("Edit configuration drafts",
|
||||
"Create and edit draft generations, manage namespace bindings and node ACLs. CanEdit policy.",
|
||||
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
||||
new("Publish generations",
|
||||
"Promote a draft to Published — triggers node roll-out. CanPublish policy.",
|
||||
[AdminRoles.FleetAdmin]),
|
||||
new("Manage certificate trust",
|
||||
"Trust rejected client certs + revoke trust. FleetAdmin-only because the trust decision gates OPC UA client access.",
|
||||
[AdminRoles.FleetAdmin]),
|
||||
new("Manage external-ID reservations",
|
||||
"Reserve / release external IDs that map into Galaxy contained names.",
|
||||
[AdminRoles.ConfigEditor, AdminRoles.FleetAdmin]),
|
||||
];
|
||||
}
|
||||
154
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor
Normal file
154
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Certificates.razor
Normal file
@@ -0,0 +1,154 @@
|
||||
@page "/certificates"
|
||||
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Roles = AdminRoles.FleetAdmin)]
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@inject CertTrustService Certs
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
@inject ILogger<Certificates> Log
|
||||
|
||||
<h1 class="mb-4">Certificate trust</h1>
|
||||
|
||||
<div class="alert alert-info small mb-4">
|
||||
PKI store root <code>@Certs.PkiStoreRoot</code>. Trusting a rejected cert moves the file into the trusted store — the OPC UA server picks up the change on the next client handshake, so operators should retry the rejected client's connection after trusting.
|
||||
</div>
|
||||
|
||||
@if (_status is not null)
|
||||
{
|
||||
<div class="alert alert-@_statusKind alert-dismissible">
|
||||
@_status
|
||||
<button type="button" class="btn-close" @onclick="ClearStatus"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<h2 class="h4">Rejected (@_rejected.Count)</h2>
|
||||
@if (_rejected.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No rejected certificates. Clients that fail to handshake with an untrusted cert land here.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _rejected)
|
||||
{
|
||||
<tr>
|
||||
<td>@c.Subject</td>
|
||||
<td>@c.Issuer</td>
|
||||
<td><code class="small">@c.Thumbprint</code></td>
|
||||
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-success me-1" @onclick="() => TrustAsync(c)">Trust</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteRejectedAsync(c)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h2 class="h4 mt-5">Trusted (@_trusted.Count)</h2>
|
||||
@if (_trusted.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No client certs have been explicitly trusted. The server's own application cert lives in <code>own/</code> and is not listed here.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Subject</th><th>Issuer</th><th>Thumbprint</th><th>Valid</th><th class="text-end">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _trusted)
|
||||
{
|
||||
<tr>
|
||||
<td>@c.Subject</td>
|
||||
<td>@c.Issuer</td>
|
||||
<td><code class="small">@c.Thumbprint</code></td>
|
||||
<td class="small">@c.NotBefore.ToString("yyyy-MM-dd") → @c.NotAfter.ToString("yyyy-MM-dd")</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => UntrustAsync(c)">Revoke</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<CertInfo> _rejected = [];
|
||||
private IReadOnlyList<CertInfo> _trusted = [];
|
||||
private string? _status;
|
||||
private string _statusKind = "success";
|
||||
|
||||
protected override void OnInitialized() => Reload();
|
||||
|
||||
private void Reload()
|
||||
{
|
||||
_rejected = Certs.ListRejected();
|
||||
_trusted = Certs.ListTrusted();
|
||||
}
|
||||
|
||||
private async Task TrustAsync(CertInfo c)
|
||||
{
|
||||
if (Certs.TrustRejected(c.Thumbprint))
|
||||
{
|
||||
await LogActionAsync("cert.trust", c);
|
||||
Set($"Trusted cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
|
||||
}
|
||||
else
|
||||
{
|
||||
Set($"Could not trust {Short(c.Thumbprint)} — file missing; another admin may have already handled it.", "warning");
|
||||
}
|
||||
Reload();
|
||||
}
|
||||
|
||||
private async Task DeleteRejectedAsync(CertInfo c)
|
||||
{
|
||||
if (Certs.DeleteRejected(c.Thumbprint))
|
||||
{
|
||||
await LogActionAsync("cert.delete.rejected", c);
|
||||
Set($"Deleted rejected cert {c.Subject} ({Short(c.Thumbprint)}).", "success");
|
||||
}
|
||||
else
|
||||
{
|
||||
Set($"Could not delete {Short(c.Thumbprint)} — file missing.", "warning");
|
||||
}
|
||||
Reload();
|
||||
}
|
||||
|
||||
private async Task UntrustAsync(CertInfo c)
|
||||
{
|
||||
if (Certs.UntrustCert(c.Thumbprint))
|
||||
{
|
||||
await LogActionAsync("cert.untrust", c);
|
||||
Set($"Revoked trust for {c.Subject} ({Short(c.Thumbprint)}).", "success");
|
||||
}
|
||||
else
|
||||
{
|
||||
Set($"Could not revoke {Short(c.Thumbprint)} — file missing.", "warning");
|
||||
}
|
||||
Reload();
|
||||
}
|
||||
|
||||
private async Task LogActionAsync(string action, CertInfo c)
|
||||
{
|
||||
// Cert trust changes are operator-initiated and security-sensitive — Serilog captures the
|
||||
// user + thumbprint trail. CertTrustService also logs at Information on each filesystem
|
||||
// move/delete; this line ties the action to the authenticated admin user so the two logs
|
||||
// correlate. DB-level ConfigAuditLog persistence is deferred — its schema is
|
||||
// cluster-scoped and cert actions are cluster-agnostic.
|
||||
var state = await AuthState.GetAuthenticationStateAsync();
|
||||
var user = state.User.Identity?.Name ?? "(anonymous)";
|
||||
Log.LogInformation("Admin cert action: user={User} action={Action} thumbprint={Thumbprint} subject={Subject}",
|
||||
user, action, c.Thumbprint, c.Subject);
|
||||
}
|
||||
|
||||
private void Set(string message, string kind)
|
||||
{
|
||||
_status = message;
|
||||
_statusKind = kind;
|
||||
}
|
||||
|
||||
private void ClearStatus() => _status = null;
|
||||
|
||||
private static string Short(string thumbprint) =>
|
||||
thumbprint.Length > 12 ? thumbprint[..12] + "…" : thumbprint;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject NodeAclService AclSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Access-control grants</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add grant</button>
|
||||
</div>
|
||||
|
||||
@if (_acls is null) { <p>Loading…</p> }
|
||||
else if (_acls.Count == 0) { <p class="text-muted">No ACL grants in this draft. Publish will result in a cluster with no external access.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>LDAP group</th><th>Scope</th><th>Scope ID</th><th>Permissions</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _acls)
|
||||
{
|
||||
<tr>
|
||||
<td>@a.LdapGroup</td>
|
||||
<td>@a.ScopeKind</td>
|
||||
<td><code>@(a.ScopeId ?? "-")</code></td>
|
||||
<td><code>@a.PermissionFlags</code></td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => RevokeAsync(a.NodeAclRowId)">Revoke</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">LDAP group</label>
|
||||
<input class="form-control" @bind="_group"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Scope kind</label>
|
||||
<select class="form-select" @bind="_scopeKind">
|
||||
@foreach (var k in Enum.GetValues<NodeAclScopeKind>()) { <option value="@k">@k</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Scope ID (empty for Cluster-wide)</label>
|
||||
<input class="form-control" @bind="_scopeId"/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Permissions (bundled presets — per-flag editor in v2.1)</label>
|
||||
<select class="form-select" @bind="_preset">
|
||||
<option value="Read">Read (Browse + Read)</option>
|
||||
<option value="WriteOperate">Read + Write Operate</option>
|
||||
<option value="Engineer">Read + Write Tune + Write Configure</option>
|
||||
<option value="AlarmAck">Read + Alarm Ack</option>
|
||||
<option value="Full">Full (every flag)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<NodeAcl>? _acls;
|
||||
private bool _showForm;
|
||||
private string _group = string.Empty;
|
||||
private NodeAclScopeKind _scopeKind = NodeAclScopeKind.Cluster;
|
||||
private string _scopeId = string.Empty;
|
||||
private string _preset = "Read";
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnParametersSetAsync() =>
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
|
||||
private NodePermissions ResolvePreset() => _preset switch
|
||||
{
|
||||
"Read" => NodePermissions.Browse | NodePermissions.Read,
|
||||
"WriteOperate" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteOperate,
|
||||
"Engineer" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.WriteTune | NodePermissions.WriteConfigure,
|
||||
"AlarmAck" => NodePermissions.Browse | NodePermissions.Read | NodePermissions.AlarmRead | NodePermissions.AlarmAcknowledge,
|
||||
"Full" => unchecked((NodePermissions)(-1)),
|
||||
_ => NodePermissions.Browse | NodePermissions.Read,
|
||||
};
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
if (string.IsNullOrWhiteSpace(_group)) { _error = "LDAP group is required"; return; }
|
||||
|
||||
var scopeId = _scopeKind == NodeAclScopeKind.Cluster ? null
|
||||
: string.IsNullOrWhiteSpace(_scopeId) ? null : _scopeId;
|
||||
|
||||
if (_scopeKind != NodeAclScopeKind.Cluster && scopeId is null)
|
||||
{
|
||||
_error = $"ScopeId required for {_scopeKind}";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await AclSvc.GrantAsync(GenerationId, ClusterId, _group, _scopeKind, scopeId,
|
||||
ResolvePreset(), notes: null, CancellationToken.None);
|
||||
_group = string.Empty; _scopeId = string.Empty;
|
||||
_showForm = false;
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task RevokeAsync(Guid rowId)
|
||||
{
|
||||
await AclSvc.RevokeAsync(rowId, CancellationToken.None);
|
||||
_acls = await AclSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject AuditLogService AuditSvc
|
||||
|
||||
<h4>Recent audit log</h4>
|
||||
|
||||
@if (_entries is null) { <p>Loading…</p> }
|
||||
else if (_entries.Count == 0) { <p class="text-muted">No audit entries for this cluster yet.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>When</th><th>Principal</th><th>Event</th><th>Node</th><th>Generation</th><th>Details</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _entries)
|
||||
{
|
||||
<tr>
|
||||
<td>@a.Timestamp.ToString("u")</td>
|
||||
<td>@a.Principal</td>
|
||||
<td><code>@a.EventType</code></td>
|
||||
<td>@a.NodeId</td>
|
||||
<td>@a.GenerationId</td>
|
||||
<td><small class="text-muted">@a.DetailsJson</small></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private List<ConfigAuditLog>? _entries;
|
||||
|
||||
protected override async Task OnParametersSetAsync() =>
|
||||
_entries = await AuditSvc.ListRecentAsync(ClusterId, limit: 100, CancellationToken.None);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
@page "/clusters/{ClusterId}"
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Hubs
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@implements IAsyncDisposable
|
||||
@rendermode RenderMode.InteractiveServer
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
@if (_cluster is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_liveBanner is not null)
|
||||
{
|
||||
<div class="alert alert-info py-2 small">
|
||||
<strong>Live update:</strong> @_liveBanner
|
||||
<button type="button" class="btn-close float-end" @onclick="() => _liveBanner = null"></button>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">@_cluster.Name</h1>
|
||||
<code class="text-muted">@_cluster.ClusterId</code>
|
||||
@if (!_cluster.Enabled) { <span class="badge bg-secondary ms-2">Disabled</span> }
|
||||
</div>
|
||||
<div>
|
||||
@if (_currentDraft is not null)
|
||||
{
|
||||
<a href="/clusters/@ClusterId/draft/@_currentDraft.GenerationId" class="btn btn-outline-primary">
|
||||
Edit current draft (gen @_currentDraft.GenerationId)
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-primary" @onclick="CreateDraftAsync" disabled="@_busy">New draft</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><button class="nav-link @Tab("overview")" @onclick='() => _tab = "overview"'>Overview</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("generations")" @onclick='() => _tab = "generations"'>Generations</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("equipment")" @onclick='() => _tab = "equipment"'>Equipment</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("uns")" @onclick='() => _tab = "uns"'>UNS Structure</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Tab("audit")" @onclick='() => _tab = "audit"'>Audit</button></li>
|
||||
</ul>
|
||||
|
||||
@if (_tab == "overview")
|
||||
{
|
||||
<dl class="row">
|
||||
<dt class="col-sm-3">Enterprise / Site</dt><dd class="col-sm-9">@_cluster.Enterprise / @_cluster.Site</dd>
|
||||
<dt class="col-sm-3">Redundancy</dt><dd class="col-sm-9">@_cluster.RedundancyMode (@_cluster.NodeCount node@(_cluster.NodeCount == 1 ? "" : "s"))</dd>
|
||||
<dt class="col-sm-3">Current published</dt>
|
||||
<dd class="col-sm-9">
|
||||
@if (_currentPublished is not null) { <span>@_currentPublished.GenerationId (@_currentPublished.PublishedAt?.ToString("u"))</span> }
|
||||
else { <span class="text-muted">none published yet</span> }
|
||||
</dd>
|
||||
<dt class="col-sm-3">Created</dt><dd class="col-sm-9">@_cluster.CreatedAt.ToString("u") by @_cluster.CreatedBy</dd>
|
||||
</dl>
|
||||
}
|
||||
else if (_tab == "generations")
|
||||
{
|
||||
<Generations ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "equipment" && _currentDraft is not null)
|
||||
{
|
||||
<EquipmentTab GenerationId="@_currentDraft.GenerationId"/>
|
||||
}
|
||||
else if (_tab == "uns" && _currentDraft is not null)
|
||||
{
|
||||
<UnsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "namespaces" && _currentDraft is not null)
|
||||
{
|
||||
<NamespacesTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "drivers" && _currentDraft is not null)
|
||||
{
|
||||
<DriversTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "acls" && _currentDraft is not null)
|
||||
{
|
||||
<AclsTab GenerationId="@_currentDraft.GenerationId" ClusterId="@ClusterId"/>
|
||||
}
|
||||
else if (_tab == "audit")
|
||||
{
|
||||
<AuditTab ClusterId="@ClusterId"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">Open a draft to edit this cluster's content.</p>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private ServerCluster? _cluster;
|
||||
private ConfigGeneration? _currentDraft;
|
||||
private ConfigGeneration? _currentPublished;
|
||||
private string _tab = "overview";
|
||||
private bool _busy;
|
||||
private HubConnection? _hub;
|
||||
private string? _liveBanner;
|
||||
|
||||
private string Tab(string key) => _tab == key ? "active" : string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
await ConnectHubAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
_cluster = await ClusterSvc.FindAsync(ClusterId, CancellationToken.None);
|
||||
var gens = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None);
|
||||
_currentDraft = gens.FirstOrDefault(g => g.Status == GenerationStatus.Draft);
|
||||
_currentPublished = gens.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||
}
|
||||
|
||||
private async Task ConnectHubAsync()
|
||||
{
|
||||
_hub = new HubConnectionBuilder()
|
||||
.WithUrl(Nav.ToAbsoluteUri("/hubs/fleet"))
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hub.On<NodeStateChangedMessage>("NodeStateChanged", async msg =>
|
||||
{
|
||||
if (msg.ClusterId != ClusterId) return;
|
||||
_liveBanner = $"Node {msg.NodeId}: {msg.LastAppliedStatus ?? "seen"} at {msg.LastAppliedAt?.ToString("u") ?? msg.LastSeenAt?.ToString("u") ?? "-"}";
|
||||
await LoadAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
|
||||
await _hub.StartAsync();
|
||||
await _hub.SendAsync("SubscribeCluster", ClusterId);
|
||||
}
|
||||
|
||||
private async Task CreateDraftAsync()
|
||||
{
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
var draft = await GenerationSvc.CreateDraftAsync(ClusterId, createdBy: "admin-ui", CancellationToken.None);
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}/draft/{draft.GenerationId}");
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_hub is not null) await _hub.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
@page "/clusters"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject ClusterService ClusterSvc
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Clusters</h1>
|
||||
<a href="/clusters/new" class="btn btn-primary">New cluster</a>
|
||||
</div>
|
||||
|
||||
@if (_clusters is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_clusters.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No clusters yet. Create the first one.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ClusterId</th><th>Name</th><th>Enterprise</th><th>Site</th>
|
||||
<th>RedundancyMode</th><th>NodeCount</th><th>Enabled</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@c.ClusterId</code></td>
|
||||
<td>@c.Name</td>
|
||||
<td>@c.Enterprise</td>
|
||||
<td>@c.Site</td>
|
||||
<td>@c.RedundancyMode</td>
|
||||
<td>@c.NodeCount</td>
|
||||
<td>
|
||||
@if (c.Enabled) { <span class="badge bg-success">Active</span> }
|
||||
else { <span class="badge bg-secondary">Disabled</span> }
|
||||
</td>
|
||||
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ServerCluster>? _clusters;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@page "/clusters/{ClusterId}/draft/{GenerationId:long}/diff"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject GenerationService GenerationSvc
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">Draft diff</h1>
|
||||
<small class="text-muted">
|
||||
Cluster <code>@ClusterId</code> — from last published (@(_fromLabel)) → to draft @GenerationId
|
||||
</small>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId/draft/@GenerationId">Back to editor</a>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Computing diff…</p>
|
||||
}
|
||||
else if (_error is not null)
|
||||
{
|
||||
<div class="alert alert-danger">@_error</div>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No differences — draft is structurally identical to the last published generation.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-hover table-sm">
|
||||
<thead><tr><th>Table</th><th>LogicalId</th><th>ChangeKind</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr>
|
||||
<td>@r.TableName</td>
|
||||
<td><code>@r.LogicalId</code></td>
|
||||
<td>
|
||||
@switch (r.ChangeKind)
|
||||
{
|
||||
case "Added": <span class="badge bg-success">@r.ChangeKind</span> break;
|
||||
case "Removed": <span class="badge bg-danger">@r.ChangeKind</span> break;
|
||||
case "Modified": <span class="badge bg-warning text-dark">@r.ChangeKind</span> break;
|
||||
default: <span class="badge bg-secondary">@r.ChangeKind</span> break;
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
private List<DiffRow>? _rows;
|
||||
private string _fromLabel = "(empty)";
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var all = await GenerationSvc.ListRecentAsync(ClusterId, 50, CancellationToken.None);
|
||||
var from = all.FirstOrDefault(g => g.Status == GenerationStatus.Published);
|
||||
_fromLabel = from is null ? "(empty)" : $"gen {from.GenerationId}";
|
||||
_rows = await GenerationSvc.ComputeDiffAsync(from?.GenerationId ?? 0, GenerationId, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
@page "/clusters/{ClusterId}/draft/{GenerationId:long}"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject DraftValidationService ValidationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h1 class="mb-0">Draft editor</h1>
|
||||
<small class="text-muted">Cluster <code>@ClusterId</code> · generation @GenerationId</small>
|
||||
</div>
|
||||
<div>
|
||||
<a class="btn btn-outline-secondary" href="/clusters/@ClusterId">Back to cluster</a>
|
||||
<a class="btn btn-outline-primary ms-2" href="/clusters/@ClusterId/draft/@GenerationId/diff">View diff</a>
|
||||
<button class="btn btn-primary ms-2" disabled="@(_errors.Count != 0 || _busy)" @onclick="PublishAsync">Publish</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><button class="nav-link @Active("equipment")" @onclick='() => _tab = "equipment"'>Equipment</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("uns")" @onclick='() => _tab = "uns"'>UNS</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||
</ul>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
@if (_tab == "equipment") { <EquipmentTab GenerationId="@GenerationId"/> }
|
||||
else if (_tab == "uns") { <UnsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card sticky-top">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Validation</strong>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="RevalidateAsync">Re-run</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (_validating) { <p class="text-muted">Checking…</p> }
|
||||
else if (_errors.Count == 0) { <div class="alert alert-success mb-0">No validation errors — safe to publish.</div> }
|
||||
else
|
||||
{
|
||||
<div class="alert alert-danger mb-2">@_errors.Count error@(_errors.Count == 1 ? "" : "s")</div>
|
||||
<ul class="list-unstyled">
|
||||
@foreach (var e in _errors)
|
||||
{
|
||||
<li class="mb-2">
|
||||
<span class="badge bg-danger me-1">@e.Code</span>
|
||||
<small>@e.Message</small>
|
||||
@if (!string.IsNullOrEmpty(e.Context)) { <div class="text-muted"><code>@e.Context</code></div> }
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_publishError is not null) { <div class="alert alert-danger mt-3">@_publishError</div> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
|
||||
private string _tab = "equipment";
|
||||
private List<ValidationError> _errors = [];
|
||||
private bool _validating;
|
||||
private bool _busy;
|
||||
private string? _publishError;
|
||||
|
||||
private string Active(string k) => _tab == k ? "active" : string.Empty;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await RevalidateAsync();
|
||||
|
||||
private async Task RevalidateAsync()
|
||||
{
|
||||
_validating = true;
|
||||
try
|
||||
{
|
||||
var errors = await ValidationSvc.ValidateAsync(GenerationId, CancellationToken.None);
|
||||
_errors = errors.ToList();
|
||||
}
|
||||
finally { _validating = false; }
|
||||
}
|
||||
|
||||
private async Task PublishAsync()
|
||||
{
|
||||
_busy = true;
|
||||
_publishError = null;
|
||||
try
|
||||
{
|
||||
await GenerationSvc.PublishAsync(ClusterId, GenerationId, notes: "Published via Admin UI", CancellationToken.None);
|
||||
Nav.NavigateTo($"/clusters/{ClusterId}");
|
||||
}
|
||||
catch (Exception ex) { _publishError = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject DriverInstanceService DriverSvc
|
||||
@inject NamespaceService NsSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>DriverInstances</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add driver</button>
|
||||
</div>
|
||||
|
||||
@if (_drivers is null) { <p>Loading…</p> }
|
||||
else if (_drivers.Count == 0) { <p class="text-muted">No drivers configured in this draft.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>DriverInstanceId</th><th>Name</th><th>Type</th><th>Namespace</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var d in _drivers)
|
||||
{
|
||||
<tr><td><code>@d.DriverInstanceId</code></td><td>@d.Name</td><td>@d.DriverType</td><td><code>@d.NamespaceId</code></td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm && _namespaces is not null)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control" @bind="_name"/>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">DriverType</label>
|
||||
<select class="form-select" @bind="_type">
|
||||
<option>Galaxy</option>
|
||||
<option>ModbusTcp</option>
|
||||
<option>AbCip</option>
|
||||
<option>AbLegacy</option>
|
||||
<option>S7</option>
|
||||
<option>Focas</option>
|
||||
<option>OpcUaClient</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Namespace</label>
|
||||
<select class="form-select" @bind="_nsId">
|
||||
@foreach (var n in _namespaces) { <option value="@n.NamespaceId">@n.Kind — @n.NamespaceUri</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">DriverConfig JSON (schemaless per driver type)</label>
|
||||
<textarea class="form-control font-monospace" rows="6" @bind="_config"></textarea>
|
||||
<div class="form-text">Phase 1: generic JSON editor — per-driver schema validation arrives in each driver's phase (decision #94).</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<DriverInstance>? _drivers;
|
||||
private List<Namespace>? _namespaces;
|
||||
private bool _showForm;
|
||||
private string _name = string.Empty;
|
||||
private string _type = "ModbusTcp";
|
||||
private string _nsId = string.Empty;
|
||||
private string _config = "{}";
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_drivers = await DriverSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_nsId = _namespaces.FirstOrDefault()?.NamespaceId ?? string.Empty;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
if (string.IsNullOrWhiteSpace(_name) || string.IsNullOrWhiteSpace(_nsId))
|
||||
{
|
||||
_error = "Name and Namespace are required";
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
await DriverSvc.AddAsync(GenerationId, ClusterId, _nsId, _name, _type, _config, CancellationToken.None);
|
||||
_name = string.Empty; _config = "{}";
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||
@inject EquipmentService EquipmentSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Equipment (draft gen @GenerationId)</h4>
|
||||
<button class="btn btn-primary btn-sm" @onclick="StartAdd">Add equipment</button>
|
||||
</div>
|
||||
|
||||
@if (_equipment is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_equipment.Count == 0 && !_showForm)
|
||||
{
|
||||
<p class="text-muted">No equipment in this draft yet.</p>
|
||||
}
|
||||
else if (_equipment.Count > 0)
|
||||
{
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>EquipmentId</th><th>Name</th><th>MachineCode</th><th>ZTag</th><th>SAPID</th>
|
||||
<th>Manufacturer / Model</th><th>Serial</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in _equipment)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@e.EquipmentId</code></td>
|
||||
<td>@e.Name</td>
|
||||
<td>@e.MachineCode</td>
|
||||
<td>@e.ZTag</td>
|
||||
<td>@e.SAPID</td>
|
||||
<td>@e.Manufacturer / @e.Model</td>
|
||||
<td>@e.SerialNumber</td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAsync(e.EquipmentRowId)">Remove</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h5>New equipment</h5>
|
||||
<EditForm Model="_draft" OnValidSubmit="SaveAsync" FormName="new-equipment">
|
||||
<DataAnnotationsValidator/>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Name (UNS segment)</label>
|
||||
<InputText @bind-Value="_draft.Name" class="form-control"/>
|
||||
<ValidationMessage For="() => _draft.Name"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">MachineCode</label>
|
||||
<InputText @bind-Value="_draft.MachineCode" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">DriverInstanceId</label>
|
||||
<InputText @bind-Value="_draft.DriverInstanceId" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">UnsLineId</label>
|
||||
<InputText @bind-Value="_draft.UnsLineId" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">ZTag</label>
|
||||
<InputText @bind-Value="_draft.ZTag" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">SAPID</label>
|
||||
<InputText @bind-Value="_draft.SAPID" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="mt-4">OPC 40010 Identification</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4"><label class="form-label">Manufacturer</label><InputText @bind-Value="_draft.Manufacturer" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Model</label><InputText @bind-Value="_draft.Model" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Serial number</label><InputText @bind-Value="_draft.SerialNumber" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Hardware rev</label><InputText @bind-Value="_draft.HardwareRevision" class="form-control"/></div>
|
||||
<div class="col-md-4"><label class="form-label">Software rev</label><InputText @bind-Value="_draft.SoftwareRevision" class="form-control"/></div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Year of construction</label>
|
||||
<InputNumber @bind-Value="_draft.YearOfConstruction" class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-3">@_error</div> }
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
private List<Equipment>? _equipment;
|
||||
private bool _showForm;
|
||||
private Equipment _draft = NewBlankDraft();
|
||||
private string? _error;
|
||||
|
||||
private static Equipment NewBlankDraft() => new()
|
||||
{
|
||||
EquipmentId = string.Empty, DriverInstanceId = string.Empty,
|
||||
UnsLineId = string.Empty, Name = string.Empty, MachineCode = string.Empty,
|
||||
};
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_equipment = await EquipmentSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private void StartAdd()
|
||||
{
|
||||
_draft = NewBlankDraft();
|
||||
_error = null;
|
||||
_showForm = true;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
_draft.EquipmentUuid = Guid.NewGuid();
|
||||
_draft.EquipmentId = DraftValidator.DeriveEquipmentId(_draft.EquipmentUuid);
|
||||
_draft.GenerationId = GenerationId;
|
||||
try
|
||||
{
|
||||
await EquipmentSvc.CreateAsync(GenerationId, _draft, CancellationToken.None);
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Guid id)
|
||||
{
|
||||
await EquipmentSvc.DeleteAsync(id, CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<h4>Generations</h4>
|
||||
|
||||
@if (_generations is null) { <p>Loading…</p> }
|
||||
else if (_generations.Count == 0) { <p class="text-muted">No generations in this cluster yet.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Status</th><th>Created</th><th>Published</th><th>PublishedBy</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var g in _generations)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@g.GenerationId</code></td>
|
||||
<td>@StatusBadge(g.Status)</td>
|
||||
<td><small>@g.CreatedAt.ToString("u") by @g.CreatedBy</small></td>
|
||||
<td><small>@(g.PublishedAt?.ToString("u") ?? "-")</small></td>
|
||||
<td><small>@g.PublishedBy</small></td>
|
||||
<td><small>@g.Notes</small></td>
|
||||
<td>
|
||||
@if (g.Status == GenerationStatus.Draft)
|
||||
{
|
||||
<a class="btn btn-sm btn-primary" href="/clusters/@ClusterId/draft/@g.GenerationId">Open</a>
|
||||
}
|
||||
else if (g.Status is GenerationStatus.Published or GenerationStatus.Superseded)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-warning" @onclick="() => RollbackAsync(g.GenerationId)">Roll back to this</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_error is not null) { <div class="alert alert-danger">@_error</div> }
|
||||
|
||||
@code {
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private List<ConfigGeneration>? _generations;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync() =>
|
||||
_generations = await GenerationSvc.ListRecentAsync(ClusterId, 100, CancellationToken.None);
|
||||
|
||||
private async Task RollbackAsync(long targetId)
|
||||
{
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
await GenerationSvc.RollbackAsync(ClusterId, targetId, notes: $"Rollback via Admin UI", CancellationToken.None);
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
}
|
||||
|
||||
private static MarkupString StatusBadge(GenerationStatus s) => s switch
|
||||
{
|
||||
GenerationStatus.Draft => new MarkupString("<span class='badge bg-info'>Draft</span>"),
|
||||
GenerationStatus.Published => new MarkupString("<span class='badge bg-success'>Published</span>"),
|
||||
GenerationStatus.Superseded => new MarkupString("<span class='badge bg-secondary'>Superseded</span>"),
|
||||
_ => new MarkupString($"<span class='badge bg-light text-dark'>{s}</span>"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject NamespaceService NsSvc
|
||||
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4>Namespaces</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showForm = true">Add namespace</button>
|
||||
</div>
|
||||
|
||||
@if (_namespaces is null) { <p>Loading…</p> }
|
||||
else if (_namespaces.Count == 0) { <p class="text-muted">No namespaces defined in this draft.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>NamespaceId</th><th>Kind</th><th>URI</th><th>Enabled</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var n in _namespaces)
|
||||
{
|
||||
<tr><td><code>@n.NamespaceId</code></td><td>@n.Kind</td><td>@n.NamespaceUri</td><td>@(n.Enabled ? "yes" : "no")</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showForm)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><label class="form-label">NamespaceUri</label><input class="form-control" @bind="_uri"/></div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Kind</label>
|
||||
<select class="form-select" @bind="_kind">
|
||||
<option value="@NamespaceKind.Equipment">Equipment</option>
|
||||
<option value="@NamespaceKind.SystemPlatform">SystemPlatform (Galaxy)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
private List<Namespace>? _namespaces;
|
||||
private bool _showForm;
|
||||
private string _uri = string.Empty;
|
||||
private NamespaceKind _kind = NamespaceKind.Equipment;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync() =>
|
||||
_namespaces = await NsSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_uri)) return;
|
||||
await NsSvc.AddAsync(GenerationId, ClusterId, _uri, _kind, CancellationToken.None);
|
||||
_uri = string.Empty;
|
||||
_showForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
@page "/clusters/new"
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<h1 class="mb-4">New cluster</h1>
|
||||
|
||||
<EditForm Model="_input" OnValidSubmit="CreateAsync" FormName="new-cluster">
|
||||
<DataAnnotationsValidator/>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">ClusterId <span class="text-danger">*</span></label>
|
||||
<InputText @bind-Value="_input.ClusterId" class="form-control"/>
|
||||
<div class="form-text">Stable internal ID. Lowercase alphanumeric + hyphens; ≤ 64 chars.</div>
|
||||
<ValidationMessage For="() => _input.ClusterId"/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Display name <span class="text-danger">*</span></label>
|
||||
<InputText @bind-Value="_input.Name" class="form-control"/>
|
||||
<ValidationMessage For="() => _input.Name"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Enterprise</label>
|
||||
<InputText @bind-Value="_input.Enterprise" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Site</label>
|
||||
<InputText @bind-Value="_input.Site" class="form-control"/>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Redundancy</label>
|
||||
<InputSelect @bind-Value="_input.RedundancyMode" class="form-select">
|
||||
<option value="@RedundancyMode.None">None (single node)</option>
|
||||
<option value="@RedundancyMode.Warm">Warm (2 nodes)</option>
|
||||
<option value="@RedundancyMode.Hot">Hot (2 nodes)</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_error))
|
||||
{
|
||||
<div class="alert alert-danger mt-3">@_error</div>
|
||||
}
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary" disabled="@_submitting">Create cluster</button>
|
||||
<a href="/clusters" class="btn btn-secondary ms-2">Cancel</a>
|
||||
</div>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private sealed class Input
|
||||
{
|
||||
[Required, RegularExpression("^[a-z0-9-]{1,64}$", ErrorMessage = "Lowercase alphanumeric + hyphens only")]
|
||||
public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
[Required, StringLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(32)] public string Enterprise { get; set; } = "zb";
|
||||
[StringLength(32)] public string Site { get; set; } = "dev";
|
||||
public RedundancyMode RedundancyMode { get; set; } = RedundancyMode.None;
|
||||
}
|
||||
|
||||
private Input _input = new();
|
||||
private bool _submitting;
|
||||
private string? _error;
|
||||
|
||||
private async Task CreateAsync()
|
||||
{
|
||||
_submitting = true;
|
||||
_error = null;
|
||||
|
||||
try
|
||||
{
|
||||
var cluster = new ServerCluster
|
||||
{
|
||||
ClusterId = _input.ClusterId,
|
||||
Name = _input.Name,
|
||||
Enterprise = _input.Enterprise,
|
||||
Site = _input.Site,
|
||||
RedundancyMode = _input.RedundancyMode,
|
||||
NodeCount = _input.RedundancyMode == RedundancyMode.None ? (byte)1 : (byte)2,
|
||||
Enabled = true,
|
||||
CreatedBy = "admin-ui",
|
||||
};
|
||||
|
||||
await ClusterSvc.CreateAsync(cluster, createdBy: "admin-ui", CancellationToken.None);
|
||||
await GenerationSvc.CreateDraftAsync(cluster.ClusterId, createdBy: "admin-ui", CancellationToken.None);
|
||||
|
||||
Nav.NavigateTo($"/clusters/{cluster.ClusterId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
finally { _submitting = false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject UnsService UnsSvc
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h4>UNS Areas</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showAreaForm = true">Add area</button>
|
||||
</div>
|
||||
|
||||
@if (_areas is null) { <p>Loading…</p> }
|
||||
else if (_areas.Count == 0) { <p class="text-muted">No areas yet.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>AreaId</th><th>Name</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var a in _areas)
|
||||
{
|
||||
<tr><td><code>@a.UnsAreaId</code></td><td>@a.Name</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showAreaForm)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="mb-2"><label class="form-label">Name (lowercase segment)</label><input class="form-control" @bind="_newAreaName"/></div>
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddAreaAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showAreaForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h4>UNS Lines</h4>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => _showLineForm = true" disabled="@(_areas is null || _areas.Count == 0)">Add line</button>
|
||||
</div>
|
||||
|
||||
@if (_lines is null) { <p>Loading…</p> }
|
||||
else if (_lines.Count == 0) { <p class="text-muted">No lines yet.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>LineId</th><th>Area</th><th>Name</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var l in _lines)
|
||||
{
|
||||
<tr><td><code>@l.UnsLineId</code></td><td><code>@l.UnsAreaId</code></td><td>@l.Name</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_showLineForm && _areas is not null)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Area</label>
|
||||
<select class="form-select" @bind="_newLineAreaId">
|
||||
@foreach (var a in _areas) { <option value="@a.UnsAreaId">@a.Name (@a.UnsAreaId)</option> }
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2"><label class="form-label">Name</label><input class="form-control" @bind="_newLineName"/></div>
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddLineAsync">Save</button>
|
||||
<button class="btn btn-sm btn-secondary ms-2" @onclick="() => _showLineForm = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private List<UnsArea>? _areas;
|
||||
private List<UnsLine>? _lines;
|
||||
private bool _showAreaForm;
|
||||
private bool _showLineForm;
|
||||
private string _newAreaName = string.Empty;
|
||||
private string _newLineName = string.Empty;
|
||||
private string _newLineAreaId = string.Empty;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_areas = await UnsSvc.ListAreasAsync(GenerationId, CancellationToken.None);
|
||||
_lines = await UnsSvc.ListLinesAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task AddAreaAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_newAreaName)) return;
|
||||
await UnsSvc.AddAreaAsync(GenerationId, ClusterId, _newAreaName, notes: null, CancellationToken.None);
|
||||
_newAreaName = string.Empty;
|
||||
_showAreaForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
|
||||
private async Task AddLineAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_newLineName) || string.IsNullOrWhiteSpace(_newLineAreaId)) return;
|
||||
await UnsSvc.AddLineAsync(GenerationId, _newLineAreaId, _newLineName, notes: null, CancellationToken.None);
|
||||
_newLineName = string.Empty;
|
||||
_showLineForm = false;
|
||||
await ReloadAsync();
|
||||
}
|
||||
}
|
||||
172
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
Normal file
172
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Fleet.razor
Normal file
@@ -0,0 +1,172 @@
|
||||
@page "/fleet"
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@implements IDisposable
|
||||
|
||||
<h1 class="mb-4">Fleet status</h1>
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
|
||||
Refresh
|
||||
</button>
|
||||
<span class="text-muted small">
|
||||
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
No node state recorded yet. Nodes publish their state to the central DB on each poll; if
|
||||
this list is empty, either no nodes have been registered or the poller hasn't run yet.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Nodes</h6>
|
||||
<div class="fs-3">@_rows.Count</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-success"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Applied</h6>
|
||||
<div class="fs-3 text-success">@_rows.Count(r => r.Status == "Applied")</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-warning"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Stale</h6>
|
||||
<div class="fs-3 text-warning">@_rows.Count(r => IsStale(r))</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-danger"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Failed</h6>
|
||||
<div class="fs-3 text-danger">@_rows.Count(r => r.Status == "Failed")</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Cluster</th>
|
||||
<th>Generation</th>
|
||||
<th>Status</th>
|
||||
<th>Last applied</th>
|
||||
<th>Last seen</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in _rows)
|
||||
{
|
||||
<tr class="@RowClass(r)">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<td>@r.ClusterId</td>
|
||||
<td>@(r.GenerationId?.ToString() ?? "—")</td>
|
||||
<td>
|
||||
<span class="badge @StatusBadge(r.Status)">@(r.Status ?? "—")</span>
|
||||
</td>
|
||||
<td>@FormatAge(r.AppliedAt)</td>
|
||||
<td class="@(IsStale(r) ? "text-warning" : "")">@FormatAge(r.SeenAt)</td>
|
||||
<td class="text-truncate" style="max-width: 320px;" title="@r.Error">@r.Error</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
// Refresh cadence. 5s matches FleetStatusPoller's poll interval — the dashboard always sees
|
||||
// the most recent published state without polling ahead of the broadcaster.
|
||||
private const int RefreshIntervalSeconds = 5;
|
||||
|
||||
private List<FleetNodeRow>? _rows;
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
|
||||
state: null,
|
||||
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
if (_refreshing) return;
|
||||
_refreshing = true;
|
||||
try
|
||||
{
|
||||
using var scope = ScopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
|
||||
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new FleetNodeRow(
|
||||
s.NodeId, n.ClusterId, s.CurrentGenerationId,
|
||||
s.LastAppliedStatus != null ? s.LastAppliedStatus.ToString() : null,
|
||||
s.LastAppliedError, s.LastAppliedAt, s.LastSeenAt))
|
||||
.OrderBy(r => r.ClusterId)
|
||||
.ThenBy(r => r.NodeId)
|
||||
.ToListAsync();
|
||||
_rows = rows;
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsStale(FleetNodeRow r)
|
||||
{
|
||||
if (r.SeenAt is null) return true;
|
||||
return (DateTime.UtcNow - r.SeenAt.Value) > TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
private static string RowClass(FleetNodeRow r) => r.Status switch
|
||||
{
|
||||
"Failed" => "table-danger",
|
||||
_ when IsStale(r) => "table-warning",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string StatusBadge(string? status) => status switch
|
||||
{
|
||||
"Applied" => "bg-success",
|
||||
"Failed" => "bg-danger",
|
||||
"Applying" => "bg-info",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime? t)
|
||||
{
|
||||
if (t is null) return "—";
|
||||
var age = DateTime.UtcNow - t.Value;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||
return t.Value.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
|
||||
internal sealed record FleetNodeRow(
|
||||
string NodeId, string ClusterId, long? GenerationId,
|
||||
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
|
||||
}
|
||||
72
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor
Normal file
72
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor
Normal file
@@ -0,0 +1,72 @@
|
||||
@page "/"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject ClusterService ClusterSvc
|
||||
@inject GenerationService GenerationSvc
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<h1 class="mb-4">Fleet overview</h1>
|
||||
|
||||
@if (_clusters is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_clusters.Count == 0)
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
No clusters configured yet. <a href="/clusters/new">Create the first cluster</a>.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body"><h6 class="text-muted">Clusters</h6><div class="fs-2">@_clusters.Count</div></div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body"><h6 class="text-muted">Active drafts</h6><div class="fs-2">@_activeDraftCount</div></div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body"><h6 class="text-muted">Published generations</h6><div class="fs-2">@_publishedCount</div></div></div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card"><div class="card-body"><h6 class="text-muted">Disabled clusters</h6><div class="fs-2">@_clusters.Count(c => !c.Enabled)</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4 mb-3">Clusters</h4>
|
||||
<table class="table table-hover">
|
||||
<thead><tr><th>ClusterId</th><th>Name</th><th>Enterprise / Site</th><th>Redundancy</th><th>Enabled</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<tr style="cursor: pointer;">
|
||||
<td><code>@c.ClusterId</code></td>
|
||||
<td>@c.Name</td>
|
||||
<td>@c.Enterprise / @c.Site</td>
|
||||
<td>@c.RedundancyMode</td>
|
||||
<td>@(c.Enabled ? "Yes" : "No")</td>
|
||||
<td><a href="/clusters/@c.ClusterId" class="btn btn-sm btn-outline-primary">Open</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ServerCluster>? _clusters;
|
||||
private int _activeDraftCount;
|
||||
private int _publishedCount;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
|
||||
foreach (var c in _clusters)
|
||||
{
|
||||
var gens = await GenerationSvc.ListRecentAsync(c.ClusterId, 50, CancellationToken.None);
|
||||
_activeDraftCount += gens.Count(g => g.Status.ToString() == "Draft");
|
||||
_publishedCount += gens.Count(g => g.Status.ToString() == "Published");
|
||||
}
|
||||
}
|
||||
}
|
||||
160
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
Normal file
160
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Hosts.razor
Normal file
@@ -0,0 +1,160 @@
|
||||
@page "/hosts"
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IServiceScopeFactory ScopeFactory
|
||||
@implements IDisposable
|
||||
|
||||
<h1 class="mb-4">Driver host status</h1>
|
||||
|
||||
<div class="d-flex align-items-center mb-3 gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshAsync" disabled="@_refreshing">
|
||||
@if (_refreshing) { <span class="spinner-border spinner-border-sm me-1" /> }
|
||||
Refresh
|
||||
</button>
|
||||
<span class="text-muted small">
|
||||
Auto-refresh every @RefreshIntervalSeconds s. Last updated: @(_lastRefreshUtc?.ToString("HH:mm:ss 'UTC'") ?? "—")
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info small mb-4">
|
||||
Each row is one host reported by a driver instance on a server node. Galaxy drivers report
|
||||
per-Platform / per-AppEngine entries; Modbus drivers report the PLC endpoint. Rows age out
|
||||
of the Server's publisher on every 10-second heartbeat — rows whose LastSeen is older than
|
||||
30s are flagged Stale, which usually means the owning Server process has crashed or lost
|
||||
its DB connection.
|
||||
</div>
|
||||
|
||||
@if (_rows is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
{
|
||||
<div class="alert alert-secondary">
|
||||
No host-status rows yet. The Server publishes its first tick 2s after startup; if this list stays empty, check that the Server is running and the driver implements <code>IHostConnectivityProbe</code>.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3"><div class="card"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Hosts</h6>
|
||||
<div class="fs-3">@_rows.Count</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-success"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Running</h6>
|
||||
<div class="fs-3 text-success">@_rows.Count(r => r.State == DriverHostState.Running && !HostStatusService.IsStale(r))</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-warning"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Stale</h6>
|
||||
<div class="fs-3 text-warning">@_rows.Count(HostStatusService.IsStale)</div>
|
||||
</div></div></div>
|
||||
<div class="col-md-3"><div class="card border-danger"><div class="card-body">
|
||||
<h6 class="text-muted mb-1">Faulted</h6>
|
||||
<div class="fs-3 text-danger">@_rows.Count(r => r.State == DriverHostState.Faulted)</div>
|
||||
</div></div></div>
|
||||
</div>
|
||||
|
||||
@foreach (var cluster in _rows.GroupBy(r => r.ClusterId ?? "(unassigned)").OrderBy(g => g.Key))
|
||||
{
|
||||
<h2 class="h5 mt-4">Cluster: <code>@cluster.Key</code></h2>
|
||||
<table class="table table-sm table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Driver</th>
|
||||
<th>Host</th>
|
||||
<th>State</th>
|
||||
<th>Last transition</th>
|
||||
<th>Last seen</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var r in cluster)
|
||||
{
|
||||
<tr class="@RowClass(r)">
|
||||
<td><code>@r.NodeId</code></td>
|
||||
<td><code>@r.DriverInstanceId</code></td>
|
||||
<td>@r.HostName</td>
|
||||
<td>
|
||||
<span class="badge @StateBadge(r.State)">@r.State</span>
|
||||
@if (HostStatusService.IsStale(r))
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1">Stale</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">@FormatAge(r.StateChangedUtc)</td>
|
||||
<td class="small @(HostStatusService.IsStale(r) ? "text-warning" : "")">@FormatAge(r.LastSeenUtc)</td>
|
||||
<td class="text-truncate small" style="max-width: 320px;" title="@r.Detail">@r.Detail</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
// Mirrors HostStatusPublisher.HeartbeatInterval — polling ahead of the broadcaster
|
||||
// produces stale-looking rows mid-cycle.
|
||||
private const int RefreshIntervalSeconds = 10;
|
||||
|
||||
private List<HostStatusRow>? _rows;
|
||||
private bool _refreshing;
|
||||
private DateTime? _lastRefreshUtc;
|
||||
private Timer? _timer;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(async _ => await InvokeAsync(RefreshAsync),
|
||||
state: null,
|
||||
dueTime: TimeSpan.FromSeconds(RefreshIntervalSeconds),
|
||||
period: TimeSpan.FromSeconds(RefreshIntervalSeconds));
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
if (_refreshing) return;
|
||||
_refreshing = true;
|
||||
try
|
||||
{
|
||||
using var scope = ScopeFactory.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<HostStatusService>();
|
||||
_rows = (await svc.ListAsync()).ToList();
|
||||
_lastRefreshUtc = DateTime.UtcNow;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string RowClass(HostStatusRow r) => r.State switch
|
||||
{
|
||||
DriverHostState.Faulted => "table-danger",
|
||||
_ when HostStatusService.IsStale(r) => "table-warning",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
private static string StateBadge(DriverHostState s) => s switch
|
||||
{
|
||||
DriverHostState.Running => "bg-success",
|
||||
DriverHostState.Stopped => "bg-secondary",
|
||||
DriverHostState.Faulted => "bg-danger",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
|
||||
private static string FormatAge(DateTime t)
|
||||
{
|
||||
var age = DateTime.UtcNow - t;
|
||||
if (age.TotalSeconds < 60) return $"{(int)age.TotalSeconds}s ago";
|
||||
if (age.TotalMinutes < 60) return $"{(int)age.TotalMinutes}m ago";
|
||||
if (age.TotalHours < 24) return $"{(int)age.TotalHours}h ago";
|
||||
return t.ToString("yyyy-MM-dd HH:mm 'UTC'");
|
||||
}
|
||||
|
||||
public void Dispose() => _timer?.Dispose();
|
||||
}
|
||||
100
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor
Normal file
100
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Login.razor
Normal file
@@ -0,0 +1,100 @@
|
||||
@page "/login"
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@using Microsoft.AspNetCore.Authentication.Cookies
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Security
|
||||
@inject IHttpContextAccessor Http
|
||||
@inject ILdapAuthService LdapAuth
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="mb-4">OtOpcUa Admin — sign in</h4>
|
||||
|
||||
<EditForm Model="_input" OnValidSubmit="SignInAsync" FormName="login">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<InputText @bind-Value="_input.Username" class="form-control" autocomplete="username"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<InputText type="password" @bind-Value="_input.Password" class="form-control" autocomplete="current-password"/>
|
||||
</div>
|
||||
|
||||
@if (_error is not null) { <div class="alert alert-danger">@_error</div> }
|
||||
|
||||
<button class="btn btn-primary w-100" type="submit" disabled="@_busy">
|
||||
@(_busy ? "Signing in…" : "Sign in")
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
<hr/>
|
||||
<small class="text-muted">
|
||||
LDAP bind against the configured directory. Dev defaults to GLAuth on
|
||||
<code>localhost:3893</code>.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private sealed class Input
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private Input _input = new();
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
private async Task SignInAsync()
|
||||
{
|
||||
_error = null;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_input.Username) || string.IsNullOrWhiteSpace(_input.Password))
|
||||
{
|
||||
_error = "Username and password are required";
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await LdapAuth.AuthenticateAsync(_input.Username, _input.Password, CancellationToken.None);
|
||||
if (!result.Success)
|
||||
{
|
||||
_error = result.Error ?? "Sign-in failed";
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Roles.Count == 0)
|
||||
{
|
||||
_error = "Sign-in succeeded but no Admin roles mapped for your LDAP groups. Contact your administrator.";
|
||||
return;
|
||||
}
|
||||
|
||||
var ctx = Http.HttpContext
|
||||
?? throw new InvalidOperationException("HttpContext unavailable at sign-in");
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, result.DisplayName ?? result.Username ?? _input.Username),
|
||||
new(ClaimTypes.NameIdentifier, _input.Username),
|
||||
};
|
||||
foreach (var role in result.Roles)
|
||||
claims.Add(new Claim(ClaimTypes.Role, role));
|
||||
foreach (var group in result.Groups)
|
||||
claims.Add(new Claim("ldap_group", group));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity));
|
||||
|
||||
ctx.Response.Redirect("/");
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
114
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Reservations.razor
Normal file
114
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Reservations.razor
Normal file
@@ -0,0 +1,114 @@
|
||||
@page "/reservations"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@attribute [Authorize(Policy = "CanPublish")]
|
||||
@inject ReservationService ReservationSvc
|
||||
|
||||
<h1 class="mb-4">External-ID reservations</h1>
|
||||
<p class="text-muted">
|
||||
Fleet-wide ZTag + SAPID reservation state (decision #124). Releasing a reservation is a
|
||||
FleetAdmin-only audit-logged action — only release when the physical asset is permanently
|
||||
retired and its ID needs to be reused by a different equipment.
|
||||
</p>
|
||||
|
||||
<h4 class="mt-4">Active</h4>
|
||||
@if (_active is null) { <p>Loading…</p> }
|
||||
else if (_active.Count == 0) { <p class="text-muted">No active reservations.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>Kind</th><th>Value</th><th>EquipmentUuid</th><th>Cluster</th><th>First published</th><th>Last published</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _active)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@r.Kind</code></td>
|
||||
<td><code>@r.Value</code></td>
|
||||
<td><code>@r.EquipmentUuid</code></td>
|
||||
<td>@r.ClusterId</td>
|
||||
<td><small>@r.FirstPublishedAt.ToString("u") by @r.FirstPublishedBy</small></td>
|
||||
<td><small>@r.LastPublishedAt.ToString("u")</small></td>
|
||||
<td><button class="btn btn-sm btn-outline-danger" @onclick='() => OpenReleaseDialog(r)'>Release…</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<h4 class="mt-4">Released (most recent 100)</h4>
|
||||
@if (_released is null) { <p>Loading…</p> }
|
||||
else if (_released.Count == 0) { <p class="text-muted">No released reservations yet.</p> }
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>Kind</th><th>Value</th><th>Released at</th><th>By</th><th>Reason</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var r in _released)
|
||||
{
|
||||
<tr><td><code>@r.Kind</code></td><td><code>@r.Value</code></td><td>@r.ReleasedAt?.ToString("u")</td><td>@r.ReleasedBy</td><td>@r.ReleaseReason</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@if (_releasing is not null)
|
||||
{
|
||||
<div class="modal show d-block" tabindex="-1" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Release reservation <code>@_releasing.Kind</code> = <code>@_releasing.Value</code></h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This makes the (Kind, Value) pair available for a different EquipmentUuid in a future publish. Audit-logged.</p>
|
||||
<label class="form-label">Reason (required)</label>
|
||||
<textarea class="form-control" rows="3" @bind="_reason"></textarea>
|
||||
@if (_error is not null) { <div class="alert alert-danger mt-2">@_error</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @onclick='() => _releasing = null'>Cancel</button>
|
||||
<button class="btn btn-danger" @onclick="ReleaseAsync" disabled="@_busy">Release</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ExternalIdReservation>? _active;
|
||||
private List<ExternalIdReservation>? _released;
|
||||
private ExternalIdReservation? _releasing;
|
||||
private string _reason = string.Empty;
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
_active = await ReservationSvc.ListActiveAsync(CancellationToken.None);
|
||||
_released = await ReservationSvc.ListReleasedAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private void OpenReleaseDialog(ExternalIdReservation r)
|
||||
{
|
||||
_releasing = r;
|
||||
_reason = string.Empty;
|
||||
_error = null;
|
||||
}
|
||||
|
||||
private async Task ReleaseAsync()
|
||||
{
|
||||
if (_releasing is null || string.IsNullOrWhiteSpace(_reason)) { _error = "Reason is required"; return; }
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
await ReservationSvc.ReleaseAsync(_releasing.Kind.ToString(), _releasing.Value, _reason, CancellationToken.None);
|
||||
_releasing = null;
|
||||
await ReloadAsync();
|
||||
}
|
||||
catch (Exception ex) { _error = ex.Message; }
|
||||
finally { _busy = false; }
|
||||
}
|
||||
}
|
||||
11
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Routes.razor
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Routes.razor
Normal file
@@ -0,0 +1,11 @@
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
|
||||
|
||||
<Router AppAssembly="@typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(MainLayout)"><p>Not found.</p></LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
14
src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor
Normal file
14
src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor
Normal file
@@ -0,0 +1,14 @@
|
||||
@using System.Net.Http
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Http
|
||||
@using Microsoft.JSInterop
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Pages.Clusters
|
||||
31
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/AlertHub.cs
Normal file
31
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/AlertHub.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes sticky alerts (crash-loop circuit trips, failed applies, reservation-release
|
||||
/// anomalies) to subscribed admin clients. Alerts don't auto-clear — the operator acks them
|
||||
/// from the UI via <see cref="AcknowledgeAsync"/>.
|
||||
/// </summary>
|
||||
public sealed class AlertHub : Hub
|
||||
{
|
||||
public const string AllAlertsGroup = "__alerts__";
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, AllAlertsGroup);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
/// <summary>Client-initiated ack. The server side of ack persistence is deferred — v2.1.</summary>
|
||||
public Task AcknowledgeAsync(string alertId) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
public sealed record AlertMessage(
|
||||
string AlertId,
|
||||
string Severity,
|
||||
string Title,
|
||||
string Detail,
|
||||
DateTime RaisedAtUtc,
|
||||
string? ClusterId,
|
||||
string? NodeId);
|
||||
39
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusHub.cs
Normal file
39
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusHub.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Pushes per-node generation-apply state changes (<c>ClusterNodeGenerationState</c>) to
|
||||
/// subscribed browser clients. Clients call <c>SubscribeCluster(clusterId)</c> on connect to
|
||||
/// scope notifications; the server sends <c>NodeStateChanged</c> messages whenever the poller
|
||||
/// observes a delta.
|
||||
/// </summary>
|
||||
public sealed class FleetStatusHub : Hub
|
||||
{
|
||||
public Task SubscribeCluster(string clusterId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clusterId)) return Task.CompletedTask;
|
||||
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(clusterId));
|
||||
}
|
||||
|
||||
public Task UnsubscribeCluster(string clusterId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clusterId)) return Task.CompletedTask;
|
||||
return Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(clusterId));
|
||||
}
|
||||
|
||||
/// <summary>Clients call this once to also receive fleet-wide status — used by the dashboard.</summary>
|
||||
public Task SubscribeFleet() => Groups.AddToGroupAsync(Context.ConnectionId, FleetGroup);
|
||||
|
||||
public const string FleetGroup = "__fleet__";
|
||||
public static string GroupName(string clusterId) => $"cluster:{clusterId}";
|
||||
}
|
||||
|
||||
public sealed record NodeStateChangedMessage(
|
||||
string NodeId,
|
||||
string ClusterId,
|
||||
long? CurrentGenerationId,
|
||||
string? LastAppliedStatus,
|
||||
string? LastAppliedError,
|
||||
DateTime? LastAppliedAt,
|
||||
DateTime? LastSeenAt);
|
||||
93
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusPoller.cs
Normal file
93
src/ZB.MOM.WW.OtOpcUa.Admin/Hubs/FleetStatusPoller.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Polls <c>ClusterNodeGenerationState</c> every <see cref="PollInterval"/> and publishes
|
||||
/// per-node deltas to <see cref="FleetStatusHub"/>. Also raises sticky
|
||||
/// <see cref="AlertMessage"/>s on transitions into <c>Failed</c>.
|
||||
/// </summary>
|
||||
public sealed class FleetStatusPoller(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IHubContext<FleetStatusHub> fleetHub,
|
||||
IHubContext<AlertHub> alertHub,
|
||||
ILogger<FleetStatusPoller> logger) : BackgroundService
|
||||
{
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly Dictionary<string, NodeStateSnapshot> _last = new();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("FleetStatusPoller starting — interval {Interval}s", PollInterval.TotalSeconds);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await PollOnceAsync(stoppingToken); }
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex, "FleetStatusPoller tick failed");
|
||||
}
|
||||
|
||||
try { await Task.Delay(PollInterval, stoppingToken); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task PollOnceAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<OtOpcUaConfigDbContext>();
|
||||
|
||||
var rows = await db.ClusterNodeGenerationStates.AsNoTracking()
|
||||
.Join(db.ClusterNodes.AsNoTracking(), s => s.NodeId, n => n.NodeId, (s, n) => new { s, n.ClusterId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var snapshot = new NodeStateSnapshot(
|
||||
r.s.NodeId, r.ClusterId, r.s.CurrentGenerationId,
|
||||
r.s.LastAppliedStatus?.ToString(), r.s.LastAppliedError,
|
||||
r.s.LastAppliedAt, r.s.LastSeenAt);
|
||||
|
||||
var hadPrior = _last.TryGetValue(r.s.NodeId, out var prior);
|
||||
if (!hadPrior || prior != snapshot)
|
||||
{
|
||||
_last[r.s.NodeId] = snapshot;
|
||||
|
||||
var msg = new NodeStateChangedMessage(
|
||||
snapshot.NodeId, snapshot.ClusterId, snapshot.GenerationId,
|
||||
snapshot.Status, snapshot.Error, snapshot.AppliedAt, snapshot.SeenAt);
|
||||
|
||||
await fleetHub.Clients.Group(FleetStatusHub.GroupName(snapshot.ClusterId))
|
||||
.SendAsync("NodeStateChanged", msg, ct);
|
||||
await fleetHub.Clients.Group(FleetStatusHub.FleetGroup)
|
||||
.SendAsync("NodeStateChanged", msg, ct);
|
||||
|
||||
if (snapshot.Status == "Failed" && (!hadPrior || prior.Status != "Failed"))
|
||||
{
|
||||
var alert = new AlertMessage(
|
||||
AlertId: $"{snapshot.NodeId}:apply-failed",
|
||||
Severity: "error",
|
||||
Title: $"Apply failed on {snapshot.NodeId}",
|
||||
Detail: snapshot.Error ?? "(no detail)",
|
||||
RaisedAtUtc: DateTime.UtcNow,
|
||||
ClusterId: snapshot.ClusterId,
|
||||
NodeId: snapshot.NodeId);
|
||||
await alertHub.Clients.Group(AlertHub.AllAlertsGroup)
|
||||
.SendAsync("AlertRaised", alert, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exposed for tests — forces a snapshot reset so stub data re-seeds.</summary>
|
||||
internal void ResetCache() => _last.Clear();
|
||||
|
||||
private readonly record struct NodeStateSnapshot(
|
||||
string NodeId, string ClusterId, long? GenerationId,
|
||||
string? Status, string? Error, DateTime? AppliedAt, DateTime? SeenAt);
|
||||
}
|
||||
87
src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
Normal file
87
src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Components;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Hubs;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((ctx, cfg) => cfg
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File("logs/otopcua-admin-.log", rollingInterval: RollingInterval.Day));
|
||||
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(o =>
|
||||
{
|
||||
o.Cookie.Name = "OtOpcUa.Admin";
|
||||
o.LoginPath = "/login";
|
||||
o.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("CanEdit", p => p.RequireRole(AdminRoles.ConfigEditor, AdminRoles.FleetAdmin))
|
||||
.AddPolicy("CanPublish", p => p.RequireRole(AdminRoles.FleetAdmin));
|
||||
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseSqlServer(builder.Configuration.GetConnectionString("ConfigDb")
|
||||
?? throw new InvalidOperationException("ConnectionStrings:ConfigDb not configured")));
|
||||
|
||||
builder.Services.AddScoped<ClusterService>();
|
||||
builder.Services.AddScoped<GenerationService>();
|
||||
builder.Services.AddScoped<EquipmentService>();
|
||||
builder.Services.AddScoped<UnsService>();
|
||||
builder.Services.AddScoped<NamespaceService>();
|
||||
builder.Services.AddScoped<DriverInstanceService>();
|
||||
builder.Services.AddScoped<NodeAclService>();
|
||||
builder.Services.AddScoped<ReservationService>();
|
||||
builder.Services.AddScoped<DraftValidationService>();
|
||||
builder.Services.AddScoped<AuditLogService>();
|
||||
builder.Services.AddScoped<HostStatusService>();
|
||||
|
||||
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
||||
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
||||
// filesystem operations.
|
||||
builder.Services.Configure<CertTrustOptions>(builder.Configuration.GetSection(CertTrustOptions.SectionName));
|
||||
builder.Services.AddSingleton<CertTrustService>();
|
||||
|
||||
// LDAP auth — parity with ScadaLink's LdapAuthService (decision #102).
|
||||
builder.Services.Configure<LdapOptions>(
|
||||
builder.Configuration.GetSection("Authentication:Ldap"));
|
||||
builder.Services.AddScoped<ILdapAuthService, LdapAuthService>();
|
||||
|
||||
// SignalR real-time fleet status + alerts (admin-ui.md §"Real-Time Updates").
|
||||
builder.Services.AddHostedService<FleetStatusPoller>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseStaticFiles();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapPost("/auth/logout", async (HttpContext ctx) =>
|
||||
{
|
||||
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
ctx.Response.Redirect("/");
|
||||
});
|
||||
|
||||
app.MapHub<FleetStatusHub>("/hubs/fleet");
|
||||
app.MapHub<AlertHub>("/hubs/alerts");
|
||||
|
||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
public partial class Program;
|
||||
6
src/ZB.MOM.WW.OtOpcUa.Admin/Security/ILdapAuthService.cs
Normal file
6
src/ZB.MOM.WW.OtOpcUa.Admin/Security/ILdapAuthService.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
public interface ILdapAuthService
|
||||
{
|
||||
Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default);
|
||||
}
|
||||
10
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthResult.cs
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthResult.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>Outcome of an LDAP bind attempt. <see cref="Roles"/> is the mapped-set of Admin roles.</summary>
|
||||
public sealed record LdapAuthResult(
|
||||
bool Success,
|
||||
string? DisplayName,
|
||||
string? Username,
|
||||
IReadOnlyList<string> Groups,
|
||||
IReadOnlyList<string> Roles,
|
||||
string? Error);
|
||||
160
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthService.cs
Normal file
160
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapAuthService.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novell.Directory.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP bind-and-search authentication mirrored from ScadaLink's <c>LdapAuthService</c>
|
||||
/// (CLAUDE.md memory: <c>scadalink_reference.md</c>) — same bind semantics, TLS guard, and
|
||||
/// service-account search-then-bind path. Adapted for the Admin app's role-mapping shape
|
||||
/// (LDAP group names → Admin roles via <see cref="LdapOptions.GroupToRole"/>).
|
||||
/// </summary>
|
||||
public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapAuthService> logger)
|
||||
: ILdapAuthService
|
||||
{
|
||||
private readonly LdapOptions _options = options.Value;
|
||||
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return new(false, null, null, [], [], "Username is required");
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return new(false, null, null, [], [], "Password is required");
|
||||
|
||||
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
||||
return new(false, null, username, [], [],
|
||||
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new LdapConnection();
|
||||
if (_options.UseTls) conn.SecureSocketLayer = true;
|
||||
|
||||
await Task.Run(() => conn.Connect(_options.Server, _options.Port), ct);
|
||||
|
||||
var bindDn = await ResolveUserDnAsync(conn, username, ct);
|
||||
await Task.Run(() => conn.Bind(bindDn, password), ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
||||
await Task.Run(() => conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||
|
||||
var displayName = username;
|
||||
var groups = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var filter = $"(cn={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter,
|
||||
attrs: null, // request ALL attributes so we can inspect memberOf + dn-derived group
|
||||
typesOnly: false), ct);
|
||||
|
||||
while (results.HasMore())
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = results.Next();
|
||||
var name = entry.GetAttribute(_options.DisplayNameAttribute);
|
||||
if (name is not null) displayName = name.StringValue;
|
||||
|
||||
var groupAttr = entry.GetAttribute(_options.GroupAttribute);
|
||||
if (groupAttr is not null)
|
||||
{
|
||||
foreach (var groupDn in groupAttr.StringValueArray)
|
||||
groups.Add(ExtractFirstRdnValue(groupDn));
|
||||
}
|
||||
|
||||
// Fallback: GLAuth places users under ou=PrimaryGroup,baseDN. When the
|
||||
// directory doesn't populate memberOf (or populates it differently), the
|
||||
// user's primary group name is recoverable from the second RDN of the DN.
|
||||
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
|
||||
{
|
||||
var primary = ExtractOuSegment(entry.Dn);
|
||||
if (primary is not null) groups.Add(primary);
|
||||
}
|
||||
}
|
||||
catch (LdapException) { break; } // no-more-entries signalled by exception
|
||||
}
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
|
||||
}
|
||||
|
||||
conn.Disconnect();
|
||||
|
||||
var roles = RoleMapper.Map(groups, _options.GroupToRole);
|
||||
return new(true, displayName, username, groups, roles, null);
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP bind failed for {User}", username);
|
||||
return new(false, null, username, [], [], "Invalid username or password");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
|
||||
return new(false, null, username, [], [], "Unexpected authentication error");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
|
||||
{
|
||||
if (username.Contains('=')) return username; // already a DN
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
||||
{
|
||||
await Task.Run(() =>
|
||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||
|
||||
var filter = $"(uid={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
||||
|
||||
if (results.HasMore())
|
||||
return results.Next().Dn;
|
||||
|
||||
throw new LdapException("User not found", LdapException.NoSuchObject,
|
||||
$"No entry for uid={username}");
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(_options.SearchBase)
|
||||
? $"cn={username}"
|
||||
: $"cn={username},{_options.SearchBase}";
|
||||
}
|
||||
|
||||
internal static string EscapeLdapFilter(string input) =>
|
||||
input.Replace("\\", "\\5c")
|
||||
.Replace("*", "\\2a")
|
||||
.Replace("(", "\\28")
|
||||
.Replace(")", "\\29")
|
||||
.Replace("\0", "\\00");
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the first <c>ou=Value</c> segment from a DN. GLAuth encodes a user's primary
|
||||
/// group as an <c>ou=</c> RDN immediately above the user's <c>cn=</c>, so this recovers
|
||||
/// the group name when <see cref="LdapOptions.GroupAttribute"/> is absent from the entry.
|
||||
/// </summary>
|
||||
internal static string? ExtractOuSegment(string dn)
|
||||
{
|
||||
var segments = dn.Split(',');
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var trimmed = segment.Trim();
|
||||
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
|
||||
return trimmed[3..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string ExtractFirstRdnValue(string dn)
|
||||
{
|
||||
var equalsIdx = dn.IndexOf('=');
|
||||
if (equalsIdx < 0) return dn;
|
||||
|
||||
var valueStart = equalsIdx + 1;
|
||||
var commaIdx = dn.IndexOf(',', valueStart);
|
||||
return commaIdx > valueStart ? dn[valueStart..commaIdx] : dn[valueStart..];
|
||||
}
|
||||
}
|
||||
38
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapOptions.cs
Normal file
38
src/ZB.MOM.WW.OtOpcUa.Admin/Security/LdapOptions.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP + role-mapping configuration for the Admin UI. Bound from <c>appsettings.json</c>
|
||||
/// <c>Authentication:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
|
||||
/// <c>C:\publish\glauth\auth.md</c>).
|
||||
/// </summary>
|
||||
public sealed class LdapOptions
|
||||
{
|
||||
public const string SectionName = "Authentication:Ldap";
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
public string Server { get; set; } = "localhost";
|
||||
public int Port { get; set; } = 3893;
|
||||
public bool UseTls { get; set; }
|
||||
|
||||
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
||||
public bool AllowInsecureLdap { get; set; }
|
||||
|
||||
public string SearchBase { get; set; } = "dc=lmxopcua,dc=local";
|
||||
|
||||
/// <summary>
|
||||
/// Service-account DN used for search-then-bind. When empty, a direct-bind with
|
||||
/// <c>cn={user},{SearchBase}</c> is attempted.
|
||||
/// </summary>
|
||||
public string ServiceAccountDn { get; set; } = string.Empty;
|
||||
public string ServiceAccountPassword { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayNameAttribute { get; set; } = "cn";
|
||||
public string GroupAttribute { get; set; } = "memberOf";
|
||||
|
||||
/// <summary>
|
||||
/// Maps LDAP group name → Admin role. Group match is case-insensitive. A user gets every
|
||||
/// role whose source group is in their membership list. Example dev mapping:
|
||||
/// <code>"ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"</code>
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
23
src/ZB.MOM.WW.OtOpcUa.Admin/Security/RoleMapper.cs
Normal file
23
src/ZB.MOM.WW.OtOpcUa.Admin/Security/RoleMapper.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic LDAP-group-to-Admin-role mapper driven by <see cref="LdapOptions.GroupToRole"/>.
|
||||
/// Every returned role corresponds to a group the user actually holds; no inference.
|
||||
/// </summary>
|
||||
public static class RoleMapper
|
||||
{
|
||||
public static IReadOnlyList<string> Map(
|
||||
IReadOnlyCollection<string> ldapGroups,
|
||||
IReadOnlyDictionary<string, string> groupToRole)
|
||||
{
|
||||
if (groupToRole.Count == 0) return [];
|
||||
|
||||
var roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var group in ldapGroups)
|
||||
{
|
||||
if (groupToRole.TryGetValue(group, out var role))
|
||||
roles.Add(role);
|
||||
}
|
||||
return [.. roles];
|
||||
}
|
||||
}
|
||||
16
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs
Normal file
16
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The three admin roles per <c>admin-ui.md</c> §"Admin Roles" — mapped from LDAP groups at
|
||||
/// sign-in. Each role has a fixed set of capabilities (cluster CRUD, draft → publish, fleet
|
||||
/// admin). The ACL-driven runtime permissions (<c>NodePermissions</c>) govern OPC UA clients;
|
||||
/// these roles govern the Admin UI itself.
|
||||
/// </summary>
|
||||
public static class AdminRoles
|
||||
{
|
||||
public const string ConfigViewer = "ConfigViewer";
|
||||
public const string ConfigEditor = "ConfigEditor";
|
||||
public const string FleetAdmin = "FleetAdmin";
|
||||
|
||||
public static IReadOnlyList<string> All => [ConfigViewer, ConfigEditor, FleetAdmin];
|
||||
}
|
||||
15
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AuditLogService.cs
Normal file
15
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AuditLogService.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class AuditLogService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ConfigAuditLog>> ListRecentAsync(string? clusterId, int limit, CancellationToken ct)
|
||||
{
|
||||
var q = db.ConfigAuditLogs.AsNoTracking();
|
||||
if (clusterId is not null) q = q.Where(a => a.ClusterId == clusterId);
|
||||
return q.OrderByDescending(a => a.Timestamp).Take(limit).ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
22
src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustOptions.cs
Normal file
22
src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustOptions.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Points the Admin UI at the OPC UA Server's PKI store root so
|
||||
/// <see cref="CertTrustService"/> can list and move certs between the
|
||||
/// <c>rejected/</c> and <c>trusted/</c> directories the server maintains. Must match the
|
||||
/// <c>OpcUaServer:PkiStoreRoot</c> the Server process is configured with.
|
||||
/// </summary>
|
||||
public sealed class CertTrustOptions
|
||||
{
|
||||
public const string SectionName = "CertTrust";
|
||||
|
||||
/// <summary>
|
||||
/// Absolute path to the PKI root. Defaults to
|
||||
/// <c>%ProgramData%\OtOpcUa\pki</c> — matches <c>OpcUaServerOptions.PkiStoreRoot</c>'s
|
||||
/// default so a standard side-by-side install needs no override.
|
||||
/// </summary>
|
||||
public string PkiStoreRoot { get; init; } =
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"OtOpcUa", "pki");
|
||||
}
|
||||
135
src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs
Normal file
135
src/ZB.MOM.WW.OtOpcUa.Admin/Services/CertTrustService.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for a certificate file found in one of the OPC UA server's PKI stores. The
|
||||
/// <see cref="FilePath"/> is the absolute path of the DER/CRT file the stack created when it
|
||||
/// rejected the cert (for <see cref="CertStoreKind.Rejected"/>) or when an operator trusted
|
||||
/// it (for <see cref="CertStoreKind.Trusted"/>).
|
||||
/// </summary>
|
||||
public sealed record CertInfo(
|
||||
string Thumbprint,
|
||||
string Subject,
|
||||
string Issuer,
|
||||
DateTime NotBefore,
|
||||
DateTime NotAfter,
|
||||
string FilePath,
|
||||
CertStoreKind Store);
|
||||
|
||||
public enum CertStoreKind
|
||||
{
|
||||
Rejected,
|
||||
Trusted,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filesystem-backed view over the OPC UA server's PKI store. The Opc.Ua stack uses a
|
||||
/// Directory-typed store — each cert is a <c>.der</c> file under <c>{root}/{store}/certs/</c>
|
||||
/// with a filename derived from subject + thumbprint. This service exposes operators for the
|
||||
/// Admin UI: list rejected, list trusted, trust a rejected cert (move to trusted), remove a
|
||||
/// rejected cert (delete), untrust a previously trusted cert (delete from trusted).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The Admin process is separate from the Server process; this service deliberately has no
|
||||
/// Opc.Ua dependency — it works on the on-disk layout directly so it can run on the Admin
|
||||
/// host even when the Server isn't installed locally, as long as the PKI root is reachable
|
||||
/// (typical deployment has Admin + Server side-by-side on the same machine).
|
||||
///
|
||||
/// Trust/untrust requires the Server to re-read its trust list. The Opc.Ua stack re-reads
|
||||
/// the Directory store on each new incoming connection, so there's no explicit signal
|
||||
/// needed — the next client handshake picks up the change. Operators should retry the
|
||||
/// rejected client's connection after trusting.
|
||||
/// </remarks>
|
||||
public sealed class CertTrustService
|
||||
{
|
||||
private readonly CertTrustOptions _options;
|
||||
private readonly ILogger<CertTrustService> _logger;
|
||||
|
||||
public CertTrustService(IOptions<CertTrustOptions> options, ILogger<CertTrustService> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string PkiStoreRoot => _options.PkiStoreRoot;
|
||||
|
||||
public IReadOnlyList<CertInfo> ListRejected() => ListStore(CertStoreKind.Rejected);
|
||||
public IReadOnlyList<CertInfo> ListTrusted() => ListStore(CertStoreKind.Trusted);
|
||||
|
||||
/// <summary>
|
||||
/// Move the cert with <paramref name="thumbprint"/> from the rejected store to the
|
||||
/// trusted store. No-op returns false if the rejected file doesn't exist (already moved
|
||||
/// by another operator, or thumbprint mismatch). Overwrites an existing trusted copy
|
||||
/// silently — idempotent.
|
||||
/// </summary>
|
||||
public bool TrustRejected(string thumbprint)
|
||||
{
|
||||
var cert = FindInStore(CertStoreKind.Rejected, thumbprint);
|
||||
if (cert is null) return false;
|
||||
|
||||
var trustedDir = CertsDir(CertStoreKind.Trusted);
|
||||
Directory.CreateDirectory(trustedDir);
|
||||
var destPath = Path.Combine(trustedDir, Path.GetFileName(cert.FilePath));
|
||||
File.Move(cert.FilePath, destPath, overwrite: true);
|
||||
_logger.LogInformation("Trusted cert {Thumbprint} (subject={Subject}) — moved {From} → {To}",
|
||||
cert.Thumbprint, cert.Subject, cert.FilePath, destPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool DeleteRejected(string thumbprint) => DeleteFromStore(CertStoreKind.Rejected, thumbprint);
|
||||
public bool UntrustCert(string thumbprint) => DeleteFromStore(CertStoreKind.Trusted, thumbprint);
|
||||
|
||||
private bool DeleteFromStore(CertStoreKind store, string thumbprint)
|
||||
{
|
||||
var cert = FindInStore(store, thumbprint);
|
||||
if (cert is null) return false;
|
||||
File.Delete(cert.FilePath);
|
||||
_logger.LogInformation("Deleted cert {Thumbprint} (subject={Subject}) from {Store} store",
|
||||
cert.Thumbprint, cert.Subject, store);
|
||||
return true;
|
||||
}
|
||||
|
||||
private CertInfo? FindInStore(CertStoreKind store, string thumbprint) =>
|
||||
ListStore(store).FirstOrDefault(c =>
|
||||
string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private IReadOnlyList<CertInfo> ListStore(CertStoreKind store)
|
||||
{
|
||||
var dir = CertsDir(store);
|
||||
if (!Directory.Exists(dir)) return [];
|
||||
|
||||
var results = new List<CertInfo>();
|
||||
foreach (var path in Directory.EnumerateFiles(dir))
|
||||
{
|
||||
// Skip CRL sidecars + private-key files — trust operations only concern public certs.
|
||||
var ext = Path.GetExtension(path);
|
||||
if (!ext.Equals(".der", StringComparison.OrdinalIgnoreCase) &&
|
||||
!ext.Equals(".crt", StringComparison.OrdinalIgnoreCase) &&
|
||||
!ext.Equals(".cer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var cert = X509CertificateLoader.LoadCertificateFromFile(path);
|
||||
results.Add(new CertInfo(
|
||||
cert.Thumbprint, cert.Subject, cert.Issuer,
|
||||
cert.NotBefore.ToUniversalTime(), cert.NotAfter.ToUniversalTime(),
|
||||
path, store));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A malformed file in the store shouldn't take down the page. Surface it in logs
|
||||
// but skip — operators see the other certs and can clean the bad file manually.
|
||||
_logger.LogWarning(ex, "Failed to parse cert at {Path} — skipping", path);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private string CertsDir(CertStoreKind store) =>
|
||||
Path.Combine(_options.PkiStoreRoot, store == CertStoreKind.Rejected ? "rejected" : "trusted", "certs");
|
||||
}
|
||||
28
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterService.cs
Normal file
28
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterService.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Cluster CRUD surface used by the Blazor pages. Writes go through stored procs in later
|
||||
/// phases; Phase 1 reads via EF Core directly (DENY SELECT on <c>dbo</c> schema means this
|
||||
/// service connects as a DB owner during dev — production swaps in a read-only view grant).
|
||||
/// </summary>
|
||||
public sealed class ClusterService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ServerCluster>> ListAsync(CancellationToken ct) =>
|
||||
db.ServerClusters.AsNoTracking().OrderBy(c => c.ClusterId).ToListAsync(ct);
|
||||
|
||||
public Task<ServerCluster?> FindAsync(string clusterId, CancellationToken ct) =>
|
||||
db.ServerClusters.AsNoTracking().FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
|
||||
|
||||
public async Task<ServerCluster> CreateAsync(ServerCluster cluster, string createdBy, CancellationToken ct)
|
||||
{
|
||||
cluster.CreatedAt = DateTime.UtcNow;
|
||||
cluster.CreatedBy = createdBy;
|
||||
db.ServerClusters.Add(cluster);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return cluster;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the managed <see cref="DraftValidator"/> against a draft's snapshot loaded from the
|
||||
/// Configuration DB. Used by the draft editor's inline validation panel and by the publish
|
||||
/// dialog's pre-check. Structural-only SQL checks live in <c>sp_ValidateDraft</c>; this layer
|
||||
/// owns the content / cross-generation / regex rules.
|
||||
/// </summary>
|
||||
public sealed class DraftValidationService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public async Task<IReadOnlyList<ValidationError>> ValidateAsync(long draftId, CancellationToken ct)
|
||||
{
|
||||
var draft = await db.ConfigGenerations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(g => g.GenerationId == draftId, ct)
|
||||
?? throw new InvalidOperationException($"Draft {draftId} not found");
|
||||
|
||||
var snapshot = new DraftSnapshot
|
||||
{
|
||||
GenerationId = draft.GenerationId,
|
||||
ClusterId = draft.ClusterId,
|
||||
Namespaces = await db.Namespaces.AsNoTracking().Where(n => n.GenerationId == draftId).ToListAsync(ct),
|
||||
DriverInstances = await db.DriverInstances.AsNoTracking().Where(d => d.GenerationId == draftId).ToListAsync(ct),
|
||||
Devices = await db.Devices.AsNoTracking().Where(d => d.GenerationId == draftId).ToListAsync(ct),
|
||||
UnsAreas = await db.UnsAreas.AsNoTracking().Where(a => a.GenerationId == draftId).ToListAsync(ct),
|
||||
UnsLines = await db.UnsLines.AsNoTracking().Where(l => l.GenerationId == draftId).ToListAsync(ct),
|
||||
Equipment = await db.Equipment.AsNoTracking().Where(e => e.GenerationId == draftId).ToListAsync(ct),
|
||||
Tags = await db.Tags.AsNoTracking().Where(t => t.GenerationId == draftId).ToListAsync(ct),
|
||||
PollGroups = await db.PollGroups.AsNoTracking().Where(p => p.GenerationId == draftId).ToListAsync(ct),
|
||||
|
||||
PriorEquipment = await db.Equipment.AsNoTracking()
|
||||
.Where(e => e.GenerationId != draftId
|
||||
&& db.ConfigGenerations.Any(g => g.GenerationId == e.GenerationId && g.ClusterId == draft.ClusterId))
|
||||
.ToListAsync(ct),
|
||||
ActiveReservations = await db.ExternalIdReservations.AsNoTracking()
|
||||
.Where(r => r.ReleasedAt == null)
|
||||
.ToListAsync(ct),
|
||||
};
|
||||
|
||||
return DraftValidator.Validate(snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class DriverInstanceService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<DriverInstance>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.DriverInstances.AsNoTracking()
|
||||
.Where(d => d.GenerationId == generationId)
|
||||
.OrderBy(d => d.DriverInstanceId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<DriverInstance> AddAsync(
|
||||
long draftId, string clusterId, string namespaceId, string name, string driverType,
|
||||
string driverConfigJson, CancellationToken ct)
|
||||
{
|
||||
var di = new DriverInstance
|
||||
{
|
||||
GenerationId = draftId,
|
||||
DriverInstanceId = $"drv-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
NamespaceId = namespaceId,
|
||||
Name = name,
|
||||
DriverType = driverType,
|
||||
DriverConfig = driverConfigJson,
|
||||
};
|
||||
db.DriverInstances.Add(di);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return di;
|
||||
}
|
||||
}
|
||||
75
src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs
Normal file
75
src/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentService.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Equipment CRUD scoped to a generation. The Admin app writes against Draft generations only;
|
||||
/// Published generations are read-only (to create changes, clone to a new draft via
|
||||
/// <see cref="GenerationService.CreateDraftAsync"/>).
|
||||
/// </summary>
|
||||
public sealed class EquipmentService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<Equipment>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.Equipment.AsNoTracking()
|
||||
.Where(e => e.GenerationId == generationId)
|
||||
.OrderBy(e => e.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<Equipment?> FindAsync(long generationId, string equipmentId, CancellationToken ct) =>
|
||||
db.Equipment.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.GenerationId == generationId && e.EquipmentId == equipmentId, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new equipment row in the given draft. The EquipmentId is auto-derived from
|
||||
/// a fresh EquipmentUuid per decision #125; operator-supplied IDs are rejected upstream.
|
||||
/// </summary>
|
||||
public async Task<Equipment> CreateAsync(long draftId, Equipment input, CancellationToken ct)
|
||||
{
|
||||
input.GenerationId = draftId;
|
||||
input.EquipmentUuid = input.EquipmentUuid == Guid.Empty ? Guid.NewGuid() : input.EquipmentUuid;
|
||||
input.EquipmentId = DraftValidator.DeriveEquipmentId(input.EquipmentUuid);
|
||||
db.Equipment.Add(input);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return input;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(Equipment updated, CancellationToken ct)
|
||||
{
|
||||
// Only editable fields are persisted; EquipmentId + EquipmentUuid are immutable once set.
|
||||
var existing = await db.Equipment
|
||||
.FirstOrDefaultAsync(e => e.EquipmentRowId == updated.EquipmentRowId, ct)
|
||||
?? throw new InvalidOperationException($"Equipment row {updated.EquipmentRowId} not found");
|
||||
|
||||
existing.Name = updated.Name;
|
||||
existing.MachineCode = updated.MachineCode;
|
||||
existing.ZTag = updated.ZTag;
|
||||
existing.SAPID = updated.SAPID;
|
||||
existing.Manufacturer = updated.Manufacturer;
|
||||
existing.Model = updated.Model;
|
||||
existing.SerialNumber = updated.SerialNumber;
|
||||
existing.HardwareRevision = updated.HardwareRevision;
|
||||
existing.SoftwareRevision = updated.SoftwareRevision;
|
||||
existing.YearOfConstruction = updated.YearOfConstruction;
|
||||
existing.AssetLocation = updated.AssetLocation;
|
||||
existing.ManufacturerUri = updated.ManufacturerUri;
|
||||
existing.DeviceManualUri = updated.DeviceManualUri;
|
||||
existing.DriverInstanceId = updated.DriverInstanceId;
|
||||
existing.DeviceId = updated.DeviceId;
|
||||
existing.UnsLineId = updated.UnsLineId;
|
||||
existing.EquipmentClassRef = updated.EquipmentClassRef;
|
||||
existing.Enabled = updated.Enabled;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid equipmentRowId, CancellationToken ct)
|
||||
{
|
||||
var row = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentRowId == equipmentRowId, ct);
|
||||
if (row is null) return;
|
||||
db.Equipment.Remove(row);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
71
src/ZB.MOM.WW.OtOpcUa.Admin/Services/GenerationService.cs
Normal file
71
src/ZB.MOM.WW.OtOpcUa.Admin/Services/GenerationService.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the draft → diff → publish workflow (decision #89). Publish + rollback call into the
|
||||
/// stored procedures; diff queries <c>sp_ComputeGenerationDiff</c>.
|
||||
/// </summary>
|
||||
public sealed class GenerationService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public async Task<ConfigGeneration> CreateDraftAsync(string clusterId, string createdBy, CancellationToken ct)
|
||||
{
|
||||
var gen = new ConfigGeneration
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
Status = GenerationStatus.Draft,
|
||||
CreatedBy = createdBy,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.ConfigGenerations.Add(gen);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return gen;
|
||||
}
|
||||
|
||||
public Task<List<ConfigGeneration>> ListRecentAsync(string clusterId, int limit, CancellationToken ct) =>
|
||||
db.ConfigGenerations.AsNoTracking()
|
||||
.Where(g => g.ClusterId == clusterId)
|
||||
.OrderByDescending(g => g.GenerationId)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task PublishAsync(string clusterId, long draftGenerationId, string? notes, CancellationToken ct)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_PublishGeneration @ClusterId = {0}, @DraftGenerationId = {1}, @Notes = {2}",
|
||||
[clusterId, draftGenerationId, (object?)notes ?? DBNull.Value],
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task RollbackAsync(string clusterId, long targetGenerationId, string? notes, CancellationToken ct)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_RollbackToGeneration @ClusterId = {0}, @TargetGenerationId = {1}, @Notes = {2}",
|
||||
[clusterId, targetGenerationId, (object?)notes ?? DBNull.Value],
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<List<DiffRow>> ComputeDiffAsync(long from, long to, CancellationToken ct)
|
||||
{
|
||||
var results = new List<DiffRow>();
|
||||
await using var conn = (SqlConnection)db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_ComputeGenerationDiff @FromGenerationId = @f, @ToGenerationId = @t";
|
||||
cmd.Parameters.AddWithValue("@f", from);
|
||||
cmd.Parameters.AddWithValue("@t", to);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(new DiffRow(reader.GetString(0), reader.GetString(1), reader.GetString(2)));
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DiffRow(string TableName, string LogicalId, string ChangeKind);
|
||||
63
src/ZB.MOM.WW.OtOpcUa.Admin/Services/HostStatusService.cs
Normal file
63
src/ZB.MOM.WW.OtOpcUa.Admin/Services/HostStatusService.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One row per <see cref="DriverHostStatus"/> record, enriched with the owning
|
||||
/// <c>ClusterNode.ClusterId</c> when available (left-join). The Admin <c>/hosts</c> page
|
||||
/// groups by cluster and renders a per-node → per-driver → per-host tree.
|
||||
/// </summary>
|
||||
public sealed record HostStatusRow(
|
||||
string NodeId,
|
||||
string? ClusterId,
|
||||
string DriverInstanceId,
|
||||
string HostName,
|
||||
DriverHostState State,
|
||||
DateTime StateChangedUtc,
|
||||
DateTime LastSeenUtc,
|
||||
string? Detail);
|
||||
|
||||
/// <summary>
|
||||
/// Read-side service for the Admin UI's per-host drill-down. Loads
|
||||
/// <see cref="DriverHostStatus"/> rows (written by the Server process's
|
||||
/// <c>HostStatusPublisher</c>) and left-joins <c>ClusterNode</c> so each row knows which
|
||||
/// cluster it belongs to — the Admin UI groups by cluster for the fleet-wide view.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The publisher heartbeat is 10s (<c>HostStatusPublisher.HeartbeatInterval</c>). The
|
||||
/// Admin page also polls every ~10s and treats rows with <c>LastSeenUtc</c> older than
|
||||
/// <c>StaleThreshold</c> (30s) as stale — covers a missed heartbeat tolerance plus
|
||||
/// a generous buffer for clock skew and publisher GC pauses.
|
||||
/// </remarks>
|
||||
public sealed class HostStatusService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public static readonly TimeSpan StaleThreshold = TimeSpan.FromSeconds(30);
|
||||
|
||||
public async Task<IReadOnlyList<HostStatusRow>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
// LEFT JOIN on NodeId so a row persists even when its owning ClusterNode row hasn't
|
||||
// been created yet (first-boot bootstrap case — keeps the UI from losing sight of
|
||||
// the reporting server).
|
||||
var rows = await (from s in db.DriverHostStatuses.AsNoTracking()
|
||||
join n in db.ClusterNodes.AsNoTracking()
|
||||
on s.NodeId equals n.NodeId into nodeJoin
|
||||
from n in nodeJoin.DefaultIfEmpty()
|
||||
orderby s.NodeId, s.DriverInstanceId, s.HostName
|
||||
select new HostStatusRow(
|
||||
s.NodeId,
|
||||
n != null ? n.ClusterId : null,
|
||||
s.DriverInstanceId,
|
||||
s.HostName,
|
||||
s.State,
|
||||
s.StateChangedUtc,
|
||||
s.LastSeenUtc,
|
||||
s.Detail)).ToListAsync(ct);
|
||||
return rows;
|
||||
}
|
||||
|
||||
public static bool IsStale(HostStatusRow row) =>
|
||||
DateTime.UtcNow - row.LastSeenUtc > StaleThreshold;
|
||||
}
|
||||
31
src/ZB.MOM.WW.OtOpcUa.Admin/Services/NamespaceService.cs
Normal file
31
src/ZB.MOM.WW.OtOpcUa.Admin/Services/NamespaceService.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class NamespaceService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<Namespace>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.Namespaces.AsNoTracking()
|
||||
.Where(n => n.GenerationId == generationId)
|
||||
.OrderBy(n => n.NamespaceId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<Namespace> AddAsync(
|
||||
long draftId, string clusterId, string namespaceUri, NamespaceKind kind, CancellationToken ct)
|
||||
{
|
||||
var ns = new Namespace
|
||||
{
|
||||
GenerationId = draftId,
|
||||
NamespaceId = $"ns-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
NamespaceUri = namespaceUri,
|
||||
Kind = kind,
|
||||
};
|
||||
db.Namespaces.Add(ns);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return ns;
|
||||
}
|
||||
}
|
||||
44
src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs
Normal file
44
src/ZB.MOM.WW.OtOpcUa.Admin/Services/NodeAclService.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class NodeAclService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<NodeAcl>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.NodeAcls.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.LdapGroup)
|
||||
.ThenBy(a => a.ScopeKind)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<NodeAcl> GrantAsync(
|
||||
long draftId, string clusterId, string ldapGroup, NodeAclScopeKind scopeKind, string? scopeId,
|
||||
NodePermissions permissions, string? notes, CancellationToken ct)
|
||||
{
|
||||
var acl = new NodeAcl
|
||||
{
|
||||
GenerationId = draftId,
|
||||
NodeAclId = $"acl-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
LdapGroup = ldapGroup,
|
||||
ScopeKind = scopeKind,
|
||||
ScopeId = scopeId,
|
||||
PermissionFlags = permissions,
|
||||
Notes = notes,
|
||||
};
|
||||
db.NodeAcls.Add(acl);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return acl;
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(Guid nodeAclRowId, CancellationToken ct)
|
||||
{
|
||||
var row = await db.NodeAcls.FirstOrDefaultAsync(a => a.NodeAclRowId == nodeAclRowId, ct);
|
||||
if (row is null) return;
|
||||
db.NodeAcls.Remove(row);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
38
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ReservationService.cs
Normal file
38
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ReservationService.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fleet-wide external-ID reservation inspector + FleetAdmin-only release flow per
|
||||
/// <c>admin-ui.md §"Release an external-ID reservation"</c>. Release is audit-logged
|
||||
/// (<see cref="ConfigAuditLog"/>) via <c>sp_ReleaseExternalIdReservation</c>.
|
||||
/// </summary>
|
||||
public sealed class ReservationService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ExternalIdReservation>> ListActiveAsync(CancellationToken ct) =>
|
||||
db.ExternalIdReservations.AsNoTracking()
|
||||
.Where(r => r.ReleasedAt == null)
|
||||
.OrderBy(r => r.Kind).ThenBy(r => r.Value)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<List<ExternalIdReservation>> ListReleasedAsync(CancellationToken ct) =>
|
||||
db.ExternalIdReservations.AsNoTracking()
|
||||
.Where(r => r.ReleasedAt != null)
|
||||
.OrderByDescending(r => r.ReleasedAt)
|
||||
.Take(100)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task ReleaseAsync(string kind, string value, string reason, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
throw new ArgumentException("ReleaseReason is required (audit invariant)", nameof(reason));
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_ReleaseExternalIdReservation @Kind = {0}, @Value = {1}, @ReleaseReason = {2}",
|
||||
[kind, value, reason],
|
||||
ct);
|
||||
}
|
||||
}
|
||||
50
src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
Normal file
50
src/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsService.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
public sealed class UnsService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<UnsArea>> ListAreasAsync(long generationId, CancellationToken ct) =>
|
||||
db.UnsAreas.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<List<UnsLine>> ListLinesAsync(long generationId, CancellationToken ct) =>
|
||||
db.UnsLines.AsNoTracking()
|
||||
.Where(l => l.GenerationId == generationId)
|
||||
.OrderBy(l => l.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<UnsArea> AddAreaAsync(long draftId, string clusterId, string name, string? notes, CancellationToken ct)
|
||||
{
|
||||
var area = new UnsArea
|
||||
{
|
||||
GenerationId = draftId,
|
||||
UnsAreaId = $"area-{Guid.NewGuid():N}"[..20],
|
||||
ClusterId = clusterId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
};
|
||||
db.UnsAreas.Add(area);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return area;
|
||||
}
|
||||
|
||||
public async Task<UnsLine> AddLineAsync(long draftId, string unsAreaId, string name, string? notes, CancellationToken ct)
|
||||
{
|
||||
var line = new UnsLine
|
||||
{
|
||||
GenerationId = draftId,
|
||||
UnsLineId = $"line-{Guid.NewGuid():N}"[..20],
|
||||
UnsAreaId = unsAreaId,
|
||||
Name = name,
|
||||
Notes = notes,
|
||||
};
|
||||
db.UnsLines.Add(line);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return line;
|
||||
}
|
||||
}
|
||||
34
src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj
Normal file
34
src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj
Normal file
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Admin</RootNamespace>
|
||||
<AssemblyName>OtOpcUa.Admin</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0"/>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Admin.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
27
src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
Normal file
27
src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"ConfigDb": "Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;"
|
||||
},
|
||||
"Authentication": {
|
||||
"Ldap": {
|
||||
"Enabled": true,
|
||||
"Server": "localhost",
|
||||
"Port": 3893,
|
||||
"UseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"SearchBase": "dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,ou=svcaccts,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"DisplayNameAttribute": "cn",
|
||||
"GroupAttribute": "memberOf",
|
||||
"GroupToRole": {
|
||||
"ReadOnly": "ConfigViewer",
|
||||
"ReadWrite": "ConfigEditor",
|
||||
"AlarmAck": "FleetAdmin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information"
|
||||
}
|
||||
}
|
||||
3
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css
Normal file
3
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css
Normal file
@@ -0,0 +1,3 @@
|
||||
/* OtOpcUa Admin — ScadaLink-parity palette. Keep it minimal here; lean on Bootstrap 5. */
|
||||
body { background-color: #f5f6fa; }
|
||||
.nav-link.active { background-color: rgba(255,255,255,0.1); border-radius: 4px; }
|
||||
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs
Normal file
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
/// <summary>
|
||||
/// Host-supplied callbacks invoked as the applier walks the diff. Callbacks are idempotent on
|
||||
/// retry (the applier may re-invoke with the same inputs if a later stage fails — nodes
|
||||
/// register-applied to the central DB only after success). Order: namespace → driver → device →
|
||||
/// equipment → poll group → tag, with Removed before Added/Modified.
|
||||
/// </summary>
|
||||
public sealed class ApplyCallbacks
|
||||
{
|
||||
public Func<EntityChange<Namespace>, CancellationToken, Task>? OnNamespace { get; init; }
|
||||
public Func<EntityChange<DriverInstance>, CancellationToken, Task>? OnDriver { get; init; }
|
||||
public Func<EntityChange<Device>, CancellationToken, Task>? OnDevice { get; init; }
|
||||
public Func<EntityChange<Equipment>, CancellationToken, Task>? OnEquipment { get; init; }
|
||||
public Func<EntityChange<PollGroup>, CancellationToken, Task>? OnPollGroup { get; init; }
|
||||
public Func<EntityChange<Tag>, CancellationToken, Task>? OnTag { get; init; }
|
||||
}
|
||||
8
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs
Normal file
8
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
public enum ChangeKind
|
||||
{
|
||||
Added,
|
||||
Removed,
|
||||
Modified,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
public sealed class GenerationApplier(ApplyCallbacks callbacks) : IGenerationApplier
|
||||
{
|
||||
public async Task<ApplyResult> ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct)
|
||||
{
|
||||
var diff = GenerationDiffer.Compute(from, to);
|
||||
var errors = new List<string>();
|
||||
|
||||
// Removed first, then Added/Modified — prevents FK dangling while cascades settle.
|
||||
await ApplyPass(diff.Tags, ChangeKind.Removed, callbacks.OnTag, errors, ct);
|
||||
await ApplyPass(diff.PollGroups, ChangeKind.Removed, callbacks.OnPollGroup, errors, ct);
|
||||
await ApplyPass(diff.Equipment, ChangeKind.Removed, callbacks.OnEquipment, errors, ct);
|
||||
await ApplyPass(diff.Devices, ChangeKind.Removed, callbacks.OnDevice, errors, ct);
|
||||
await ApplyPass(diff.Drivers, ChangeKind.Removed, callbacks.OnDriver, errors, ct);
|
||||
await ApplyPass(diff.Namespaces, ChangeKind.Removed, callbacks.OnNamespace, errors, ct);
|
||||
|
||||
foreach (var kind in new[] { ChangeKind.Added, ChangeKind.Modified })
|
||||
{
|
||||
await ApplyPass(diff.Namespaces, kind, callbacks.OnNamespace, errors, ct);
|
||||
await ApplyPass(diff.Drivers, kind, callbacks.OnDriver, errors, ct);
|
||||
await ApplyPass(diff.Devices, kind, callbacks.OnDevice, errors, ct);
|
||||
await ApplyPass(diff.Equipment, kind, callbacks.OnEquipment, errors, ct);
|
||||
await ApplyPass(diff.PollGroups, kind, callbacks.OnPollGroup, errors, ct);
|
||||
await ApplyPass(diff.Tags, kind, callbacks.OnTag, errors, ct);
|
||||
}
|
||||
|
||||
return errors.Count == 0 ? ApplyResult.Ok(diff) : ApplyResult.Fail(diff, errors);
|
||||
}
|
||||
|
||||
private static async Task ApplyPass<T>(
|
||||
IReadOnlyList<EntityChange<T>> changes,
|
||||
ChangeKind kind,
|
||||
Func<EntityChange<T>, CancellationToken, Task>? callback,
|
||||
List<string> errors,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (callback is null) return;
|
||||
|
||||
foreach (var change in changes.Where(c => c.Kind == kind))
|
||||
{
|
||||
try { await callback(change, ct); }
|
||||
catch (Exception ex) { errors.Add($"{typeof(T).Name} {change.Kind} '{change.LogicalId}': {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs
Normal file
70
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
/// <summary>
|
||||
/// Per-entity diff computed locally on the node. The enumerable order matches the dependency
|
||||
/// order expected by <see cref="IGenerationApplier"/>: namespace → driver → device → equipment →
|
||||
/// poll group → tag → ACL, with Removed processed before Added inside each bucket so cascades
|
||||
/// settle before new rows appear.
|
||||
/// </summary>
|
||||
public sealed record GenerationDiff(
|
||||
IReadOnlyList<EntityChange<Namespace>> Namespaces,
|
||||
IReadOnlyList<EntityChange<DriverInstance>> Drivers,
|
||||
IReadOnlyList<EntityChange<Device>> Devices,
|
||||
IReadOnlyList<EntityChange<Equipment>> Equipment,
|
||||
IReadOnlyList<EntityChange<PollGroup>> PollGroups,
|
||||
IReadOnlyList<EntityChange<Tag>> Tags);
|
||||
|
||||
public sealed record EntityChange<T>(ChangeKind Kind, string LogicalId, T? From, T? To);
|
||||
|
||||
public static class GenerationDiffer
|
||||
{
|
||||
public static GenerationDiff Compute(DraftSnapshot? from, DraftSnapshot to)
|
||||
{
|
||||
from ??= new DraftSnapshot { GenerationId = 0, ClusterId = to.ClusterId };
|
||||
|
||||
return new GenerationDiff(
|
||||
Namespaces: DiffById(from.Namespaces, to.Namespaces, x => x.NamespaceId,
|
||||
(a, b) => (a.ClusterId, a.NamespaceUri, a.Kind, a.Enabled, a.Notes)
|
||||
== (b.ClusterId, b.NamespaceUri, b.Kind, b.Enabled, b.Notes)),
|
||||
Drivers: DiffById(from.DriverInstances, to.DriverInstances, x => x.DriverInstanceId,
|
||||
(a, b) => (a.ClusterId, a.NamespaceId, a.Name, a.DriverType, a.Enabled, a.DriverConfig)
|
||||
== (b.ClusterId, b.NamespaceId, b.Name, b.DriverType, b.Enabled, b.DriverConfig)),
|
||||
Devices: DiffById(from.Devices, to.Devices, x => x.DeviceId,
|
||||
(a, b) => (a.DriverInstanceId, a.Name, a.Enabled, a.DeviceConfig)
|
||||
== (b.DriverInstanceId, b.Name, b.Enabled, b.DeviceConfig)),
|
||||
Equipment: DiffById(from.Equipment, to.Equipment, x => x.EquipmentId,
|
||||
(a, b) => (a.EquipmentUuid, a.DriverInstanceId, a.UnsLineId, a.Name, a.MachineCode, a.ZTag, a.SAPID, a.Enabled)
|
||||
== (b.EquipmentUuid, b.DriverInstanceId, b.UnsLineId, b.Name, b.MachineCode, b.ZTag, b.SAPID, b.Enabled)),
|
||||
PollGroups: DiffById(from.PollGroups, to.PollGroups, x => x.PollGroupId,
|
||||
(a, b) => (a.DriverInstanceId, a.Name, a.IntervalMs)
|
||||
== (b.DriverInstanceId, b.Name, b.IntervalMs)),
|
||||
Tags: DiffById(from.Tags, to.Tags, x => x.TagId,
|
||||
(a, b) => (a.DriverInstanceId, a.DeviceId, a.EquipmentId, a.PollGroupId, a.FolderPath, a.Name, a.DataType, a.AccessLevel, a.WriteIdempotent, a.TagConfig)
|
||||
== (b.DriverInstanceId, b.DeviceId, b.EquipmentId, b.PollGroupId, b.FolderPath, b.Name, b.DataType, b.AccessLevel, b.WriteIdempotent, b.TagConfig)));
|
||||
}
|
||||
|
||||
private static List<EntityChange<T>> DiffById<T>(
|
||||
IReadOnlyList<T> from, IReadOnlyList<T> to,
|
||||
Func<T, string> id, Func<T, T, bool> equal)
|
||||
{
|
||||
var fromById = from.ToDictionary(id);
|
||||
var toById = to.ToDictionary(id);
|
||||
var result = new List<EntityChange<T>>();
|
||||
|
||||
foreach (var (logicalId, src) in fromById.Where(kv => !toById.ContainsKey(kv.Key)))
|
||||
result.Add(new(ChangeKind.Removed, logicalId, src, default));
|
||||
|
||||
foreach (var (logicalId, dst) in toById)
|
||||
{
|
||||
if (!fromById.TryGetValue(logicalId, out var src))
|
||||
result.Add(new(ChangeKind.Added, logicalId, default, dst));
|
||||
else if (!equal(src, dst))
|
||||
result.Add(new(ChangeKind.Modified, logicalId, src, dst));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
/// <summary>
|
||||
/// Applies a <see cref="GenerationDiff"/> to whatever backing runtime the node owns: the OPC UA
|
||||
/// address space, driver subscriptions, the local cache, etc. The Core project wires concrete
|
||||
/// callbacks into this via <see cref="ApplyCallbacks"/> so the Configuration project stays free
|
||||
/// of a Core/Server dependency (interface independence per decision #59).
|
||||
/// </summary>
|
||||
public interface IGenerationApplier
|
||||
{
|
||||
Task<ApplyResult> ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record ApplyResult(
|
||||
bool Succeeded,
|
||||
GenerationDiff Diff,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static ApplyResult Ok(GenerationDiff diff) => new(true, diff, []);
|
||||
public static ApplyResult Fail(GenerationDiff diff, IReadOnlyList<string> errors) => new(false, diff, errors);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Used by <c>dotnet ef</c> at design time (migrations, scaffolding). Reads the connection string
|
||||
/// from the <c>OTOPCUA_CONFIG_CONNECTION</c> environment variable, falling back to the local dev
|
||||
/// container on <c>localhost:1433</c>.
|
||||
/// </summary>
|
||||
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
// Host-port 14330 avoids collision with the native MSSQL14 instance on 1433 (Galaxy "ZB" DB).
|
||||
private const string DefaultConnectionString =
|
||||
"Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
public OtOpcUaConfigDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connection = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_CONNECTION")
|
||||
?? DefaultConnectionString;
|
||||
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer(connection, sql => sql.MigrationsAssembly(typeof(OtOpcUaConfigDbContext).Assembly.FullName))
|
||||
.Options;
|
||||
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
}
|
||||
51
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs
Normal file
51
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>Physical OPC UA server node within a <see cref="ServerCluster"/>.</summary>
|
||||
public sealed class ClusterNode
|
||||
{
|
||||
/// <summary>Stable per-machine logical ID, e.g. "LINE3-OPCUA-A".</summary>
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required RedundancyRole RedundancyRole { get; set; }
|
||||
|
||||
/// <summary>Machine hostname / IP.</summary>
|
||||
public required string Host { get; set; }
|
||||
|
||||
public int OpcUaPort { get; set; } = 4840;
|
||||
|
||||
public int DashboardPort { get; set; } = 8081;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>ApplicationUri</c> — MUST be unique per node per OPC UA spec. Clients pin trust here.
|
||||
/// Fleet-wide unique index enforces no two nodes share a value (decision #86).
|
||||
/// Stored explicitly, NOT derived from <see cref="Host"/> at runtime — silent rewrite on
|
||||
/// hostname change would break all client trust.
|
||||
/// </summary>
|
||||
public required string ApplicationUri { get; set; }
|
||||
|
||||
/// <summary>Primary = 200, Secondary = 150 by default.</summary>
|
||||
public byte ServiceLevelBase { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Per-node override JSON keyed by DriverInstanceId, merged onto cluster-level DriverConfig
|
||||
/// at apply time. Minimal by intent (decision #81). Nullable when no overrides exist.
|
||||
/// </summary>
|
||||
public string? DriverConfigOverridesJson { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
// Navigation
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
public ICollection<ClusterNodeCredential> Credentials { get; set; } = [];
|
||||
public ClusterNodeGenerationState? GenerationState { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a <see cref="ClusterNode"/> to the central config DB.
|
||||
/// Per decision #83 — credentials bind to NodeId, not ClusterId.
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeCredential
|
||||
{
|
||||
public Guid CredentialId { get; set; }
|
||||
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
public required CredentialKind Kind { get; set; }
|
||||
|
||||
/// <summary>Login name / cert thumbprint / SID / gMSA name.</summary>
|
||||
public required string Value { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public DateTime? RotatedAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
public ClusterNode? Node { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which generation each node has applied. Per-node (not per-cluster) — both nodes of a
|
||||
/// 2-node cluster track independently per decision #84.
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeGenerationState
|
||||
{
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
public long? CurrentGenerationId { get; set; }
|
||||
|
||||
public DateTime? LastAppliedAt { get; set; }
|
||||
|
||||
public NodeApplyStatus? LastAppliedStatus { get; set; }
|
||||
|
||||
public string? LastAppliedError { get; set; }
|
||||
|
||||
/// <summary>Updated on every poll for liveness detection.</summary>
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
|
||||
public ClusterNode? Node { get; set; }
|
||||
public ConfigGeneration? CurrentGeneration { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Append-only audit log for every config write + authorization-check event. Grants revoked for
|
||||
/// UPDATE / DELETE on all principals (enforced by the authorization migration in B.3).
|
||||
/// </summary>
|
||||
public sealed class ConfigAuditLog
|
||||
{
|
||||
public long AuditId { get; set; }
|
||||
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string Principal { get; set; }
|
||||
|
||||
/// <summary>DraftCreated | DraftEdited | Published | RolledBack | NodeApplied | CredentialAdded | CredentialDisabled | ClusterCreated | NodeAdded | ExternalIdReleased | CrossClusterNamespaceAttempt | OpcUaAccessDenied | …</summary>
|
||||
public required string EventType { get; set; }
|
||||
|
||||
public string? ClusterId { get; set; }
|
||||
|
||||
public string? NodeId { get; set; }
|
||||
|
||||
public long? GenerationId { get; set; }
|
||||
|
||||
public string? DetailsJson { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Atomic, immutable snapshot of one cluster's configuration.
|
||||
/// Per decision #82 — cluster-scoped, not fleet-scoped.
|
||||
/// </summary>
|
||||
public sealed class ConfigGeneration
|
||||
{
|
||||
/// <summary>Monotonically increasing ID, generated by <c>IDENTITY(1, 1)</c>.</summary>
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required GenerationStatus Status { get; set; }
|
||||
|
||||
public long? ParentGenerationId { get; set; }
|
||||
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
|
||||
public string? PublishedBy { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
public ConfigGeneration? Parent { get; set; }
|
||||
}
|
||||
23
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs
Normal file
23
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>Per-device row for multi-device drivers (Modbus, AB CIP). Optional for single-device drivers.</summary>
|
||||
public sealed class Device
|
||||
{
|
||||
public Guid DeviceRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string DeviceId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="DriverInstance.DriverInstanceId"/>.</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Schemaless per-driver-type device config (host, port, unit ID, slot, etc.).</summary>
|
||||
public required string DeviceConfig { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-host connectivity snapshot the Server publishes for each driver's
|
||||
/// <c>IHostConnectivityProbe.GetHostStatuses</c> entry. One row per
|
||||
/// (<see cref="NodeId"/>, <see cref="DriverInstanceId"/>, <see cref="HostName"/>) triple —
|
||||
/// a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6
|
||||
/// rows, not 3, because each server node owns its own runtime view.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Closes the data-layer piece of LMX follow-up #7 (per-AppEngine Admin dashboard
|
||||
/// drill-down). The publisher hosted service on the Server side subscribes to every
|
||||
/// registered driver's <c>OnHostStatusChanged</c> and upserts rows on transitions +
|
||||
/// periodic liveness heartbeats. <see cref="LastSeenUtc"/> advances on every
|
||||
/// heartbeat so the Admin UI can flag stale rows from a crashed Server.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No foreign-key to <see cref="ClusterNode"/> — a Server may start reporting host
|
||||
/// status before its ClusterNode row exists (e.g. first-boot bootstrap), and we'd
|
||||
/// rather keep the status row than drop it. The Admin-side service left-joins on
|
||||
/// NodeId when presenting rows.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DriverHostStatus
|
||||
{
|
||||
/// <summary>Server node that's running the driver.</summary>
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
/// <summary>Driver instance's stable id (matches <c>IDriver.DriverInstanceId</c>).</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Driver-side host identifier — Galaxy Platform / AppEngine name, Modbus
|
||||
/// <c>host:port</c>, whatever the probe returns. Opaque to the Admin UI except as
|
||||
/// a display string.
|
||||
/// </summary>
|
||||
public required string HostName { get; set; }
|
||||
|
||||
public DriverHostState State { get; set; } = DriverHostState.Unknown;
|
||||
|
||||
/// <summary>Timestamp of the last state transition (not of the most recent heartbeat).</summary>
|
||||
public DateTime StateChangedUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Advances on every publisher heartbeat — the Admin UI uses
|
||||
/// <c>now - LastSeenUtc > threshold</c> to flag rows whose owning Server has
|
||||
/// stopped reporting (crashed, network-partitioned, etc.), independent of
|
||||
/// <see cref="State"/>.
|
||||
/// </summary>
|
||||
public DateTime LastSeenUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional human-readable detail populated when <see cref="State"/> is
|
||||
/// <see cref="DriverHostState.Faulted"/> — e.g. the exception message from the
|
||||
/// driver's probe. Null for Running / Stopped / Unknown transitions.
|
||||
/// </summary>
|
||||
public string? Detail { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>One driver instance in a cluster's generation. JSON config is schemaless per-driver-type.</summary>
|
||||
public sealed class DriverInstance
|
||||
{
|
||||
public Guid DriverInstanceRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Logical FK to <see cref="Namespace.NamespaceId"/>. Same-cluster binding enforced by
|
||||
/// <c>sp_ValidateDraft</c> per decision #122: Namespace.ClusterId must equal DriverInstance.ClusterId.
|
||||
/// </summary>
|
||||
public required string NamespaceId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Galaxy | ModbusTcp | AbCip | AbLegacy | S7 | TwinCat | Focas | OpcUaClient</summary>
|
||||
public required string DriverType { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
|
||||
public required string DriverConfig { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
64
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// UNS level-5 entity. Only for drivers in Equipment-kind namespaces.
|
||||
/// Per decisions #109 (first-class), #116 (5-identifier model), #125 (system-generated EquipmentId),
|
||||
/// #138–139 (OPC 40010 Identification fields as first-class columns).
|
||||
/// </summary>
|
||||
public sealed class Equipment
|
||||
{
|
||||
public Guid EquipmentRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// System-generated stable internal logical ID. Format: <c>'EQ-' + first 12 hex chars of EquipmentUuid</c>.
|
||||
/// NEVER operator-supplied, NEVER in CSV imports, NEVER editable in Admin UI (decision #125).
|
||||
/// </summary>
|
||||
public required string EquipmentId { get; set; }
|
||||
|
||||
/// <summary>UUIDv4, IMMUTABLE across all generations of the same EquipmentId. Downstream-consumer join key.</summary>
|
||||
public Guid EquipmentUuid { get; set; }
|
||||
|
||||
/// <summary>Logical FK to the driver providing data for this equipment.</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
/// <summary>Optional logical FK to a multi-device driver's device.</summary>
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="UnsLine.UnsLineId"/>.</summary>
|
||||
public required string UnsLineId { get; set; }
|
||||
|
||||
/// <summary>UNS level 5 segment, matches <c>^[a-z0-9-]{1,32}$</c>.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
// Operator-facing / external-system identifiers (decision #116)
|
||||
|
||||
/// <summary>Operator colloquial id (e.g. "machine_001"). Unique within cluster. Required.</summary>
|
||||
public required string MachineCode { get; set; }
|
||||
|
||||
/// <summary>ERP equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>. Primary browse identifier in Admin UI.</summary>
|
||||
public string? ZTag { get; set; }
|
||||
|
||||
/// <summary>SAP PM equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>.</summary>
|
||||
public string? SAPID { get; set; }
|
||||
|
||||
// OPC UA Companion Spec OPC 40010 Machinery Identification fields (decision #139).
|
||||
// All nullable so equipment can be added before identity is fully captured.
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? HardwareRevision { get; set; }
|
||||
public string? SoftwareRevision { get; set; }
|
||||
public short? YearOfConstruction { get; set; }
|
||||
public string? AssetLocation { get; set; }
|
||||
public string? ManufacturerUri { get; set; }
|
||||
public string? DeviceManualUri { get; set; }
|
||||
|
||||
/// <summary>Nullable hook for future schemas-repo template ID (decision #112).</summary>
|
||||
public string? EquipmentClassRef { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Fleet-wide rollback-safe reservation of ZTag and SAPID. Per decision #124 — NOT generation-versioned.
|
||||
/// Exists outside generation flow specifically because old generations and disabled equipment can
|
||||
/// still hold the same external IDs; per-generation uniqueness indexes fail under rollback/re-enable.
|
||||
/// </summary>
|
||||
public sealed class ExternalIdReservation
|
||||
{
|
||||
public Guid ReservationId { get; set; }
|
||||
|
||||
public required ReservationKind Kind { get; set; }
|
||||
|
||||
public required string Value { get; set; }
|
||||
|
||||
/// <summary>The equipment that owns this reservation. Stays bound even when equipment is disabled.</summary>
|
||||
public Guid EquipmentUuid { get; set; }
|
||||
|
||||
/// <summary>First cluster to publish this reservation.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public DateTime FirstPublishedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string FirstPublishedBy { get; set; }
|
||||
|
||||
public DateTime LastPublishedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Non-null when explicitly released by FleetAdmin (audit-logged, requires reason).</summary>
|
||||
public DateTime? ReleasedAt { get; set; }
|
||||
|
||||
public string? ReleasedBy { get; set; }
|
||||
|
||||
public string? ReleaseReason { get; set; }
|
||||
}
|
||||
31
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs
Normal file
31
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA namespace served by a cluster. Generation-versioned per decision #123 —
|
||||
/// namespaces are content (affect what consumers see at the endpoint), not topology.
|
||||
/// </summary>
|
||||
public sealed class Namespace
|
||||
{
|
||||
public Guid NamespaceRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>Stable logical ID across generations, e.g. "LINE3-OPCUA-equipment".</summary>
|
||||
public required string NamespaceId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required NamespaceKind Kind { get; set; }
|
||||
|
||||
/// <summary>E.g. "urn:zb:warsaw-west:equipment". Unique fleet-wide per generation.</summary>
|
||||
public required string NamespaceUri { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
32
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs
Normal file
32
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One ACL grant: an LDAP group gets a set of <see cref="NodePermissions"/> at a specific scope.
|
||||
/// Generation-versioned per decision #130. See <c>acl-design.md</c> for evaluation algorithm.
|
||||
/// </summary>
|
||||
public sealed class NodeAcl
|
||||
{
|
||||
public Guid NodeAclRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string NodeAclId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required string LdapGroup { get; set; }
|
||||
|
||||
public required NodeAclScopeKind ScopeKind { get; set; }
|
||||
|
||||
/// <summary>NULL when <see cref="ScopeKind"/> = <see cref="NodeAclScopeKind.Cluster"/>; otherwise the scoped entity's logical ID.</summary>
|
||||
public string? ScopeId { get; set; }
|
||||
|
||||
/// <summary>Bitmask of <see cref="NodePermissions"/>. Stored as int in SQL.</summary>
|
||||
public required NodePermissions PermissionFlags { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs
Normal file
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>Driver-scoped polling group. Tags reference it via <see cref="Tag.PollGroupId"/>.</summary>
|
||||
public sealed class PollGroup
|
||||
{
|
||||
public Guid PollGroupRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string PollGroupId { get; set; }
|
||||
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
public int IntervalMs { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level deployment unit. 1 or 2 <see cref="ClusterNode"/> members.
|
||||
/// Per <c>config-db-schema.md</c> ServerCluster table.
|
||||
/// </summary>
|
||||
public sealed class ServerCluster
|
||||
{
|
||||
/// <summary>Stable logical ID, e.g. "LINE3-OPCUA".</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>UNS level 1. Canonical org value: "zb" per decision #140.</summary>
|
||||
public required string Enterprise { get; set; }
|
||||
|
||||
/// <summary>UNS level 2, e.g. "warsaw-west".</summary>
|
||||
public required string Site { get; set; }
|
||||
|
||||
public byte NodeCount { get; set; }
|
||||
|
||||
public required RedundancyMode RedundancyMode { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
public DateTime? ModifiedAt { get; set; }
|
||||
|
||||
public string? ModifiedBy { get; set; }
|
||||
|
||||
// Navigation
|
||||
public ICollection<ClusterNode> Nodes { get; set; } = [];
|
||||
public ICollection<Namespace> Namespaces { get; set; } = [];
|
||||
public ICollection<ConfigGeneration> Generations { get; set; } = [];
|
||||
}
|
||||
47
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs
Normal file
47
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One canonical tag (signal) in a cluster's generation. Per decision #110:
|
||||
/// <see cref="EquipmentId"/> is REQUIRED when the driver is in an Equipment-kind namespace
|
||||
/// and NULL when in SystemPlatform-kind namespace (Galaxy hierarchy preserved).
|
||||
/// </summary>
|
||||
public sealed class Tag
|
||||
{
|
||||
public Guid TagRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string TagId { get; set; }
|
||||
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Required when driver is in Equipment-kind namespace; NULL when in SystemPlatform-kind.
|
||||
/// Cross-table invariant enforced by sp_ValidateDraft (decision #110).
|
||||
/// </summary>
|
||||
public string? EquipmentId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Only used when <see cref="EquipmentId"/> is NULL (SystemPlatform namespace).</summary>
|
||||
public string? FolderPath { get; set; }
|
||||
|
||||
/// <summary>OPC UA built-in type name (Boolean / Int32 / Float / etc.).</summary>
|
||||
public required string DataType { get; set; }
|
||||
|
||||
public required TagAccessLevel AccessLevel { get; set; }
|
||||
|
||||
/// <summary>Per decisions #44–45 — opt-in for write retry eligibility.</summary>
|
||||
public bool WriteIdempotent { get; set; }
|
||||
|
||||
public string? PollGroupId { get; set; }
|
||||
|
||||
/// <summary>Register address / scaling / poll group / byte-order / etc. — schemaless per driver type.</summary>
|
||||
public required string TagConfig { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs
Normal file
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>UNS level-3 segment. Generation-versioned per decision #115.</summary>
|
||||
public sealed class UnsArea
|
||||
{
|
||||
public Guid UnsAreaRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string UnsAreaId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>UNS level 3 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs
Normal file
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>UNS level-4 segment. Generation-versioned per decision #115.</summary>
|
||||
public sealed class UnsLine
|
||||
{
|
||||
public Guid UnsLineRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string UnsLineId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="UnsArea.UnsAreaId"/>; resolved within the same generation.</summary>
|
||||
public required string UnsAreaId { get; set; }
|
||||
|
||||
/// <summary>UNS level 4 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/CredentialKind.cs
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/CredentialKind.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Credential kind for <see cref="Entities.ClusterNodeCredential"/>. Per decision #83.</summary>
|
||||
public enum CredentialKind
|
||||
{
|
||||
SqlLogin,
|
||||
ClientCertThumbprint,
|
||||
ADPrincipal,
|
||||
gMSA,
|
||||
}
|
||||
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs
Normal file
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/DriverHostState.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Persisted mirror of <c>Core.Abstractions.HostState</c> — the lifecycle state each
|
||||
/// <c>IHostConnectivityProbe</c>-capable driver reports for its per-host topology
|
||||
/// (Galaxy Platforms / AppEngines, Modbus PLC endpoints, future OPC UA gateway upstreams).
|
||||
/// Defined here instead of re-using <c>Core.Abstractions.HostState</c> so the
|
||||
/// Configuration project stays free of driver-runtime dependencies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The server-side publisher (follow-up PR) translates
|
||||
/// <c>HostStatusChangedEventArgs.NewState</c> to this enum on every transition and
|
||||
/// upserts into <see cref="Entities.DriverHostStatus"/>. Admin UI reads from the DB.
|
||||
/// </remarks>
|
||||
public enum DriverHostState
|
||||
{
|
||||
Unknown,
|
||||
Running,
|
||||
Stopped,
|
||||
Faulted,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Generation lifecycle state. Draft → Published → Superseded | RolledBack.</summary>
|
||||
public enum GenerationStatus
|
||||
{
|
||||
Draft,
|
||||
Published,
|
||||
Superseded,
|
||||
RolledBack,
|
||||
}
|
||||
25
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs
Normal file
25
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>OPC UA namespace kind per decision #107. One of each kind per cluster per generation.</summary>
|
||||
public enum NamespaceKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Equipment namespace — raw signals from native-protocol drivers (Modbus, AB CIP, AB Legacy,
|
||||
/// S7, TwinCAT, FOCAS, and OpcUaClient when gatewaying raw equipment). UNS 5-level hierarchy
|
||||
/// applies.
|
||||
/// </summary>
|
||||
Equipment,
|
||||
|
||||
/// <summary>
|
||||
/// System Platform namespace — Galaxy / MXAccess processed data (v1 LmxOpcUa folded in).
|
||||
/// UNS rules do NOT apply; Galaxy hierarchy preserved as v1 expressed it.
|
||||
/// </summary>
|
||||
SystemPlatform,
|
||||
|
||||
/// <summary>
|
||||
/// Reserved for future replay driver per handoff §"Digital Twin Touchpoints" — not populated
|
||||
/// in v2.0 but enum value reserved so the schema does not need to change when the replay
|
||||
/// driver lands.
|
||||
/// </summary>
|
||||
Simulated,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user