Replicated the Modbus #218 bring-up against the AB CIP + S7 seeds to
confirm the factories + seeds shipped in #217 actually work end-to-end.
Both pass 5/5 e2e stages with `OpcUaServer:AnonymousRoles=[WriteOperate]`
(the #221 knob).
## AB CIP (against ab_server controllogix fixture, port 44818)
```
=== AB CIP e2e summary: 5/5 passed ===
[PASS] Probe
[PASS] Driver loopback
[PASS] Server bridge (driver → server → client)
[PASS] OPC UA write bridge (client → server → driver)
[PASS] Subscribe sees change
```
Server log: `DriverInstance abcip-smoke-drv (AbCip) registered +
initialized` ✓.
## S7 (against python-snap7 s7_1500 fixture, port 1102)
```
=== S7 e2e summary: 5/5 passed ===
[PASS] Probe
[PASS] Driver loopback
[PASS] Server bridge
[PASS] OPC UA write bridge
[PASS] Subscribe sees change
```
Server log: `DriverInstance s7-smoke-drv (S7) registered + initialized` ✓.
## Seed fixes so bring-up is idempotent
Live-boot exposed two seed-level papercuts when applying multiple
smoke seeds in sequence:
1. **SA credential collision.** `UX_ClusterNodeCredential_Value` is a
unique index on `(Kind, Value) WHERE Enabled=1`, so `sa` can only
bind to one node at a time. Each seed's DELETE block only dropped
the credential tied to ITS node — seeding AbCip after Modbus blew
up with `Cannot insert duplicate key` on the sa binding. Added a
global `DELETE FROM dbo.ClusterNodeCredential WHERE Kind='SqlLogin'
AND Value='sa'` before the per-cluster INSERTs. Production deployments
using non-SA logins aren't affected.
2. **DashboardPort 5000 → 15050.** `HealthEndpointsHost` uses
`HttpListener`, which rejects port 5000 on Windows without a
`netsh http add urlacl` grant or admin rights. 15050 is unreserved
+ loopback-safe per the HealthEndpointsHost remarks. Applied to all
four smoke seeds (Modbus was patched at runtime in #218; now baked
into the seed).
## AB Legacy status
Not live-boot verified — ab_server PCCC dispatcher is upstream-broken
(#222). The factory + seed ship ready for hardware; the seed's DELETE
+ DashboardPort fixes land in this PR so when real SLC/MicroLogix/PLC-5
arrives the sql just applies.
## Closes#220
Umbrella #209 was already closed; #220 was the final child.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Booted the server against the Modbus seed end-to-end to exercise the
factory wiring shipped in #216 + #217. Surfaced two real issues with
the seeds themselves; fixed both:
1. **Missing ClusterNodeCredential.** `sp_GetCurrentGenerationForCluster`
enforces `ClusterNodeCredential.Value = SUSER_SNAME()` and aborts
with `RAISERROR('Unauthorized: caller sa is not bound to NodeId')`.
All four seed scripts now insert the binding row alongside the
ClusterNode row. Without this, the server fails bootstrap with
`BootstrapException: Central DB unreachable and no local cache
available` (the Unauthorized error gets swallowed on top of the
HTTP fallback path).
2. **Config cache gitignore.** Running the server from the repo root
writes `config_cache.db` + `config_cache-log.db` next to the cwd,
outside the existing `src/.../Server/config_cache.db` pattern. Add
a `config_cache*.db` pattern so any future run location is covered.
## Verified live against Modbus
Booted server against `seed-modbus-smoke.sql` → pymodbus standard
fixture → ran `scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"`:
=== Modbus e2e summary: 4/5 passed ===
[PASS] Probe
[PASS] Driver loopback
[PASS] Server bridge (driver → server → client)
[FAIL] OPC UA write bridge (0x801F0000)
[PASS] Subscribe sees change
The forward direction + subscription delivery are proven working through
the server. The reverse-write failure is a seed-or-ACL issue — server
log shows no exception on the write path, so the client-side status is
coming from the stack's type/ACL guards. Tracking as a follow-up issue
so the remaining three factory wirings can be smoke-booted against the
same pattern.
Note for future runs: two stale v1 `ZB.MOM.WW.LmxOpcUa.Host.exe`
processes from `C:\publish\lmxopcua\instance{1,2}\` squat on ports
4840 + 4841 on this dev box; kill them first or bump the seed's
DashboardPort.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Parent: #209. Adds the server-side wiring so a Config DB `DriverType='Modbus'`
row actually boots a Modbus driver instance + publishes its tags under OPC UA
NodeIds, instead of being silently skipped by DriverInstanceBootstrapper.
Changes:
- `ModbusDriverFactoryExtensions` (new) — mirrors
`GalaxyProxyDriverFactoryExtensions` + `FocasDriverFactoryExtensions`.
`DriverTypeName="Modbus"`, `CreateInstance` deserialises
`ModbusDriverConfigDto` (Host/Port/UnitId/TimeoutMs/Probe/Tags) to a full
`ModbusDriverOptions` and hands back a `ModbusDriver`. Strict enum parsing
(Region / DataType / ByteOrder / StringByteOrder) — unknown values fail
fast with an explicit "expected one of" error rather than at first read.
- `Program.cs` — register the factory after Galaxy + FOCAS.
- `Driver.Modbus.csproj` — add `Core` project reference (the DI-free factory
needs `DriverFactoryRegistry` from `Core.Hosting`). Matches the FOCAS
driver's reference shape.
- `Server.csproj` — add the `Driver.Modbus` ProjectReference so the
Program.cs registration compiles against the same assembly the server
loads at runtime.
- `scripts/smoke/seed-modbus-smoke.sql` (new) — one-cluster smoke seed
modelled on `seed-phase-7-smoke.sql`. Creates a `modbus-smoke` cluster +
`modbus-smoke-node` + Draft generation + Namespace + UnsArea/UnsLine/
Equipment + one Modbus `DriverInstance` pointing at the pymodbus standard
fixture (`127.0.0.1:5020`) + one Tag at `HR[200]:UInt16`, ending in
`EXEC sp_PublishGeneration`. HR[100] is deliberately *not* used because
pymodbus `standard.json` runs an auto-increment action on that register.
Full-solution build: 0 errors, only the pre-existing xUnit1051 warnings.
AB CIP / S7 / AB Legacy factories follow in their own PRs per #211 / #212 /
#213. Live boot verification happens in the exit-gate PR once all four
factories are in place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>