Compare commits

...

29 Commits

Author SHA1 Message Date
Joseph Doherty 4e22f02434 test(focas): thread the test cancellation token through the two new Task.Delay waits
v2-ci / build (push) Successful in 4m33s
v2-ci / unit-tests (push) Failing after 17m25s
2026-08-05 17:48:30 -04:00
Joseph Doherty 8f0d6ae2cc merge: FOCAS fixed-tree tags are authorable and readable (#540); skipped-tag invisibility split to #541 2026-08-05 17:46:04 -04:00
Joseph Doherty b61fe38823 fix(focas): browse-committed fixed-tree tags are readable (Gitea #540)
Authoring FOCAS tags through /raw -> Browse device... produced tags the driver
could never read. Every committed tag read BadNodeIdUnknown forever, and the
failure was silent end to end: the AdminUI accepted it, the deploy gate sealed
green, and the driver reported Healthy with 0 errors while ignoring the tags.
Net effect: the whole fixed-tree surface could not be published through any v3
authoring path. Found cutting wonder-app-vd03 over to v3 against a real Makino
Pro 5 / Fanuc 30i, where 0 of 20 committed tags read while two hand-authored
MACRO: tags on the same device read Good.

A captured browse leaf's id IS its DriverAttributeInfo.FullName, and the fixed
tree emitted "{deviceHost}/{path}" there -- an internal cache key, not an
address. Both authoring paths wrote it verbatim into TagConfig.address, and
FocasAddress.TryParse accepts no such form, so the driver skipped the tags at
init.

Fixed at the emitter rather than by stripping the host at commit, which fixes
the browse-commit flow and the manual picker's tree-select together and leaves
one canonical address form:

- FocasAddress gains a fourth kind, FIXED:<path>, and moves to Contracts so the
  AdminUI and ControlPlane can parse without referencing the runtime driver
  (#526). A FIXED: read is served from the fixed-tree snapshot the poll loop
  already maintains -- no area read, no connection. Writes refuse: nothing in
  that tree is a writable location on the CNC.
- The address carries no device. A tag's device comes from its Device row, and
  a second routing channel for one fact is what created this defect.
- An authored FIXED: tag arms the fixed-tree poll loop by itself. Enabled
  defaults false and the browser force-enables it only for a browse, so the
  operator would otherwise browse, commit, deploy, and land on a driver whose
  loop never starts -- every tag Bad with a correct address.
- Fixed-tree values are now dated from the last answered poll and go
  BadCommunicationError once it is stale (8 cadences, floored at 30s). The poll
  loop swallows a failed tick by design, so a dead CNC would otherwise serve its
  last snapshot as Good indefinitely -- harmless while nothing could bind to the
  tree, not harmless now.

Guardrails, so this class cannot recur silently:

- FocasTagConfigModel.Validate() runs the real parser instead of a non-empty
  check, and FocasTagDefinitionFactory.Inspect() warns at the deploy gate, which
  covers CSV/API config that never touches the modal.
- The manual picker offered axis/spindle/program/status "parameter groups" --
  none of which is a FOCAS addressing space -- and its own default value,
  axis:5, was unusable. It now composes PMC / PARAM: / MACRO: through
  FocasAddress, so what it emits is parseable by construction.

Tests assert addresses ROUND-TRIP through the parser rather than matching an
expected string: the previous FocasAddressBuilderTests pinned "axis:5" exactly,
which is how a green suite coexisted with a picker nobody could use. Verified
red-first against the old emitter. FOCAS 308, AdminUI 1095, ControlPlane 169,
Core 581 -- all pass, 0 warnings.

The remaining half of #540 -- a driver reporting Healthy/0 errors while
skipping tags at init -- is fleet-wide (eight drivers do this) and is filed
separately rather than patched per-driver.
2026-08-05 17:32:18 -04:00
Joseph Doherty 4e4619514c docs(sql): chrome/AdminUI gate PASSED; records the Test-Connect provider-gate defect it found
Also adds the two fixture connection strings to both docker-dev centrals, so
the rig can author + browse the Postgres and Oracle fixtures on 10.100.0.35.
2026-08-05 07:55:12 -04:00
Joseph Doherty 75a057f268 fix(sql): Test Connect must construct Postgres and Oracle dialects too
SqlDriverProbe.DefaultDialectFor was left SqlServer-only when the provider
gates were widened, so AdminUI Test Connect failed for both new providers
with InvalidOperationException while Deploy and browse worked. Found by the
live chrome/AdminUI gate the plan deferred, not by any test: every case in
SqlDriverProbeTests builds its probe through ForTest(), which injects the
dialect and never reaches the production switch.

Widen the switch arm-for-arm with SqlDriverFactoryExtensions.CreateDialect,
and cover the gate directly via an internal seam — plus a reserved-provider
falsification control and an end-to-end production-constructor case.
2026-08-05 07:43:31 -04:00
Joseph Doherty c89da8eb73 docs(sql): shared-host live run PASSED (11/11); chrome/AdminUI gate handed to the operator 2026-08-05 07:28:52 -04:00
Joseph Doherty d54f3c8342 merge: Sql driver Postgres + Oracle providers (managed Npgsql/ODP.NET, dialect-owned binding, live-gated fixtures) 2026-08-05 07:28:29 -04:00
Joseph Doherty bfdf74ec0b docs(sql): p_-prefixed Oracle examples in the four stale doc comments (final-review nit) 2026-08-05 07:28:24 -04:00
Joseph Doherty db295c1439 docs(sql): verification green across five suites; tracker + design status reconciled (Postgres/Oracle shipped) 2026-08-05 07:21:25 -04:00
Joseph Doherty 9675823cd6 test(sql): env-gated Postgres + Oracle integration suites
Two disposable docker fixtures (postgres:17-alpine on 15432, gvenzl/oracle-free:
23-slim on 11521) shaped like the existing mssql one — project label, distinct
container name, healthcheck, one-shot seed service — plus a live suite per
provider, gated on SQL_PG_* / SQL_ORACLE_* and skipping clean offline.

Each suite covers what a DIALECT can get wrong, rather than re-running the
provider-agnostic read semantics already pinned against SQL Server and SQLite:
liveness + catalog load, a KeyValue read, the provider's own row-limit syntax via
topByTimestamp (LIMIT 1 / FETCH FIRST 1 ROWS ONLY, where T-SQL emits TOP 1),
identifier case-folding through the design §8.1 catalog gate, and the AdminUI
browse walk driven through SqlDriverBrowser.

Two seed decisions are load-bearing rather than scaffolding:

- The Postgres schema is owned by a role the suite is NOT; the connecting login
  holds USAGE + SELECT only. That is the exact provisioning PostgresDialect's
  ListSchemasSql exists for (information_schema.schemata lists only schemas the
  role OWNS, so a granted-not-owning poller would see none and every tag would be
  rejected). A fixture that owned its own schema would pass either way.
- Every identifier in both seeds is created UNQUOTED, so the catalogs hold
  lower-case (Postgres) and UPPER-case (Oracle) spellings. The case-folding legs
  author the opposite case and pin SqlCatalog.TryMatch's REAL contract — exact
  ordinal first, then a UNIQUE case-insensitive match whose catalog spelling is
  substituted — which on both providers is what makes mixed-case authoring work
  at all, since both dialects emit case-sensitive double-quoted identifiers.

Connection strings are composed through dialect.Factory.CreateConnectionString-
Builder() and the abstract DbConnectionStringBuilder indexer, so the csproj keeps
its no-direct-provider-reference discipline; every key used was verified accepted
through that indexer. The csproj gains only Driver.Sql.Browser, for the browse leg.

Live-run evidence (local OrbStack, both images arm64): Postgres 5/5 green;
Oracle 6/6 green after the ORA-01745 dialect fix this suite found. Offline the
whole project is 33 skipped / 0 failed.
2026-08-05 07:15:43 -04:00
Joseph Doherty ec90b7c416 fix(sql): Oracle bind names must not be reserved words (ORA-01745)
Found by the new OracleReadTests against a real Oracle 23 Free: EVERY Oracle
driver instance failed its Initialize-time catalog load, and every AdminUI browse
of a table's columns threw, with

    ORA-01745: invalid host/bind variable name

Oracle rejects a bind variable named after a reserved word, and the catalog
readers bind a parameter whose base name is `table` (SqlCatalogLoader /
SqlBrowseSession bind `schema` + `table`), so `ListColumnsSql` could not execute
at all. The provider was structurally non-functional.

OracleDialect now namespaces every marker/bind name through one `p_` constant
(`:p_k0` / `p_k0`), which a reserved word can never collide with, and its catalog
SQL is respelled to match. Prefixing everything rather than carrying a
reserved-word list is deliberate: the list is long and version-dependent, and a
future base name (`date`, `user`, `comment`, `level`…) would silently re-open the
same hole. Marker and bind name are derived from the same constant, so they
cannot drift apart.

No offline test could have caught this — only a server parses the SQL — so the
regression test asserts the invariant that made it possible (a bind name is never
the bare base name), with `table` called out explicitly.

Live evidence: 6/6 OracleReadTests red with ORA-01745 before, 6/6 green after,
against gvenzl/oracle-free:23-slim.
2026-08-05 07:15:23 -04:00
Joseph Doherty e238a71bbc docs(mtconnect): live gate PASSED against the shared AllowPut fixture (15/15); rig OPC UA leg deferred to the chrome session 2026-08-05 07:14:55 -04:00
Joseph Doherty 0e3381d599 merge: MTConnect write-back (IWritable via agent AllowPut observation writes); stale MQTT P3/browse-gap records reconciled 2026-08-05 07:13:34 -04:00
Joseph Doherty f13a89dbc3 docs(mtconnect): final-review nits — two stale read-only comments; task statuses 2026-08-05 07:12:59 -04:00
Joseph Doherty b4480bf45a test(mtconnect): live write legs against the AllowPut fixture
The fixture Agent flips to AllowPut = true (its comment rewritten: writes are in
scope now that the driver implements IWritable) and the suite grows three
env-gated write legs, all still Assert.Skip-clean with no Agent reachable.

Devices.xml gains a DataItem, and that is the deliberate part. The write target
has to be one the SHDR adapter NEVER feeds, or the assertion races the stream --
but it must NOT be the existing never-fed fixture_block: the first successful
write takes an item off UNAVAILABLE for the life of the container, which would
silently rob Read_maps_an_unavailable_observation_to_BadNoCommunication of its
subject. So fixture_writeback (free-text EVENT, PROGRAM_COMMENT) is a separate,
dedicated target, overridable via MTCONNECT_WRITE_TARGET for a run pointed at a
real machine tool.

The three legs:

  * Round trip -- driver write accepted, then the injected value read back off
    /current over plain HTTP (not through the parser under test) AND through the
    driver's own ReadAsync. The value carries a TickCount64 suffix: the Agent
    keeps an injected observation for the container's life, so a constant would
    be satisfied by a previous run's leftover.
  * Agent refusal -> BadCommunicationError. The design doc doubted this leg was
    constructible ("an id in the probe but refused by the agent"); it is, and the
    mechanism is BuildWriteTargets. Write targets are derived from the tags the
    BINDING serves, with the device resolved from the probe model and falling
    back to the configured deviceName -- so an authored tag naming an id the
    Agent never declared, on a driver whose deviceName IS set, resolves to a real
    URL and is POSTed. That is exactly the shape a stale tag has after a DataItem
    is renamed. The second half of the test is the falsifiability control: the
    SAME unknown id with NO deviceName has no URL and must be refused locally as
    BadNodeIdUnknown. Two different codes is what proves the first write actually
    reached the Agent rather than being short-circuited into a look-alike.
  * Disabled gate -- write.enabled absent returns BadNotWritable AND /current is
    unchanged from the snapshot taken just before, which is what distinguishes
    "refused" from "reported refused and sent anyway".

RAN LIVE, not just compiled: the fixture was brought up locally (OrbStack,
MTCONNECT_AGENT_HOST_PORT=5555 -- macOS AirPlay squats :5000) and the whole
suite passed 15/15, 0 skipped. Evidence beyond the green: the Agent reports
<ProgramComment dataItemId="fixture_writeback">OTOPCUA-WRITE-2CBCB530</...> and
its log carries "Error processing request:
/OtFixtureCnc?otopcua_no_such_data_item_2CBCB3D9=refuse-me" -- the refusal leg
demonstrably went to the wire. Offline (no Agent) the suite is 15 skipped, 0
failed; build 0 warnings / 0 errors.

Note for anyone reading agent.cfg: BufferSize = 14 is an EXPONENT (2^14 = 16384
observations), not a 14-deep ring, and /current is answered from the Agent's
per-DataItem checkpoint anyway -- so eviction is not a hazard for a write
read-back even on this busy fixture stream.

LiveOptions/ConfigJson/LiveDriverAsync gained writeEnabled + deviceName
parameters, both defaulted off/null so every pre-existing test still runs
against the shipped read-only driver whatever the Agent's AllowPut says. The
config JSON sources write.enabled FROM the options object, so the parsed config
can never disagree with the options a test thinks it built.
2026-08-05 06:56:16 -04:00
Joseph Doherty 203000a80a docs: MTConnect write-back recorded; stale MQTT write-through/browse-gap records reconciled 2026-08-05 06:55:14 -04:00
Joseph Doherty 429ead837f feat(sql): construct Postgres and Oracle providers; MySql/Odbc stay reserved
Widens the two provider gates so an authored `{"provider":"Postgres"}` or
`{"provider":"Oracle"}` binds that provider's own dialect instead of failing
"not available in this build":

- `SqlDriverFactoryExtensions.CreateDialect` gains a Postgres and an Oracle
  arm. There is deliberately no fallback arm — a default resolving to
  SqlServerDialect would construct happily and then emit T-SQL at a Postgres
  or Oracle server. The rejection message now names the three supported and
  the two still-reserved providers.
- `SqlDriverBrowser.DefaultDialectSelector` widens identically, so a provider
  the runtime constructs is also browsable; MySql/Odbc keep resolving to null
  and the caller keeps reporting them as unavailable.

Tests: the existing rejected-provider controls flip from Postgres to MySql
(factory) and Odbc (browser) so the reserved half stays falsifiable, and the
new legs assert the bound dialect TYPE rather than mere construction —
"no throw" alone would pass against a SqlServer fallback.

Doc-comment sweep (contract addition, one line each): the "v1 constructs
SqlServer only" / "reserved for P2" claims in SqlProvider, SqlDriverConfigDto,
ISqlDialect.Provider and SqlServerDialect are now false and were corrected in
the same change.
2026-08-05 06:51:48 -04:00
Joseph Doherty b833dda673 fix(mtconnect): a throwing value formatter cannot escape the write batch
TryFormatValue's catch filter named only FormatException / InvalidCastException
/ OverflowException, and the call sits OUTSIDE WriteAsync's per-slot try. But
Convert.ToString falls back to the value's OWN ToString/IFormattable - arbitrary
caller code that may throw anything - so any other exception escaped WriteAsync
uncaught and failed the entire batch, breaking the documented never-throws
per-slot contract. Broadened to a catch-all; the slot gets BadTypeMismatch and
the exception is logged at Warning (TryFormatValue became an instance method to
reach _logger). One unformattable value now costs its own slot and nothing more.

Falsification checked, not assumed: restoring the narrow filter makes the new
test fail with a raw "System.InvalidOperationException : this formatter is
broken" propagating out of WriteAsync - i.e. it fails for the contract-breach
reason, not on an assertion.

Two tests added:
- a value whose ToString throws InvalidOperationException is refused
  BadTypeMismatch while the *following* good slot still writes (the load-bearing
  half - it proves the batch survived).
- mid-batch cancellation: cancellation injected from inside slot 1's own
  formatting, so slot 0 has provably already reached the Agent. It keeps its
  real Good outcome while slots 1-2 fill BadCommunicationError. Reporting a
  landed write as failed is worse than reporting a failure, and a fill that
  started at 0 instead of i would do exactly that.

Tests: 534 passed / 0 failed (was 532). Solution build: 0 errors, no warnings
in any MTConnect project.
2026-08-05 06:48:49 -04:00
Joseph Doherty 74435801bb feat(sql): OracleDialect (ODP.NET managed, BindByName) 2026-08-05 06:40:41 -04:00
Joseph Doherty b97b3a3604 feat(sql): PostgresDialect (Npgsql) 2026-08-05 06:40:24 -04:00
Joseph Doherty b29623cf99 feat(mtconnect): IWritable via agent observation writes
MTConnectDriver now implements IWritable over the Task-1 agent-client seam
(WriteObservationAsync -> the Agent's AllowPut POST surface). A write is
OBSERVATION INJECTION, not machine command: it sets what the Agent reports for
a DataItem, is visible to every consumer of that Agent, and is overwritten by
the next adapter/SHDR update. Off at two independent gates, both off by default
- the Agent's own AllowPut and the driver's write.enabled (Task 0).

Per-slot refusal order (never throws, strict N-in/N-out batch shape):
  write disabled          -> BadNotWritable
  no live client          -> BadNotConnected  (ReadAsync's existing convention)
  reference unresolved    -> BadNodeIdUnknown
  no resolvable device    -> BadNodeIdUnknown
  CONDITION category      -> BadNotWritable
  null / non-scalar value -> BadTypeMismatch
  agent refusal OR throw  -> BadCommunicationError
OperationCanceledException fills the remaining slots and returns. The Agent's
refusal text is logged at Warning and never enters DriverHealth.LastError
(type-only discipline); a refused write leaves health untouched.

Device-name resolution: TagBinding gains WriteTargetsByDataItemId, built in
BuildBinding from a probe-model walk that remembers which device each DataItem
was declared under (name, or id when nameless) plus its category. Falls back to
options.DeviceName; unresolvable -> BadNodeIdUnknown rather than a guess.
Values render via Convert.ToString(..., InvariantCulture) - so a Boolean writes
the invariant string "True"/"False", NOT "1"/"0". Arrays are refused rather
than rendered: Convert.ToString would emit the literal "System.Double[]", which
the Agent stores happily and which is invisible until read-back.

FINDING - the CurrentWrite bit does NOT key on SecurityClassification, and the
discovery stamping below is therefore cosmetic. Traced end to end: a browsed
leaf's SecurityClass survives into the browse session's AttributeInfo
(CapturedTreeBrowseSession.cs:53) and stops there - RawBrowseModal never reads
it, RawBrowseCommitMapper.MapLeaf hardcodes TagAccessLevel.Read, and Tag has no
SecurityClass column. AddressSpaceComposer:746/:809 sets Writable purely from
Tag.AccessLevel == ReadWrite; AddressSpaceApplier ANDs !IsArray;
OtOpcUaNodeManager.ComposeAccessLevel:2002 composes CurrentWrite from that
alone. Driver capability is never consulted at compose time either - IWritable
is checked only at runtime in DriverInstanceActor:755. Consequences: (a) raw
tags already committed keep their authored AccessLevel, so nothing this commit
does makes an existing node writable; (b) an operator must still set the tag to
ReadWrite for an MTConnect write to be reachable over OPC UA; (c) conversely a
ReadWrite tag on a non-IWritable driver has always advertised CurrentWrite and
failed only at the actor - pre-existing, not touched here.

Discovery stamps CanWrite(writeEnabled, dataItem) = write.enabled && !CONDITION
-> Operate, else ViewOnly - kept honest so the browse never advertises a tier
the write path would refuse (MQTT's CanWrite principle), with the caveat above
recorded verbatim at the call site.

Status-code constants are spelled WITHOUT MqttDriver's "Status" prefix, to
match the two this file already declares (BadCommunicationError,
BadNotConnected). StatusCodeParityTests strips either spelling and passes.

TDD: WriteAsync landed first as a NotImplementedException scaffold - 19 of 20
new tests red on behaviour, 1 (the capability assertion) green on the scaffold
- then implemented. MTConnectFactoryTests' "not_writable" characterization test
is rewritten to pin the new truth, and is the guard for the concrete-return
contract the factory remark explains.

Tests: 532 passed / 0 failed (was ~509). Full solution build: 0 errors,
0 warnings; OTOPCUA0001 clean.
2026-08-05 06:36:08 -04:00
Joseph Doherty 1da770253e refactor(sql): dialect-owned parameter markers, bind names and command configuration
ISqlDialect gains ParameterMarker (the token in the SQL text), ParameterBindName
(the DbParameter.ParameterName) and ConfigureCommand (per-command provider setup,
where Oracle will set BindByName). SQL Server and the test SQLite dialect answer
"@"+name for both spellings and no-op the hook, so SQL Server behaviour is
byte-identical — every existing expected-SQL assertion passes untouched.

SqlGroupPlanner drops its hardcoded @k/@w consts and mints base names (k0..kN, w)
that the dialect spells; SqlQueryPlan carries the emitted markers AND the matching
bind names, so SqlPollReader keeps copying the plan's pairing rather than
re-deriving a provider convention (the two diverge on ODP.NET: ":k0" in the text,
bare "k0" on the parameter). SqlCatalogLoader and SqlBrowseSession bind
schema/table through the dialect, so their bind names cannot drift from the
markers in the dialect-owned catalog SQL. Every parameter-carrying command site
calls ConfigureCommand before its first parameter.

Controlled by a new test-only ColonMarkerDialect (":name" markers, bare bind
names) that really executes against the SQLite fixture. Each leg was falsified
against the pre-change behaviour: the planner emitted "@k0", the reader bound
":k0", and a hardcoded "@schema" in the loader failed catalog load outright.
2026-08-05 06:25:53 -04:00
Joseph Doherty 3b8b5a77e1 fix(mtconnect): share MTConnectError text extraction; pin hollow-error refusal 2026-08-05 06:23:08 -04:00
Joseph Doherty 9f17d7efa2 feat(mtconnect): agent-client observation write (HTTP POST, AllowPut surface) 2026-08-05 06:16:35 -04:00
Joseph Doherty 08c1728a98 feat(sql-adminui): Postgres + Oracle in the provider dropdown; docs 2026-08-05 06:13:36 -04:00
Joseph Doherty b5e39e1f32 feat(mtconnect): parse the write.enabled config block 2026-08-05 06:13:30 -04:00
Joseph Doherty d1572a2b29 build(sql): pin Npgsql + Oracle.ManagedDataAccess.Core (managed providers only) 2026-08-05 06:12:53 -04:00
Joseph Doherty ab54fb013a docs(plan): Sql Postgres + Oracle provider design + implementation plan 2026-08-05 06:08:45 -04:00
Joseph Doherty e784545844 docs(plan): MTConnect write-back design + implementation plan; MQTT P3 recorded as already shipped 2026-08-05 06:08:44 -04:00
83 changed files with 7581 additions and 452 deletions
+11
View File
@@ -89,6 +89,12 @@
document (the XML formatter ships in a third, unreferenced package) nor frame the /sample
multipart stream socket-free. The driver uses HttpClient + System.Xml.Linq only. See the
Task 0 CORRECTION block in docs/plans/2026-07-24-mtconnect-driver.md. -->
<!-- Fully managed PostgreSQL ADO.NET provider for the Sql poll driver's PostgresDialect
(NpgsqlFactory.Instance). No native libpq / client install — the same "managed only"
constraint that rules out Odbc. Referenced by Driver.Sql.Dialect alongside
Microsoft.Data.SqlClient; consumers inherit it transitively. Latest stable, shipping a
native lib/net10.0 TFM. -->
<PackageVersion Include="Npgsql" Version="10.0.3" />
<PackageVersion Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106" />
<!-- Core carries Opc.Ua.StatusCodes, the oracle StatusCodeParityTests checks the drivers'
@@ -97,6 +103,11 @@
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Core" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106" />
<PackageVersion Include="OPCFoundation.NetStandard.Opc.Ua.Server" Version="1.5.378.106" />
<!-- Fully managed ODP.NET Core for the Sql poll driver's OracleDialect
(OracleClientFactory.Instance). The ".Core" package is the 100%-managed driver — no Oracle
Instant Client / OCI native install, matching the Npgsql constraint above. Latest stable of
the 23.x (Oracle 23ai) line, which ships lib/net8.0 + netstandard2.1. -->
<PackageVersion Include="Oracle.ManagedDataAccess.Core" Version="23.26.300" />
<!-- OpenTelemetry.Api < 1.15.3 has GHSA-g94r-2vxg-569j (header-parsing memory DoS). The trio
is pulled transitively at 1.9.0; pin to 1.15.3 (matches the Extensions.Hosting pin below),
applied via a direct PackageReference in the Cluster project so the server stack inherits it. -->
+59 -12
View File
@@ -123,14 +123,21 @@ reflection in `tests/Core/…Core.Abstractions.Tests/DriverTypeNamesGuardTests.c
| Driver | Verdict | Merged | Capabilities | Notes |
|---|---|---|---|---|
| **MTConnect** | Complete-with-gaps | `90bdaa44` (#506) | `IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable` (`MTConnectDriver.cs:63`) | No `IWritable` **by design** (`:50`). No device form. |
| **MQTT / Sparkplug B** | **Complete** | `c3a2b0f7` | `+ IRediscoverable, IAsyncDisposable` (`MqttDriver.cs:65-66`) | Cleanest of the five: picker + driver form + device form + tag editor all present. 8 open follow-up issues. |
| **MTConnect** | Complete-with-gaps | `90bdaa44` (#506) | `IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable` (`MTConnectDriver.cs:63`) | ~~No `IWritable` **by design** (`:50`).~~ ✅ **`IWritable` added 2026-08-05 (`b29623cf`)** — observation injection via the Agent's `AllowPut` surface, off by default at two gates (`Write.Enabled` + the Agent's own `AllowPut`); MTConnect *Interfaces* stays deferred. No device form. |
| **MQTT / Sparkplug B** | **Complete** | `c3a2b0f7` | `+ IRediscoverable, IAsyncDisposable` (`MqttDriver.cs:65-66`) — **and `IWritable` since `72be1922`** | Cleanest of the five: picker + driver form + device form + tag editor all present. ~~8 open follow-up issues.~~ ✅ All eight (#508–#515) **closed 2026-07-31**. |
| **Sql** | Complete-with-gaps | `4ad54037`, follow-ups `28c28667` | `IDriver, ITagDiscovery, IReadable, ISubscribable, IHostConnectivityProbe` (`SqlDriver.cs:28-29`) | No device form; no browse-commit branch; stale comments. |
| **Modbus RTU-over-TCP** | **Complete** | `0f38f486` (#495) | transport mode inside Modbus (`ModbusTransportMode.cs`, `ModbusRtuOverTcpTransport.cs`) | Parses transport **by name** (`ModbusDriverFactoryExtensions.cs:77-78`) — dodges the systemic numeric-enum authoring bug. |
| **Calculation** | **Partial (authoring gap)** | pre-existing | `IDriver, IDependencyConsumer, ISubscribable, IReadable` (`CalculationDriver.cs:38`) | **Registered + offered in the picker, but has no config form.** See §1.2. |
`IWritable` is absent from MTConnect, MQTT, Sql and Calculation. In all four this is **structural and
documented**, not a stub (`MTConnectDriver.cs:50`, `MqttDriver.cs:483`, `SqlDriver.cs:15`).
~~`IWritable` is absent from MTConnect, MQTT, Sql and Calculation. In all four this is **structural and
documented**, not a stub (`MTConnectDriver.cs:50`, `MqttDriver.cs:483`, `SqlDriver.cs:15`).~~
✅ **Two of the four gained it since the audit. As of 2026-08-05 `IWritable` is absent from Sql and
Calculation only** — and in both that absence is still structural and documented (`SqlDriver.cs:15`).
**MQTT** gained it 2026-07-31 (`72be1922`,
[#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated — plain `writeTopic`
publish + Sparkplug NCMD/DCMD); **MTConnect** gained it 2026-08-05 (`b29623cf` — observation injection
through the Agent's `AllowPut` surface, off by default at two independent gates).
**Zero `NotImplementedException`** across all five drivers. Every `NotSupportedException` is either a
`catch` filter or a deliberate domain error.
@@ -195,14 +202,14 @@ incomplete), 2 CANNOT VERIFY (correctly filed as hardware/fixture-gated).
| **519** | No Bad/Uncertain **sub-code** reaches a client | **CONFIRMED** | Collapse at `DriverInstanceActor.cs:1033-1041` (`statusCode >> 30`), re-expand at `OtOpcUaNodeManager.cs:3318-3322`. Enum is 3-state at `IOpcUaAddressSpaceSink.cs:165`. **Wider than filed** — `StatusFromQuality` also used at `:429/:506/:576/:761`, so alarm-condition Quality collapses too. | Design consequence |
| **517** | Health published AFTER teardown | **CONFIRMED, fleet-wide** | `ModbusDriver.cs:244-250`, `MTConnectDriver.cs:579-590`, `S7Driver.cs:292-326` — all `Teardown…` then `WriteHealth(Unknown)`; `GetHealth()` is a plain field read | Defect (race) |
| **516** ✅ | Driver config edits silently discarded — **RESOLVED `d32d89c3` + `5184a2e1`: seam respawn + in-place re-parse; all 12 drivers now honour a changed config. Stale-open on tracker.** | **CONFIRMED — survey now complete** | `DriverInstanceActor.cs:316` (only `_driver` assignment) + `DriverHostActor.cs:2565` (`Tell` existing child, no respawn) + `:653/:660` (green seal). **Affected: Modbus, FOCAS, OpcUaClient, + AbLegacy (issue missed it), + Sql (deliberate/documented).** AbCip/TwinCAT re-parse ✅; Galaxy fails **loud** (throws, `GalaxyDriver.cs:600-641`) | Defect |
| **515** | Sparkplug `DataSet`/`Template`/`PropertySet` | **CONFIRMED** | `SparkplugCodec.cs:207-210` decode to `Unsupported` — visible refusal, not silent drop. *(`PropertySet` isn't a `ValueOneofCase`; it rides `metric.Properties`)* | Deferred feature |
| **514** | Rebirth unarmable on a plant that hasn't birthed | **CONFIRMED** | `RawBrowseModal.razor:119-120` disabled; `_rebirthTarget` set only by tree-click handlers (`:432`, `:437`, `:439-451`). Empty tree ⇒ permanently disabled | Defect (chicken-and-egg) |
| **513** | Meaningless `payloadFormat` in Sparkplug blob | **CONFIRMED** | `MqttTagConfigModel.cs:210` unconditional `Set`, vs. mode-aware `:216` | Defect (cosmetic) |
| **512** | `.Browser` → `.Driver` layering | **CONFIRMED** | `…Mqtt.Browser.csproj:43` (**not :44**), boxed "KNOWN, DELIBERATE EXCEPTION" at `:16-42` | Deferred refactor |
| **511** | `ActAsPrimaryHost` does nothing | **CONFIRMED** | Option at `MqttDriverOptions.cs:193`, round-tripped in the UI, handled only by a warning at `MqttDriver.cs:933-948`. No STATE publish, no Last Will | Deferred feature |
| **510** | `_canRebirth` captured at browse-open | **CONFIRMED** | `RawBrowseModal.razor:377` sole `true` assignment; failure path `:516-539` never re-evaluates | Defect (stale affordance) |
| **509** | Metric name with `/` unbrowse-committable | **CONFIRMED** | `RawBrowseCommitMapper.cs:63` uses browse name verbatim → `RawPaths.cs:39` rejects `/` → `RawTreeService.cs:976` **all-or-nothing** kills the whole batch | Defect |
| **508** | MQTT write-through | **CONFIRMED** | `MqttDriver.cs:66` — no `IWritable`; consequence at `DriverInstanceActor.cs:673-677` | Deferred feature |
| **515** ✅ | Sparkplug `DataSet`/`Template`/`PropertySet` — **RESOLVED `c74ccc55`: structured metrics flattened to sibling tags; live-gated.** | **CONFIRMED** | `SparkplugCodec.cs:207-210` decode to `Unsupported` — visible refusal, not silent drop. *(`PropertySet` isn't a `ValueOneofCase`; it rides `metric.Properties`)* | Deferred feature |
| **514** ✅ | Rebirth unarmable on a plant that hasn't birthed — **RESOLVED `7f53bd96`: the browse modal takes a typed `group/edgeNode` scope when no tree scope is armed (edge node only, never a group broadcast).** | **CONFIRMED** | `RawBrowseModal.razor:119-120` disabled; `_rebirthTarget` set only by tree-click handlers (`:432`, `:437`, `:439-451`). Empty tree ⇒ permanently disabled | Defect (chicken-and-egg) |
| **513** ✅ | Meaningless `payloadFormat` in Sparkplug blob — **RESOLVED `7f53bd96`.** | **CONFIRMED** | `MqttTagConfigModel.cs:210` unconditional `Set`, vs. mode-aware `:216` | Defect (cosmetic) |
| **512** ✅ | `.Browser` → `.Driver` layering — **RESOLVED `7f53bd96`.** | **CONFIRMED** | `…Mqtt.Browser.csproj:43` (**not :44**), boxed "KNOWN, DELIBERATE EXCEPTION" at `:16-42` | Deferred refactor |
| **511** ✅ | `ActAsPrimaryHost` does nothing — **RESOLVED `7f2108dc`: Sparkplug primary-host STATE publishing + Will, Primary-gated; live-gated.** | **CONFIRMED** | Option at `MqttDriverOptions.cs:193`, round-tripped in the UI, handled only by a warning at `MqttDriver.cs:933-948`. No STATE publish, no Last Will | Deferred feature |
| **510** ✅ | `_canRebirth` captured at browse-open — **RESOLVED `7f53bd96`.** | **CONFIRMED** | `RawBrowseModal.razor:377` sole `true` assignment; failure path `:516-539` never re-evaluates | Defect (stale affordance) |
| **509** ✅ | Metric name with `/` unbrowse-committable — **RESOLVED `7f53bd96`: `RawBrowseCommitMapper.ToRawSegment` sanitises the segment and refuses a collision in words.** | **CONFIRMED** | `RawBrowseCommitMapper.cs:63` uses browse name verbatim → `RawPaths.cs:39` rejects `/` → `RawTreeService.cs:976` **all-or-nothing** kills the whole batch | Defect |
| **508** ✅ | MQTT write-through — **RESOLVED `72be1922`: `IWritable` shipped (plain `writeTopic` publish + Sparkplug NCMD/DCMD), live-gated 2026-07-31. The evidence column is the audit snapshot; `MqttDriver.cs:66` now names `IWritable`.** | **CONFIRMED** | `MqttDriver.cs:66` — no `IWritable`; consequence at `DriverInstanceActor.cs:673-677` | Deferred feature |
| **507**→ see above | | | | |
| **491** | Continuous-historization live gate | **CANNOT VERIFY** (needs VPN'd gateway) | Code **is** complete: `AddressSpaceApplier.cs:238`→`:540`→`ActorHistorizedTagSubscriptionSink.cs:27-38`→`ContinuousHistorizationRecorder.cs:84`, wired `ServiceCollectionExtensions.cs:435` | Deferred test |
| **489** | Hot-rebind renamed raw tag | **CONFIRMED** | No implementation exists | Deferred feature |
@@ -1250,6 +1257,46 @@ drivers), 3 carried-open archreview findings led by 01/S-1 (`AddressSpaceApplier
---
### 2026-08-05 — MTConnect write-back; the MQTT closure records reconciled
Recorded from branch `feature/mqtt-mtconnect-write`. This is exactly the drift shape the 2026-08-04
entry named — **work that closes an item elsewhere leaves the register asserting the item is open** —
so it is written back here in the same change as the work.
**§1.1 corrected on two rows.** `IWritable` is no longer absent from four drivers; **after this branch
it is absent from Sql and Calculation only**, and in both that is still structural.
| Driver | Gained `IWritable` | Commit | Shape |
|---|---|---|---|
| **MQTT / Sparkplug B** | 2026-07-31 ([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated) | `72be1922` | Plain `writeTopic` publish + Sparkplug NCMD/DCMD |
| **MTConnect** | 2026-08-05 | `b29623cf` | **Observation injection** through the Agent's optional `AllowPut` surface — off by default at two independent gates (`Write.Enabled` and the Agent's own `AllowPut`), CONDITION items refused |
⚠️ **MTConnect write-back is not a machine command, and the register should not be read as saying the
driver can now command a CNC.** A write sets what the Agent *reports* for a DataItem; the next
adapter/SHDR update overwrites it. MTConnect *Interfaces* — the mechanism the original deferral
(`2026-07-15-mtconnect-driver-design.md` §3.6) assumed write-back would need — **remains deferred**, and
§3.6 now carries a superseded note saying so. Design:
`docs/plans/2026-08-05-mtconnect-writeback-design.md`.
⚠️ **It also does not make any existing node writable.** The OPC UA `CurrentWrite` bit is composed from
the authored `Tag.AccessLevel` alone; driver capability is never consulted at compose time. An operator
must author the raw tag **ReadWrite**. Browse's new `Operate` stamp on writable leaves is honest
labelling only — nothing persists `SecurityClass`.
**§2's eight MQTT rows (#508–#515) marked ✅ in place** with the commit that resolved each (`72be1922`,
`7f53bd96`, `7f2108dc`, `c74ccc55`), following the register's existing convention. Their evidence
columns are left as the audit snapshot — #508's `MqttDriver.cs:66 — no IWritable` in particular is now
false of the tree, and is annotated as such rather than rewritten.
**Docs reconciled in the same change** (they contradicted the code, and each other):
`docs/drivers/MTConnect.md` (capability surface, new §Write-back, Deferred, the `DeviceName`
double-duty note), `docs/drivers/Mqtt.md` (deleted the #514 "empty tree cannot arm a rebirth" known-gap
block and the "Read-only. No writes reach the broker" operational note — both contradicted by that
file's own closure line), and `docs/plans/2026-07-24-driver-expansion-tracking.md` (P2 gap bullets +
§Next actions, which still listed MQTT P3 and both browse gaps as remaining work).
---
---
*§1–§8 generated 2026-07-27 against `master` @ `90bdaa44` by five parallel source-verification agents.
+10
View File
@@ -286,6 +286,12 @@ services:
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
# The Postgres + Oracle counterparts, pointing at the two DISPOSABLE fixture containers on the
# shared docker host (tests/.../Driver.Sql.IntegrationTests/Docker/{postgres,oracle}/). Same
# committed-dev-secret exception; the passwords are each fixture compose's documented default.
# Unlike DevSql these are NOT on the rig's own network, so they carry the host's IP.
Sql__ConnectionStrings__FixturePg: "Host=10.100.0.35;Port=15432;Database=sqlpollfixture;Username=otopcua;Password=OtOpcUaPg2026x"
Sql__ConnectionStrings__FixtureOracle: "User Id=otopcua;Password=OtOpcUaOra2026x;Data Source=10.100.0.35:11521/FREEPDB1"
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "central-1"
@@ -426,6 +432,10 @@ services:
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
# Postgres + Oracle fixture connection strings — see central-1. Both centrals must carry them:
# :9200 round-robins, so the AdminUI browse can land on either node.
Sql__ConnectionStrings__FixturePg: "Host=10.100.0.35;Port=15432;Database=sqlpollfixture;Username=otopcua;Password=OtOpcUaPg2026x"
Sql__ConnectionStrings__FixtureOracle: "User Id=otopcua;Password=OtOpcUaOra2026x;Data Source=10.100.0.35:11521/FREEPDB1"
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "central-2"
+53 -5
View File
@@ -87,12 +87,33 @@ on.
| `X0.0` / `R100` / `R100.3` | PMC bit or byte | Letter + number; optional `.bit` for bit access |
| `PARAM:1815` / `PARAM:1815/0` | CNC parameter | Number + optional axis index |
| `MACRO:500` | Custom macro variable | System / user macro variable number |
| `FIXED:Identity/SeriesNumber` | Fixed-tree field | A path into the [fixed node tree](#fixed-node-tree) — read-only |
The first three name a location the driver asks the CNC for on demand.
`FIXED:` does not: it names a field of the per-device fixed-tree snapshot the
driver's own poll loop already maintains, so a `FIXED:` read issues no wire
traffic. It exists because the fixed tree is the only part of the FOCAS surface
with no address of its own, and v3 publishes nodes only for authored tags — so
without a form to author, the fixed tree could not be published at all.
**An address never carries the device.** A tag's device comes from its raw-tag
Device row; an address that also named a host would be a second, conflicting
routing channel. This is worth stating because it is exactly what went wrong in
[#540](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/540): browse leaves
advertised `{deviceHost}/{path}` as their reference, the browse-commit wrote that
into the address, and every committed tag read `BadNodeIdUnknown` forever.
Addresses are validated against the per-device `Series` at `InitializeAsync` —
a config referencing a number outside the documented range for that series
fails at load time with an error message naming the limit. See
[`docs/v2/focas-version-matrix.md`](../v2/focas-version-matrix.md) for the
authoritative range table.
authoritative range table. (`FIXED:` paths have no numeric range to check; a
path the machine does not expose reads `BadNodeIdUnknown`.)
An address the parser cannot read is rejected **when you author it** — by the
`/raw` tag modal, and by the deploy-time inspector for config arriving via CSV
import or the API. It is not a warning you can deploy past into a driver that
skips the tag at startup.
## Backend selection
@@ -150,7 +171,34 @@ timers without declaring per-address tags.
}
```
What gets populated (all under `FOCAS/{deviceHostAddress}/`):
`Enabled` defaults to **false** so an upgrade does not grow nodes on configs
that never asked for them. You do not have to set it to use the tree: **authoring
any `FIXED:` tag arms the poll loop by itself**, because committing those tags
already says you want it. The flag remains the way to publish the tree without
authoring anything (and the browser turns it on for the duration of a browse).
### Authoring fixed-tree tags
Browse the device (`/raw` → device → **Browse device…**, or the tag modal's
**Browse machine**) and commit the leaves you want. Each leaf carries its own
`FIXED:` address, so the commit produces working tags with no editing. The
manual composer next to the browser covers PMC / `PARAM:` / `MACRO:` only —
fixed-tree paths are discovered from the machine, not composed from parts.
Two caveats worth knowing before you browse:
- **A real CNC can report duplicate sibling names.** One observed Makino returns
two axis folders both called `AA`. Committing both is refused at commit time
(they would be the same RawPath), and the runtime cache is keyed by the same
name — so the `Axes/` subtree on such a machine cannot currently distinguish
them. Prefer the non-axis subtrees there until this is resolved.
- **Values are dated from the last answered poll, not from the read.** If a CNC
stops answering, its fixed-tree values go `BadCommunicationError` rather than
continuing to serve the last snapshot as `Good`; the budget is eight poll
cadences, floored at 30 s.
What gets populated (all under `FOCAS/{deviceHostAddress}/`, addressed as
`FIXED:{subtree}/{field}`):
| Subtree | Nodes | Source call |
|---------|-------|-------------|
@@ -195,9 +243,9 @@ The driver now attempts to auto-scale each axis position using the CNC's `cnc_ge
- No upper-bound clamp on the figure magnitude returned by the CNC — a misbehaving figure could overflow `Math.Pow`; a max-figure guard should be added.
- The negative-figure branch (falls back to manual `PositionDecimalPlaces`) is handled but not explicitly unit-tested.
**Still user-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific
numbers whose meaning is MTB-specific. Those go under the device folder
alongside the fixed subtree.
**Still hand-authored**: `PARAM:6711`, `MACRO:500`, `R100` etc. — specific
numbers whose meaning is MTB-specific, and which no browse can discover. Those
go under the device folder alongside the fixed subtree.
## Alarm projection
+120 -15
View File
@@ -15,7 +15,7 @@ fixture recipe read
## What it talks to
A **MTConnect Agent** — the vendor-neutral read-only telemetry endpoint that
A **MTConnect Agent** — the vendor-neutral telemetry endpoint that
front-ends a machine tool (or fronts an adapter that itself talks to the
machine over SHDR). The driver speaks plain HTTP + XML against the Agent's
three standard REST paths:
@@ -25,8 +25,13 @@ three standard REST paths:
- `/sample` — a `multipart/x-mixed-replace` long-poll stream of observation
deltas, keyed by a monotonic sequence number
v1 is **agent-first and read-only** — Discover + Read + Subscribe, no Write —
because the mainstream Agent surface has no "set value" operation.
Those three are the whole data plane: **agent-first** — Discover + Read +
Subscribe. There is a fourth path, and it is not part of the data plane: the
Agent's optional **`AllowPut`** surface, off by default in `agent.cfg`, which
this driver's `IWritable` uses (`b29623cf`, 2026-08-05). It sets what the Agent
*reports* for a DataItem; nothing reaches the machine. Read
[Write-back](#write-back--observation-injection-off-by-default) before enabling
it.
## Built vs. planned — read this before trusting the design doc's §2
@@ -68,18 +73,22 @@ below).
## Capability surface
`MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable`
`MTConnectDriver : IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable`
(`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs`).
Deliberately **not** `IWritable` — the Agent surface is read-only by design.
Write-back exists only via optional, rarely-deployed MTConnect *Interfaces*
(a request/response handshake, not a "set value" operation), and is out of
scope for this build — see [Deferred](#deferred-not-in-this-build).
`IWritable` landed 2026-08-05 (`b29623cf`), against the Agent's `AllowPut`
surface rather than MTConnect *Interfaces*. It is **observation injection, not
machine command**, and is off at two independent gates by default — read
[Write-back](#write-back--observation-injection-off-by-default) before
authoring a writable MTConnect tag. *Interfaces* itself — the protocol's own
request/response handshake — is a different mechanism and remains deferred; see
[Deferred](#deferred-not-in-this-build).
| Capability | Path | Notes |
|------------|------|-------|
| `ITagDiscovery` | `DiscoverAsync` — streams `/probe`'s Device→Component→DataItem tree into the address-space builder | `SupportsOnlineDiscovery = true`, `RediscoverPolicy = Once` |
| `IReadable` | `ReadAsync` → one `/current` per call | **Not the production data path** — see below |
| `IWritable` | `WriteAsync` → `POST {agent}/{device}?{dataItemId}={value}` on the Agent's `AllowPut` surface | **Off by default** — needs `Write.Enabled` **and** the Agent's `AllowPut`; CONDITION items are refused |
| `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** |
| `IHostConnectivityProbe` | periodic `/probe` under `Probe.*` | No consumer wires it today — see [Known limitations](#known-limitations) |
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
@@ -105,6 +114,7 @@ correct and tested, just not on the hot path.
"HeartbeatMs": 10000,
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
"Reconnect": { "MinBackoffMs": 0, "MaxBackoffMs": 30000, "BackoffMultiplier": 2.0 },
"Write": { "Enabled": false },
"Tags": []
}
}
@@ -123,13 +133,14 @@ Read off `MTConnectDriverOptions` / `MTConnectDriverConfigDto`
| Key | Default | Notes |
|---|---|---|
| `AgentUri` | — (required) | Agent base URI; the driver appends `/probe`, `/current`, `/sample` |
| `DeviceName` | `null` | Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent |
| `DeviceName` | `null` | **Double duty.** (1) Scopes requests to `{AgentUri}/{DeviceName}/...`; `null` = whole Agent. (2) The write path's fallback device — the device a write is addressed to when the probe model does not say which device declared the DataItem (see [Write-back](#write-back--observation-injection-off-by-default)) |
| `RequestTimeoutMs` | `5000` | Per-call deadline for `/probe` and `/current` |
| `SampleIntervalMs` | `1000` | The Agent's `/sample?interval=` query param |
| `SampleCount` | `1000` | The Agent's `/sample?count=` query param |
| `HeartbeatMs` | `10000` | The Agent's `/sample?heartbeat=` query param — also the watchdog's liveness window |
| `Probe.Enabled` / `Probe.IntervalMs` / `Probe.TimeoutMs` | `true` / `5000` / `2000` | Background connectivity-probe knobs (mirrors `ModbusProbeOptions`) |
| `Reconnect.MinBackoffMs` / `MaxBackoffMs` / `BackoffMultiplier` | `0` / `30000` / `2.0` | Geometric backoff after a failed request or a dropped `/sample` stream |
| `Write.Enabled` | `false` | The driver-side half of the write-back opt-in. An absent block means disabled — see [Write-back](#write-back--observation-injection-off-by-default) |
| `Tags[]` | `[]` | Pre-v3 / CLI authoring surface — one `MTConnectTagDefinition` per DataItem `id` |
| `RawTags[]` | `[]` (deploy-injected) | The v3 data-plane binding — see below |
@@ -224,6 +235,84 @@ the two need disambiguating, not just OR-ing together). On a changed
`/hosts` page — see [Known limitations](#known-limitations) for what that does
and does not do.
## Write-back — observation injection, off by default
> ⚠️ **A write sets what the Agent *reports*, not what the machine does.** The value
> lands in the Agent's observation buffer exactly as if an adapter had reported it —
> visible to **every** consumer of that Agent, and **overwritten by the next
> adapter/SHDR update for that DataItem**. Nothing reaches the CNC. Never document,
> script, or operate this as a machine command. It is genuinely useful for
> operator-set EVENT values, for external systems feeding DataItems the adapter does
> not own, and for test rigs — and nothing else.
The mechanism is the Agent's **`AllowPut`** surface (`POST
{agent}/{device}?{dataItemId}={value}`), a standard cppagent feature that is **off by
default**. It is not MTConnect *Interfaces* — see [Deferred](#deferred-not-in-this-build)
for why Interfaces was rejected as the write-back shape. Design record:
[`2026-08-05-mtconnect-writeback-design.md`](../plans/2026-08-05-mtconnect-writeback-design.md)
§2.2; implementation `b29623cf`.
### Two independent gates, both off by default
1. **The Agent** — `AllowPut = true` in `agent.cfg`. Optionally scope it with
`AllowPutFrom = <host list>` so only named hosts may write; that list is the Agent's
own access control and this driver neither reads nor influences it.
2. **The driver** — `"Write": { "Enabled": true }` in the driver config (default
`false`; an absent block is disabled).
Either gate alone leaves the shipped read-only behaviour unchanged: with `Write.Enabled`
off every slot is refused `BadNotWritable` without an Agent being touched, and with
`AllowPut` off the Agent refuses and the driver reports `BadCommunicationError`.
### The OPC UA write bit is authored, not derived — read this before filing a bug
**Enabling write-back does not make any existing node writable over OPC UA.** The
`CurrentWrite` access bit is composed from the **authored `Tag.AccessLevel`** alone
(`AddressSpaceComposer` → `AddressSpaceApplier`, which additionally requires a
non-array tag → `OtOpcUaNodeManager.ComposeAccessLevel`); driver capability is never
consulted at compose time. An operator must set the raw tag to **ReadWrite** — a
browse-committed tag is created `Read` (`RawBrowseCommitMapper.MapLeaf`) whatever the
browse said.
Browse *does* stamp writable leaves `SecurityClassification.Operate` when
`Write.Enabled` is on (and `ViewOnly` otherwise, CONDITION always `ViewOnly`), on the
same "a node cannot advertise writability the driver would refuse" principle MQTT
pinned. But that stamp is **honest labelling, not the access bit**: nothing persists
`SecurityClass` — `Tag` has no such column — so it reaches the browse session's
attribute side-panel and stops there.
### What a write does, slot by slot
`WriteAsync` never throws and one bad slot never fails the batch (matching `ReadAsync`);
even cancellation returns a full-length result set. The refusal order is fixed, and each
step answers a different operator question:
| Condition | Status | Meaning |
|---|---|---|
| `Write.Enabled` off, **or** the DataItem is CONDITION-category | `BadNotWritable` (`0x803B0000`) | Nothing was attempted; no Agent was involved |
| No live Agent client (pre-`InitializeAsync`, faulted initialize, post-shutdown) | `BadNotConnected` (`0x808A0000`) | This driver is not running |
| The reference resolves to no DataItem, or to one whose owning device cannot be named | `BadNodeIdUnknown` (`0x80340000`) | There is no Agent URL the write could be addressed to |
| The value has no invariant string form — `null`, or non-scalar | `BadTypeMismatch` (`0x80740000`) | Arrays are refused rather than rendered (`Convert.ToString` would emit the literal `System.Double[]`, which the Agent stores happily) |
| The Agent refused, or could not be reached | `BadCommunicationError` (`0x80050000`) | Includes an `MTConnectError` document returned under HTTP 200 |
**CONDITION DataItems are excluded deliberately** — they carry alarm semantics and a
different `level|text` wire format, and are out of scope for v1.
**The Agent's refusal text is logged at Warning and never stored.** It stays out of
`DriverHealth.LastError`, which is type-only by repo-wide discipline, and a refused write
is not evidence the read path degraded — so a refusal leaves driver health untouched.
**Device resolution needs no extra config.** `TagBinding.WriteTargetsByDataItemId`,
built at bind time from a walk of the cached probe model, remembers which device declared
each DataItem, so a multi-device Agent is addressed correctly; the fallback is the
configured `DeviceName`, and an unresolvable device is `BadNodeIdUnknown` rather than a
guess.
**Values are rendered with `CultureInfo.InvariantCulture`.** A Boolean therefore writes
the invariant string `"True"`/`"False"`, **not** `"1"`/`"0"` — if the consuming adapter or
Agent vocabulary expects something else, author the tag as a `String` and write the exact
token.
## Browse — free via the universal browser
MTConnect ships **no bespoke browser project**. Setting
@@ -272,6 +361,13 @@ for anything beyond values-and-conditions.
driver instance both resolve to the same one Agent connection. Real
per-device routing (a client per device) would be a design change, not a
bug fix.
**The write path is the one place a device name is resolved per DataItem**,
and it does not use `RawTagEntry.DeviceName` either: it reads the owning
device out of the cached probe model and falls back to the top-level
`DeviceName` (see [Write-back](#write-back--observation-injection-off-by-default)).
So `MTConnectDriverOptions.DeviceName` does double duty — request scope for
reads/browse, fallback write target for writes.
3. **Nothing validates a raw tag's declared OPC UA `DataType` against the
blob's coercion type.** The `/probe`-sourced inference
(`MTConnectDataTypeInference`) narrows how often an author picks a wrong
@@ -294,11 +390,17 @@ for anything beyond values-and-conditions.
## Deferred (not in this build)
- **Write-back (MTConnect Interfaces).** Design
[§3.6](../plans/2026-07-15-mtconnect-driver-design.md#36-iwritable--not-implemented-v1):
the mainstream Agent surface is read-only by design; Interfaces is a rare,
optional request/response handshake that would mislead if modelled as
`IWritable`. Revisit only on a concrete deployment need.
- **MTConnect *Interfaces*.** Still deferred, and for the reason design
[§3.6](../plans/2026-07-15-mtconnect-driver-design.md#36-iwritable--not-implemented-v1)
gave: it is a rare, optional request/response **state-machine handshake**
requiring adapter-side cooperation, not a "set value" operation, and would
mislead if modelled as `IWritable`.
**Write-back itself is no longer deferred** — `b29623cf` shipped `IWritable`
over the Agent's `AllowPut` observation-injection surface instead, a different
mechanism entirely. See [Write-back](#write-back--observation-injection-off-by-default)
and
[`2026-08-05-mtconnect-writeback-design.md`](../plans/2026-08-05-mtconnect-writeback-design.md)
§2.1.
- **P1.5 fast-follow** (design
[§9](../plans/2026-07-15-mtconnect-driver-design.md#9-phasing--effort)):
CONDITION → native OPC UA Part 9 alarms via `IAlarmSource` (the Galaxy
@@ -317,7 +419,10 @@ for anything beyond values-and-conditions.
discovery tree shape, `MTConnectDataTypeInference`, observation indexing,
`UNAVAILABLE` → `BadNoCommunication`, CONDITION state-word mapping,
multipart chunk framing, and ring-buffer re-baseline paging (both the
sequence-gap and the `OUT_OF_RANGE` legs).
sequence-gap and the `OUT_OF_RANGE` legs). The write path adds
`MTConnectAgentClientWriteTests` (`9f17d7ef` — verb, URL, agent-error-in-200
detection) and `MTConnectWriteTests` (`b29623cf` — every refusal leg, the
batch shape, the browse-stamping flip).
- **Integration tests** —
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/` — env-gated
on `MTCONNECT_AGENT_ENDPOINT` (default `http://10.100.0.35:5000`), skips
+5 -10
View File
@@ -521,6 +521,11 @@ and only** browse action that publishes anything.
- **Scope** is the last node clicked in the tree. A device or metric resolves **up to its owning edge
node** (Sparkplug has no device-scoped rebirth); a group fans out to every observed edge node
beneath it and is refused **whole** past 32, publishing nothing.
- **An empty tree can still arm a rebirth** ([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514),
fixed `7f53bd96`): when no scope is selected the panel offers a `group/edgeNode` input, so a plant
whose edge nodes all birthed before the window opened — the exact case the affordance exists for —
is reachable without a tree click. Deliberately an **edge node**, never a group: a typed group
broadcast is a self-inflicted broker storm on a large plant.
- **Two clicks, never one** — `Request rebirth…` arms a confirm panel naming the resolved scope and
its consequence; changing the selection disarms it.
- **Gated on `DriverOperator`**, enforced server-side in `BrowserSessionService.RequestRebirthAsync`
@@ -547,14 +552,6 @@ in the rebuilt tree.
> affected **every** driver's picker since 2026-05-28; it surfaced here because choosing a rebirth
> scope is the first flow that requires clicking a label rather than the ▶ toggle.
> ⚠️ **Known gap — a completely empty tree cannot arm a rebirth.** The rebirth scope is taken from the
> last node clicked *in the tree*, so on a plant that has not birthed since the window opened there is
> nothing to click and `Request rebirth…` stays disabled — the exact case the affordance exists for.
> `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare `{group}/{edgeNode}` for a node the
> window has never seen (it calls that "the prime rebirth target"); what is missing is a UI path to
> enter one, or to default the scope to the driver's own configured `GroupId`. Until then: use
> **Refresh** after any birth, or author the tags by **Manual entry**, which is always available.
## Test Connect
`MqttDriverProbe` performs **one CONNECT** (no SUBSCRIBE, no publish) under a `-probe-` client id and
@@ -583,8 +580,6 @@ identical words. See [TestConnectProbes.md](TestConnectProbes.md).
## Operational Notes
- **Read-only.** No writes reach the broker; every node is ViewOnly. Sparkplug NCMD is used for
**rebirth requests only** — never to command a device.
- **No alarms, no driver-side history.** Historization is a server-side concern (`isHistorized`).
- **Both pair nodes subscribe independently** — MQTT fan-out is per-connection, and the Primary gate
applies downstream, not to ingest. This is also why `clientId` must stay unset (above).
+22 -5
View File
@@ -12,9 +12,20 @@ An ordinary SQL database — typically a MES / LIMS / historian staging table th
already writes to. There is no protocol here and nothing to "connect" to a machine: the driver runs
`SELECT`s on a timer and turns cells into tag values.
**v1 constructs SQL Server only** (`Microsoft.Data.SqlClient`). `SqlProvider` also declares
`Postgres`, `MySql`, `Odbc` and `Oracle`, but those are **reserved names, not working backends** —
authoring one fails the driver rather than silently falling back.
**Three providers construct**: `SqlServer` (`Microsoft.Data.SqlClient`), `Postgres`
(`Npgsql`) and `Oracle` (ODP.NET managed — **Oracle 12.2 or later**, the floor the
`FETCH FIRST` paging needs). `SqlProvider` also declares `MySql` and `Odbc`, but those remain
**reserved names, not working backends** — authoring one fails the driver rather than silently
falling back.
Two provider-specific things are worth knowing before you author against them:
- **Identifier case folds, and the catalog gate absorbs it.** Postgres lower-cases and Oracle
upper-cases unquoted identifiers. Every authored identifier is resolved to the catalog's own
spelling, so an authored `widgets` binds to Oracle's `WIDGETS` rather than failing.
- **Oracle `NUMBER` infers as `Float64`.** Column metadata does not carry precision/scale to the
type map, so an integer-shaped `NUMBER` still infers double. Use the per-tag `type` override
(see [Optional per-tag fields](#optional-per-tag-fields)) when the OPC UA type matters.
**Read-only, structurally.** `SqlDriver` does not implement `IWritable`, so no configuration can
produce a write. The `allowWrites` config field exists and is **inert**; the factory *warns* when it
@@ -94,7 +105,7 @@ ignoring it later.
| Key | Default | Notes |
|---|---|---|
| `provider` | `SqlServer` | The only provider v1 constructs. |
| `provider` | `SqlServer` | `SqlServer`, `Postgres` or `Oracle`. `MySql` / `Odbc` are reserved and rejected. |
| `connectionStringRef` | — | **A name, never a connection string.** See below. |
| `defaultPollInterval` | `00:00:05` | Applied to any group without its own interval. |
| `operationTimeout` | `00:00:15` | Client-side wall-clock deadline; a breach surfaces `BadTimeout`. |
@@ -132,7 +143,9 @@ The two classes of authored string are handled differently, and deliberately so:
concatenated into command text.
- **Identifier-bearing** fields (`table`, `keyColumn`, `valueColumn`, `timestampColumn`,
`columnName`, `whereColumn`, `topByTimestamp`) cannot be parameterised in SQL. They are validated
against **`INFORMATION_SCHEMA`** and dialect-quoted before they reach a command.
against the live catalog — **`INFORMATION_SCHEMA`** on SQL Server and Postgres, the **`ALL_*`**
views (`ALL_USERS` / `ALL_TABLES` ∪ `ALL_VIEWS` / `ALL_TAB_COLUMNS`) on Oracle — and
dialect-quoted before they reach a command.
The parser deliberately does **not** sanitise either kind — sanitising a value would corrupt
legitimate data, and sanitising an identifier would mask an authoring error the catalog check reports
@@ -152,6 +165,10 @@ can commit columns as tags. A committed leaf carries **schema, table and column*
cannot address a SQL tag, and an earlier version that emitted only the column produced tags whose
typed editor opened empty.
Browse works against all three constructed providers; the catalog query is the dialect's own
(`INFORMATION_SCHEMA` on SQL Server and Postgres — with `pg_catalog` / `information_schema`
themselves excluded — and the `ALL_*` views on Oracle).
## Authoring in the AdminUI
- **Driver config** — `/raw` → driver node → *Configure*. Sql is a **single-connection** driver: the
@@ -114,6 +114,21 @@ Pure function in `.Contracts` (shared by driver, browser-commit, and the typed e
### 3.6 `IWritable` — **not implemented (v1)**
> **SUPERSEDED 2026-08-05 — write-back shipped, by a different mechanism.**
> `MTConnectDriver` now implements `IWritable` (`b29623cf`) over the Agent's optional
> **`AllowPut`** surface (`POST {agent}/{device}?{dataItemId}={value}`), gated off by
> default at both the Agent (`AllowPut`) and the driver (`Write.Enabled`). That is
> **observation injection into the Agent's buffer, not machine command** — see
> [`2026-08-05-mtconnect-writeback-design.md`](2026-08-05-mtconnect-writeback-design.md)
> §2.2 and `docs/drivers/MTConnect.md` §Write-back.
> **The paragraph below is retained because its rationale still holds for *Interfaces*,
> which remains deferred** (design §2.1 of the write-back doc re-affirms it: rarely
> deployed, needs adapter-side cooperation, a state machine rather than a value write).
> Only its two incidental claims about this build are out of date — the driver is no
> longer `IWritable`-less, and nodes no longer materialize without `CurrentWrite` *because
> of a missing capability*; the `CurrentWrite` bit was in fact always composed from the
> authored `Tag.AccessLevel` alone, never from driver capability.
Justified per research §2.1: the MTConnect Agent surface is read-only by design; write-back exists only via optional, rarely-deployed MTConnect *Interfaces* (a request/response state-machine handshake, not a "set value" — modelling it as `IWritable` would mislead). Capability interfaces are composable, so omitting `IWritable` is idiomatic (Galaxy's write path is even fire-and-forget). Nodes materialize **without** the `AccessLevels.CurrentWrite` bit automatically. Revisit Interfaces only if a concrete deployment needs it.
### 3.7 `IHostConnectivityProbe` + `IRediscoverable`
@@ -154,12 +154,12 @@ SQL poll reuses the always-on central SQL Server. **Both shipped.**
### SQL poll — ✅ Done (merged `4ad54037`, live gate PASSED)
- **Design:** [`2026-07-15-sql-poll-driver-design.md`](2026-07-15-sql-poll-driver-design.md)
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks, all done**. `ISqlDialect` + `SqlServerDialect` shipped; Postgres/ODBC remain deferred to P2/P3 behind the same seam.
- **Implementation plan:** [`2026-07-24-sql-poll-driver.md`](2026-07-24-sql-poll-driver.md) · [`.tasks.json`](2026-07-24-sql-poll-driver.md.tasks.json) — **22 tasks, all done**. `ISqlDialect` + `SqlServerDialect` shipped. ~~Postgres/ODBC remain deferred to P2/P3 behind the same seam.~~ **Postgres + Oracle shipped 2026-08-05** via managed providers (Npgsql 10.0.3, ODP.NET Core 23.26.300) — dialect-owned parameter markers/bind names + Oracle `BindByName`, `INFORMATION_SCHEMA` vs `ALL_*` catalog SQL, provider gates widened, AdminUI dropdown, env-gated docker suites (postgres:17-alpine / gvenzl/oracle-free:23-slim). The live suites found + fixed ORA-01745 (Oracle rejects reserved-word bind names — `:table` — so the first Oracle build was structurally broken until binds were `p_`-prefixed). MySql/Odbc stay reserved. See [`2026-08-05-sql-postgres-oracle-design.md`](2026-08-05-sql-postgres-oracle-design.md).
- **Post-merge follow-ups closed** in `28c28667`: #496 catalog identifier-validation gate
(`4dc2ad80`), #497 wrong status-code constants across six drivers, #498 deploy-gate rejection of a
persisted `connectionString` (`1919a8e5`).
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server
shipped; Postgres/ODBC deferred behind `ISqlDialect`).
- **Scope:** a bespoke schema-browser driver polling a SQL table into equipment tags (SQL Server,
**PostgreSQL and Oracle** shipped behind `ISqlDialect`; MySql/Odbc reserved).
- **Fixture:** SQLite unit fixture (primary) + an **env-gated integration fixture against the
existing central SQL Server** (`10.100.0.35,14330`) with a seeded `SqlPollFixture` DB. The
blackhole/timeout live-gate pauses a **dedicated** `mssql` container — **never** the shared
@@ -179,7 +179,8 @@ Both CI-simulatable on the shared docker host, no hardware.
surface, data-plane precedence rules, quality-code mapping, and (most load-bearing) the **Known
limitations** section.
- **Scope shipped:** `IDriver`+`ITagDiscovery`+`IReadable`+`ISubscribable`+`IHostConnectivityProbe`+`IRediscoverable`
(deliberately no `IWritable` — read-only Agent surface), browse **free via the Wave-0 universal
(P1 shipped deliberately without `IWritable`; **`IWritable` added 2026-08-05, `b29623cf`** — see the
Deferred line below), browse **free via the Wave-0 universal
browser** (`SupportsOnlineDiscovery=true`, no browser project), typed AdminUI tag editor,
`UNAVAILABLE→BadNoCommunication` mapping, CONDITION-as-`String`, ring-buffer re-baseline paging
(both the sequence-gap and `OUT_OF_RANGE` legs).
@@ -191,7 +192,18 @@ Both CI-simulatable on the shared docker host, no hardware.
- **Test results:** MTConnect unit suite 491/491; integration suite 12/12 against a real Agent
(`mtconnect/agent:2.7.0.12`, **not** `mtconnect/cppagent` — that Docker Hub repo doesn't exist);
skips cleanly (12 Skipped) offline. AdminUI 749/749; full solution 0 build errors.
- **Deferred (documented, not built):** write-back via MTConnect Interfaces (design §3.6); P1.5
- **Write-back — SHIPPED 2026-08-05, not via Interfaces** (`b29623cf`, branch
`feature/mqtt-mtconnect-write`; design
[`2026-08-05-mtconnect-writeback-design.md`](2026-08-05-mtconnect-writeback-design.md)).
`IWritable` over the Agent's optional **`AllowPut`** surface
(`POST {agent}/{device}?{dataItemId}={value}`) — **observation injection into the Agent's
buffer, never a machine command**, gated off by default at both the Agent (`AllowPut`) and the
driver (`Write.Enabled`), CONDITION items refused. **MTConnect *Interfaces* remains deferred**
— design §3.6's rationale still holds for it (rare, adapter-side cooperation, a state machine
rather than a value write), and it is a different mechanism, not this one. Caveat recorded in
the driver guide: the OPC UA `CurrentWrite` bit still comes from the authored
`Tag.AccessLevel` alone, so an operator must author **ReadWrite**.
- **Deferred (documented, not built):** MTConnect *Interfaces* (design §3.6, above); P1.5
CONDITION→native Part-9 alarms, `TIME_SERIES` array materialization, EVENT→enum vocab (design §9);
P2 SHDR adapter ingest (design §9). Also two pre-existing fleet-wide gaps this build surfaced
rather than caused: `IRediscoverable`/`IHostConnectivityProbe` have no server-side consumer (eight
@@ -210,7 +222,11 @@ Both CI-simulatable on the shared docker host, no hardware.
`OnDataChange`, retained last-value read, `#`-observation browser); P2 Sparkplug B ingest
(vendored Tahu proto + `Grpc.Tools` codegen, birth/alias/seq-gap/rebirth state machine,
death→STALE, birth-driven browse tree, scoped Request-rebirth NCMD, typed tag editor).
**Write-through (`IWritable`) is deferred to P3** — every MQTT node materializes read-only.
**P3 write-through (`IWritable`) shipped 2026-07-31** — `72be1922`
([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508), live-gated): plain
`writeTopic` publish + Sparkplug NCMD/DCMD, with a node discovered `ViewOnly` when the encoder
would refuse the write. Superseded the "every MQTT node materializes read-only" line this entry
carried; see `docs/drivers/Mqtt.md` §Write-through.
- **Fixture:** Mosquitto TLS+auth broker + JSON publisher sidecar, **live** at `10.100.0.35:8883`
(`:1883` plaintext-but-authenticated); stack `/opt/otopcua-mqtt`, compose in
`tests/Drivers/…​.Driver.Mqtt.IntegrationTests/Docker/`. Env-gated live suite
@@ -236,13 +252,31 @@ Both CI-simulatable on the shared docker host, no hardware.
Sparkplug keys, merge-not-replace on save, group-id segment validation) + 8 falsifiable
`MqttDriverFormModelTests` cases. **Three gates, three instances of the same defect class: a
hand-maintained AdminUI surface left behind by a driver-side feature.**
- **P2 live gate — two open UI gaps, documented not fixed** (see `docs/drivers/Mqtt.md` §Known gaps):
- **A completely empty browse tree cannot arm a rebirth.** The scope comes from a tree click, so on
a plant that has not birthed since the window opened `Request rebirth…` stays disabled — the very
case the affordance exists for. `MqttBrowseSession.ResolveRebirthTargets` already accepts a bare
`{group}/{edgeNode}` ("the prime rebirth target"); only a UI path to enter one is missing.
Mitigation shipped: a **Refresh** button on the browse tree (it re-reads the same session, which
previously rendered exactly once at open and never again).
- **P2 live gate — four UI gaps recorded, ALL FOUR NOW CLOSED 2026-07-31** (`7f53bd96`; see
`docs/drivers/Mqtt.md` §Known gaps, which lists the closures). Recorded as found, with what
actually fixed each:
- **A completely empty browse tree cannot arm a rebirth**
([#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514)). The scope comes from a tree
click, so on a plant that has not birthed since the window opened `Request rebirth…` stays
disabled — the very case the affordance exists for. `MqttBrowseSession.ResolveRebirthTargets`
already accepts a bare `{group}/{edgeNode}` ("the prime rebirth target"); only a UI path to
enter one was missing. Mitigation shipped at the time: a **Refresh** button on the browse tree
(it re-reads the same session, which previously rendered exactly once at open and never again).
**FIXED —** `RawBrowseModal` now offers a `group/edgeNode` scope input whenever no tree scope is
armed; edge-node only, never a typed group broadcast.
- **A metric name containing `/` cannot be browse-committed**
([#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509)). The derived raw **tag Name**
is a RawPath segment and may not contain `/`, while `metricName` legitimately may — so the
spec-mandatory `Node Control/Rebirth` was refused at commit (`Row N: Name must not contain '/'`).
The refusal was loud and all-or-nothing, never a silently mis-bound tag, and **Manual entry** was
the working path. Fixing it needed a name-sanitisation policy with a collision answer, so it was
recorded rather than guessed at. **FIXED —** `RawBrowseCommitMapper.ToRawSegment` sanitises the
segment and refuses a collision explicitly.
- **`_canRebirth` is captured at browse-open**, so a reaped session still drew the button
([#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510)). **FIXED.**
- **The tag editor injects the Plain-only `payloadFormat` key into a Sparkplug blob**
([#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513)) — cosmetic; the factory
routes on the Sparkplug tuple and ignored it. **FIXED.**
- **P2 live gate also found a PRE-EXISTING, cross-driver AdminUI defect — FIXED:** every node label in
the shared `DriverBrowseTree` was an `<a href="#">`. In a Blazor Web App, `blazor.web.js`'s
enhanced-navigation click interceptor resolves a bare `#` against `<base href="/">`, so **clicking
@@ -252,19 +286,14 @@ Both CI-simulatable on the shared docker host, no hardware.
now `<button type="button">`. It dates to the component's introduction (2026-05-28) and affects
**every** driver's picker; it only surfaced now because Sparkplug's Request-rebirth is the first
feature that requires clicking a *label* rather than the ▶ toggle (always a `<button>`, always fine).
- **A metric name containing `/` cannot be browse-committed.** The derived raw **tag Name** is a
RawPath segment and may not contain `/`, while `metricName` legitimately may — so the
spec-mandatory `Node Control/Rebirth` is refused at commit (`Row N: Name must not contain '/'`).
The refusal is loud and all-or-nothing, never a silently mis-bound tag, and **Manual entry** is
the working path (the Sparkplug tag editor accepts a slashed metric name). Fixing it needs a
name-sanitisation policy with a collision answer, so it was recorded rather than guessed at.
- `_canRebirth` is captured at browse-open, so a reaped session still draws the button.
- The tag editor injects the Plain-only `payloadFormat` key into a Sparkplug blob — cosmetic; the
factory routes on the Sparkplug tuple and ignores it.
- **Rediscovery is inert in v3 (platform gap, not MQTT's).** The Sparkplug driver raises
`OnRediscoveryNeeded` on a changed birth metric-set, but **nothing subscribes to it** and
`DriverHostActor.HandleDiscoveredNodes` hard-returns. A DBIRTH introducing a metric does **not**
change the OPC UA tree — redeploy. Driver-side behaviour is log-observable only.
- **Rediscovery was inert in v3 (platform gap, not MQTT's) — HALF-CLOSED since 2026-07-27.** The
Sparkplug driver raises `OnRediscoveryNeeded` on a changed birth metric-set; at the time nothing
subscribed to it and `DriverHostActor.HandleDiscoveredNodes` hard-returned. `DriverInstanceActor`
now **consumes** the raise (`09a401b8`) and it rides the driver-health snapshot to the AdminUI
`/hosts` page as a **re-browse chip**, while the injection path was **deleted** rather than
revived (#507, `adce27e7` — its two inputs are structurally empty in v3). So the standing part is
unchanged and deliberate: a DBIRTH introducing a metric still does **not** change the served OPC
UA tree; an operator re-browses and commits through `/raw`.
- **Redundant-pair hazard (documented, not a code change):** both nodes of a pair run the driver, so a
**fixed `clientId` makes them evict each other forever** — the broker logs
`already connected, closing old connection` and both nodes reconnect every ~2 s while still
@@ -283,9 +312,27 @@ Both CI-simulatable on the shared docker host, no hardware.
pass-through) CLOSED 2026-08-05 — see the Wave 0 section above.
2. **Wave 1 is done.** Both deliverables shipped and live-gated: **Modbus RTU-over-TCP** (merged
`0f38f486` / #495) and **SQL poll** (merged `4ad54037`; follow-ups #496/#497/#498 closed in
`28c28667`). Remaining SQL work is optional follow-on — Postgres/ODBC dialects behind
`28c28667`). **Postgres + Oracle dialects shipped 2026-08-05** (managed providers, see the SQL
poll section above); remaining SQL work is optional follow-on — MySql/Odbc dialects behind
`ISqlDialect`, and `SqlTagModel.Query` (P3, currently rejected end-to-end by design).
3. **Wave 2 is done.** Both deliverables shipped and live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506). Remaining work on both is optional follow-on — MQTT P3 write-through (`IWritable`: NCMD/DCMD + plain publish) plus its two browse-UI
gaps, and MTConnect write-back (deliberately deferred; the Agent surface is read-only).
3. **Wave 2 is done, and so is the follow-on this entry used to list.** Both deliverables shipped and
live-gated: **MQTT / Sparkplug B** (P1 + P2) and **MTConnect Agent** (P1 Agent MVP, PR #506).
Since then:
- **MQTT P3 write-through shipped and was live-gated 2026-07-31** — `72be1922`
([#508](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/508)): plain `writeTopic` publish
+ Sparkplug NCMD/DCMD.
- **Both browse-UI gaps are closed**, with the other two P2 records, in `7f53bd96`
([#509](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/509),
[#510](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/510),
[#513](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/513),
[#514](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/514)) — see the P2 section above.
- **MTConnect write-back is landing on `feature/mqtt-mtconnect-write`** (`b29623cf`), by
**observation injection** through the Agent's `AllowPut` surface rather than the Interfaces
handshake the original deferral assumed — design
[`2026-08-05-mtconnect-writeback-design.md`](2026-08-05-mtconnect-writeback-design.md), which
is also the record for this reconciliation. MTConnect *Interfaces* stays deferred.
Remaining optional follow-on for the pair: MTConnect P1.5 (CONDITION→Part-9 alarms,
`TIME_SERIES` arrays, EVENT→enum vocab) and P2 SHDR ingest.
Update this file's Summary table and per-wave status whenever a deliverable changes state.
@@ -0,0 +1,162 @@
# MTConnect write-back + MQTT write-through record reconciliation — design
**Status:** DONE 2026-08-05 — merged to master (`0e3381d5`); unit suites green (MTConnect 534/534,
MQTT 658/658, AdminUI 1064/1064); **live gate PASSED** (§4): the full integration suite ran live
against the shared fixture (`10.100.0.35:5000`, AllowPut now on) — 15/15, 0 skipped — covering the
write round-trip (value read back from `/current`), the real agent-refusal → `BadCommunicationError`
mapping (with the no-`deviceName` → `BadNodeIdUnknown` falsifiability control), and the disabled
gate (`BadNotWritable`, agent untouched). The same legs also ran against a local OrbStack fixture
during development with agent-log corroboration. **One leg is deliberately deferred to the
operator's chrome session:** the docker-dev rig OPC UA leg (author an MTConnect driver with
`write.enabled` + a `ReadWrite` tag in AdminUI, deploy, `Client.CLI write`, read back) — authoring
requires the AdminUI, and per the operator's instruction chrome-driven gates run separately.
Remember the CurrentWrite caveat when running it: the tag must be authored `ReadWrite`
(browse-commit defaults to `Read`).
## 1. Scope correction: the MQTT half of this request is already shipped
The request ("MQTT P3 write-through plus its two documented browse-UI gaps, MTConnect write-back")
came from `docs/plans/2026-07-24-driver-expansion-tracking.md` §Next actions (lines 288–289) — which
is **stale**. On master today:
| Item | Status | Evidence |
|---|---|---|
| MQTT P3 write-through (`IWritable`: plain `writeTopic` publish + Sparkplug NCMD/DCMD) | **Shipped, live-gated** — #508, `72be1922`, 2026-07-31 | `MqttDriver.cs:65-67` implements `IWritable`; `Mqtt.md:16-20` status banner |
| Browse-UI gap 1 (empty tree cannot arm a rebirth) | **Fixed** — #514, `7f53bd96` | `RawBrowseModal.razor:127-157` manual `{group}/{edgeNode}` scope input |
| Browse-UI gap 2 (slashed metric name refused at browse-commit) | **Fixed** — #509, `7f53bd96` | `RawBrowseCommitMapper.ToRawSegment` (`:107`), collision refusal (`:117`) |
So the only MQTT work left is **documentation reconciliation** — three stale records that now
contradict the code and `Mqtt.md`'s own closure line (`:597-602`):
1. `docs/drivers/Mqtt.md:550-556` — still prints the #514 rebirth gap as an open "Known gap".
2. `docs/drivers/Mqtt.md:586-587` — "Read-only. No writes reach the broker; every node is ViewOnly"
contradicts §Write-through (`:407-480`) in the same file.
3. `docs/plans/2026-07-24-driver-expansion-tracking.md:288-289` §Next actions still lists MQTT P3 +
the two gaps as remaining work; the P2 section (`:239-263`) still describes the gaps as
"documented not fixed".
4. `deferment.md:132-133` — "`IWritable` is absent from MTConnect, MQTT, Sql and Calculation" was
audited pre-#508 and is wrong about MQTT (and, after this branch, about MTConnect).
## 2. MTConnect write-back — the real work
### 2.1 What was deferred, and what mechanism to build on
The driver shipped deliberately read-only (`MTConnectDriver.cs:63` — no `IWritable`;
`docs/plans/2026-07-15-mtconnect-driver-design.md` §3.6). The deferral named **MTConnect
Interfaces** (a request/response state-machine handshake between two agents) as the only write-back
shape and judged it misleading to model as `IWritable` — "revisit only on a concrete deployment
need". This request is that need, but Interfaces is still the wrong mechanism: it is rarely
deployed, requires adapter-side cooperation, and is a state machine rather than a value write.
The mechanism this design uses instead is the **Agent's HTTP PUT/POST surface** — a standard
cppagent feature, off by default, enabled by `AllowPut = true` (optionally scoped by
`AllowPutFrom = <host list>`) in `agent.cfg`. With it enabled, `PUT`/`POST
{agent}/{deviceName}?{dataItemId}={value}` sets the named observation in the agent's buffer exactly
as if an adapter had reported it. The repo's own integration fixture already carries the key —
currently `AllowPut = false` with a "must not accept writes" comment
(`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg:25-26`).
### 2.2 Honest semantics — this is observation injection, not machine command
A PUT write updates **what the agent reports**, not the machine: the value lands in the agent's
observation buffer, is visible to every agent consumer, and the next adapter/SHDR update for that
data item overwrites it. That is genuinely useful (operator-set EVENT values, external systems
feeding data items the adapter does not own, test rigs) but it must never be documented as
commanding the CNC. The docs section this branch writes states this prominently, and the feature is
**opt-in at two independent layers**: the agent (`AllowPut`) and the driver config
(`write.enabled`, default `false`). Either alone leaves today's read-only behaviour untouched.
### 2.3 Driver design
**Config.** `MTConnectDriverConfigDto` (+ typed `MTConnectDriverOptions`) gains a `Write` block:
```json
"write": { "enabled": true }
```
Default absent/`false` — nothing changes for existing deployments. No per-tag key: writability is
derived (below), matching how the driver derives everything else from the probe model.
**Capability.** `MTConnectDriver` adds `IWritable`. The factory already returns the concrete type
(`MTConnectDriverFactoryExtensions.cs:33-39` documents why), so capability dispatch picks it up
with no registry change.
**`WriteAsync` shape** (mirrors `MqttDriver.cs:670-745` — never throws, batch shape preserved,
per-slot codes):
- `write.enabled == false` → every slot `BadNotWritable` (0x803B0000).
- No live client (pre-Initialize / post-Shutdown) → `BadNotConnected`, matching `ReadAsync`'s
convention (`MTConnectDriver.cs:628-695`).
- RawPath does not resolve via `TagBinding.ToDataItemId` → `BadNodeIdUnknown`.
- Data item is CONDITION-category, or the value is null / not representable as an invariant string
→ `BadNotWritable` / `BadTypeMismatch` respectively. (Condition PUTs use a different level|text
wire format and carry alarm semantics — excluded from v1.)
- HTTP call: `POST {agentBase}/{deviceName}?{dataItemId}={value}` via the agent-client seam. The
owning `deviceName` comes from a `dataItemId → deviceName` map built at bind time from the cached
probe model (falling back to `options.DeviceName`); a multi-device agent is addressed correctly
without config.
- Agent refusal (non-2xx, or an `MTConnectError` document in a 200 body) → `BadCommunicationError`
with the agent's own error text logged (never in `LastError` — type-only discipline).
- `OperationCanceledException` fills remaining slots and returns; any other exception → log warning
+ `BadCommunicationError`.
- Values format with `CultureInfo.InvariantCulture`; booleans as the raw invariant string.
**Seam extension.** `IMTConnectAgentClient` gains
`Task<MTConnectWriteOutcome> WriteObservationAsync(string deviceName, string dataItemId, string value, CancellationToken ct)`
(outcome = accepted / agent-error-with-text / transport-error). `MTConnectAgentClient` implements
it over `_http` (the unary client with the bounded timeout) using `HttpMethod.Post`; the stub
`HttpMessageHandler` ctor seam (`MTConnectAgentClient.cs:107`) covers response-shape unit tests
with no socket. The unit-test `CannedAgentClient` records write calls and returns scripted
outcomes.
**Discovery stamping.** `DiscoverAsync` currently stamps every leaf
`SecurityClassification.ViewOnly` (`MTConnectDriver.cs:1040-1042`) with a comment saying that is
the only honest tier for a driver with no `IWritable`. That derivation becomes conditional:
`CanWrite(dataItem)` (= `write.enabled` && category is not CONDITION) → `Operate`, else `ViewOnly`
— the same "a node cannot advertise writability the driver would refuse" principle MQTT pinned
(`MqttDriver.cs:504`, `CanWrite` at `:625-627`). The implementer must verify how materialization
derives the `CurrentWrite` access bit (capability presence + SecurityClass) so a stamped-Operate
node is actually writable over OPC UA, and a ViewOnly node is not.
**Doc comments to update in the same change:** the class remarks
(`MTConnectDriver.cs:46-53` "deliberately no IWritable"), the factory remark
(`MTConnectDriverFactoryExtensions.cs:39`), and `MTConnectTagDefinition.cs:10-14` ("no
corresponding write path").
### 2.4 Out of scope
- **MTConnect Interfaces** — still deferred, same rationale as §3.6; this design does not touch it.
- Asset writes (`POST /asset`), per-tag write opt-outs, write retry (`isIdempotent` stays `false`
at `DriverInstanceActor.cs:776`), and PUT `time=` timestamps — YAGNI.
- Any AdminUI surface — the driver form gains nothing; `write.enabled` is authored in the existing
driver-config JSON. (No chrome gate needed for this branch.)
### 2.5 Testing
1. **Unit (offline, falsifiable).** New `MTConnectWriteTests`: batch contract + every refusal leg
via `CannedAgentClient` (disabled-by-default, unknown RawPath, condition category, null value,
no client, cancellation fill, agent-error outcome). Encoder-free — the write is a URL, so the
URL/verb/device-resolution assertions live in `MTConnectAgentClient` stub-handler tests
(MTConnectError-in-200 detection included). Discovery stamping test: same probe fixture browsed
with write on/off flips leaf SecurityClass, CONDITION stays ViewOnly.
2. **Integration (env-gated, docker fixture).** Flip the fixture to `AllowPut = true` (update the
comment to say writes are now in scope) and add a write leg: write via the driver → read
`/current` back → the observation carries the written value; a second leg proves an
`AllowPut = false`-style refusal maps to `BadCommunicationError` is *not* needed (we cannot run
two agent configs in one fixture) — instead a bad-dataItemId write asserts the agent-error
mapping. Remove the "(b) Writes" not-covered remark
(`MTConnectAgentIntegrationTests.cs:64-65`).
3. **Live gate (no chrome).** Against the docker-dev rig + the shared fixture agent: author an
MTConnect driver with `write.enabled`, deploy, `Client.CLI write` to a writable node, confirm
via `/current` and via OPC UA read-back. Runnable headless by the executing session.
## 3. Bookkeeping (both halves)
- `docs/drivers/MTConnect.md`: capability surface (`:71-77`), Deferred (`:295-301`), new
§Write-back section with the §2.2 semantics warning.
- `docs/plans/2026-07-15-mtconnect-driver-design.md` §3.6: superseded note pointing here.
- `docs/plans/2026-07-24-driver-expansion-tracking.md`: MQTT P2 gap bullets + §Next actions
rewritten to record #508/#509/#514 closure and this branch's MTConnect write-back.
- `docs/drivers/Mqtt.md`: delete stale blocks `:550-556` and `:586-587`.
- `deferment.md`: correct `:132-133` (post-branch: `IWritable` absent from Sql and Calculation
only) and the MTConnect row (`:126`).
@@ -0,0 +1,183 @@
# MTConnect write-back Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
> (opus implementers per user instruction).
**Goal:** Implement `IWritable` on `MTConnectDriver` via the Agent's HTTP PUT/POST surface
(opt-in at agent `AllowPut` AND driver `write.enabled`), and reconcile the stale MQTT
write-through/browse-gap records.
**Architecture:** See `docs/plans/2026-08-05-mtconnect-writeback-design.md` (in this branch).
Write = observation injection into the agent buffer, never machine command. Batch-safe
`WriteAsync` mirroring `MqttDriver.cs:670-745`; new seam method on `IMTConnectAgentClient`;
discovery stamps `Operate` only for leaves the driver would accept.
**Tech Stack:** .NET 10, xUnit v3 + Shouldly, existing `CannedAgentClient` / stub
`HttpMessageHandler` seams. No new packages.
**Worktree:** `/Users/dohertj2/Desktop/OtOpcUa/.claude/worktrees/mqtt-mtconnect-write`
(branch `feature/mqtt-mtconnect-write`). All paths below relative to it.
---
### Task 0: Write options block (config + parse)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDriverOptions.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs` (config DTO region
~:3257-3345 + `ParseOptions` ~:2222)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverOptionsTests.cs`,
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs`
TDD: failing test first — `{"write":{"enabled":true}}` parses to `Options.Write.Enabled == true`;
absent block defaults to `false`; malformed value behaves like the sibling `Probe`/`Reconnect`
blocks (match their existing tolerance/failure semantics exactly — read them first). Add
`MTConnectWriteOptions` (sealed record, `Enabled`, default false) on `MTConnectDriverOptions`
following the `Probe`/`Reconnect` pattern, a nullable `WriteDto` on the config DTO, wire through
`ParseOptions`. Commit `feat(mtconnect): parse the write.enabled config block`.
### Task 1: Agent-client write seam
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectAgentClientWriteTests.cs` (new)
Add to the seam:
```csharp
Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken);
```
with `MTConnectWriteOutcome` a small readonly record: `Accepted` bool + `AgentError` string?
(agent-produced MTConnectError text) — transport failures THROW (HttpRequestException etc.), the
driver maps them; agent refusals return `Accepted=false` with the text. Implementation in
`MTConnectAgentClient` over `_http` (the bounded-timeout unary client): `HttpMethod.Post` to
`{base}/{deviceName}?{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}` built with
the same left-part logic as `BuildRequestUri` (:382-403). A 2xx whose body parses as an
`MTConnectError` document is a refusal (reuse the LocalName-only XML helpers in
`MTConnectXml.cs`); non-2xx is a refusal carrying the status; empty 2xx is accepted.
TDD via the internal `HttpMessageHandler` ctor seam (`MTConnectAgentClient.cs:107`), the same
pattern `MTConnectDriverProbeTests.cs:22-28` uses: assert verb, exact URI (device path + escaped
query), accepted on empty 200, refusal on MTConnectError-in-200 (agent text surfaced), refusal on
404, throw on handler-thrown transport error. Commit
`feat(mtconnect): agent-client observation write (HTTP POST, AllowPut surface)`.
### Task 2: `MTConnectDriver` implements `IWritable` + discovery stamping
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs` (class decl :63,
remarks :46-53, WriteAsync new, `TagBinding` device-name map ~:3170-3213, `DiscoverAsync`
stamping :1030-1046)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs`
(remark :33-39)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectTagDefinition.cs`
(remark :10-14)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs`
(record writes, scripted outcomes)
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectWriteTests.cs` (new),
`MTConnectDiscoverTests.cs` (stamping cases)
Per design §2.3. Status-code constants: follow MQTT's named-const pattern
(`MqttDriver.cs:76-89`) — `StatusBadNodeIdUnknown 0x80340000`, `StatusBadNotWritable 0x803B0000`,
`StatusBadTypeMismatch 0x80740000`, `StatusBadCommunicationError 0x80050000`, plus
`BadNotConnected` matching `ReadAsync`'s existing constant. Refusal order per slot: disabled →
BadNotWritable; no client → BadNotConnected; unresolved RawPath → BadNodeIdUnknown; CONDITION
category → BadNotWritable; null/unrepresentable value → BadTypeMismatch; then the seam call —
`Accepted=false` or thrown transport error → BadCommunicationError (log agent text at Warning,
never into `LastError`). `OperationCanceledException` fills remaining slots with
BadCommunicationError and returns. Never throw. Success = `WriteResult(0u)`.
Device-name resolution: extend `TagBinding` (or a parallel FrozenDictionary built in
`BuildBinding` ~:2887-2980) with `dataItemId → owning deviceName` from the probe model; fall back
to `options.DeviceName`; unresolvable device → BadNodeIdUnknown. Values format via
`Convert.ToString(value, CultureInfo.InvariantCulture)`.
Discovery: `CanWrite(dataItem)` = `options.Write.Enabled && !IsCondition(category)` →
`SecurityClassification.Operate` else `ViewOnly`; update the :1040-1042 comment. **Verify** how
materialization derives `CurrentWrite` (search for how MQTT's Operate-stamped nodes get the write
bit — SecurityClass + capability) and state the finding in the commit message; if a server-side
gate keys on the driver capability alone, note that existing ViewOnly-committed raw tags keep
their authored SecurityClass.
TDD: every refusal leg observed red first where feasible; the batch-shape test (one bad slot
doesn't fail the batch) mirrors `MqttDriverWriteTests.cs:74`. The publish itself is proven by
Task 3 live legs, not by asserting a fake was called beyond the seam-call recording (same
philosophy as `MqttDriverWriteTests.cs:11-15`). Commit
`feat(mtconnect): IWritable via agent observation writes (#468-era deferral closed)`.
### Task 3: Integration fixture + write legs
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4
**Files:**
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg`
(`AllowPut = true` + comment rewrite)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs`
(remove the "(b) Writes" not-covered remark :64-65; add write legs)
Legs (env-gated by the existing `MTCONNECT_AGENT_ENDPOINT` fixture, `Assert.Skip` clean):
1. Driver with `write.enabled` writes a distinct value to a writable EVENT data item (pick one
from `Devices.xml` the SHDR adapter does not continuously overwrite — read `adapter.py` to
choose; if every item is pumped, add a static EVENT item to `Devices.xml`) → poll `/current`
until the observation carries the value (bounded wait, `StreamWait` pattern).
2. A write naming a nonexistent dataItemId → `BadCommunicationError` (agent refusal mapping).
3. `write.enabled` absent → same write returns `BadNotWritable` without touching the agent.
Commit `test(mtconnect): live write legs against the AllowPut fixture`.
### Task 4: Docs reconciliation (MTConnect + stale MQTT records)
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3
**Files:**
- Modify: `docs/drivers/MTConnect.md` (capability :71-77, Deferred :295-301, new §Write-back)
- Modify: `docs/plans/2026-07-15-mtconnect-driver-design.md` (§3.6 superseded note)
- Modify: `docs/drivers/Mqtt.md` (delete stale :550-556 and :586-587)
- Modify: `docs/plans/2026-07-24-driver-expansion-tracking.md` (P2 gap bullets :239-263 → closed
#509/#510/#513/#514; §Next actions :288-289 rewritten)
- Modify: `deferment.md` (:126 MTConnect row, :132-133 four-driver IWritable line → Sql +
Calculation only)
Per design §1 + §3. The Write-back section MUST carry the §2.2 semantics warning (observation
injection, adapter overwrites, two-layer opt-in, `AllowPutFrom` pointer). Commit
`docs: MTConnect write-back recorded; stale MQTT write-through/browse-gap records reconciled`.
### Task 5: Full-suite verification
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none
`dotnet build ZB.MOM.WW.OtOpcUa.slnx` (0 errors) + full MTConnect unit suite + AdminUI suite +
`dotnet test --filter "FullyQualifiedName~Mqtt"` (docs-only but cheap insurance). Integration
suite compiles; live legs skip offline. Fix fallout, commit if changes.
### Task 6: Live gate (main session, headless — NO chrome)
**Classification:** standard
**Estimated implement time:** ~10 min (main session, after merge)
Main session, not a subagent: bring up the MTConnect fixture (`ssh dohertj2@10.100.0.35` compose
in `/opt/otopcua-mtconnect/` — `lmxopcua-fix sync` equivalent via rsync first, since agent.cfg
changed), run the Task 3 legs live (`dotnet test --filter` the new write tests with
`MTCONNECT_AGENT_ENDPOINT` set), then the docker-dev rig leg: author + deploy an MTConnect driver
with `write.enabled`, `Client.CLI write` a value, read it back via Client.CLI and `/current`.
Record results in the design doc §Outcome.
@@ -0,0 +1,13 @@
{
"planPath": "docs/plans/2026-08-05-mtconnect-writeback.md",
"tasks": [
{ "id": 0, "subject": "Task 0: Write options block (config + parse)", "status": "completed" },
{ "id": 1, "subject": "Task 1: Agent-client write seam", "status": "completed" },
{ "id": 2, "subject": "Task 2: MTConnectDriver IWritable + discovery stamping", "status": "completed", "blockedBy": [0, 1] },
{ "id": 3, "subject": "Task 3: Integration fixture + write legs", "status": "completed", "blockedBy": [2] },
{ "id": 4, "subject": "Task 4: Docs reconciliation (MTConnect + stale MQTT records)", "status": "completed", "blockedBy": [2] },
{ "id": 5, "subject": "Task 5: Full-suite verification", "status": "completed", "blockedBy": [3, 4] },
{ "id": 6, "subject": "Task 6: Live gate (main session, headless)", "status": "completed", "blockedBy": [5], "note": "15/15 live vs shared fixture 2026-08-05; docker-dev rig OPC UA leg deferred to the operator's chrome session (see design doc Status)" }
],
"lastUpdated": "2026-08-05T00:00:00Z"
}
@@ -0,0 +1,178 @@
# Sql driver: Postgres + Oracle providers (managed drivers only) — design
**Status:** DONE 2026-08-05 — all eight tasks delivered and reviewed. Suites: Driver.Sql.Tests
355/355, Browser.Tests 75/75, IntegrationTests 33 skip-clean offline; **both new integration suites
also ran LIVE during development** against real local containers (postgres:17-alpine 5/5,
gvenzl/oracle-free:23-slim 6/6), which surfaced and fixed a provider-fatal defect no offline test
could see: **ORA-01745** — Oracle rejects a bind variable named after a reserved word (`:table`),
so every Oracle catalog load failed until OracleDialect namespaced all binds with a `p_` prefix
(`ec90b7c4`, pinned by a never-bare-base-name invariant theory). Merged to master `d54f3c83`.
**Shared-host live run PASSED (2026-08-05, Task 8).** Both fixtures deployed to the shared docker
host (`/opt/otopcua-sql-pg` port 15432, `/opt/otopcua-sql-oracle` port 11521, both `project:
lmxopcua`-labelled, seeds exited 0) and left running; the env-gated suites ran against them from
this repo: **11/11 passed, 0 skipped** (5 Postgres + 6 Oracle — initialize/catalog, KeyValue,
WideRow `topByTimestamp`, catalog case-folding, browse walk). Env recipe used:
`SQL_PG_ENDPOINT=10.100.0.35:15432 SQL_PG_USER=otopcua SQL_PG_PASSWORD=<compose-up value>` +
`SQL_ORACLE_ENDPOINT=10.100.0.35:11521 SQL_ORACLE_USER=otopcua SQL_ORACLE_PASSWORD=<compose-up
value>` (service defaults `FREEPDB1`).
**§4.3's chrome/AdminUI gate PASSED (2026-08-05) — and it found a real defect.** Run against a
docker-dev rig rebuilt from master (both centrals on one image, provider assemblies confirmed
present in `/app`) with `Sql__ConnectionStrings__FixturePg` / `__FixtureOracle` added to central-1
**and** central-2 (`:9200` round-robins, so a browse can land on either node).
⚠️ **The gate's first finding: Test Connect was broken for BOTH new providers.**
`SqlDriverProbe.DefaultDialectFor` was never widened — it still constructed `SqlServerDialect`
only and threw `InvalidOperationException` for Postgres/Oracle, so a config that **deployed and
browsed fine failed Test Connect**. There are *three* dialect-selection sites and Task 4's `Files:`
block named two (`SqlDriverFactoryExtensions`, `SqlDriverBrowser`) plus the enum. No test caught
it because **every case in `SqlDriverProbeTests` builds its probe through `ForTest(...)`**, which
injects `_ => dialect` and never reaches the production switch — the parameterless constructor had
zero coverage. Fixed in `75a057f2`: switch widened arm-for-arm with the factory, plus an internal
seam covering the gate directly, a reserved-provider (MySql/Odbc) falsification control, and an
end-to-end production-constructor case. Red-first confirmed (3 failures) before the widening;
Driver.Sql.Tests then 361/361.
Legs, after the fix:
1. **Provider dropdown authoring ✅** — dropdown offers SqlServer/Postgres/Oracle; both saved and
persisted as `{"provider":"Postgres",…}` / `{"provider":"Oracle",…}`, i.e. serialized by **name**,
not ordinal (the driver-enum-serialization defect class does not recur here).
2. **Test-connect ✅** — Postgres `OK · 174 ms`, Oracle `OK · 386 ms`. **Negative control:** the
Oracle driver cross-wired to the Postgres connection string returns
`FAILED · … (ARGUMENTEXCEPTION)` — the provider itself rejecting the string, and *not*
`InvalidOperationException`, which proves the dialect was constructed and the green came from a
real round trip rather than an unconditional OK.
3. **`/raw` browse + browse-commit ✅** — Postgres browse discovered schema `sqlpoll` → `tag_values`
/ `latest_status` → per-column LEAFs; **the schema is owned by `sqlpoll_owner` while the poller
connects as `otopcua`**, so this is the live confirmation that `PostgresDialect.ListSchemasSql`
sources from `information_schema.tables` rather than `.schemata`. Oracle browse listed the PDB's
schemas → `OTOPCUA` → `TAG_VALUES`/`LATEST_STATUS` → LEAFs. One leaf committed per provider,
both persisting schema-qualified with the right type:
`{"model":"WideRow","table":"sqlpoll.tag_values","columnName":"num_value"}` [Double] and
`{"model":"WideRow","table":"OTOPCUA.TAG_VALUES","columnName":"NUM_VALUE"}` [Double].
## 1. Problem
`docs/drivers/Sql.md:15-18`: v1 constructs SQL Server only; `SqlProvider` declares `Postgres`,
`MySql`, `Odbc`, `Oracle` as reserved names that fail the driver. This branch makes **Postgres and
Oracle** working backends via fully managed ADO.NET providers — `Npgsql` and
`Oracle.ManagedDataAccess.Core` — no native/thin-client installs. `MySql`/`Odbc` stay reserved.
The seam already exists and is proven: `ISqlDialect` exposes an abstract `DbProviderFactory`
(`ISqlDialect.cs:38`), all runtime I/O goes through `DbConnection`/`DbCommand`/`DbParameter`
(`SqlPollReader.cs:379-389`), and the test-only `SqliteDialect` is the standing falsifiability
control that the planner emits dialect SQL rather than T-SQL. The concrete gaps:
1. **Parameter markers are hardcoded `@`** in two places: `SqlGroupPlanner.cs:21-25` (`@k`/`@w`)
and the catalog path (`SqlServerDialect.cs:56/:61` SQL text + `SqlCatalogLoader.cs:97-113` bind
names). Npgsql happens to accept `@`; Oracle requires `:`.
2. **Two provider gates** reject everything but SqlServer: `CreateDialect`
(`SqlDriverFactoryExtensions.cs:208-215`) and the browser's `DefaultDialectSelector`
(`SqlDriverBrowser.cs:126-127`).
3. **Oracle has no `INFORMATION_SCHEMA`** — catalog SQL must come from `ALL_USERS` /
`ALL_TABLES` + `ALL_VIEWS` / `ALL_TAB_COLUMNS`; liveness is `SELECT 1 FROM DUAL`; row-limit is
`FETCH FIRST 1 ROWS ONLY`.
4. **AdminUI** provider dropdown is single-option (`SqlDriverForm.razor:18-25`).
5. **Packages** — `Npgsql` and `Oracle.ManagedDataAccess.Core` are absent from
`Directory.Packages.props`.
## 2. Decisions
- **Both dialects live in the existing `ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect` project**, which
exists precisely because provider packages are unavoidable there (`.Dialect.csproj:20-26`). Its
csproj gains the two `PackageReference`s; consumers get them transitively exactly as
`Microsoft.Data.SqlClient` flows today. No new projects, no `.slnx` churn.
- **Dialect-owned parameter naming.** `ISqlDialect` gains three members with SqlServer-compatible
semantics implemented on every dialect:
- `string ParameterMarker(string baseName)` — the token in SQL text (`"@k0"` / `":k0"`).
- `string ParameterBindName(string baseName)` — the `DbParameter.ParameterName` value (`"@k0"`
for SqlClient/Npgsql; bare `"k0"` for ODP.NET, its documented convention).
- `void ConfigureCommand(DbCommand command)` — default no-op; **OracleDialect sets
`OracleCommand.BindByName = true`** (ODP.NET defaults to bind-by-position, which would silently
mis-bind the planner's multi-parameter `IN` lists — this is the sharpest correctness edge in
the branch).
`SqlGroupPlanner` drops its `@k`/`@w` consts and takes markers from the dialect (bind names ride
`SqlQueryPlan` alongside, keeping `SqlPollReader.cs:398-405`'s copy-don't-derive discipline).
`SqlCatalogLoader` binds `schema`/`table` through the dialect instead of the literal `"@schema"`.
Every command creation site (`SqlPollReader`, `SqlCatalogLoader`, `SqlDriverProbe`,
`SqlBrowseSession`) calls `ConfigureCommand` after `CreateCommand`.
- **`PostgresDialect`** (`NpgsqlFactory.Instance`): `@` markers; `"`-quoting with `""` escape, max
identifier 63; `LivenessSql = "SELECT 1"`; limit prefix `""` / suffix `" LIMIT 1"`;
`INFORMATION_SCHEMA` catalog SQL with `pg_catalog`/`information_schema` schemas excluded and
`DefaultSchemaSql = "SELECT current_schema()"`; type map over Postgres names (`integer`,
`bigint`, `smallint`, `real`, `double precision`, `numeric`, `boolean`, `text`,
`character varying`, `timestamp*`, `uuid`, `bytea`, …).
- **`OracleDialect`** (`OracleClientFactory.Instance`): `:` markers, bare bind names, BindByName;
`"`-quoting with `""` escape, max identifier 128 (declares Oracle 12.2+ as the supported floor —
`FETCH FIRST` needs 12c anyway); `LivenessSql = "SELECT 1 FROM DUAL"`; limit suffix
`" FETCH FIRST 1 ROWS ONLY"`; catalog SQL from `ALL_USERS` (schemas),
`ALL_TABLES` ∪ `ALL_VIEWS` (tables — matching `INFORMATION_SCHEMA.TABLES`' view inclusion),
`ALL_TAB_COLUMNS` (columns), `DefaultSchemaSql = "SELECT sys_context('userenv','current_schema') FROM dual"`;
type map over Oracle names — **`NUMBER` maps to `Float64`** (precision/scale are not visible to
`MapColumnType(string)`; the per-tag `DeclaredType` override is the documented escape hatch),
plus `BINARY_DOUBLE`/`BINARY_FLOAT`, `VARCHAR2`/`NVARCHAR2`/`CHAR`/`NCHAR`, `DATE`/`TIMESTAMP*`,
`CLOB`, `RAW`.
- **Case-folding rides the existing catalog gate.** Postgres lower-cases and Oracle upper-cases
unquoted identifiers; `SqlCatalogGate` already resolves every authored identifier to the
catalog's own spelling, so authored `widgets` binds to Oracle `WIDGETS`. The implementer verifies
the gate's comparison is case-insensitive and pins it per-dialect with tests.
- **Gates widen to exactly these two.** `CreateDialect` and `DefaultDialectSelector` add
`Postgres`/`Oracle`; `MySql`/`Odbc` keep the existing loud rejection (tests updated: the
"Postgres is rejected" assertions at `SqlDriverFactoryTests.cs:133-135` and
`SqlDriverBrowserTests.cs:188-196` flip to `MySql` as the still-reserved control).
- **AdminUI**: `SqlDriverForm.razor` dropdown gains `Postgres` and `Oracle` options; helper text
updated. No other UI change — Sql stays a single-connection driver with no device form
(`DriverFormMaps.cs:81-89`), `connectionStringRef` + `Sql__ConnectionStrings__<ref>` env
resolution is provider-agnostic already, and the `SqlConnectionStringPersisted` deploy gate needs
no change (it keys on the literal `connectionString` key, not the provider).
- **Packages**: `Directory.Packages.props` gains `Npgsql` (between `MQTTnet` and `Novell.*`) and
`Oracle.ManagedDataAccess.Core` (between the `OPCFoundation.*` block and `OpenTelemetry`), latest
stable versions on the feed. `CentralPackageTransitivePinningEnabled` is OFF by design, so if
restore surfaces an NU1903 in either package's transitive tree, the fix is the established
surgical direct-pin pattern (`Directory.Packages.props:118-145`), never a suppress.
- **Credential discipline holds automatically**: `SqlDriver` already renders endpoints via the
provider-neutral `DbConnectionStringBuilder` reading `host`/`data source` keys
(`SqlDriver.cs:47-51, :952-968`) and logs exception types only.
## 3. Out of scope
- `MySql`/`Odbc` dialects, write support (`IWritable` stays structurally absent, `allowWrites`
stays inert), the deferred `Query` tag model, connection pooling knobs, and Kerberos/wallet
auth schemes (connection-string concerns, owned by the operator's `Sql__ConnectionStrings__*`
env value).
## 4. Testing
1. **Unit.**
- `PostgresDialectTests` / `OracleDialectTests`: quoting (escape doubling, length caps,
control-char rejection — mirror `SqlServerDialectTests`), liveness/limit/catalog SQL text,
type maps, markers/bind names, and `ConfigureCommand` (Oracle: a real `OracleCommand` has
`BindByName == true` after the call; others: no-op).
- Planner/loader marker plumbing: a colon-marker fake dialect (or `SqliteDialect` extended)
proves the planner emits dialect markers and the reader binds the dialect's bind names —
the falsifiability control for gap 1.
- Gate tests updated as in §2 (MySql becomes the rejected control).
- The SQLite-backed read/poll suites keep running unchanged — they prove provider-agnosticism.
2. **Integration (env-gated, skip-clean, docker).** Two new fixtures under
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/`, deployed to the shared
docker host per the established pattern:
- `postgres:17-alpine` (project `otopcua-sql-pg`, host port 15432) — env
`SQL_PG_ENDPOINT`/`SQL_PG_USER`/`SQL_PG_PASSWORD`.
- `gvenzl/oracle-free:23-slim` (project `otopcua-sql-oracle`, host port 11521) — env
`SQL_ORACLE_ENDPOINT`/`SQL_ORACLE_USER`/`SQL_ORACLE_PASSWORD`.
Each suite: initialize (liveness + catalog load), KeyValue + WideRow reads against a seeded
schema, catalog-gate case-folding leg (authored lower-case vs Oracle upper-case catalog),
browse-session schema/table/column walk. The integration csproj keeps its no-direct-provider-
reference discipline (`.IntegrationTests.csproj:29-33`) — packages arrive transitively.
3. **Live `/run` chrome gate — DEFERRED, run separately by the operator.** AdminUI legs (provider
dropdown authoring, Test-connect, `/raw` browse against the Postgres and Oracle fixtures,
browse-commit of a column). The executing session must NOT run this; it hands the operator the
command at the end.
## 5. Bookkeeping
`docs/drivers/Sql.md` (§providers table, config table, browse section),
`SqlProvider.cs` doc comments ("reserved for P2" → supported), the tracking doc's Sql row, and
`docs/research/drivers/sql-poll.md` stays as-is (research is a record, not living doc).
@@ -0,0 +1,210 @@
# Sql Postgres + Oracle providers Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
> (opus implementers per user instruction).
**Goal:** Make `SqlProvider.Postgres` and `SqlProvider.Oracle` working backends via managed
ADO.NET providers (Npgsql, Oracle.ManagedDataAccess.Core).
**Architecture:** See `docs/plans/2026-08-05-sql-postgres-oracle-design.md` (this branch). Two new
dialects in the existing `.Dialect` project; dialect-owned parameter markers/bind-names +
`ConfigureCommand` (Oracle BindByName); provider gates widened; AdminUI dropdown; env-gated docker
integration suites. Chrome/AdminUI live gate is DEFERRED to the operator.
**Tech Stack:** .NET 10, central package management (`Directory.Packages.props`), xUnit v3 +
Shouldly, SQLite falsifiability control.
**Worktree:** `/Users/dohertj2/Desktop/OtOpcUa/.claude/worktrees/sql-multi-provider`
(branch `feature/sql-multi-provider`). All paths below relative to it.
---
### Task 0: Package pins
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1, Task 5
**Files:**
- Modify: `Directory.Packages.props`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj`
Add `PackageVersion` entries for `Npgsql` (alphabetical slot after `MQTTnet`) and
`Oracle.ManagedDataAccess.Core` (after the `OPCFoundation.*` block) — latest stable on the feed
(`dotnet package search <id> --exact-match` to confirm; Npgsql 9.x line, ODP.NET Core 23.x line;
both must target net8.0+). Add both `PackageReference`s (version-less) to the `.Dialect` csproj
under the existing `Microsoft.Data.SqlClient` reference, extending that ItemGroup's
rationale comment. `dotnet restore ZB.MOM.WW.OtOpcUa.slnx` must come back clean WITH the audit
active — if NU1903 fires on a transitive dep, apply the surgical direct-pin pattern documented at
`Directory.Packages.props:118-145`, never a suppress. Commit
`build(sql): pin Npgsql + Oracle.ManagedDataAccess.Core (managed providers only)`.
### Task 1: Dialect-owned parameter naming + `ConfigureCommand`
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0, Task 5
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlGroupPlanner.cs` (:21-25 consts,
:143, :151, :188)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlQueryPlan.cs` (bind names alongside
markers if the plan record needs both)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlPollReader.cs` (:379-405 — ConfigureCommand
call + bind-name copy)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlCatalogLoader.cs` (:97-113, `Bind` :181-188)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverProbe.cs` (ConfigureCommand)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlBrowseSession.cs` (ConfigureCommand)
- Modify: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/SqliteDialect.cs` (implement new
members; keep linked copy in Browser.Tests compiling)
- Test: `SqlGroupPlannerTests.cs`, `SqlPollReaderTests.cs`, `SqlCatalogGateDriverTests.cs` (+ a
colon-marker fake dialect in the test tree)
Per design §2 bullet 2. `ISqlDialect` gains `ParameterMarker(string baseName)`,
`ParameterBindName(string baseName)`, `ConfigureCommand(DbCommand)`; SqlServerDialect returns
`"@"+name` / `"@"+name` / no-op — **behaviour today must be byte-identical** (existing planner
SQL-text assertions must pass untouched; if any needs editing, that is a regression, stop).
Planner takes markers from its dialect; catalog SQL text and loader bind names route through the
dialect so the two can never disagree. TDD: the colon-marker fake proves markers flow (observed
red against the `@k` consts first). Every `CreateCommand` site calls `ConfigureCommand`
immediately after creation. Commit
`refactor(sql): dialect-owned parameter markers, bind names and command configuration`.
### Task 2: `PostgresDialect`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3, Task 5
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/PostgresDialect.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/PostgresDialectTests.cs` (new)
Per design §2 Postgres bullet. `Factory => NpgsqlFactory.Instance`; `Provider =>
SqlProvider.Postgres`; `@` markers/bind names; `"`-quoting `""`-escaped, MaxIdentifierLength 63,
same control-char/Unicode-Format rejection discipline as `SqlServerDialect.QuoteIdentifier`
(:88-114); `LivenessSql "SELECT 1"`; limit `""`/`" LIMIT 1"`; catalog SQL over
`information_schema.schemata/tables/columns` excluding `pg_catalog` + `information_schema`,
`DefaultSchemaSql "SELECT current_schema()"` (parameter markers in catalog SQL via the Task 1
members); `MapColumnType` per design (unknown → the same fallback SqlServerDialect uses — read it
first). Tests mirror `SqlServerDialectTests` structure: quoting theory, SQL text, type-map theory,
marker members. Commit `feat(sql): PostgresDialect (Npgsql)`.
### Task 3: `OracleDialect`
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 5
**Files:**
- Create: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/OracleDialect.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/OracleDialectTests.cs` (new)
Per design §2 Oracle bullet. `OracleClientFactory.Instance`; `:` markers, **bare** bind names;
`ConfigureCommand` casts to `OracleCommand` and sets `BindByName = true` (the test constructs a
real `OracleCommand` and asserts the flag — this is the load-bearing test of the whole branch;
also assert a foreign `DbCommand` passes through untouched). Quoting `"`/`""`, max 128;
`LivenessSql "SELECT 1 FROM DUAL"`; limit `""`/`" FETCH FIRST 1 ROWS ONLY"`; catalog SQL:
`ALL_USERS` schemas, `ALL_TABLES` ∪ `ALL_VIEWS` tables, `ALL_TAB_COLUMNS` columns,
`sys_context('userenv','current_schema')` default schema — markers via `:schema`/`:table` through
the Task 1 members; `MapColumnType`: `NUMBER`→Float64 (doc the DeclaredType escape hatch in the
XML doc comment), `BINARY_DOUBLE`/`BINARY_FLOAT`, `VARCHAR2`/`NVARCHAR2`/`CHAR`/`NCHAR`/`CLOB`,
`DATE`/`TIMESTAMP*`, `RAW`, plus `FLOAT`/`INTEGER` synonyms. Commit
`feat(sql): OracleDialect (ODP.NET managed, BindByName)`.
### Task 4: Widen the provider gates
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlDriverFactoryExtensions.cs` (:208-215)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs` (:126-127,
:146-151 comment)
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/SqlProvider.cs` (doc comments)
- Test: `SqlDriverFactoryTests.cs` (:133-135 → MySql as rejected control; new: Postgres/Oracle
construct with their dialects), `SqlDriverBrowserTests.cs` (:188-196 same flip)
TDD: red first — author `{"provider":"Postgres"}` and assert construction reaches the
connection-string resolver (or dialect type assertion via the binding) instead of the
not-available throw. Rejection message updated to name the three supported providers. Commit
`feat(sql): construct Postgres and Oracle providers; MySql/Odbc stay reserved`.
### Task 5: AdminUI dropdown + docs
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2, Task 3
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/SqlDriverForm.razor`
(:18-25)
- Modify: `docs/drivers/Sql.md` (:15-18, :96 provider row, browse section)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/` — extend the existing Sql driver-form
model test file (find `SqlDriverFormModelTests` or nearest equivalent) with round-trip of
`provider: Postgres` / `Oracle`
Dropdown gains `Postgres` + `Oracle` options; helper text → "SQL Server, PostgreSQL and Oracle
are supported; MySql/Odbc are reserved." Sql.md rewritten per design §5 including the Oracle
`NUMBER`→Float64 note and the 12.2+ floor. Commit
`feat(sql-adminui): Postgres + Oracle in the provider dropdown; docs`.
### Task 6: Integration fixtures + env-gated suites
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/docker-compose.yml`
+ `seed-pg.sql`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/docker-compose.yml`
+ `seed-oracle.sql`
- Create: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/PostgresReadTests.cs`,
`OracleReadTests.cs`
Per design §4.2. Compose shapes mirror the existing mssql fixture (`Docker/docker-compose.yml`:
project label `project: lmxopcua`, one-shot seed service, healthcheck): `postgres:17-alpine`
port `15432:5432` env `POSTGRES_PASSWORD=${SQL_PG_PASSWORD}`; `gvenzl/oracle-free:23-slim` port
`11521:1521` env `ORACLE_PASSWORD=${SQL_ORACLE_PASSWORD}` (gvenzl images take `APP_USER`/
`APP_USER_PASSWORD` for a non-SYS user — use `otopcua`/the password env). Seeds create one
KeyValue table + one WideRow table with a timestamp column, plus (Oracle) a deliberately
UPPER-CASE table authored lower-case in the test to pin catalog case-folding. Test classes follow
`SqlServerReadTests`/`SqlBlackholeTimeoutTests` conventions exactly: env vars
`SQL_PG_ENDPOINT`/`SQL_PG_USER`/`SQL_PG_PASSWORD`, `SQL_ORACLE_ENDPOINT`/`SQL_ORACLE_USER`/
`SQL_ORACLE_PASSWORD`; `Assert.SkipWhen` absent; connection strings via each provider's builder
obtained FROM THE FACTORY (`dialect.Factory.CreateConnectionStringBuilder()`) so the csproj keeps
its no-direct-provider-reference discipline (`.IntegrationTests.csproj:29-33`). Legs per suite:
initialize (liveness + catalog), KeyValue read, WideRow topByTimestamp read, browse-session
walk, case-folding leg. Commit `test(sql): env-gated Postgres + Oracle integration suites`.
### Task 7: Full-suite verification + bookkeeping
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
`dotnet build ZB.MOM.WW.OtOpcUa.slnx` (0 errors), full Sql unit + Browser.Tests + AdminUI.Tests +
Configuration.Tests (the SqlCredentialGuard suites must stay green), integration suites compile +
skip clean offline. Update `docs/plans/2026-07-24-driver-expansion-tracking.md` Sql row/deferral
note. Commit.
### Task 8: Fixture deploy + live integration run (main session)
**Classification:** standard
**Estimated implement time:** ~15 min (main session, after merge)
Main session, not a subagent: rsync the two fixture dirs to `dohertj2@10.100.0.35:/opt/otopcua-sql-pg/`
and `/opt/otopcua-sql-oracle/`, `docker compose up -d` each over ssh, then run
`dotnet test --filter "FullyQualifiedName~PostgresRead|FullyQualifiedName~OracleRead"` with the
env vars set. (oracle-free first pull is ~1.5 GB — allow time; if the host can't take it, record
the suite as authored-but-not-yet-run.) Record results in the design doc §Outcome.
**Chrome/AdminUI live gate — DEFERRED to the operator.** After merge the operator runs, in a
chrome-capable session:
`/superpowers-extended-cc:executing-plans docs/plans/2026-08-05-sql-postgres-oracle.md` — gate
section: provider dropdown authoring, Test-connect, `/raw` browse + browse-commit against both
fixtures at `http://localhost:9200`.
@@ -0,0 +1,15 @@
{
"planPath": "docs/plans/2026-08-05-sql-postgres-oracle.md",
"tasks": [
{ "id": 0, "subject": "Task 0: Package pins", "status": "completed" },
{ "id": 1, "subject": "Task 1: Dialect-owned parameter naming + ConfigureCommand", "status": "completed" },
{ "id": 2, "subject": "Task 2: PostgresDialect", "status": "completed", "blockedBy": [0, 1] },
{ "id": 3, "subject": "Task 3: OracleDialect", "status": "completed", "blockedBy": [0, 1] },
{ "id": 4, "subject": "Task 4: Widen the provider gates", "status": "completed", "blockedBy": [2, 3] },
{ "id": 5, "subject": "Task 5: AdminUI dropdown + docs", "status": "completed" },
{ "id": 6, "subject": "Task 6: Integration fixtures + env-gated suites", "status": "completed", "blockedBy": [4], "note": "found+fixed ORA-01745 (ec90b7c4); both suites ran live locally (pg 5/5, oracle 6/6)" },
{ "id": 7, "subject": "Task 7: Full-suite verification + bookkeeping", "status": "completed", "blockedBy": [5, 6] },
{ "id": 8, "subject": "Task 8: Fixture deploy + live integration run (main session); chrome gate deferred to operator", "status": "completed", "blockedBy": [7], "note": "shared-host live run 11/11 2026-08-05; chrome/AdminUI gate handed to the operator (design doc Status)" }
],
"lastUpdated": "2026-08-05T00:00:00Z"
}
@@ -58,7 +58,8 @@ public static class DriverTypeNames
/// <summary>MQTT / Sparkplug B broker-subscription driver.</summary>
public const string Mqtt = "Mqtt";
/// <summary>MTConnect Agent driver — read-only HTTP/XML over an Agent's probe/current/sample surface.</summary>
/// <summary>MTConnect Agent driver — HTTP/XML over an Agent's probe/current/sample surface, plus
/// opt-in observation write-back via the Agent's AllowPut POST surface.</summary>
public const string MTConnect = "MTConnect";
/// <summary>
@@ -0,0 +1,178 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Parsed FOCAS address covering the four addressing spaces a driver touches:
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
/// <see cref="FocasAreaKind.Fixed"/> (a logical path into the driver's fixed tree —
/// <c>FIXED:Identity/SeriesNumber</c>, <c>FIXED:Axes/X/AbsolutePosition</c>).
/// </summary>
/// <remarks>
/// <para>PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.</para>
/// <para>
/// <b><c>FIXED:</c> is not an area read.</b> The first three forms name a location the driver
/// asks the CNC for on demand; a <c>FIXED:</c> path names a field of the per-device fixed-tree
/// snapshot the driver's own poll loop already maintains (<c>cnc_sysinfo</c>, <c>cnc_rddynamic2</c>,
/// program info, timers, spindle data). It exists because the fixed tree is the only part of the
/// FOCAS surface with no address of its own, and v3 publishes nodes only for authored tags — so
/// without a form to author, the fixed tree could not be published at all (Gitea #540).
/// </para>
/// <para>
/// <b>The path carries no device.</b> A tag's device comes from its raw-tag Device row, and an
/// address that also named a host would be a second, conflicting routing channel — which is
/// precisely the defect #540 reported: the browse leaf's <c>{host}/{path}</c> full reference was
/// written into the address field verbatim and then failed to parse, so every browse-committed
/// fixed-tree tag read <c>BadNodeIdUnknown</c> forever.
/// </para>
/// </remarks>
public sealed record FocasAddress(
FocasAreaKind Kind,
string? PmcLetter,
int Number,
int? BitIndex,
string? FixedPath = null)
{
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Fixed"/> address.</summary>
public const string FixedPrefix = "FIXED:";
/// <summary>Gets the canonical string representation of this address.</summary>
public string Canonical => Kind switch
{
FocasAreaKind.Pmc => BitIndex is null
? $"{PmcLetter}{Number}"
: $"{PmcLetter}{Number}.{BitIndex}",
FocasAreaKind.Parameter => BitIndex is null
? $"PARAM:{Number}"
: $"PARAM:{Number}/{BitIndex}",
FocasAreaKind.Macro => $"MACRO:{Number}",
FocasAreaKind.Fixed => FixedPrefix + FixedPath,
_ => $"?{Number}",
};
/// <summary>
/// Builds the canonical authored address for a fixed-tree path. The one place the
/// <c>FIXED:</c> spelling is constructed, so the browse emitter and the parser cannot drift.
/// </summary>
/// <param name="path">The fixed-tree path, e.g. <c>Identity/SeriesNumber</c>.</param>
/// <returns>The canonical authored address string.</returns>
public static string ForFixedPath(string path) => FixedPrefix + path;
/// <summary>Attempts to parse a FOCAS address from the given string.</summary>
/// <param name="value">The address string to parse.</param>
/// <returns>A FocasAddress if parsing succeeds; otherwise null.</returns>
public static FocasAddress? TryParse(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var src = value.Trim();
if (src.StartsWith("PARAM:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["PARAM:".Length..], FocasAreaKind.Parameter, bitSeparator: '/');
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
// Fixed-tree path. Tested BEFORE the PMC branch on purpose: "F" is a valid PMC letter, so a
// FIXED: address entering that branch would be diagnosed as a malformed PMC address rather
// than as the fixed-tree address it is. (It would still return null either way — this is
// about which rule owns the string, not about the result.)
if (src.StartsWith(FixedPrefix, StringComparison.OrdinalIgnoreCase))
{
var path = NormalizeFixedPath(src[FixedPrefix.Length..]);
return path is null ? null : new FocasAddress(FocasAreaKind.Fixed, null, 0, null, path);
}
// PMC path: letter + digits + optional .bit
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
var letter = src[0..1].ToUpperInvariant();
if (!IsValidPmcLetter(letter)) return null;
var remainder = src[1..];
int? bit = null;
var dotIdx = remainder.IndexOf('.');
if (dotIdx >= 0)
{
if (!int.TryParse(remainder[(dotIdx + 1)..], out var bitValue) || bitValue is < 0 or > 7)
return null;
bit = bitValue;
remainder = remainder[..dotIdx];
}
if (!int.TryParse(remainder, out var number) || number < 0) return null;
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
}
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
{
int? bit = null;
if (bitSeparator is char sep)
{
var slashIdx = body.IndexOf(sep);
if (slashIdx >= 0)
{
if (!int.TryParse(body[(slashIdx + 1)..], out var bitValue) || bitValue is < 0 or > 31)
return null;
bit = bitValue;
body = body[..slashIdx];
}
}
if (!int.TryParse(body, out var number) || number < 0) return null;
return new FocasAddress(kind, PmcLetter: null, number, bit);
}
/// <summary>
/// Validates a fixed-tree path and returns it, or null when it is not a usable path.
/// Accepts <c>Segment[/Segment…]</c> where each segment is non-blank and carries no
/// <c>:</c> — a colon would collide with the <c>PARAM:</c>/<c>MACRO:</c>/<c>FIXED:</c>
/// prefix grammar and make the address ambiguous to read back.
/// </summary>
/// <remarks>
/// Deliberately shape-only: it does NOT check the path against the fixed tree a particular
/// CNC actually exposes. What that tree contains is per-device and discovered at runtime
/// (spindles, program info and timers are each capability-gated), so a parser that judged
/// membership would have to reject addresses that are valid on the machine they were
/// authored against. An unknown path resolves to no value at read time, which is where the
/// device is known.
/// </remarks>
private static string? NormalizeFixedPath(string body)
{
var path = body.Trim();
if (path.Length == 0) return null;
if (path.Contains(':', StringComparison.Ordinal)) return null;
if (path[0] == '/' || path[^1] == '/') return null;
foreach (var segment in path.Split('/'))
if (string.IsNullOrWhiteSpace(segment)) return null;
return path;
}
private static bool IsValidPmcLetter(string letter) => letter switch
{
"X" or "Y" or "F" or "G" or "R" or "D" or "C" or "K" or "A" or "E" or "T" => true,
_ => false,
};
}
/// <summary>Addressing-space kinds the driver understands.</summary>
public enum FocasAreaKind
{
/// <summary>PMC area — letter + byte + optional bit (<c>X0.0</c>, <c>R100</c>).</summary>
Pmc,
/// <summary>CNC parameter number (<c>PARAM:1020</c>, <c>PARAM:1815/0</c>).</summary>
Parameter,
/// <summary>Macro variable number (<c>MACRO:100</c>).</summary>
Macro,
/// <summary>
/// A logical path into the driver's per-device fixed tree (<c>FIXED:Identity/SeriesNumber</c>).
/// Served from the fixed-tree poll snapshot, never from an area read — and read-only, because
/// nothing in that tree is a writable location on the CNC.
/// </summary>
Fixed,
}
@@ -117,6 +117,21 @@ public static class FocasTagDefinitionFactory
}
var w = TagConfigJson.DescribeInvalidEnum<FocasDataType>(root, "dataType");
if (w is not null) warnings.Add(w);
// Gitea #540: an address the driver cannot parse is skipped at init and reads
// BadNodeIdUnknown forever, while driver health stays Healthy with zero errors. The
// AdminUI modal rejects it outright; this covers the paths that never touch the modal
// (CSV import, the API) by at least surfacing it on the deployment.
if (root.TryGetProperty("address", out var addr)
&& addr.ValueKind == JsonValueKind.String
&& addr.GetString() is { } address
&& !string.IsNullOrWhiteSpace(address)
&& FocasAddress.TryParse(address) is null)
{
warnings.Add(
$"FOCAS address '{address.Trim()}' is not parseable (expected X0.0 / R100, " +
"PARAM:1020, MACRO:500 or FIXED:Identity/SeriesNumber) — the driver will skip " +
"this tag at startup and it will read BadNodeIdUnknown.");
}
if (root.TryGetProperty("writable", out var wr) && wr.ValueKind == JsonValueKind.True)
warnings.Add("FOCAS wire is read-only — an authored 'writable:true' still fails at the wire with BadNotWritable.");
}
@@ -1,99 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
/// </summary>
/// <remarks>
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
/// </remarks>
public sealed record FocasAddress(
FocasAreaKind Kind,
string? PmcLetter,
int Number,
int? BitIndex)
{
/// <summary>Gets the canonical string representation of this address.</summary>
public string Canonical => Kind switch
{
FocasAreaKind.Pmc => BitIndex is null
? $"{PmcLetter}{Number}"
: $"{PmcLetter}{Number}.{BitIndex}",
FocasAreaKind.Parameter => BitIndex is null
? $"PARAM:{Number}"
: $"PARAM:{Number}/{BitIndex}",
FocasAreaKind.Macro => $"MACRO:{Number}",
_ => $"?{Number}",
};
/// <summary>Attempts to parse a FOCAS address from the given string.</summary>
/// <param name="value">The address string to parse.</param>
/// <returns>A FocasAddress if parsing succeeds; otherwise null.</returns>
public static FocasAddress? TryParse(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var src = value.Trim();
if (src.StartsWith("PARAM:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["PARAM:".Length..], FocasAreaKind.Parameter, bitSeparator: '/');
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
// PMC path: letter + digits + optional .bit
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
var letter = src[0..1].ToUpperInvariant();
if (!IsValidPmcLetter(letter)) return null;
var remainder = src[1..];
int? bit = null;
var dotIdx = remainder.IndexOf('.');
if (dotIdx >= 0)
{
if (!int.TryParse(remainder[(dotIdx + 1)..], out var bitValue) || bitValue is < 0 or > 7)
return null;
bit = bitValue;
remainder = remainder[..dotIdx];
}
if (!int.TryParse(remainder, out var number) || number < 0) return null;
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
}
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
{
int? bit = null;
if (bitSeparator is char sep)
{
var slashIdx = body.IndexOf(sep);
if (slashIdx >= 0)
{
if (!int.TryParse(body[(slashIdx + 1)..], out var bitValue) || bitValue is < 0 or > 31)
return null;
bit = bitValue;
body = body[..slashIdx];
}
}
if (!int.TryParse(body, out var number) || number < 0) return null;
return new FocasAddress(kind, PmcLetter: null, number, bit);
}
private static bool IsValidPmcLetter(string letter) => letter switch
{
"X" or "Y" or "F" or "G" or "R" or "D" or "C" or "K" or "A" or "E" or "T" => true,
_ => false,
};
}
/// <summary>Addressing-space kinds the driver understands.</summary>
public enum FocasAreaKind
{
Pmc,
Parameter,
Macro,
}
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// <summary>
/// Documented-API capability matrix — per CNC series, what ranges each
/// Documented-API capability matrix — per CNC series, what ranges each addressed
/// <see cref="FocasAreaKind"/> supports. Authoritative source for the driver's
/// pre-flight validation in <see cref="FocasDriver.InitializeAsync"/>.
/// </summary>
@@ -35,6 +35,10 @@ public static class FocasCapabilityMatrix
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
// Fixed-tree paths are not area reads, so there is no per-series numeric range to check.
// What a given CNC actually exposes there is discovered at runtime and capability-gated
// per subtree; a path this machine does not have resolves to BadNodeIdUnknown at read.
FocasAreaKind.Fixed => null,
_ => null,
};
}
@@ -49,6 +49,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// produced equipment tags are encountered for the first time.
private readonly ConcurrentDictionary<string, FocasAddress> _parsedAddressesByTagName =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Whether this driver instance runs its per-device fixed-tree poll loop, and therefore
/// serves fixed-tree values. Computed at <see cref="InitializeAsync"/> from the config flag
/// OR the presence of at least one authored <see cref="FocasAreaKind.Fixed"/> tag.
/// </summary>
/// <remarks>
/// <b>The OR is load-bearing.</b> <c>FocasFixedTreeOptions.Enabled</c> defaults to
/// <see langword="false"/> so an upgrade does not grow nodes on configs that never asked for
/// them, and the universal browser force-enables it for the duration of a browse only. An
/// operator therefore browses the fixed tree, commits leaves from it, deploys — and lands on
/// a driver whose loop never starts, so every one of those tags reads Bad with a correct
/// address. Authoring a <c>FIXED:</c> tag IS the request for the tree; the config flag stays
/// as the way to publish it without authoring anything.
/// </remarks>
private bool _fixedTreeActive;
private FocasAlarmProjection? _alarmProjection;
// _health is read/written from multiple threads (ReadAsync, WriteAsync, ProbeLoopAsync).
// Volatile.Read/Write ensures every thread sees the latest reference without a lock — the
@@ -248,7 +263,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (_options.AlarmProjection.Enabled)
_alarmProjection = new FocasAlarmProjection(this, _options.AlarmProjection.PollInterval, _logger);
if (_options.FixedTree.Enabled)
// See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the
// config flag defaults off and the operator who committed those tags has already said
// what they want.
_fixedTreeActive = _options.FixedTree.Enabled
|| _parsedAddressesByTagName.Values.Any(a => a.Kind == FocasAreaKind.Fixed);
if (_fixedTreeActive)
{
foreach (var state in _devices.Values)
{
@@ -306,6 +326,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_tagsByRawPath.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
_parsedAddressesByTagName.Clear();
_fixedTreeActive = false;
Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
}
@@ -377,7 +398,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
// dynamic snapshot + sysinfo; no P/Invoke per Read since the poll loop
// already fires them on cadence.
if (_options.FixedTree.Enabled && TryReadFixedTree(reference, now) is { } fx)
//
// This branch matches a reference that IS a "{host}/{path}" fixed-tree key. Authored
// tags do not take it — their reference is a RawPath — and are served further down by
// their FIXED: address instead (Gitea #540).
if (_fixedTreeActive && TryReadFixedTree(reference, now) is { } fx)
{
results[i] = fx;
continue;
@@ -396,8 +421,28 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = ResolveParsedAddress(def);
// A FIXED: tag names a field of this device's fixed-tree snapshot, which the
// fixed-tree loop already maintains — there is no area read to issue, and no
// connection to force. A null here means the path resolves to nothing on THIS
// device (an unsupported capability, a renamed axis, a typo), which is a genuine
// BadNodeIdUnknown; a device that has not answered yet reports a comms fault
// instead, because the address may well be fine.
if (parsed.Kind == FocasAreaKind.Fixed)
{
results[i] = TryReadFixedTreeOnDevice(device, parsed.FixedPath!, now)
?? new DataValueSnapshot(
null,
device.LastFixedPollUtc is null
? FocasStatusMapper.BadCommunicationError
: FocasStatusMapper.BadNodeIdUnknown,
null,
now);
continue;
}
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, status, now, now);
@@ -459,8 +504,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try
{
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = ResolveParsedAddress(def);
// Nothing in the fixed tree is a writable location on the CNC — it is a read-out of
// machine state (identity, positions, program, timers). Refuse before opening a
// connection rather than sending a write the wire would reject anyway.
if (parsed.Kind == FocasAreaKind.Fixed)
{
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
continue;
}
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var status = await client.WriteAsync(parsed, def.DataType, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(status);
}
@@ -525,31 +580,31 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Fixed-tree T1 — Identity + Axes subtrees, populated once per session
// from cnc_sysinfo + cnc_rdaxisname at init time and kept in DeviceState.
if (_options.FixedTree.Enabled
if (_fixedTreeActive
&& _devices.TryGetValue(device.HostAddress, out var state)
&& state.FixedTreeCache is { } cache)
{
var identity = deviceFolder.Folder("Identity", "Identity");
EmitIdentityVariable(identity, device.HostAddress, "SeriesNumber", FocasDriverDataType.String);
EmitIdentityVariable(identity, device.HostAddress, "Version", FocasDriverDataType.String);
EmitIdentityVariable(identity, device.HostAddress, "MaxAxes", FocasDriverDataType.Int32);
EmitIdentityVariable(identity, device.HostAddress, "CncType", FocasDriverDataType.String);
EmitIdentityVariable(identity, device.HostAddress, "MtType", FocasDriverDataType.String);
EmitIdentityVariable(identity, device.HostAddress, "AxisCount", FocasDriverDataType.Int32);
EmitIdentityVariable(identity, "SeriesNumber", FocasDriverDataType.String);
EmitIdentityVariable(identity, "Version", FocasDriverDataType.String);
EmitIdentityVariable(identity, "MaxAxes", FocasDriverDataType.Int32);
EmitIdentityVariable(identity, "CncType", FocasDriverDataType.String);
EmitIdentityVariable(identity, "MtType", FocasDriverDataType.String);
EmitIdentityVariable(identity, "AxisCount", FocasDriverDataType.Int32);
var axesFolder = deviceFolder.Folder("Axes", "Axes");
foreach (var axis in cache.Axes)
{
var axisFolder = axesFolder.Folder(axis.Display, axis.Display);
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "AbsolutePosition");
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "MachinePosition");
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "RelativePosition");
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "DistanceToGo");
EmitAxisVariable(axisFolder, axis.Display, "AbsolutePosition");
EmitAxisVariable(axisFolder, axis.Display, "MachinePosition");
EmitAxisVariable(axisFolder, axis.Display, "RelativePosition");
EmitAxisVariable(axisFolder, axis.Display, "DistanceToGo");
if (cache.Capabilities.ServoLoad)
EmitAxisVariable(axisFolder, device.HostAddress, axis.Display, "ServoLoad");
EmitAxisVariable(axisFolder, axis.Display, "ServoLoad");
}
EmitAxisVariable(axesFolder, device.HostAddress, "FeedRate", "Actual");
EmitAxisVariable(axesFolder, device.HostAddress, "SpindleSpeed", "Actual");
EmitAxisVariable(axesFolder, "FeedRate", "Actual");
EmitAxisVariable(axesFolder, "SpindleSpeed", "Actual");
// Spindle subtree — one folder per discovered spindle, suppressed
// entirely on series that don't export cnc_rdspdlname. Per-spindle
@@ -563,9 +618,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var name = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
var spindleFolder = spindleRoot.Folder(name, name);
if (cache.Capabilities.SpindleLoad)
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "Load", DriverDataType.Int32);
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "Load", DriverDataType.Int32);
if (cache.Capabilities.SpindleMaxRpm && i < cache.SpindleMaxRpms.Count)
EmitFixedVariable(spindleFolder, device.HostAddress, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
EmitFixedVariable(spindleFolder, $"Spindle/{name}", "MaxRpm", DriverDataType.Int32);
}
}
@@ -573,26 +628,26 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (cache.Capabilities.ProgramInfo)
{
var program = deviceFolder.Folder("Program", "Program");
EmitFixedVariable(program, device.HostAddress, "Program", "Name", DriverDataType.String);
EmitFixedVariable(program, device.HostAddress, "Program", "ONumber", DriverDataType.Int32);
EmitFixedVariable(program, device.HostAddress, "Program", "Number", DriverDataType.Int32);
EmitFixedVariable(program, device.HostAddress, "Program", "MainNumber", DriverDataType.Int32);
EmitFixedVariable(program, device.HostAddress, "Program", "Sequence", DriverDataType.Int32);
EmitFixedVariable(program, device.HostAddress, "Program", "BlockCount", DriverDataType.Int32);
EmitFixedVariable(program, "Program", "Name", DriverDataType.String);
EmitFixedVariable(program, "Program", "ONumber", DriverDataType.Int32);
EmitFixedVariable(program, "Program", "Number", DriverDataType.Int32);
EmitFixedVariable(program, "Program", "MainNumber", DriverDataType.Int32);
EmitFixedVariable(program, "Program", "Sequence", DriverDataType.Int32);
EmitFixedVariable(program, "Program", "BlockCount", DriverDataType.Int32);
var opMode = deviceFolder.Folder("OperationMode", "OperationMode");
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "Mode", DriverDataType.Int32);
EmitFixedVariable(opMode, device.HostAddress, "OperationMode", "ModeText", DriverDataType.String);
EmitFixedVariable(opMode, "OperationMode", "Mode", DriverDataType.Int32);
EmitFixedVariable(opMode, "OperationMode", "ModeText", DriverDataType.String);
}
// Fixed-tree T3 — Timers subtree (power-on / operating / cutting / cycle).
if (cache.Capabilities.Timers)
{
var timers = deviceFolder.Folder("Timers", "Timers");
EmitFixedVariable(timers, device.HostAddress, "Timers", "PowerOnSeconds", DriverDataType.Float64);
EmitFixedVariable(timers, device.HostAddress, "Timers", "OperatingSeconds", DriverDataType.Float64);
EmitFixedVariable(timers, device.HostAddress, "Timers", "CuttingSeconds", DriverDataType.Float64);
EmitFixedVariable(timers, device.HostAddress, "Timers", "CycleSeconds", DriverDataType.Float64);
EmitFixedVariable(timers, "Timers", "PowerOnSeconds", DriverDataType.Float64);
EmitFixedVariable(timers, "Timers", "OperatingSeconds", DriverDataType.Float64);
EmitFixedVariable(timers, "Timers", "CuttingSeconds", DriverDataType.Float64);
EmitFixedVariable(timers, "Timers", "CycleSeconds", DriverDataType.Float64);
}
}
@@ -622,10 +677,33 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private enum FocasDriverDataType { String, Int32, Float64 }
/// <summary>
/// Emit one <c>Identity</c> leaf.
/// </summary>
/// <remarks>
/// <para>
/// <b>The emitted <c>FullName</c> is the authorable address, not the runtime cache key.</b>
/// A captured browse leaf's id IS its <c>FullName</c>, and that string is what both the
/// <c>/raw</c> browse-commit mapper and the manual picker's tree-select write into the tag's
/// <c>TagConfig.address</c>. Emitting the host-prefixed cache key therefore authored an
/// address no FOCAS parser accepts: the driver skipped every such tag at init and each one
/// read <c>BadNodeIdUnknown</c> forever, while the deploy sealed green and driver health
/// stayed Healthy (Gitea #540).
/// </para>
/// <para>
/// The host is dropped rather than stripped later because a tag's device is already carried
/// by its raw-tag Device row. Two routing channels for one fact is what created the defect;
/// one channel is the fix. <see cref="FixedTreeReference"/> keeps the host for the internal
/// per-device caches, which do need to distinguish devices.
/// </para>
/// </remarks>
/// <param name="folder">The address-space folder to emit into.</param>
/// <param name="field">The identity field name.</param>
/// <param name="type">The field's data type.</param>
private static void EmitIdentityVariable(
IAddressSpaceBuilder folder, string deviceHost, string field, FocasDriverDataType type)
IAddressSpaceBuilder folder, string field, FocasDriverDataType type)
{
var fullName = FixedTreeReference(deviceHost, $"Identity/{field}");
var fullName = FocasAddress.ForFixedPath($"Identity/{field}");
folder.Variable(field, field, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: type switch
@@ -643,10 +721,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
NativeDataType: NativeName(type)));
}
/// <summary>Emit one <c>Axes</c> leaf. See <see cref="EmitIdentityVariable"/> on the address shape.</summary>
/// <param name="folder">The address-space folder to emit into.</param>
/// <param name="axisName">The axis display name.</param>
/// <param name="field">The per-axis field name.</param>
private static void EmitAxisVariable(
IAddressSpaceBuilder folder, string deviceHost, string axisName, string field)
IAddressSpaceBuilder folder, string axisName, string field)
{
var fullName = FixedTreeReference(deviceHost, $"Axes/{axisName}/{field}");
var fullName = FocasAddress.ForFixedPath($"Axes/{axisName}/{field}");
folder.Variable(field, field, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: DriverDataType.Float64,
@@ -675,13 +757,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>
/// Emit a variable under a named fixed-tree folder (Program, OperationMode,
/// …). Full-reference shape is <c>{deviceHost}/{folderPath}/{field}</c>.
/// …). Address shape is <c>FIXED:{folderPath}/{field}</c> — see
/// <see cref="EmitIdentityVariable"/>.
/// </summary>
/// <param name="folder">The address-space folder to emit into.</param>
/// <param name="folderPath">The fixed-tree folder path.</param>
/// <param name="field">The field name.</param>
/// <param name="type">The field's data type.</param>
private static void EmitFixedVariable(
IAddressSpaceBuilder folder, string deviceHost, string folderPath,
string field, DriverDataType type)
IAddressSpaceBuilder folder, string folderPath, string field, DriverDataType type)
{
var fullName = FixedTreeReference(deviceHost, $"{folderPath}/{field}");
var fullName = FocasAddress.ForFixedPath($"{folderPath}/{field}");
folder.Variable(field, field, new DriverAttributeInfo(
FullName: fullName,
DriverDataType: type,
@@ -700,14 +786,19 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
/// <summary>
/// Canonical full-reference shape for a fixed-tree node. Keeps the device
/// Canonical <b>runtime cache key</b> for a fixed-tree node. Keeps the device
/// host as a prefix so multi-device configs don't collide, and the rest is
/// the path inside the tree. Matches what poll-loop snapshots publish +
/// what <see cref="ReadAsync"/> looks up.
/// what <see cref="TryReadFixedTreeOnDevice"/> looks up.
/// </summary>
/// <remarks>
/// <b>This is an internal key, not an authorable address</b> — see
/// <see cref="EmitIdentityVariable"/> for why the two are deliberately different strings
/// (Gitea #540).
/// </remarks>
/// <param name="deviceHost">The host address of the device.</param>
/// <param name="path">The path within the fixed tree.</param>
/// <returns>The canonical full reference for the node.</returns>
/// <returns>The canonical cache key for the node.</returns>
internal static string FixedTreeReference(string deviceHost, string path) =>
$"{deviceHost}/{path}";
@@ -823,6 +914,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.FixedTreeCache = new FocasFixedTreeCache(
sys, [.. axes], [.. spindles], [.. spindleMaxRpms], caps);
// The bootstrap IS an answer from the device, and Identity comes entirely from it —
// so stamp liveness here as well as per tick, or a CNC whose axis poll never
// succeeds would serve a perfectly valid sysinfo as stale.
state.LastFixedPollUtc = DateTime.UtcNow;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch (Exception ex)
@@ -872,6 +967,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
}
// The device answered this tick. Stamped after the axis pass rather than at the top
// of the loop: EnsureConnectedAsync can hand back a cached handle without any wire
// traffic, so it alone does not prove the CNC is still there.
state.LastFixedPollUtc = DateTime.UtcNow;
// Servo loads + spindle loads — both return bulk arrays, so folding
// into the axis cadence is cheap. Each is gated by the bootstrap
// capability probe — unsupported on this series = silent skip.
@@ -1054,79 +1154,119 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
foreach (var state in _devices.Values)
{
if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue;
if (state.LastFixedSnapshots.TryGetValue(reference, out var raw))
return new DataValueSnapshot(raw, FocasStatusMapper.Good, now, now);
if (TryReadFixedTreeOnDevice(state, reference[(state.Options.HostAddress.Length + 1)..], now) is { } hit)
return hit;
}
return null;
}
// Servo-load match: reference shape is "{host}/Axes/{name}/ServoLoad"
var suffixFull = reference[(state.Options.HostAddress.Length + 1)..];
if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal))
{
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
if (state.LastServoLoads.TryGetValue(axisName, out var load))
return new DataValueSnapshot(load, FocasStatusMapper.Good, now, now);
}
/// <summary>
/// How long after a device's last answered fixed-tree poll its cached values stop being
/// served as Good. Eight axis-poll cadences, floored at 30 s so the sub-second default
/// (250 ms) does not make an ordinarily slow CNC flap.
/// </summary>
/// <param name="pollInterval">The configured fixed-tree poll interval.</param>
/// <returns>The staleness budget.</returns>
internal static TimeSpan FixedTreeStaleAfter(TimeSpan pollInterval) =>
TimeSpan.FromTicks(Math.Max(pollInterval.Ticks * 8, TimeSpan.FromSeconds(30).Ticks));
// Spindle matches: "{host}/Spindle/{name}/Load" + "{host}/Spindle/{name}/MaxRpm"
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
&& state.FixedTreeCache is { } spindleCache)
/// <summary>
/// Resolves one fixed-tree <paramref name="path"/> (<c>Identity/SeriesNumber</c>,
/// <c>Axes/X/AbsolutePosition</c>, …) against a single device's caches. Returns null when
/// this device's fixed tree has no such path — the caller decides what that means.
/// </summary>
/// <param name="state">The device whose caches to read.</param>
/// <param name="path">The fixed-tree path, with no device prefix.</param>
/// <param name="now">The read timestamp.</param>
/// <returns>The snapshot, or null when the path names nothing on this device.</returns>
/// <remarks>
/// Values are dated from <see cref="DeviceState.LastFixedPollUtc"/>, not from
/// <paramref name="now"/>, and go <c>BadCommunicationError</c> once that stamp is older than
/// <see cref="FixedTreeStaleAfter"/>. A cache hit deliberately does NOT advance driver health:
/// the value came from the poll loop, not from this call, and crediting it would let a driver
/// serving nothing but fixed-tree tags report a fresh successful read while its CNC was gone.
/// </remarks>
private DataValueSnapshot? TryReadFixedTreeOnDevice(DeviceState state, string path, DateTime now)
{
var polledUtc = state.LastFixedPollUtc;
if (polledUtc is null) return null;
var status = now - polledUtc.Value > FixedTreeStaleAfter(_options.FixedTree.PollInterval)
? FocasStatusMapper.BadCommunicationError
: FocasStatusMapper.Good;
var sourceUtc = polledUtc.Value;
if (state.LastFixedSnapshots.TryGetValue(
FixedTreeReference(state.Options.HostAddress, path), out var raw))
return new DataValueSnapshot(raw, status, sourceUtc, now);
// Servo-load match: path shape is "Axes/{name}/ServoLoad"
var suffixFull = path;
if (suffixFull.StartsWith("Axes/", StringComparison.Ordinal) && suffixFull.EndsWith("/ServoLoad", StringComparison.Ordinal))
{
var axisName = suffixFull["Axes/".Length..^"/ServoLoad".Length];
if (state.LastServoLoads.TryGetValue(axisName, out var load))
return new DataValueSnapshot(load, status, sourceUtc, now);
}
// Spindle matches: "Spindle/{name}/Load" + "Spindle/{name}/MaxRpm"
if (suffixFull.StartsWith("Spindle/", StringComparison.Ordinal)
&& state.FixedTreeCache is { } spindleCache)
{
var tail = suffixFull["Spindle/".Length..];
var slash = tail.IndexOf('/');
if (slash > 0)
{
var tail = suffixFull["Spindle/".Length..];
var slash = tail.IndexOf('/');
if (slash > 0)
var spindleName = tail[..slash];
var field = tail[(slash + 1)..];
var idx = -1;
for (var i = 0; i < spindleCache.Spindles.Count; i++)
{
var spindleName = tail[..slash];
var field = tail[(slash + 1)..];
var idx = -1;
for (var i = 0; i < spindleCache.Spindles.Count; i++)
var s = spindleCache.Spindles[i];
var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
}
if (idx >= 0)
{
object? value = field switch
{
var s = spindleCache.Spindles[i];
var display = string.IsNullOrEmpty(s.Display) ? $"S{i + 1}" : s.Display;
if (string.Equals(display, spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
}
if (idx >= 0)
{
object? value = field switch
{
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
_ => null,
};
if (value is not null)
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
}
"Load" => state.LastSpindleLoads.TryGetValue(idx, out var l) ? (object)l : null,
"MaxRpm" => idx < spindleCache.SpindleMaxRpms.Count ? (object)spindleCache.SpindleMaxRpms[idx] : null,
_ => null,
};
if (value is not null)
return new DataValueSnapshot(value, status, sourceUtc, now);
}
}
// Identity strings + program / op-mode fields aren't cached as doubles —
// re-derive from the struct caches.
if (state.FixedTreeCache is { } cache)
}
// Identity strings + program / op-mode fields aren't cached as doubles —
// re-derive from the struct caches.
if (state.FixedTreeCache is { } cache)
{
var value = path switch
{
var suffix = reference[(state.Options.HostAddress.Length + 1)..];
var value = suffix switch
{
"Identity/SeriesNumber" => (object)cache.SysInfo.Series,
"Identity/Version" => cache.SysInfo.Version,
"Identity/MaxAxes" => cache.SysInfo.MaxAxis,
"Identity/CncType" => cache.SysInfo.CncType,
"Identity/MtType" => cache.SysInfo.MtType,
"Identity/AxisCount" => cache.SysInfo.AxesCount,
"Program/Name" => (object?)state.LastProgramInfo?.Name,
"Program/ONumber" => state.LastProgramInfo?.ONumber,
"Program/BlockCount" => state.LastProgramInfo?.BlockCount,
"Program/Number" => state.LastProgramAxisRef?.ProgramNumber,
"Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
"Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
"OperationMode/ModeText" => state.LastProgramInfo is { } pi
? FocasOpMode.ToText(pi.Mode) : null,
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
"Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
_ => null,
};
if (value is not null)
return new DataValueSnapshot(value, FocasStatusMapper.Good, now, now);
}
"Identity/SeriesNumber" => (object)cache.SysInfo.Series,
"Identity/Version" => cache.SysInfo.Version,
"Identity/MaxAxes" => cache.SysInfo.MaxAxis,
"Identity/CncType" => cache.SysInfo.CncType,
"Identity/MtType" => cache.SysInfo.MtType,
"Identity/AxisCount" => cache.SysInfo.AxesCount,
"Program/Name" => (object?)state.LastProgramInfo?.Name,
"Program/ONumber" => state.LastProgramInfo?.ONumber,
"Program/BlockCount" => state.LastProgramInfo?.BlockCount,
"Program/Number" => state.LastProgramAxisRef?.ProgramNumber,
"Program/MainNumber" => state.LastProgramAxisRef?.MainProgramNumber,
"Program/Sequence" => state.LastProgramAxisRef?.SequenceNumber,
"OperationMode/Mode" => state.LastProgramInfo?.Mode,
"OperationMode/ModeText" => state.LastProgramInfo is { } pi
? FocasOpMode.ToText(pi.Mode) : null,
"Timers/PowerOnSeconds" => TimerValue(state, FocasTimerKind.PowerOn),
"Timers/OperatingSeconds" => TimerValue(state, FocasTimerKind.Operating),
"Timers/CuttingSeconds" => TimerValue(state, FocasTimerKind.Cutting),
"Timers/CycleSeconds" => TimerValue(state, FocasTimerKind.Cycle),
_ => null,
};
if (value is not null)
return new DataValueSnapshot(value, status, sourceUtc, now);
}
return null;
}
@@ -1357,6 +1497,21 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public CancellationTokenSource? FixedTreeCts { get; set; }
/// <summary>Gets or sets the fixed-tree cache for this device.</summary>
public FocasFixedTreeCache? FixedTreeCache { get; set; }
/// <summary>
/// Gets or sets the UTC time the fixed-tree loop last got an answer out of this device
/// — set when the bootstrap completes and again after each poll tick's axis pass. Null
/// until the device has answered once.
/// </summary>
/// <remarks>
/// <b>This is what stops a dead CNC publishing its last values as Good forever.</b> The
/// fixed-tree poll loop swallows a failed tick (LogDebug + retry) by design, so a device
/// that stops answering leaves the caches populated and unchanging with nothing marking
/// them stale. That was harmless while nothing could bind to the fixed tree; Gitea #540
/// makes those nodes authorable, so reads are now dated from this stamp and go Bad once
/// it falls outside <see cref="FixedTreeStaleAfter"/>.
/// </remarks>
public DateTime? LastFixedPollUtc { get; set; }
/// <summary>
/// Gets the last fixed-tree snapshots by field name. Double-typed so axis
/// positions can carry the <c>10^PositionDecimalPlaces</c> engineering-unit
@@ -109,6 +109,12 @@ public sealed class MTConnectDriverOptions
/// <c>/sample</c> streaming connection.
/// </summary>
public MTConnectReconnectOptions Reconnect { get; init; } = new();
/// <summary>
/// Write-back settings. Off by default — the driver shipped read-only, and enabling this is
/// only half the gate: the Agent's own <c>AllowPut</c> must be on too.
/// </summary>
public MTConnectWriteOptions Write { get; init; } = new();
}
/// <summary>Background connectivity-probe knobs, mirroring <c>ModbusProbeOptions</c>.</summary>
@@ -132,3 +138,16 @@ public sealed class MTConnectReconnectOptions
/// <summary>Multiplier applied each retry. Default <c>2.0</c> doubles each step.</summary>
public double BackoffMultiplier { get; init; } = 2.0;
}
/// <summary>
/// Write-back knobs. A write is <b>observation injection</b> via the Agent's <c>AllowPut</c>
/// surface — it sets what the Agent reports for a DataItem, not the machine itself (design §2.2).
/// </summary>
public sealed class MTConnectWriteOptions
{
/// <summary>
/// Gets a value indicating whether write-back is enabled. Default <c>false</c> — an existing
/// deployment that never authors this block keeps today's read-only behaviour exactly.
/// </summary>
public bool Enabled { get; init; }
}
@@ -8,9 +8,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// current value from the Agent's <c>/current</c> snapshot / <c>/sample</c> stream by that id.
/// </summary>
/// <param name="FullName">
/// The MTConnect DataItem's <c>id</c> attribute (the Agent's <c>/probe</c> response). This is
/// the driver's reference for read/subscribe — MTConnect is a read-only source protocol, so
/// there is no corresponding write path.
/// The MTConnect DataItem's <c>id</c> attribute (the Agent's <c>/probe</c> response). This is the
/// driver's reference for read, subscribe <b>and write</b> — the write being an <i>observation
/// injection</i> through the Agent's <c>AllowPut</c> surface
/// (<c>POST {agent}/{device}?{id}={value}</c>), which sets what the Agent reports for the DataItem
/// rather than commanding the machine. It is off unless both the Agent's <c>AllowPut</c> and the
/// driver's <c>write.enabled</c> say otherwise, and CONDITION-category items are excluded.
/// </param>
/// <param name="DriverDataType">Logical data type the DataItem's value is coerced to.</param>
/// <param name="IsArray">
@@ -1,5 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The result of a <see cref="IMTConnectAgentClient.WriteObservationAsync"/> call, as the
/// <b>Agent</b> answered it. A transport failure is never represented here — it throws (see the
/// method's contract), so an outcome always means "the Agent replied, and this is what it said".
/// </summary>
/// <param name="Accepted">
/// <c>true</c> when the Agent took the observation: a 2xx response whose body is not an
/// <c>MTConnectError</c> document. <c>false</c> is a refusal, and
/// <see cref="AgentError"/> then always carries text.
/// </param>
/// <param name="AgentError">
/// Why the Agent refused, in its own words where it supplied any — the joined
/// <c>errorCode: text</c> of the <c>MTConnectError</c> document, or the HTTP status line for a
/// non-2xx answer. <c>null</c> if and only if <see cref="Accepted"/> is <c>true</c>.
/// <para>
/// This is Agent-authored text destined for a <b>log line</b>, never for a driver's
/// <c>LastError</c> (which is type-only by repo-wide discipline) and never for an OPC UA
/// status code's own message.
/// </para>
/// </param>
public sealed record MTConnectWriteOutcome(bool Accepted, string? AgentError);
/// <summary>
/// The seam between the MTConnect driver and an MTConnect Agent's HTTP surface
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>). This interface is transport-neutral by
@@ -17,7 +39,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <c>/sample</c> long poll. (Task 0 planned to build on the TrakHound MTConnect.NET libraries;
/// Tasks 6 and 7 proved they can neither parse a document nor frame the stream socket-free, and
/// dropped the references — see the Task 0 CORRECTION block in the plan.) A fake for tests only
/// needs to implement these three instance members; <see cref="IsSequenceGap"/> is a static
/// needs to implement these four instance members; <see cref="IsSequenceGap"/> is a static
/// protocol rule that lives here, rather than on the implementation, so a consumer written
/// against this seam never has to reference the concrete client.
/// </para>
@@ -88,6 +110,67 @@ public interface IMTConnectAgentClient : IDisposable
/// <param name="ct">Cancellation token; cancelling is the contracted way to end the stream.</param>
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
/// <summary>
/// Sets the Agent's observation for one DataItem, through the Agent's <c>AllowPut</c>
/// surface (<c>POST {agent}/{deviceName}?{dataItemId}={value}</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>This is observation injection, not machine command.</b> The value lands in the
/// Agent's observation buffer exactly as if an adapter had reported it — every consumer
/// of that Agent then sees it, and the next adapter/SHDR update for the same DataItem
/// overwrites it. Nothing is sent to the machine. It is also gated twice and off at both
/// gates by default: the Agent's own <c>AllowPut</c> (<c>agent.cfg</c>) and the driver's
/// <c>write.enabled</c>.
/// </para>
/// <para>
/// <b>A refusal is a return value; a transport failure is an exception.</b> The split is
/// the whole point of the outcome type, and it is what lets the driver map the two to
/// different OPC UA status codes without inspecting exception text:
/// <list type="bullet">
/// <item>
/// The Agent answered and <b>took</b> the observation (2xx, body empty or not an
/// <c>MTConnectError</c>) ⇒ <see cref="MTConnectWriteOutcome.Accepted"/>. An
/// <b>empty 2xx body is acceptance</b> — that is what a cppagent actually sends,
/// so treating a body-less answer as suspect would refuse every successful write.
/// </item>
/// <item>
/// The Agent answered and <b>refused</b> — a non-2xx status, or an
/// <c>MTConnectError</c> document served (as Agents do) under <b>HTTP 200</b> ⇒
/// not accepted, with the Agent's own text in
/// <see cref="MTConnectWriteOutcome.AgentError"/>. The 200-with-error shape is
/// why the status code alone can never decide this.
/// </item>
/// <item>
/// The Agent could not be <b>reached or did not answer in time</b> ⇒ throws
/// (<see cref="HttpRequestException"/>, or a <see cref="TimeoutException"/> for
/// the per-call deadline; <see cref="OperationCanceledException"/> when
/// <paramref name="cancellationToken"/> is cancelled or the client is disposed).
/// Folding these into a <c>false</c> outcome would make "the machine's Agent is
/// unreachable" indistinguishable from "the Agent rejected this DataItem" —
/// identical to the caller, opposite in what an operator must do about it.
/// </item>
/// </list>
/// </para>
/// <para>
/// <b>The write is a URL, so escaping is this seam's job, not the caller's.</b>
/// <paramref name="dataItemId"/> and <paramref name="value"/> are passed <i>unescaped</i>
/// and are percent-encoded by the implementation. A caller that pre-escaped would
/// double-encode and write a literal <c>%20</c> into the Agent's buffer, which the Agent
/// accepts — so the corruption is silent and shows up only as a wrong value on read-back.
/// </para>
/// </remarks>
/// <param name="deviceName">
/// The device that owns the DataItem — the path segment of the request. Required even on a
/// single-device Agent, because the Agent's PUT surface is addressed per device.
/// </param>
/// <param name="dataItemId">The DataItem's <c>id</c>, as declared by <c>/probe</c>. Unescaped.</param>
/// <param name="value">The value to report, already rendered to its invariant string form. Unescaped.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>What the Agent said — see the remarks.</returns>
Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken);
/// <summary>
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
/// when the chunk's oldest retained sequence is <i>strictly</i> newer than
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using System.Xml;
using System.Xml.Linq;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
@@ -75,6 +77,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
private readonly ILogger? _logger;
private readonly Uri _probeUri;
private readonly Uri _currentUri;
private readonly string _agentRoot;
private readonly string _sampleUriBase;
private readonly string _sampleQuerySuffix;
private readonly TimeSpan _requestTimeout;
@@ -133,6 +136,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
// Agent and an idle one both emit at least one chunk per heartbeat.
_streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
_agentRoot = ResolveAgentRoot(options);
_probeUri = BuildRequestUri(options, "probe");
_currentUri = BuildRequestUri(options, "current");
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
@@ -242,6 +246,130 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
}
}
/// <inheritdoc/>
/// <remarks>
/// Issued on <c>_http</c>, the <b>unary</b> client — the write is a short request/response
/// and must be bounded by <c>RequestTimeoutMs</c> like the other unary legs. Sending it on
/// the streaming client would inherit that client's infinite timeout, and a frozen Agent
/// would park a write forever with nothing to bound it (the streaming leg's heartbeat
/// watchdog protects a multipart body, not a one-shot POST).
/// </remarks>
public async Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
ArgumentNullException.ThrowIfNull(value);
var uri = BuildWriteUri(deviceName, dataItemId, value);
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
deadline.CancelAfter(_requestTimeout);
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request, deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
throw TimedOut(uri);
}
using (response)
{
string body;
try
{
body = await response.Content.ReadAsStringAsync(deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
throw TimedOut(uri);
}
// Deliberately NOT EnsureSuccessStatusCode: a refused write is an answer, and the caller
// needs it as a value it can map to a per-tag status code rather than as an exception
// indistinguishable from the Agent being unreachable.
var agentError = DescribeAgentError(body);
if (!response.IsSuccessStatusCode)
{
var status = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".TrimEnd();
return new MTConnectWriteOutcome(
Accepted: false, agentError is null ? status : $"{status}: {agentError}");
}
// An MTConnectError document under HTTP 200 is how an Agent actually refuses; the status
// code alone would report it as a success.
return agentError is null
? new MTConnectWriteOutcome(Accepted: true, AgentError: null)
: new MTConnectWriteOutcome(Accepted: false, agentError);
}
}
/// <summary>
/// The Agent's <c>AllowPut</c> URL: the device as a path segment, the DataItem id and value
/// as a query pair. Every caller-supplied part is percent-encoded here — see the seam's
/// contract for why escaping cannot be the caller's job.
/// </summary>
private Uri BuildWriteUri(string deviceName, string dataItemId, string value)
{
var device = Uri.EscapeDataString(deviceName.Trim());
var query = $"{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}";
return new Uri($"{_agentRoot}/{device}?{query}", UriKind.Absolute);
}
/// <summary>
/// The Agent's own refusal text when <paramref name="body"/> is an <c>MTConnectError</c>
/// document, else <c>null</c> (including for an empty body, a non-XML body, and any other
/// MTConnect document).
/// </summary>
/// <remarks>
/// Read by <see cref="XName.LocalName"/> only, like every other document this driver parses:
/// the namespace URI carries the MTConnect version, so a qualified match would silently stop
/// recognising errors the day an Agent is upgraded — and an unrecognised error reads as a
/// successful write. The error-text walk itself is
/// <see cref="MTConnectXml.ExtractErrorText"/>, shared with the read paths'
/// <see cref="MTConnectXml.DescribeUnexpectedRoot"/> so the two cannot drift on what counts
/// as an Agent objection.
/// </remarks>
private static string? DescribeAgentError(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
XElement? root;
try
{
root = XDocument.Parse(body).Root;
}
catch (XmlException)
{
// Not XML at all — an Agent that answered 2xx with a plain-text or HTML body has not
// refused anything, so this is not the place to invent a failure.
return null;
}
if (root is null || !MTConnectXml.IsNamed(root, "MTConnectError"))
{
return null;
}
// An error document with no readable detail is still a refusal, so the shared helper's null
// — which means "nothing readable in here", NOT "this was not an error" — must become text
// rather than being passed on. Returning it as null would turn the refusal into acceptance.
return MTConnectXml.ExtractErrorText(root)
?? "The Agent answered with an MTConnectError document carrying no error detail.";
}
/// <inheritdoc/>
public void Dispose()
{
@@ -379,7 +507,31 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
return boundary;
}
private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
/// <summary>
/// A read path's request URI, scoped to the configured device. Reads <see cref="_agentRoot"/>,
/// so it may only be called after that field is assigned — which the constructor does first.
/// </summary>
private Uri BuildRequestUri(MTConnectDriverOptions options, string request)
{
var deviceName = options.DeviceName?.Trim();
var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
return new Uri($"{_agentRoot}{deviceSegment}/{request}", UriKind.Absolute);
}
/// <summary>
/// Validates <see cref="MTConnectDriverOptions.AgentUri"/> and reduces it to the base every
/// request path is appended to: its left part up to and including the path (so a proxied
/// Agent keeps its prefix), with any trailing slash removed so callers can append
/// <c>/segment</c> unconditionally.
/// </summary>
/// <remarks>
/// Extracted rather than duplicated because the write path resolves its device at call time,
/// not at construction: two copies of this rule would drift, and the drift would surface as
/// writes landing on a URL the reads never used — against an Agent that answers 200 either
/// way.
/// </remarks>
private static string ResolveAgentRoot(MTConnectDriverOptions options)
{
var agentUri = options.AgentUri?.Trim();
if (string.IsNullOrEmpty(agentUri))
@@ -395,11 +547,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
}
var root = parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
var deviceName = options.DeviceName?.Trim();
var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
return new Uri($"{root}{deviceSegment}/{request}", UriKind.Absolute);
return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
}
/// <summary>
@@ -44,12 +44,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// </para>
/// <para>
/// <b>Scope.</b> This type implements <see cref="IDriver"/>, <see cref="IReadable"/>,
/// <see cref="ISubscribable"/>, <see cref="ITagDiscovery"/>,
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="ITagDiscovery"/>,
/// <see cref="IHostConnectivityProbe"/> and <see cref="IRediscoverable"/> — the last two
/// built on state this class already caches (the Agent <c>instanceId</c> and the probe
/// model). There is deliberately no <c>IWritable</c>: MTConnect is a read-only protocol,
/// which is also why every discovered leaf is
/// <see cref="SecurityClassification.ViewOnly"/>.
/// model).
/// </para>
/// <para>
/// <b><see cref="IWritable"/> is <i>observation injection</i>, not machine command, and it is
/// off by default.</b> A write goes to the Agent's <c>AllowPut</c> surface
/// (<c>POST {agent}/{device}?{dataItemId}={value}</c>) and sets what the Agent <i>reports</i>
/// for that DataItem — visible to every consumer of that Agent, and overwritten by the next
/// adapter/SHDR update. Nothing reaches the CNC. It is therefore gated twice, and off at both
/// gates by default: the Agent's own <c>AllowPut</c> (<c>agent.cfg</c>) and this driver's
/// <see cref="MTConnectWriteOptions.Enabled"/>. Either gate alone leaves the shipped read-only
/// behaviour byte-for-byte unchanged. CONDITION data items are excluded — they carry alarm
/// semantics and a different <c>level|text</c> wire format. MTConnect <b>Interfaces</b>, the
/// protocol's own request/response handshake, remains deferred and is a different mechanism
/// entirely (see <c>docs/plans/2026-08-05-mtconnect-writeback-design.md</c> §2.1).
/// </para>
/// <para>
/// <b>The read path never writes the shared observation index</b> — see
@@ -60,15 +71,43 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <see cref="AgentSession.ProbeModel"/> (see <see cref="RunProbeLoopAsync"/>).
/// </para>
/// </remarks>
public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable
public sealed class MTConnectDriver
: IDriver, IReadable, IWritable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable
{
/// <summary>
/// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct
/// from the index's <c>BadNoCommunication</c>, which means the Agent answered and said the
/// machine has no value — the two failures need different operator responses.
/// machine has no value — the two failures need different operator responses. On the write
/// path it also covers an Agent that answered and <i>refused</i> — see <see cref="WriteAsync"/>.
/// </summary>
private const uint BadCommunicationError = 0x80050000u;
// ---- Write status codes ----
//
// NAMED constants, never inline literals: StatusCodeParityTests checks each against the pinned SDK
// by reflection and can only see a named const (Gitea #497/#519). The names must match
// Opc.Ua.StatusCodes members exactly. Spelled WITHOUT the "Status" prefix MqttDriver uses, to match
// the two this file already declares (the parity guard strips either spelling).
/// <summary>
/// The reference names no DataItem this driver serves, or names one whose owning device cannot
/// be resolved — there is no Agent URL a write could be addressed to.
/// </summary>
private const uint BadNodeIdUnknown = 0x80340000u;
/// <summary>
/// Writing is switched off (<c>write.enabled</c>), or the DataItem is CONDITION-category. The
/// honest answer to "can I write this?", and deliberately not a communication failure: nothing
/// was attempted and no Agent was involved.
/// </summary>
private const uint BadNotWritable = 0x803B0000u;
/// <summary>
/// The supplied value has no invariant string form a PUT could carry — <c>null</c>, or a
/// non-scalar (see <see cref="TryFormatValue"/>).
/// </summary>
private const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// There is no Agent client at all: the driver has not been initialized, its initialize
/// faulted, or it has been shut down. Deliberately not one of the index's codes — none of
@@ -694,6 +733,220 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
}
// ---- IWritable: observation injection through the Agent's AllowPut surface ----
/// <summary>
/// Sets the Agent's reported observation for each requested reference, through its
/// <c>AllowPut</c> surface. Returns one status per request, in request order.
/// </summary>
/// <param name="writes">The requested writes.</param>
/// <param name="cancellationToken">Cancellation for the batch.</param>
/// <returns>One <see cref="WriteResult"/> per request, in request order.</returns>
/// <remarks>
/// <para>
/// <b>This writes the Agent, not the machine</b> — see the class remarks. Good here means
/// "the Agent took the observation", which is a stronger statement than MQTT's optimistic
/// "published" (the Agent answers) and a weaker one than a device acknowledgement (no
/// MTConnect Agent speaks for the machine). The next adapter update for that DataItem
/// overwrites what was written, and the ordinary <c>/sample</c> ingest path is what
/// corrects the node.
/// </para>
/// <para>
/// <b>Never throws, and one bad slot never fails the batch</b> — matching
/// <see cref="ReadAsync"/>. Failing the whole call would let one stale reference reject
/// every other write in the same OPC UA request. Even cancellation returns a full-length
/// batch rather than propagating, so the node-manager can tell which slots were attempted.
/// </para>
/// <para>
/// <b>Refusal order is fixed, and each step answers a different operator question:</b>
/// writing switched off ⇒ <c>BadNotWritable</c>; no live client ⇒ <c>BadNotConnected</c>
/// (this driver is not running — <see cref="ReadAsync"/>'s convention); the reference
/// resolves to no DataItem, or to one whose owning device cannot be named ⇒
/// <c>BadNodeIdUnknown</c>; CONDITION category ⇒ <c>BadNotWritable</c>; no invariant string
/// form ⇒ <c>BadTypeMismatch</c>; and only then the Agent, whose refusal <b>or</b> whose
/// unreachability ⇒ <c>BadCommunicationError</c>.
/// </para>
/// <para>
/// <b>The Agent's refusal text is logged, never stored.</b> It goes to a Warning line and
/// nowhere near <see cref="DriverHealth.LastError"/>, which is type-only by repo-wide
/// discipline — and a refused write is not evidence the read path degraded, so health is
/// left exactly as it is (unlike <see cref="DegradeAfterReadFailure"/>).
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="writes"/> is <c>null</c> — a caller bug.</exception>
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
if (writes.Count == 0)
{
return [];
}
// Read once each, for the same reason ReadAsync does: every slot in this batch is judged
// against ONE options document and ONE tag table, never half against a table a concurrent
// re-initialize just replaced.
var options = _options;
var client = Volatile.Read(ref _session)?.Client;
var binding = Volatile.Read(ref _binding);
var results = new WriteResult[writes.Count];
for (var i = 0; i < writes.Count; i++)
{
var request = writes[i];
if (!options.Write.Enabled)
{
results[i] = new WriteResult(BadNotWritable);
continue;
}
if (client is null)
{
results[i] = new WriteResult(BadNotConnected);
continue;
}
if (!binding.TryResolveWriteTarget(request.FullReference, out var dataItemId, out var target)
|| target.DeviceName is null)
{
results[i] = new WriteResult(BadNodeIdUnknown);
continue;
}
if (target.IsCondition)
{
results[i] = new WriteResult(BadNotWritable);
continue;
}
if (!TryFormatValue(request.Value, out var text))
{
results[i] = new WriteResult(BadTypeMismatch);
continue;
}
try
{
var outcome = await client
.WriteObservationAsync(target.DeviceName, dataItemId, text, cancellationToken)
.ConfigureAwait(false);
if (outcome.Accepted)
{
results[i] = new WriteResult(0u);
continue;
}
_logger.LogWarning(
"MTConnect driver {DriverInstanceId}: {AgentUri} refused the write of '{Value}' to DataItem '{DataItemId}' on device '{DeviceName}' — {AgentError}. Check the Agent's AllowPut / AllowPutFrom settings and that the DataItem exists.",
_driverInstanceId, options.AgentUri, text, dataItemId, target.DeviceName, outcome.AgentError);
results[i] = new WriteResult(BadCommunicationError);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Caller-cancelled: fill the remainder rather than throwing, so the batch shape holds.
for (var j = i; j < writes.Count; j++)
{
results[j] = new WriteResult(BadCommunicationError);
}
return results;
}
catch (Exception ex)
{
// A transport failure, a blown per-call deadline, or a disposed client. Logged with the
// exception (its TYPE is the diagnosis) and reported per slot; health is untouched.
_logger.LogWarning(
ex,
"MTConnect driver {DriverInstanceId} could not write DataItem '{DataItemId}' on device '{DeviceName}' to {AgentUri}.",
_driverInstanceId, dataItemId, target.DeviceName, options.AgentUri);
results[i] = new WriteResult(BadCommunicationError);
}
}
return results;
}
/// <summary>
/// Renders a value to the invariant string a PUT query carries, or reports that it has no such
/// form.
/// </summary>
/// <param name="value">The value the OPC UA client supplied.</param>
/// <param name="text">The rendered value; empty when this returns <see langword="false"/>.</param>
/// <returns><see langword="false"/> for a value that must be refused as a type mismatch.</returns>
/// <remarks>
/// <para>
/// <b><see cref="CultureInfo.InvariantCulture"/> is load-bearing.</b> A comma decimal
/// separator produces a value the Agent accepts and every consumer reads back as a
/// different number. Booleans render as <c>True</c>/<c>False</c> —
/// <see cref="bool.ToString()"/> is culture-independent, so that IS the invariant form, and
/// it is what an MTConnect consumer will see verbatim.
/// </para>
/// <para>
/// <b>Arrays are refused rather than rendered.</b>
/// <see cref="Convert.ToString(object, IFormatProvider)"/> falls back to
/// <see cref="object.ToString"/> for a non-<see cref="IConvertible"/> value, so an array
/// would be written as the literal text <c>System.Double[]</c> — which the Agent stores
/// happily, making the corruption invisible until someone reads the observation back.
/// TIME_SERIES / DATA_SET writes are out of scope for v1.
/// </para>
/// <para>
/// <b>Every formatting throw is caught, not just the documented conversion ones.</b> The
/// rendering runs the value's own <see cref="object.ToString"/> /
/// <see cref="IFormattable"/>, which is arbitrary code — and this runs OUTSIDE
/// <see cref="WriteAsync"/>'s per-slot <c>try</c>, so anything that escapes here escapes
/// the whole batch and breaks its never-throws contract. It costs its own slot a
/// <c>BadTypeMismatch</c> and is logged.
/// </para>
/// </remarks>
private bool TryFormatValue(object? value, out string text)
{
text = string.Empty;
// A PUT query has no null to send, and the empty string is a real observation rather than an
// absence — so "no value" is refused instead of being silently turned into one.
if (value is null or Array)
{
return false;
}
string? rendered;
try
{
rendered = Convert.ToString(value, CultureInfo.InvariantCulture);
}
catch (Exception ex)
{
// Catch-all, and deliberately so. Convert.ToString falls back to the value's OWN
// ToString/IFormattable, which is arbitrary caller code that may throw ANYTHING — and this
// call sits outside WriteAsync's per-slot try, so a narrower filter (FormatException /
// InvalidCastException / OverflowException) let every other exception escape WriteAsync
// uncaught and break the documented never-throws contract for the whole batch. One
// unformattable value must cost its own slot, nothing more.
_logger.LogWarning(
ex,
"MTConnect driver {DriverInstanceId} could not render a write value of type {ValueType} to its invariant string form; the write is refused as a type mismatch.",
_driverInstanceId,
value.GetType());
return false;
}
if (rendered is null)
{
return false;
}
text = rendered;
return true;
}
// ---- ISubscribable: ONE shared /sample long poll behind every handle ----
/// <inheritdoc/>
@@ -940,7 +1193,11 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
// Read once: a concurrent re-initialize must not scope half this walk to one device and half
// to another.
var deviceScope = _options.DeviceName?.Trim();
// Both read once, together: a concurrent re-initialize must not scope half this walk to one
// device and half to another, nor stamp half its leaves writable and half not.
var options = _options;
var deviceScope = options.DeviceName?.Trim();
var writeEnabled = options.Write.Enabled;
var devices = 0;
var variables = 0;
@@ -966,7 +1223,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
devices++;
variables += StreamContainer(device, builder.Folder(name, name));
variables += StreamContainer(device, builder.Folder(name, name), writeEnabled);
}
// A configured scope that matches NO device is an authoring error — almost always a typo or
@@ -1003,7 +1260,14 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// picker exists to show. Recursion depth is bounded by the parser, which refuses a probe
/// document nested beyond its own limit.
/// </remarks>
private static int StreamContainer(IMTConnectComponentContainer container, IAddressSpaceBuilder scope)
/// <param name="container">The device or component whose data items are being streamed.</param>
/// <param name="scope">The builder folder they belong under.</param>
/// <param name="writeEnabled">
/// <see cref="MTConnectWriteOptions.Enabled"/>, read once by the caller so one walk cannot
/// stamp half its leaves under an old options document and half under a new one.
/// </param>
private static int StreamContainer(
IMTConnectComponentContainer container, IAddressSpaceBuilder scope, bool writeEnabled)
{
var streamed = 0;
@@ -1037,9 +1301,21 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
IsArray: inferred.IsArray,
ArrayDim: inferred.ArrayDim,
// MTConnect is read-only and this driver implements no IWritable, so the tier
// that is read-only from OPC UA is the only honest one.
SecurityClass: SecurityClassification.ViewOnly,
// Operate exactly for the leaves WriteAsync would accept — the "a node cannot
// advertise writability the driver would refuse" principle. With write.enabled
// off (the default) every leaf stays ViewOnly, which is what this driver shipped.
//
// ⚠️ This does NOT make the committed tag writable over OPC UA, and must not be
// read as doing so. Nothing persists SecurityClass: RawBrowseCommitMapper.MapLeaf
// hardcodes TagAccessLevel.Read, Tag has no SecurityClass column, and the
// CurrentWrite bit is composed from Tag.AccessLevel alone
// (OtOpcUaNodeManager.ComposeAccessLevel). The stamp reaches the browse session's
// attribute side-channel and stops there, so an operator still has to author
// ReadWrite on the tag. It is kept honest anyway — a browse that advertised
// ViewOnly for a leaf this driver now writes would be a lie an operator reads.
SecurityClass: CanWrite(writeEnabled, dataItem)
? SecurityClassification.Operate
: SecurityClassification.ViewOnly,
// Historization is authored per tag, never inferred from a device model.
IsHistorized: false,
@@ -1067,12 +1343,26 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
continue;
}
streamed += StreamContainer(component, scope.Folder(name, name));
streamed += StreamContainer(component, scope.Folder(name, name), writeEnabled);
}
return streamed;
}
/// <summary>
/// Whether <see cref="WriteAsync"/> would accept a write to <paramref name="dataItem"/> — the
/// browse-time answer, asked of the same two facts the runtime asks of.
/// </summary>
/// <param name="writeEnabled">Whether <c>write.enabled</c> is on.</param>
/// <param name="dataItem">The declared data item.</param>
/// <remarks>
/// The device-name leg of the runtime refusal is not asked here and does not need to be: a
/// leaf reached by this walk is <i>inside</i> a device the model declares, so its owning device
/// is known by construction.
/// </remarks>
private static bool CanWrite(bool writeEnabled, MTConnectDataItem dataItem) =>
writeEnabled && !IsCondition(dataItem.Category);
/// <summary>
/// Whether a device is in this instance's configured scope. An unset scope is agent-wide.
/// </summary>
@@ -2276,6 +2566,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
MaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? 30_000,
BackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? 2.0,
},
// Absent block == disabled: write-back is opt-in, and the agent's own AllowPut has to be
// on as well, so neither layer can arm the other by omission.
Write = new MTConnectWriteOptions
{
Enabled = dto.Write?.Enabled ?? false,
},
};
}
@@ -2926,10 +3223,13 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
if (options.RawTags.Count == 0)
{
MTConnectTagDefinition[] only = [.. definitions.Values];
return new TagBinding(
FrozenDictionary<string, string>.Empty,
FrozenDictionary<string, IReadOnlyList<string>>.Empty,
[.. definitions.Values]);
only,
BuildWriteTargets(only, probeModel, options.DeviceName));
}
var declared = IndexProbeDataItems(probeModel);
@@ -2975,13 +3275,106 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
}
MTConnectTagDefinition[] indexTags = [.. definitions.Values];
return new TagBinding(
idByRawPath.ToFrozenDictionary(StringComparer.Ordinal),
rawPathsById.ToFrozenDictionary(
pair => pair.Key,
pair => (IReadOnlyList<string>)pair.Value,
StringComparer.Ordinal),
[.. definitions.Values]);
indexTags,
BuildWriteTargets(indexTags, probeModel, options.DeviceName));
}
/// <summary>
/// Derives the write-addressing table: for every DataItem this driver serves, the device the
/// Agent's <c>AllowPut</c> URL must name, and whether the item is CONDITION-category.
/// </summary>
/// <param name="definitions">Every DataItem the binding serves (the union of both authoring surfaces).</param>
/// <param name="probeModel">The Agent's device model when one has been fetched; else null.</param>
/// <param name="configuredDeviceName">
/// <see cref="MTConnectDriverOptions.DeviceName"/> — the fallback for a DataItem the
/// <c>/probe</c> model does not declare.
/// </param>
/// <remarks>
/// <para>
/// <b>The device name is derived, not authored.</b> The Agent's PUT surface is addressed
/// per device even on a single-device Agent, and the probe model already says which device
/// owns which DataItem — so a multi-device Agent is addressed correctly with no extra
/// config. Config is the fallback, for the dataItemId-keyed authoring surface (the driver
/// CLI) and for a DataItem the Agent has since stopped declaring.
/// </para>
/// <para>
/// <b>A null <see cref="WriteTarget.DeviceName"/> is a refusal, not a default.</b> Neither
/// the model nor config names a device, so there is no URL to POST to; guessing one yields
/// a 404 an operator reads as "the Agent is broken". <see cref="WriteAsync"/> reports it as
/// <c>BadNodeIdUnknown</c>.
/// </para>
/// <para>
/// <b>The category comes from the probe first, the authored blob second.</b> The Agent is
/// the authority on what a DataItem is; a blob's <c>mtCategory</c> is optional metadata
/// that may be absent (browse-commit writes only the address) or stale.
/// </para>
/// </remarks>
private static FrozenDictionary<string, WriteTarget> BuildWriteTargets(
IReadOnlyList<MTConnectTagDefinition> definitions,
MTConnectProbeModel? probeModel,
string? configuredDeviceName)
{
if (definitions.Count == 0)
{
return FrozenDictionary<string, WriteTarget>.Empty;
}
var owners = IndexProbeOwners(probeModel);
var fallback = string.IsNullOrWhiteSpace(configuredDeviceName) ? null : configuredDeviceName.Trim();
var targets = new Dictionary<string, WriteTarget>(definitions.Count, StringComparer.Ordinal);
foreach (var definition in definitions)
{
if (definition is null || string.IsNullOrWhiteSpace(definition.FullName)) continue;
var owner = owners.TryGetValue(definition.FullName, out var declared) ? declared : null;
targets[definition.FullName] = new WriteTarget(
owner?.DeviceName ?? fallback,
IsCondition(owner?.Category ?? definition.MtCategory));
}
return targets.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>
/// Flattens a device model into a <c>dataItemId → owning device</c> lookup; null ⇒ empty.
/// Separate from <see cref="IndexProbeDataItems"/> because this one must remember which device
/// the walk was inside, which a flat DataItem lookup has thrown away.
/// </summary>
/// <param name="probeModel">The Agent's device model, or null.</param>
private static FrozenDictionary<string, DataItemOwner> IndexProbeOwners(MTConnectProbeModel? probeModel)
{
if (probeModel is null) return FrozenDictionary<string, DataItemOwner>.Empty;
var map = new Dictionary<string, DataItemOwner>(StringComparer.Ordinal);
foreach (var device in probeModel.Devices)
{
if (device is null) continue;
// The same name/id fallback the browse tree uses: an Agent addresses a device by its
// name, and by its id when it declares no name.
var deviceName = FolderName(device.Name, device.Id);
var owner = new DataItemOwner(
string.IsNullOrWhiteSpace(deviceName) ? null : deviceName, Category: null);
foreach (var dataItem in device.AllDataItems())
{
if (dataItem is null || string.IsNullOrWhiteSpace(dataItem.Id)) continue;
map[dataItem.Id] = owner with { Category = dataItem.Category };
}
}
return map.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>
@@ -3167,11 +3560,45 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
/// <see cref="MTConnectDriverOptions.RawTags"/>. This is how a raw tag's declared (or
/// probe-inferred) <c>DriverDataType</c> reaches the coercion.
/// </param>
/// <param name="WriteTargetsByDataItemId">
/// How each DataItem is addressed on the Agent's <c>AllowPut</c> surface, and whether it is
/// CONDITION-category — see <see cref="BuildWriteTargets"/>. Keyed by DataItem id, so it
/// doubles as the write path's "is this a reference I serve at all?" test: a miss is
/// <c>BadNodeIdUnknown</c>, exactly as an index miss is on the read path.
/// </param>
private sealed record TagBinding(
FrozenDictionary<string, string> DataItemIdByRawPath,
FrozenDictionary<string, IReadOnlyList<string>> RawPathsByDataItemId,
IReadOnlyList<MTConnectTagDefinition> IndexTags)
IReadOnlyList<MTConnectTagDefinition> IndexTags,
FrozenDictionary<string, WriteTarget> WriteTargetsByDataItemId)
{
/// <summary>
/// Resolves a write reference to the DataItem id and the Agent address it writes through.
/// </summary>
/// <param name="reference">The caller's reference (a RawPath under v3, or a DataItem id).</param>
/// <param name="dataItemId">The resolved DataItem id; empty when this returns false.</param>
/// <param name="target">Where and whether it can be written; <c>default</c> when false.</param>
/// <returns><see langword="false"/> when no DataItem this driver serves answers to that reference.</returns>
/// <remarks>
/// Stricter than <see cref="ToDataItemId"/>, deliberately. A read may fall through to an
/// index miss and report itself; a write that fell through would POST an arbitrary
/// operator string at the Agent as if it were a DataItem id — the Agent's answer to which
/// is a refusal that reads like a comms fault. Unknown is answered here, before the wire.
/// </remarks>
public bool TryResolveWriteTarget(string? reference, out string dataItemId, out WriteTarget target)
{
dataItemId = ToDataItemId(reference);
if (dataItemId.Length != 0 && WriteTargetsByDataItemId.TryGetValue(dataItemId, out target))
{
return true;
}
target = default;
return false;
}
/// <summary>
/// Translates a subscriber/read reference to the DataItem id the index is keyed by.
/// </summary>
@@ -3211,6 +3638,24 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
}
}
/// <summary>
/// Everything <see cref="WriteAsync"/> needs to decide one slot before it touches the network.
/// </summary>
/// <param name="DeviceName">
/// The device path segment of the Agent PUT URL, or <c>null</c> when neither the probe model
/// nor config names one — which is a refusal, not a default (see <see cref="BuildWriteTargets"/>).
/// </param>
/// <param name="IsCondition">
/// Whether the DataItem is CONDITION-category. Excluded from v1 writes: a condition PUT carries
/// alarm semantics and a different <c>level|text</c> wire format.
/// </param>
private readonly record struct WriteTarget(string? DeviceName, bool IsCondition);
/// <summary>The device a DataItem was declared under, and the category it was declared with.</summary>
/// <param name="DeviceName">The owning device's name (or id); null when it declares neither.</param>
/// <param name="Category">The DataItem's <c>category</c> attribute as the Agent spelled it.</param>
private sealed record DataItemOwner(string? DeviceName, string? Category);
/// <summary>
/// One live subscription: its handle and the references it wants. Both are effectively
/// immutable once registered, so the pump reads <see cref="References"/> without a lock —
@@ -3272,6 +3717,7 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
public List<RawTagEntry>? RawTags { get; init; }
public MTConnectProbeDto? Probe { get; init; }
public MTConnectReconnectDto? Reconnect { get; init; }
public MTConnectWriteDto? Write { get; init; }
}
/// <summary>
@@ -3347,4 +3793,10 @@ public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDis
public int? MaxBackoffMs { get; init; }
public double? BackoffMultiplier { get; init; }
}
/// <summary>Write-back knobs — the driver-side half of the two-layer opt-in on observation injection.</summary>
private sealed class MTConnectWriteDto
{
public bool? Enabled { get; init; }
}
}
@@ -31,12 +31,20 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// </para>
/// <para>
/// <b>The instance is returned as its concrete type.</b> The runtime resolves every optional
/// capability (<see cref="IReadable"/>, <see cref="ISubscribable"/>,
/// capability (<see cref="IReadable"/>, <see cref="IWritable"/>, <see cref="ISubscribable"/>,
/// <see cref="ITagDiscovery"/>, <see cref="IHostConnectivityProbe"/>,
/// <see cref="IRediscoverable"/>) by pattern-matching the object
/// <see cref="DriverFactoryRegistry"/> hands back, so nothing here may narrow it — a
/// narrowed return is how a capability ends up implemented but never dispatched. There is
/// deliberately no <c>IWritable</c>: the MTConnect Agent surface is read-only.
/// narrowed return is how a capability ends up implemented but never dispatched.
/// <see cref="IWritable"/> is the standing example: it was added to
/// <see cref="MTConnectDriver"/> with no change whatsoever to this file, and would have been
/// silently undispatched had the return type ever been narrowed to <see cref="IDriver"/>.
/// </para>
/// <para>
/// <b>Registering the factory does not enable writing.</b> An MTConnect write is observation
/// injection through the Agent's <c>AllowPut</c> surface and is off at two independent gates
/// — the Agent's <c>agent.cfg</c> and the driver's own <c>write.enabled</c> (default
/// <see langword="false"/>). See <see cref="MTConnectDriver"/>'s remarks.
/// </para>
/// </remarks>
public static class MTConnectDriverFactoryExtensions
@@ -127,6 +127,40 @@ internal static class MTConnectXml
return message;
}
var errors = ExtractErrorText(root);
return errors is null ? message : $"{message} The Agent reported: {errors}";
}
/// <summary>
/// The Agent's own words from an <c>MTConnectError</c> document: every <c>&lt;Error&gt;</c>
/// beneath <paramref name="root"/>, rendered <c>errorCode: text</c> where a code is present
/// and joined with <c>"; "</c>. <c>null</c> when the document carries no readable error
/// detail at all (no <c>&lt;Error&gt;</c> elements, or only blank ones).
/// </summary>
/// <remarks>
/// <para>
/// <b>Shared because both callers must read the same document the same way.</b> An
/// Agent's objection reaches this driver through two unrelated doors — a read path that
/// got an error document where it expected a model
/// (<see cref="DescribeUnexpectedRoot"/>), and the write path deciding whether a 200
/// actually accepted the observation. Two copies of this walk would drift on the same
/// two silent rules the type-level remarks describe, and on the write side a missed
/// <c>&lt;Error&gt;</c> does not merely degrade a message: it reads as a successful
/// write.
/// </para>
/// <para>
/// <b><c>null</c> means "no readable detail", NOT "not an error document".</b> Only the
/// caller knows which document it is holding — check the root name first. A caller that
/// treats a <c>null</c> here as "no error occurred" turns a hollow
/// <c>&lt;MTConnectError/&gt;</c> into a success.
/// </para>
/// </remarks>
/// <param name="root">The <c>MTConnectError</c> document's root element.</param>
public static string? ExtractErrorText(XElement root)
{
ArgumentNullException.ThrowIfNull(root);
var errors = root.Descendants()
.Where(e => IsNamed(e, "Error"))
.Select(e =>
@@ -139,8 +173,6 @@ internal static class MTConnectXml
.Where(text => text.Length > 0)
.ToList();
return errors.Count == 0
? message
: $"{message} The Agent reported: {string.Join("; ", errors)}";
return errors.Count == 0 ? null : string.Join("; ", errors);
}
}
@@ -12,8 +12,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
/// <para><b>Every level is the dialect's catalog SQL</b> (<see cref="ISqlDialect.ListSchemasSql"/> /
/// <see cref="ISqlDialect.ListTablesSql"/> / <see cref="ISqlDialect.ListColumnsSql"/>) — nothing here
/// knows what <c>INFORMATION_SCHEMA</c> is, because Oracle and SQLite do not have it. The catalog SQL is
/// read verbatim and <c>@schema</c> / <c>@table</c> are <b>bound as parameters</b>; no name from a NodeId
/// is ever concatenated into a command text, so a hostile or merely awkward catalog name is inert here.
/// read verbatim and its <c>schema</c> / <c>table</c> parameters are <b>bound as parameters</b> under
/// <see cref="ISqlDialect.ParameterBindName"/> — so the bind names cannot drift from the markers the
/// dialect's own SQL carries; no name from a NodeId is ever concatenated into a command text, so a
/// hostile or merely awkward catalog name is inert here.
/// <see cref="ISqlDialect.QuoteIdentifier"/> is deliberately <em>not</em> used on this path — the browse
/// never needs an identifier in text.</para>
/// <para><b>Connection ownership: this session owns the connection it is handed and closes it on
@@ -47,6 +49,12 @@ internal sealed class SqlBrowseSession : IBrowseSession
/// <summary>The <c>TABLE_TYPE</c> value marking a view rather than a base table.</summary>
private const string ViewTableType = "VIEW";
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
private const string SchemaParameter = "schema";
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
private const string TableParameter = "table";
/// <summary>
/// The security class every browsed column reports. The <c>Sql</c> driver is read-only in v1
/// (design §4.3), so there is nothing else a picked column could be.
@@ -195,7 +203,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
{
var rows = await QueryAsync(
_dialect.ListTablesSql,
command => Bind(command, "@schema", schema),
command => Bind(command, _dialect.ParameterBindName(SchemaParameter), schema),
static reader => (
Name: ReadString(reader, TableNameColumn),
Type: ReadOptionalString(reader, TableTypeColumn)),
@@ -248,8 +256,8 @@ internal sealed class SqlBrowseSession : IBrowseSession
_dialect.ListColumnsSql,
command =>
{
Bind(command, "@schema", schema);
Bind(command, "@table", table);
Bind(command, _dialect.ParameterBindName(SchemaParameter), schema);
Bind(command, _dialect.ParameterBindName(TableParameter), table);
},
static reader => (
Name: ReadString(reader, ColumnNameColumn),
@@ -275,6 +283,9 @@ internal sealed class SqlBrowseSession : IBrowseSession
var results = new List<T>();
await using var command = _connection.CreateCommand();
// Before the binds: on ODP.NET this is what switches bind-by-position off, and the two-parameter
// column query is exactly where positional binding swaps the schema and the table silently.
_dialect.ConfigureCommand(command);
command.CommandText = sql;
bind(command);
@@ -80,8 +80,8 @@ public sealed class SqlDriverBrowser : IDriverBrowser
/// <param name="dialectSelector">
/// Maps an authored <see cref="SqlProvider"/> onto the dialect that browses it, or <see langword="null"/>
/// for a provider this build does not carry. Defaults to <see cref="DefaultDialectSelector"/>
/// (SqlServer only, in v1). A constructor parameter rather than a test hatch: it is also how a P2
/// provider gets added without touching this class.
/// (SqlServer, Postgres and Oracle). A constructor parameter rather than a test hatch: it is also how
/// a further provider gets added without touching this class.
/// </param>
/// <param name="environmentReader">
/// Reads one environment variable. Defaults to <see cref="Environment.GetEnvironmentVariable(string)"/>.
@@ -117,14 +117,23 @@ public sealed class SqlDriverBrowser : IDriverBrowser
ConnectionStringEnvironmentPrefix + connectionStringRef;
/// <summary>
/// The providers this build can browse. v1 constructs SQL Server only; every other
/// <see cref="SqlProvider"/> member is a reserved name, so it resolves to <see langword="null"/> and
/// the caller reports it as unavailable rather than crashing on a null dialect.
/// The providers this build can browse — SQL Server, PostgreSQL and Oracle. The remaining
/// <see cref="SqlProvider"/> members (<see cref="SqlProvider.MySql"/>, <see cref="SqlProvider.Odbc"/>)
/// are reserved names, so they resolve to <see langword="null"/> and the caller reports them as
/// unavailable rather than crashing on a null dialect.
/// <para>Kept in step with <c>SqlDriverFactoryExtensions.CreateDialect</c>: a provider the runtime
/// driver constructs but the browser cannot select would leave the operator authoring tags by hand
/// against a database the AdminUI refuses to show them.</para>
/// </summary>
/// <param name="provider">The authored provider.</param>
/// <returns>The dialect, or <see langword="null"/> when this build does not carry the provider.</returns>
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) =>
provider == SqlProvider.SqlServer ? new SqlServerDialect() : null;
public static ISqlDialect? DefaultDialectSelector(SqlProvider provider) => provider switch
{
SqlProvider.SqlServer => new SqlServerDialect(),
SqlProvider.Postgres => new PostgresDialect(),
SqlProvider.Oracle => new OracleDialect(),
_ => null,
};
/// <inheritdoc />
/// <exception cref="InvalidOperationException">
@@ -143,12 +152,13 @@ public sealed class SqlDriverBrowser : IDriverBrowser
var literal = ReadPastedConnectionString(configJson);
var config = Deserialize(configJson, literal);
// Provider first: "this build cannot browse Postgres" is true regardless of how the database is
// Provider first: "this build cannot browse MySql" is true regardless of how the database is
// named, and answering it before touching the environment keeps the two failures from masking.
var dialect = _dialectSelector(config.Provider)
?? throw new InvalidOperationException(
$"Sql provider '{config.Provider}' is not available in this build; v1 browses "
+ $"'{SqlProvider.SqlServer}' only.");
$"Sql provider '{config.Provider}' is not available in this build; browsing supports "
+ $"'{SqlProvider.SqlServer}', '{SqlProvider.Postgres}' and '{SqlProvider.Oracle}' "
+ $"('{SqlProvider.MySql}' and '{SqlProvider.Odbc}' are reserved names).");
var reference = config.ConnectionStringRef;
var connectionString = ResolveConnectionString(literal, reference);
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// </summary>
public sealed class SqlDriverConfigDto
{
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/> — the only provider v1 constructs.</summary>
/// <summary>Which ADO.NET provider/dialect backs this instance. Defaults to <see cref="SqlProvider.SqlServer"/>; <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/> are also constructed.</summary>
public SqlProvider Provider { get; init; } = SqlProvider.SqlServer;
/// <summary>
@@ -1,21 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>Which ADO.NET provider/dialect backs a Sql driver instance. v1 constructs only SqlServer.</summary>
/// <summary>
/// Which ADO.NET provider/dialect backs a Sql driver instance. <b>Constructed:</b> SqlServer, Postgres,
/// Oracle — each over a fully managed provider package, no native client install. <b>Reserved:</b> MySql
/// and Odbc are declared names only; authoring one fails at driver construction and at browse.
/// </summary>
public enum SqlProvider
{
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c> — the only provider v1 constructs.</summary>
/// <summary>Microsoft SQL Server via <c>Microsoft.Data.SqlClient</c>.</summary>
SqlServer,
/// <summary>PostgreSQL (Npgsql) — reserved for P2; not constructed in v1.</summary>
/// <summary>PostgreSQL via the managed <c>Npgsql</c> provider.</summary>
Postgres,
/// <summary>MySQL / MariaDB — reserved; not constructed in v1.</summary>
/// <summary>MySQL / MariaDB — reserved; no dialect ships, so this is not constructed.</summary>
MySql,
/// <summary>Generic ODBC — reserved for P2; not constructed in v1.</summary>
/// <summary>Generic ODBC — reserved; no dialect ships, so this is not constructed.</summary>
Odbc,
/// <summary>Oracle — reserved; not constructed in v1.</summary>
/// <summary>Oracle Database via the fully managed <c>Oracle.ManagedDataAccess.Core</c> provider.</summary>
Oracle,
}
@@ -28,7 +28,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// </summary>
public interface ISqlDialect
{
/// <summary>Which backend this dialect speaks. v1 constructs <see cref="SqlProvider.SqlServer"/> only.</summary>
/// <summary>
/// Which backend this dialect speaks. One implementation ships per constructed
/// <see cref="SqlProvider"/> member — <see cref="SqlProvider.SqlServer"/>,
/// <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/>.
/// </summary>
SqlProvider Provider { get; }
/// <summary>
@@ -50,6 +54,58 @@ public interface ISqlDialect
/// <exception cref="ArgumentException">The value cannot be a valid catalog identifier.</exception>
string QuoteIdentifier(string ident);
/// <summary>
/// The token that stands for a bound parameter <b>in the command text</b> — <c>"@k0"</c> for
/// SqlClient/Npgsql, <c>":p_k0"</c> for ODP.NET — given the caller's bare base name (<c>"k0"</c>,
/// <c>"w"</c>, <c>"schema"</c>).
/// <para><b>Why the marker and the bind name are two members.</b> They are the same string on every
/// provider but Oracle, where the SQL text carries <c>:p_schema</c> while
/// <see cref="DbParameter.ParameterName"/> is the bare <c>schema</c>. Modelling one and deriving the
/// other means a call site guessing at a convention it does not own — the exact defect this seam
/// exists to prevent, and one that surfaces as a runtime bind failure (or, worse under
/// bind-by-position, as a <em>silently mis-bound value</em>) rather than a compile error.</para>
/// <para>The base name is caller-owned and never operator input: the poll planner mints
/// <c>k0…kN</c>/<c>w</c>, the catalog path uses <c>schema</c>/<c>table</c>. Authored <em>values</em>
/// ride the parameter; authored <em>identifiers</em> go through <see cref="QuoteIdentifier"/>. Nothing
/// else reaches a command text.</para>
/// </summary>
/// <param name="baseName">The bare base name, without any provider prefix.</param>
/// <returns>The marker to embed in the SQL text.</returns>
string ParameterMarker(string baseName);
/// <summary>
/// The value to assign to <see cref="DbParameter.ParameterName"/> for the same
/// <paramref name="baseName"/> — <c>"@k0"</c> for SqlClient-style providers (which expect the marker
/// verbatim), the bare <c>"k0"</c> for ODP.NET-style ones (whose documented convention is a
/// prefix-less bind name).
/// <para>Always paired with <see cref="ParameterMarker"/> over the <em>same</em> base name: a plan
/// carries both lists and every binding site <b>copies</b> them index-for-index rather than
/// re-deriving either from the other.</para>
/// </summary>
/// <param name="baseName">The bare base name, without any provider prefix.</param>
/// <returns>The parameter name to bind under.</returns>
string ParameterBindName(string baseName);
/// <summary>
/// Applies provider-specific setup to a freshly created <see cref="DbCommand"/>, <b>before</b> its
/// parameters are added or it is executed. A no-op for providers that need none.
/// <para><b>Oracle uses this to set <c>OracleCommand.BindByName = true</c></b>, and that call is the
/// sharpest correctness edge on this seam: ODP.NET binds <em>by position</em> by default, so a
/// multi-parameter statement (the planner's <c>IN (:p_k0, :p_k1, …)</c> list, the catalog path's
/// <c>schema</c> + <c>table</c> pair) would bind values to the wrong markers whenever the parameter
/// order and the marker order differ — publishing one tag's value onto another tag's node with no
/// error anywhere. Every command that carries parameters — the poll path, the catalog path, the
/// browse path — therefore calls this immediately after <c>CreateCommand()</c> and <b>before</b> the
/// first parameter is added. (Bind mode cannot affect a parameterless statement, so the liveness
/// ones are indifferent: <c>SqlDriverProbe</c> calls it anyway for uniformity,
/// <c>SqlDriver.PingAsync</c> does not.)</para>
/// <para>Implementations must tolerate <em>any</em> <see cref="DbCommand"/> they are handed: the
/// driver can be pointed at an instrumented or substituted factory, so a dialect that casts to its own
/// provider type must do so defensively rather than throwing.</para>
/// </summary>
/// <param name="command">The command to configure.</param>
void ConfigureCommand(DbCommand command);
/// <summary>Cheapest statement that proves the connection is alive (<c>SELECT 1</c>; Oracle needs <c>FROM DUAL</c>).</summary>
string LivenessSql { get; }
@@ -105,10 +161,19 @@ public interface ISqlDialect
/// </remarks>
string DefaultSchemaSql { get; }
/// <summary>Catalog query listing tables + views in one schema — the browser's schema-expand level. Bind <c>@schema</c>.</summary>
/// <summary>
/// Catalog query listing tables + views in one schema — the browser's schema-expand level. Carries one
/// parameter under the base name <c>schema</c>, spelled with this dialect's own
/// <see cref="ParameterMarker"/> (<c>@schema</c> on SqlClient, <c>:p_schema</c> on ODP.NET); callers bind
/// it under <see cref="ParameterBindName"/> of the same base name.
/// </summary>
string ListTablesSql { get; }
/// <summary>Catalog query listing columns of one table — the browser's table-expand / attributes level. Bind <c>@schema</c> and <c>@table</c>.</summary>
/// <summary>
/// Catalog query listing columns of one table — the browser's table-expand / attributes level. Carries
/// parameters under the base names <c>schema</c> and <c>table</c>, spelled and bound exactly as
/// <see cref="ListTablesSql"/> describes.
/// </summary>
string ListColumnsSql { get; }
/// <summary>
@@ -0,0 +1,301 @@
using System.Data.Common;
using System.Globalization;
using Oracle.ManagedDataAccess.Client;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Oracle Database dialect over the <b>fully managed</b> ODP.NET provider
/// (<c>Oracle.ManagedDataAccess.Core</c>) — no Instant Client, no native library, no bitness constraint.
/// Double-quote identifier quoting, <c>:</c> parameter markers with <b>colon-less</b> bind names, the
/// <c>ALL_*</c> catalog views in place of the <c>INFORMATION_SCHEMA</c> Oracle does not have, and
/// <c>FETCH FIRST</c> row limiting.
/// <para><b>Supported floor: Oracle 12.2.</b> Two things pin it — <c>FETCH FIRST n ROWS ONLY</c> arrived
/// in 12.1, and the 128-character identifier limit in 12.2 (it was 30 before). Against an older server a
/// long-but-legal-here identifier would be rejected by the server rather than by
/// <see cref="QuoteIdentifier"/>, which is a clear error at the right layer, not a silent wrong answer.</para>
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session.</para>
/// </summary>
public sealed class OracleDialect : ISqlDialect
{
/// <summary>
/// Oracle 12.2+'s identifier ceiling. Enforced because a longer string cannot name a real catalog
/// object — it is either a config mistake or padding, and rejecting it keeps the quoted output bounded.
/// </summary>
internal const int MaxIdentifierLength = 128;
/// <inheritdoc/>
public SqlProvider Provider => SqlProvider.Oracle;
/// <inheritdoc/>
public DbProviderFactory Factory => OracleClientFactory.Instance;
/// <summary>
/// Prepended to every Oracle bind name. <b>It is what keeps a bind name from colliding with an
/// Oracle reserved word</b>, which is a hard error rather than a style problem — see
/// <see cref="ParameterMarker"/>.
/// </summary>
private const string BindPrefix = "p_";
/// <summary>
/// Oracle markers are <c>:</c>-prefixed <b>and namespaced</b> — <c>ParameterMarker("k0")</c> is
/// <c>":p_k0"</c>.
/// </summary>
/// <remarks>
/// <para><b>The <c>p_</c> is load-bearing, and was found live.</b> Oracle rejects a bind variable
/// whose name is a reserved word with <c>ORA-01745: invalid host/bind variable name</c> — and the
/// catalog readers' own base names include <c>table</c> (<c>SqlCatalogLoader</c> /
/// <c>SqlBrowseSession</c> bind <c>schema</c> + <c>table</c>). So an un-prefixed
/// <see cref="ListColumnsSql"/> could not execute at all: every Oracle driver instance failed its
/// Initialize-time catalog load and rejected every authored tag, and every AdminUI browse of a table's
/// columns threw. Caught by <c>OracleReadTests</c> against a real database; invisible to any offline
/// test, because the SQL is only ever parsed by a server.</para>
/// <para>Prefixing <em>everything</em> rather than maintaining a reserved-word list is deliberate:
/// the list is long, version-dependent, and a future base name (<c>date</c>, <c>user</c>,
/// <c>comment</c>, <c>level</c>…) would re-open the same hole silently. A prefixed name can never be
/// a reserved word, and the marker and the bind name are derived from the same constant, so they
/// cannot drift apart.</para>
/// </remarks>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>:</c>-prefixed, namespaced marker.</returns>
public string ParameterMarker(string baseName) => ":" + BindPrefix + baseName;
/// <summary>
/// The same namespaced name <b>without the <c>:</c></b> — ODP.NET's documented convention for
/// <see cref="DbParameter.ParameterName"/>, and deliberately <em>not</em>
/// <see cref="ParameterMarker"/>. This is the divergence that makes the two members exist at all
/// (see <see cref="ISqlDialect.ParameterBindName"/>).
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The colon-less bind name.</returns>
public string ParameterBindName(string baseName) => BindPrefix + baseName;
/// <summary>
/// Sets <c>OracleCommand.BindByName = true</c>. <b>This is the sharpest correctness edge in the
/// driver's provider support.</b>
/// <para>ODP.NET binds <em>by position</em> by default: parameter 0 fills the first marker in the
/// statement, parameter 1 the second, and so on, <em>regardless of the names</em>. Every statement this
/// driver builds with more than one marker would then be a coin flip — the planner's
/// <c>IN (:p_k0, :p_k1, …)</c> key list, and <see cref="ListColumnsSql"/>'s <c>schema</c> + <c>table</c>
/// pair — and the failure mode is not an exception but a <em>successful query over the wrong rows</em>,
/// publishing one tag's value onto another tag's node. Worse still,
/// <see cref="ListTablesSql"/> names <c>:p_schema</c> <b>twice</b> (once per branch of its
/// <c>UNION ALL</c>) and is bound once: under positional binding that statement cannot even execute,
/// because the second occurrence has no parameter to take.</para>
/// <para>Must be called while the parameter collection is still empty — which is exactly what every
/// call site does (immediately after <c>CreateCommand()</c>, before the first bind).</para>
/// <para><b>A foreign command passes through untouched rather than throwing.</b> The dialect can be
/// handed a <see cref="DbCommand"/> from another provider — an instrumented or substituted factory in
/// a shared test harness is the realistic case — and an <c>InvalidCastException</c> there would fail a
/// poll for a reason that has nothing to do with the database. There is nothing to configure on a
/// non-ODP.NET command anyway: every other provider this repo speaks binds by name already.</para>
/// </summary>
/// <param name="command">The command to configure; only an <see cref="OracleCommand"/> is touched.</param>
public void ConfigureCommand(DbCommand command)
{
if (command is OracleCommand oracle) oracle.BindByName = true;
}
/// <summary>Oracle has no bare <c>SELECT</c>; every projection needs a table, and <c>DUAL</c> is the one-row one.</summary>
public string LivenessSql => "SELECT 1 FROM DUAL";
/// <summary>
/// Empty — Oracle 12c+ spells the row limit at the <em>end</em> of the statement (see
/// <see cref="SingleRowLimitSuffix"/>). Anything here would land between <c>SELECT</c> and the column
/// list, where Oracle has no limit syntax at all.
/// </summary>
public string SingleRowLimitPrefix => string.Empty;
/// <summary>
/// The ANSI row-limiting clause Oracle 12.1 added, leading space included so it appends directly to a
/// finished statement.
/// </summary>
/// <remarks>
/// Deliberately <b>not</b> the older <c>WHERE ROWNUM = 1</c>: <c>ROWNUM</c> is assigned <em>before</em>
/// <c>ORDER BY</c> runs, so it would take an arbitrary row and then sort it — silently returning the
/// wrong row for the <c>topByTimestamp</c> selector, whose entire purpose is "the newest one". Making
/// that correct pre-12c requires wrapping the ordered query in a subselect, i.e. authorship of the
/// statement shape, which this seam deliberately leaves with the planner.
/// </remarks>
public string SingleRowLimitSuffix => " FETCH FIRST 1 ROWS ONLY";
/// <summary>
/// Schemas visible to the connecting principal, from <c>ALL_USERS</c> (Oracle equates a schema with a
/// user), aliased to the <c>TABLE_SCHEMA</c> column name the catalog readers project by.
/// </summary>
/// <remarks>
/// Broader than the SQL Server dialect's <c>DISTINCT TABLE_SCHEMA FROM INFORMATION_SCHEMA.TABLES</c>,
/// which lists only schemas that own an object: <c>ALL_USERS</c> includes empty ones. Harmless here —
/// the list is used to resolve an authored schema name to the catalog's own spelling, and a schema
/// holding no tables simply resolves and then matches no relation.
/// </remarks>
public string ListSchemasSql =>
"SELECT username AS TABLE_SCHEMA FROM ALL_USERS ORDER BY username";
/// <summary>
/// <c>sys_context('userenv','current_schema')</c> is the schema an unqualified name resolves in for
/// this session — which is what the catalog gate asks, and it correctly follows an
/// <c>ALTER SESSION SET CURRENT_SCHEMA</c> rather than reporting the login name.
/// </summary>
public string DefaultSchemaSql => "SELECT sys_context('userenv','current_schema') FROM dual";
/// <summary>
/// Tables and views in one schema, unioned because Oracle splits them across two catalog views where
/// <c>INFORMATION_SCHEMA.TABLES</c> carries both. The synthesised <c>TABLE_TYPE</c> literals are the
/// <c>INFORMATION_SCHEMA</c> spellings the browser compares against to mark a view node.
/// </summary>
/// <remarks>
/// <c>UNION ALL</c> rather than <c>UNION</c>: the two sources are disjoint by construction (a name
/// cannot be both a table and a view in one schema), so de-duplicating would only buy a sort.
/// <para>Note this statement names <c>:p_schema</c> twice and is bound <b>once</b> — see
/// <see cref="ConfigureCommand"/>.</para>
/// </remarks>
public string ListTablesSql =>
"SELECT table_name AS TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE owner = :p_schema " +
"UNION ALL " +
"SELECT view_name AS TABLE_NAME, 'VIEW' AS TABLE_TYPE FROM ALL_VIEWS WHERE owner = :p_schema " +
"ORDER BY TABLE_NAME";
/// <summary>
/// Columns of one relation, from <c>ALL_TAB_COLUMNS</c> — which covers views as well as tables, so it
/// serves both halves of <see cref="ListTablesSql"/>. <c>column_id</c> is Oracle's
/// <c>ORDINAL_POSITION</c>.
/// </summary>
/// <remarks>
/// <c>ALL_TAB_COLUMNS.NULLABLE</c> is <c>Y</c>/<c>N</c> where <c>INFORMATION_SCHEMA.IS_NULLABLE</c> is
/// <c>YES</c>/<c>NO</c>, so it is translated rather than merely aliased: a column that carries a
/// shared name across dialects has to carry shared <em>values</em> too, or the first consumer to read
/// it gets a different answer per provider.
/// </remarks>
public string ListColumnsSql =>
"SELECT column_name AS COLUMN_NAME, data_type AS DATA_TYPE, " +
"CASE nullable WHEN 'Y' THEN 'YES' ELSE 'NO' END AS IS_NULLABLE " +
"FROM ALL_TAB_COLUMNS WHERE owner = :p_schema AND table_name = :p_table ORDER BY column_id";
/// <summary>
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — the Oracle escape rule, and the
/// only metacharacter that could terminate a quoted identifier early. Everything else (quotes of the
/// other kind, semicolons, comment markers) is inert inside the quotes and passes through unchanged.
/// </summary>
/// <remarks>
/// <para><b>Case matters once an identifier is quoted.</b> Oracle upper-cases unquoted identifiers but
/// takes a quoted one literally, so <c>"speed"</c> and <c>"SPEED"</c> are different columns. This is
/// safe here only because the catalog gate has already substituted the catalog's own spelling
/// (<c>ALL_TAB_COLUMNS.column_name</c>, i.e. what the server actually stored) before anything reaches
/// this method — an operator who authored <c>speed</c> against an Oracle <c>SPEED</c> column resolves
/// to <c>SPEED</c> at Initialize, and it is <c>SPEED</c> that gets quoted.</para>
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>): null / empty / all-whitespace; longer
/// than <see cref="MaxIdentifierLength"/>; any control character (which covers embedded NUL, and the
/// C0/C1 ranges that can truncate or corrupt a logged/audited statement); and any Unicode
/// <see cref="UnicodeCategory.Format"/> character — bidi overrides, zero-width spaces, soft hyphen,
/// BOM. Those last cannot escape the quotes, but they spoof the <em>rendered</em> text of a log line or
/// an AdminUI label while comparing byte-different from the real catalog name (the Trojan-Source class,
/// CVE-2021-42574) — the same log/audit-integrity concern that motivates the control-character rule.</para>
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.</para>
/// <para><b>This is a backstop, not the only defence</b> — see
/// <see cref="ISqlDialect.QuoteIdentifier"/> on the catalog gate. The rules below still run, and must
/// hold for any future caller that has no catalog to check against.</para>
/// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The double-quoted identifier.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid Oracle identifier.</exception>
public string QuoteIdentifier(string ident)
{
if (string.IsNullOrWhiteSpace(ident))
throw new ArgumentException(
"A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
if (ident.Length > MaxIdentifierLength)
throw new ArgumentException(
$"An Oracle identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).",
nameof(ident));
foreach (var ch in ident)
{
if (char.IsControl(ch))
throw new ArgumentException(
"A SQL identifier may not contain control characters (including NUL).", nameof(ident));
// Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and
// zero-width character. They are inert inside the quotes but spoof rendered log/UI text.
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format)
throw new ArgumentException(
"A SQL identifier may not contain Unicode format characters "
+ "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident));
}
return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
}
/// <summary>
/// Folds an <c>ALL_TAB_COLUMNS.DATA_TYPE</c> family name onto a <see cref="DriverDataType"/>.
/// </summary>
/// <remarks>
/// <para><b><c>NUMBER</c> maps to <see cref="DriverDataType.Float64"/>, and that is a deliberate
/// under-specification.</b> Oracle has one numeric type: an integer column, a currency column and a
/// scientific measurement are all <c>NUMBER</c>, differing only in the precision/scale this method
/// never sees — its whole input is the family name. Choosing an integral driver type would truncate
/// every scaled value, so the widest numeric type wins. Two consequences worth knowing: a
/// <c>NUMBER(38)</c> beyond 2^53 loses low-order digits in a <c>double</c>, and an integer-valued
/// column surfaces as a floating-point OPC UA node. The documented escape hatch for both is authoring
/// an explicit <c>"type"</c> on the tag (<c>Int64</c> for a true integer key, <c>String</c> to keep an
/// exact high-precision decimal), which overrides this inference.</para>
/// <para><b><c>TIMESTAMP</c> is matched by prefix</b>, unlike every other entry. Oracle reports the
/// fractional-seconds precision and the time-zone qualifier <em>inside</em> the family name —
/// <c>TIMESTAMP(6)</c>, <c>TIMESTAMP(6) WITH TIME ZONE</c>, <c>TIMESTAMP(6) WITH LOCAL TIME ZONE</c> —
/// where <c>INFORMATION_SCHEMA</c> would report a bare <c>timestamp</c>. An exact-match table would
/// send all of them to the String fallback.</para>
/// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
/// throws — a schema browse must render a table containing an exotic column (<c>RAW</c>,
/// <c>LONG RAW</c>, <c>BLOB</c>, <c>BFILE</c>, <c>ROWID</c>, <c>XMLTYPE</c>, an object type) rather
/// than crash on it. Null/blank input takes the same fallback. <c>RAW</c>/<c>BLOB</c> land here for
/// the same reason SQL Server's <c>varbinary</c> does: there is no binary driver type.</para>
/// <para><b>Deliberate non-mapping:</b> the <c>INTERVAL</c> types are durations, not points in time,
/// and take the String fallback rather than being mistaken for a
/// <see cref="DriverDataType.DateTime"/> — the same call the SQL Server dialect makes for
/// <c>time</c>.</para>
/// <para>Oracle's <c>DATE</c> carries a time-of-day component (it is not a date-only type), so
/// <see cref="DriverDataType.DateTime"/> is the faithful mapping rather than a widening.</para>
/// </remarks>
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
public DriverDataType MapColumnType(string sqlDataType)
{
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
// ALL_TAB_COLUMNS reports these upper-cased; normalising up (rather than down, as the SQL Server
// dialect does) keeps the arms readable as the names an operator sees in the browse tree.
var family = sqlDataType.Trim().ToUpperInvariant();
// Prefix, not equality: the precision and time-zone qualifier live inside the family name.
if (family.StartsWith("TIMESTAMP", StringComparison.Ordinal)) return DriverDataType.DateTime;
return family switch
{
// One numeric family, plus the ANSI synonyms Oracle accepts at DDL time. Those synonyms are
// resolved to NUMBER/FLOAT in the catalog, so they should never actually be reported — they are
// here so a server or gateway that does surface one is not silently mapped to String.
"NUMBER" or "FLOAT" or "BINARY_DOUBLE" or "DOUBLE PRECISION"
or "INTEGER" or "INT" or "SMALLINT"
or "DECIMAL" or "DEC" or "NUMERIC" => DriverDataType.Float64,
// The only single-precision storage type Oracle has.
"BINARY_FLOAT" => DriverDataType.Float32,
"VARCHAR2" or "NVARCHAR2" or "VARCHAR" or "CHAR" or "NCHAR"
or "CLOB" or "NCLOB" => DriverDataType.String,
// Native BOOLEAN column type (Oracle 23ai and later). Older estates spell a boolean as
// NUMBER(1) or CHAR(1), which are invisible here and map by their own family.
"BOOLEAN" => DriverDataType.Boolean,
"DATE" => DriverDataType.DateTime,
_ => DriverDataType.String,
};
}
}
@@ -0,0 +1,251 @@
using System.Data.Common;
using System.Globalization;
using Npgsql;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// PostgreSQL dialect (design §2), over the fully managed <c>Npgsql</c> ADO.NET provider — no native
/// client install anywhere. Double-quote identifier quoting, <c>information_schema</c> catalog SQL, an
/// end-of-statement <c>LIMIT 1</c>, and a type map over the type names
/// <c>information_schema.columns.data_type</c> actually reports.
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session — the same contract <c>SqlServerDialect</c> holds to.</para>
/// </summary>
public sealed class PostgresDialect : ISqlDialect
{
/// <summary>
/// PostgreSQL's identifier ceiling — <c>NAMEDATALEN - 1</c>, 63 by default. Enforced because the
/// server <b>silently truncates</b> anything longer, so a 70-character authored name would resolve to
/// a different object than it spells; rejecting it surfaces the config mistake instead.
/// </summary>
internal const int MaxIdentifierLength = 63;
/// <inheritdoc/>
public SqlProvider Provider => SqlProvider.Postgres;
/// <inheritdoc/>
public DbProviderFactory Factory => NpgsqlFactory.Instance;
/// <summary>
/// Npgsql markers are <c>@</c>-prefixed, exactly as SQL Server's are — <c>ParameterMarker("k0")</c>
/// is <c>"@k0"</c>. (Npgsql also accepts <c>:</c>, but this dialect commits to one spelling so the
/// catalog SQL below and the planner's markers can never drift apart.)
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed marker.</returns>
public string ParameterMarker(string baseName) => "@" + baseName;
/// <summary>
/// Identical to <see cref="ParameterMarker"/>: <c>NpgsqlParameter.ParameterName</c> is matched against
/// the marker in the statement, and Npgsql strips the prefix itself either way. Spelling it out keeps
/// the parameter collection readable in a log and matches what the SQL text says.
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed bind name.</returns>
public string ParameterBindName(string baseName) => "@" + baseName;
/// <summary>
/// No-op. <c>NpgsqlCommand</c> binds by name out of the box, so there is nothing Postgres needs set on
/// a fresh command — the member exists for Oracle's <c>BindByName</c> (see
/// <see cref="ISqlDialect.ConfigureCommand"/>). It deliberately does not cast
/// <paramref name="command"/>, so a substituted or instrumented factory's command passes through
/// untouched rather than throwing.
/// </summary>
/// <param name="command">The command to configure; untouched here.</param>
public void ConfigureCommand(DbCommand command)
{
// Intentionally empty: see the summary.
}
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
/// <summary>
/// Empty — Postgres spells its row limit at the end of the statement, in
/// <see cref="SingleRowLimitSuffix"/>. The mirror image of T-SQL's <c>TOP 1</c>, which is exactly why
/// the seam carries both ends.
/// </summary>
public string SingleRowLimitPrefix => string.Empty;
/// <summary>
/// <c>" LIMIT 1"</c>, leading space included so it appends straight onto the finished statement
/// (<c>… ORDER BY "sample_ts" DESC LIMIT 1</c>).
/// </summary>
public string SingleRowLimitSuffix => " LIMIT 1";
/// <summary>
/// The schemas holding relations this connection can actually see, system schemas excluded.
/// </summary>
/// <remarks>
/// <para><b>Sourced from <c>information_schema.tables</c>, not <c>information_schema.schemata</c></b>
/// — and that is a deliberate correctness choice, not a copy of the SQL Server dialect's shape.
/// Postgres' <c>schemata</c> view lists only schemas the current role <em>owns</em>, so a service
/// account granted <c>USAGE</c> + <c>SELECT</c> on someone else's schema — the normal way a read-only
/// poller is provisioned — would see an empty schema list and every tag would be rejected. Deriving
/// the list from the relations the principal can see answers the question the catalog gate is really
/// asking: "which schemas hold something I may read?"</para>
/// <para><b>What counts as a system schema.</b> <c>pg_catalog</c> and <c>information_schema</c> are
/// named explicitly (design §2). <c>pg_toast</c> and the per-backend <c>pg_temp_N</c> /
/// <c>pg_toast_temp_N</c> namespaces are excluded by prefix as well: they hold storage internals and
/// session-local temp objects, can never contain an authored poll target, and a <c>pg_temp_N</c> in
/// particular names a different thing per session — offering it in the browse tree would present a
/// schema that vanishes. The <c>ESCAPE</c> clause makes the <c>_</c> literal rather than the
/// single-character wildcard <c>LIKE</c> would otherwise treat it as.</para>
/// <para>Aliased <c>"TABLE_SCHEMA"</c> in double quotes — see <see cref="ListTablesSql"/>.</para>
/// </remarks>
public string ListSchemasSql =>
"SELECT DISTINCT table_schema AS \"TABLE_SCHEMA\" FROM information_schema.tables " +
"WHERE table_schema NOT IN ('pg_catalog', 'information_schema') " +
"AND table_schema NOT LIKE 'pg\\_toast%' ESCAPE '\\' " +
"AND table_schema NOT LIKE 'pg\\_temp%' ESCAPE '\\' " +
"ORDER BY \"TABLE_SCHEMA\"";
/// <summary>
/// <c>current_schema()</c> is the first schema on the connection's <c>search_path</c> — the one an
/// unqualified object name resolves in, which is precisely the question the catalog gate asks.
/// </summary>
public string DefaultSchemaSql => "SELECT current_schema()";
/// <summary>
/// Tables + views in one schema, matching <c>SqlServerDialect.ListTablesSql</c> column for column
/// (<c>TABLE_NAME</c>, <c>TABLE_TYPE</c>, ordered by name) so both consumers read the same shape.
/// </summary>
/// <remarks>
/// <para><b>The aliases are quoted, and that is load-bearing.</b> <c>SqlCatalogLoader</c> and
/// <c>SqlBrowseSession</c> both read every catalog column by name
/// (<c>DbDataReader.GetOrdinal("TABLE_NAME")</c>), while Postgres folds an <em>unquoted</em> output
/// column name to lower case. Double-quoting each alias pins the exact upper-case spelling those
/// consumers ask for rather than relying on a provider's case-insensitive <c>GetOrdinal</c>
/// fallback.</para>
/// <para>The <c>@schema</c> marker is written literally here because
/// <see cref="ParameterMarker"/> spells it identically; the two agree by construction on this dialect
/// (they diverge only on ODP.NET), and a test pins that they do.</para>
/// </remarks>
public string ListTablesSql =>
"SELECT table_name AS \"TABLE_NAME\", table_type AS \"TABLE_TYPE\" " +
"FROM information_schema.tables " +
"WHERE table_schema = @schema ORDER BY \"TABLE_NAME\"";
/// <summary>
/// Columns of one table in ordinal order — the same three columns
/// <c>SqlServerDialect.ListColumnsSql</c> projects (<c>COLUMN_NAME</c>, <c>DATA_TYPE</c>,
/// <c>IS_NULLABLE</c>), quoted for the reason <see cref="ListTablesSql"/> gives.
/// </summary>
public string ListColumnsSql =>
"SELECT column_name AS \"COLUMN_NAME\", data_type AS \"DATA_TYPE\", " +
"is_nullable AS \"IS_NULLABLE\" FROM information_schema.columns " +
"WHERE table_schema = @schema AND table_name = @table ORDER BY ordinal_position";
/// <summary>
/// Double-quotes one identifier part, doubling any embedded <c>"</c> — the SQL-standard escape rule
/// Postgres follows, and the only metacharacter that could terminate a quoted identifier early.
/// Backslashes, semicolons and comment markers are inert inside a quoted identifier and pass through
/// unchanged.
/// </summary>
/// <remarks>
/// <para><b>Rejects</b> (all as <see cref="ArgumentException"/>, mirroring
/// <c>SqlServerDialect.QuoteIdentifier</c> so a test written against one dialect fails the same way
/// against the other): null / empty / all-whitespace; longer than
/// <see cref="MaxIdentifierLength"/>; any control character (which covers embedded NUL and the C0/C1
/// ranges that can truncate or corrupt a logged/audited statement); and any Unicode
/// <see cref="UnicodeCategory.Format"/> character — bidi overrides, zero-width spaces, soft hyphen,
/// BOM. Those last cannot escape the quotes, but they spoof the <em>rendered</em> text of a log line
/// or an AdminUI label while comparing byte-different from the real catalog name (the Trojan-Source
/// class, CVE-2021-42574).</para>
/// <para>NUL deserves its own note here: Postgres rejects a NUL byte anywhere in a query string
/// outright, so passing one through would fail the whole statement rather than just the one
/// identifier.</para>
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.</para>
/// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
/// (<c>SqlCatalogGate</c>) resolves an authored table/column against the live catalog at Initialize
/// and substitutes the catalog's own spelling — which on Postgres is the <em>lower-cased</em> one an
/// unquoted <c>CREATE TABLE</c> produced — so on the poll path the value arriving here is a string
/// read back out of the catalog.</para>
/// </remarks>
/// <param name="ident">The bare, unquoted identifier part.</param>
/// <returns>The double-quoted identifier.</returns>
/// <exception cref="ArgumentException">The value cannot be a valid PostgreSQL identifier.</exception>
public string QuoteIdentifier(string ident)
{
if (string.IsNullOrWhiteSpace(ident))
throw new ArgumentException(
"A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));
if (ident.Length > MaxIdentifierLength)
throw new ArgumentException(
$"A PostgreSQL identifier may not exceed {MaxIdentifierLength} characters (got {ident.Length}).",
nameof(ident));
foreach (var ch in ident)
{
if (char.IsControl(ch))
throw new ArgumentException(
"A SQL identifier may not contain control characters (including NUL).", nameof(ident));
// Cf is a *separate* category from Cc, so char.IsControl misses every bidi override and
// zero-width character. They are inert inside the quotes but spoof rendered log/UI text.
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.Format)
throw new ArgumentException(
"A SQL identifier may not contain Unicode format characters "
+ "(bidi overrides, zero-width spaces, soft hyphen, BOM).", nameof(ident));
}
return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
}
/// <summary>
/// Folds an <c>information_schema.columns.data_type</c> family name onto a
/// <see cref="DriverDataType"/> per design §3.7.
/// </summary>
/// <remarks>
/// <para><b>The input is the canonical, spelled-out name Postgres reports</b> —
/// <c>double precision</c>, <c>character varying</c>, <c>timestamp without time zone</c> — never the
/// <c>float8</c>/<c>varchar</c>/<c>timestamptz</c> aliases and never a length
/// (<c>character varying(50)</c> is reported as <c>character varying</c>). The internal aliases are
/// accepted anyway, so a caller reading types from <c>pg_catalog</c> instead maps identically.</para>
/// <para><b>Precision caveat:</b> <c>numeric</c> / <c>decimal</c> / <c>money</c> collapse to
/// <see cref="DriverDataType.Float64"/>, as they do on SQL Server. A Postgres <c>numeric</c> is
/// arbitrary-precision, so a high-scale column loses low-order digits; authoring an explicit
/// <c>"type": "String"</c> on the tag preserves the exact textual value when that matters.</para>
/// <para><b>Fallback:</b> an unrecognised family maps to <see cref="DriverDataType.String"/> and never
/// throws — a schema browse must render a table containing an exotic column (<c>json</c>,
/// <c>jsonb</c>, <c>inet</c>, <c>tsvector</c>, an enum or composite reported as
/// <c>USER-DEFINED</c>, an array reported as <c>ARRAY</c>) rather than crash on it. Null/blank input
/// takes the same fallback, and so does <c>bytea</c> — matching how SQL Server's <c>varbinary</c>
/// falls back rather than pretending binary is a scalar.</para>
/// <para><b>Deliberate non-mappings.</b> Postgres <c>bit</c> / <c>bit varying</c> are <em>bit
/// strings</em>, not booleans — mapping <c>bit</c> to <see cref="DriverDataType.Boolean"/> the way
/// T-SQL's <c>bit</c> demands would silently coerce a multi-bit value, so they fall back.
/// <c>time</c> / <c>time with time zone</c> are times of day rather than points in time and take the
/// same fallback as T-SQL's <c>time</c>; <c>interval</c> is a duration, likewise.</para>
/// <para><c>uuid</c> maps to <see cref="DriverDataType.String"/>, matching how SQL Server's
/// <c>uniqueidentifier</c> is handled.</para>
/// </remarks>
/// <param name="sqlDataType">The bare SQL type family name; matched case-insensitively.</param>
/// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
public DriverDataType MapColumnType(string sqlDataType)
{
if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;
return sqlDataType.Trim().ToLowerInvariant() switch
{
"boolean" or "bool" => DriverDataType.Boolean,
"smallint" or "int2" => DriverDataType.Int16,
"integer" or "int" or "int4" => DriverDataType.Int32,
"bigint" or "int8" => DriverDataType.Int64,
"real" or "float4" => DriverDataType.Float32,
"double precision" or "float8"
or "numeric" or "decimal" or "money" => DriverDataType.Float64,
"text" or "character varying" or "varchar" or "character" or "char" or "bpchar"
or "name" or "uuid" => DriverDataType.String,
"date"
or "timestamp without time zone" or "timestamp with time zone"
or "timestamp" or "timestamptz" => DriverDataType.DateTime,
_ => DriverDataType.String,
};
}
}
@@ -7,7 +7,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <summary>
/// Microsoft SQL Server dialect (design §2.2) — the only provider v1 constructs. Bracket quoting,
/// Microsoft SQL Server dialect (design §2.2) — the default provider. Bracket quoting,
/// <c>INFORMATION_SCHEMA</c> catalog SQL, and the design §3.7 type-family map.
/// <para>Stateless and immutable, so a single instance is safely shared by the driver's poll reader and
/// the AdminUI browse session.</para>
@@ -27,6 +27,34 @@ public sealed class SqlServerDialect : ISqlDialect
/// <inheritdoc/>
public DbProviderFactory Factory => SqlClientFactory.Instance;
/// <summary>
/// T-SQL markers are <c>@</c>-prefixed — <c>ParameterMarker("k0")</c> is <c>"@k0"</c>.
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed marker.</returns>
public string ParameterMarker(string baseName) => "@" + baseName;
/// <summary>
/// Identical to <see cref="ParameterMarker"/>: <c>Microsoft.Data.SqlClient</c> expects
/// <see cref="DbParameter.ParameterName"/> to carry the same <c>@</c> the statement does. (It also
/// accepts a bare name and prepends the <c>@</c> itself, but spelling it out keeps the parameter
/// collection readable in a profiler trace and matches what the SQL text says.)
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed bind name.</returns>
public string ParameterBindName(string baseName) => "@" + baseName;
/// <summary>
/// No-op. <c>SqlCommand</c> binds by name out of the box, so there is nothing SQL Server needs set on a
/// fresh command — the member exists for Oracle's <c>BindByName</c> (see
/// <see cref="ISqlDialect.ConfigureCommand"/>).
/// </summary>
/// <param name="command">The command to configure; untouched here.</param>
public void ConfigureCommand(DbCommand command)
{
// Intentionally empty: see the summary.
}
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
@@ -21,8 +21,13 @@
<!-- The reason this project exists rather than the dialect living in .Contracts: ISqlDialect
exposes DbProviderFactory and SqlServerDialect returns SqlClientFactory.Instance, so the
provider package is unavoidable here. .Contracts is deliberately provider-free. This is
the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type. -->
the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type.
PostgresDialect and OracleDialect land here for the same reason, each returning its
provider's DbProviderFactory (NpgsqlFactory / OracleClientFactory); both packages are fully
managed ADO.NET drivers, so adding them costs no native client install anywhere. -->
<PackageReference Include="Microsoft.Data.SqlClient" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Oracle.ManagedDataAccess.Core" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
@@ -14,8 +14,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// tables issues a handful of round-trips at Initialize and none thereafter; it never enumerates a
/// warehouse.</para>
/// <para><b>Every parameter is bound.</b> Authored names reach the catalog queries as
/// <c>@schema</c>/<c>@table</c> parameters — the same discipline the schema browser follows — so loading
/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.</para>
/// <c>schema</c>/<c>table</c> parameters — the same discipline the schema browser follows — so loading
/// the allow-list cannot itself be an injection vector. No identifier is quoted into text on this path.
/// The bind names come from <see cref="ISqlDialect.ParameterBindName"/> over those two base names, so
/// they can never disagree with the markers in the dialect's own catalog SQL (<c>@schema</c> on SQL
/// Server, <c>:p_schema</c> on Oracle).</para>
/// <para><b>Failures throw; they do not degrade to an empty catalog.</b> An empty catalog would reject
/// every tag, which is indistinguishable at the OPC UA surface from a database whose objects were all
/// dropped. A caller that cannot read the catalog has not learned that the tags are invalid — it has
@@ -33,6 +36,12 @@ internal static class SqlCatalogLoader
/// <summary>Column alias <see cref="ISqlDialect.ListColumnsSql"/> projects.</summary>
private const string ColumnNameColumn = "COLUMN_NAME";
/// <summary>Base name of the schema parameter every dialect's catalog SQL carries.</summary>
private const string SchemaParameter = "schema";
/// <summary>Base name of the table parameter <see cref="ISqlDialect.ListColumnsSql"/> carries.</summary>
private const string TableParameter = "table";
/// <summary>
/// Reads the catalog slice covering <paramref name="authoredTables"/>.
/// </summary>
@@ -53,7 +62,7 @@ internal static class SqlCatalogLoader
var timeoutSeconds = Math.Max(1, (int)Math.Ceiling(commandTimeout.TotalSeconds));
var schemas = await QueryColumnAsync(
connection, dialect.ListSchemasSql, SchemaColumn,
connection, dialect, dialect.ListSchemasSql, SchemaColumn,
static _ => { }, timeoutSeconds, cancellationToken).ConfigureAwait(false);
// Zero schemas is a permissions or visibility symptom, never a real database. Rejecting every tag on
@@ -94,8 +103,8 @@ internal static class SqlCatalogLoader
if (!tablesBySchema.TryGetValue(schema, out var tables))
{
tables = await QueryColumnAsync(
connection, dialect.ListTablesSql, TableNameColumn,
command => Bind(command, "@schema", schema),
connection, dialect, dialect.ListTablesSql, TableNameColumn,
command => Bind(command, dialect.ParameterBindName(SchemaParameter), schema),
timeoutSeconds, cancellationToken).ConfigureAwait(false);
tablesBySchema[schema] = tables;
}
@@ -106,11 +115,11 @@ internal static class SqlCatalogLoader
if (columnsByTable.ContainsKey(key)) continue;
columnsByTable[key] = await QueryColumnAsync(
connection, dialect.ListColumnsSql, ColumnNameColumn,
connection, dialect, dialect.ListColumnsSql, ColumnNameColumn,
command =>
{
Bind(command, "@schema", schema);
Bind(command, "@table", table);
Bind(command, dialect.ParameterBindName(SchemaParameter), schema);
Bind(command, dialect.ParameterBindName(TableParameter), table);
},
timeoutSeconds, cancellationToken).ConfigureAwait(false);
}
@@ -133,6 +142,7 @@ internal static class SqlCatalogLoader
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
dialect.ConfigureCommand(command);
command.CommandText = dialect.DefaultSchemaSql;
command.CommandTimeout = timeoutSeconds;
var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
@@ -144,6 +154,7 @@ internal static class SqlCatalogLoader
/// <summary>Runs one catalog query and projects a single string column from every row.</summary>
private static async Task<IReadOnlyList<string>> QueryColumnAsync(
DbConnection connection,
ISqlDialect dialect,
string sql,
string columnName,
Action<DbCommand> bind,
@@ -155,6 +166,8 @@ internal static class SqlCatalogLoader
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
// Before the binds: ODP.NET's BindByName has to be set while the parameter collection is empty.
dialect.ConfigureCommand(command);
command.CommandText = sql;
command.CommandTimeout = timeoutSeconds;
bind(command);
@@ -201,17 +201,27 @@ public static class SqlDriverFactoryExtensions
/// <summary>
/// Selects the provider seam.
/// <para>v1 constructs <see cref="SqlProvider.SqlServer"/> only. The other members exist on the shipped
/// enum as reserved names; authoring one is a config mistake that must fail at construction with a
/// message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database.</para>
/// <para>This build constructs <see cref="SqlProvider.SqlServer"/>, <see cref="SqlProvider.Postgres"/>
/// and <see cref="SqlProvider.Oracle"/>. <see cref="SqlProvider.MySql"/> and
/// <see cref="SqlProvider.Odbc"/> remain reserved names on the shipped enum; authoring one is a config
/// mistake that must fail at construction with a message that says so, rather than silently degrading
/// to another backend's SQL dialect against a database that does not speak it.</para>
/// <para><b>Every arm returns that provider's OWN dialect — there is no fallback arm.</b> A default
/// that resolved to <see cref="SqlServerDialect"/> would construct happily and then emit T-SQL
/// (<c>INFORMATION_SCHEMA</c>, <c>TOP 1</c>, <c>@</c> markers) at a Postgres or Oracle server, which
/// fails at poll time as an unintelligible provider error rather than here as a config error.</para>
/// </summary>
private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId)
=> provider switch
{
SqlProvider.SqlServer => new SqlServerDialect(),
SqlProvider.Postgres => new PostgresDialect(),
SqlProvider.Oracle => new OracleDialect(),
_ => throw new InvalidOperationException(
$"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
$"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."),
$"available in this build. Supported: '{nameof(SqlProvider.SqlServer)}', " +
$"'{nameof(SqlProvider.Postgres)}', '{nameof(SqlProvider.Oracle)}'. Reserved (declared on " +
$"the enum but not implemented): '{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."),
};
/// <summary>
@@ -134,6 +134,9 @@ public sealed class SqlDriverProbe : IDriverProbe
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
// Uniformly applied at every command site, even where this statement takes no
// parameters — a provider hook that is only *sometimes* honoured is the harder bug.
dialect.ConfigureCommand(command);
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
}
@@ -161,11 +164,37 @@ public sealed class SqlDriverProbe : IDriverProbe
}
}
/// <summary>Constructs the production dialect for a provider; v1 supports SQL Server only.</summary>
/// <summary>
/// Test seam over <see cref="DefaultDialectFor"/>. The parameterless constructor is the only
/// production path into that switch, and <see cref="ForTest"/> deliberately bypasses it — so without
/// this seam the gate has no direct coverage at all, which is how it kept a SqlServer-only switch
/// after the factory and browser were widened.
/// </summary>
/// <param name="provider">The provider whose dialect to construct.</param>
/// <returns>The dialect this build constructs for <paramref name="provider"/>.</returns>
/// <exception cref="InvalidOperationException">The provider is reserved and not constructed.</exception>
internal static ISqlDialect DefaultDialectForTest(SqlProvider provider) => DefaultDialectFor(provider);
/// <summary>
/// Constructs the production dialect for a provider — <b>the same set
/// <see cref="SqlDriverFactoryExtensions"/> constructs</b>, because Test Connect must exercise the
/// backend a Deploy would actually use. This build constructs <see cref="SqlProvider.SqlServer"/>,
/// <see cref="SqlProvider.Postgres"/> and <see cref="SqlProvider.Oracle"/>;
/// <see cref="SqlProvider.MySql"/> and <see cref="SqlProvider.Odbc"/> stay reserved.
/// <para><b>Keep this arm-for-arm with the factory's <c>CreateDialect</c>.</b> When the factory and the
/// browser were widened to Postgres + Oracle this switch was left SqlServer-only, so a config that
/// deployed and browsed fine failed Test Connect with an <see cref="InvalidOperationException"/> — and
/// no test noticed, because every probe test injects its dialect through <see cref="ForTest"/>.</para>
/// </summary>
private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch
{
SqlProvider.SqlServer => new SqlServerDialect(),
SqlProvider.Postgres => new PostgresDialect(),
SqlProvider.Oracle => new OracleDialect(),
_ => throw new InvalidOperationException(
$"provider '{provider}' is not available in this build (only SqlServer is supported)."),
$"provider '{provider}' is not available in this build. Supported: " +
$"'{nameof(SqlProvider.SqlServer)}', '{nameof(SqlProvider.Postgres)}', " +
$"'{nameof(SqlProvider.Oracle)}'. Reserved (declared on the enum but not implemented): " +
$"'{nameof(SqlProvider.MySql)}', '{nameof(SqlProvider.Odbc)}'."),
};
}
@@ -13,16 +13,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <c>SqlGroupPlannerTests</c>.</para>
/// <para><b>Injection boundary (design §8.1).</b> Identifiers reach the command text through
/// <see cref="ISqlDialect.QuoteIdentifier"/> and nothing else; every authored value becomes a
/// <c>@k0</c>/<c>@w</c> marker with the value carried in <see cref="SqlQueryPlan.Parameters"/>. There is no
/// interpolation of tag content into SQL anywhere in this type.</para>
/// <c>k0</c>/<c>w</c> parameter — spelled with the dialect's own <see cref="ISqlDialect.ParameterMarker"/>
/// (<c>@k0</c> on SQL Server, <c>:p_k0</c> on Oracle) — with the value carried in
/// <see cref="SqlQueryPlan.Parameters"/>. There is no interpolation of tag content into SQL anywhere in
/// this type.</para>
/// <para><b>The base names are this planner's, the spelling is the dialect's.</b> Each plan carries the
/// markers it emitted <em>and</em> the matching <see cref="ISqlDialect.ParameterBindName"/>s, so the
/// reader binds what the planner minted instead of re-deriving a provider convention — the two differ on
/// ODP.NET (<c>:p_k0</c> in the text, bare <c>p_k0</c> on the parameter).</para>
/// </summary>
public static class SqlGroupPlanner
{
/// <summary>The parameter-marker prefix for a key-value model's <c>IN</c> list.</summary>
private const string KeyMarkerPrefix = "@k";
/// <summary>The base-name prefix for a key-value model's <c>IN</c>-list parameters (<c>k0…kN</c>).</summary>
private const string KeyBaseNamePrefix = "k";
/// <summary>The single parameter marker for a wide-row model's row selector.</summary>
private const string RowSelectorMarker = "@w";
/// <summary>The single parameter base name for a wide-row model's row selector.</summary>
private const string RowSelectorBaseName = "w";
/// <summary>
/// Groups <paramref name="tags"/> by source and emits one <see cref="SqlQueryPlan"/> per group.
@@ -132,7 +138,8 @@ public static class SqlGroupPlanner
if (timestampColumn is not null) AddDistinct(selected, timestampColumn);
// A keyValue may legitimately be the empty string, so only null is a broken invariant.
var names = new List<string>(members.Count);
var markers = new List<string>(members.Count);
var bindNames = new List<string>(members.Count);
var values = new List<object?>(members.Count);
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var member in members)
@@ -140,7 +147,9 @@ public static class SqlGroupPlanner
var keyValue = member.KeyValue
?? throw MissingField(nameof(SqlTagDefinition.KeyValue), member);
if (!seen.Add(keyValue)) continue;
names.Add(KeyMarkerPrefix + values.Count.ToString(CultureInfo.InvariantCulture));
var baseName = KeyBaseNamePrefix + values.Count.ToString(CultureInfo.InvariantCulture);
markers.Add(dialect.ParameterMarker(baseName));
bindNames.Add(dialect.ParameterBindName(baseName));
values.Add(keyValue);
}
@@ -148,10 +157,11 @@ public static class SqlGroupPlanner
.Append(QuoteList(selected, dialect))
.Append(" FROM ").Append(QuoteQualifiedName(first.Table, dialect))
.Append(" WHERE ").Append(dialect.QuoteIdentifier(keyColumn))
.Append(" IN (").AppendJoin(", ", names).Append(')')
.Append(" IN (").AppendJoin(", ", markers).Append(')')
.ToString();
return new SqlQueryPlan(SqlTagModel.KeyValue, groupKey, sql, names, values, members, selected);
return new SqlQueryPlan(
SqlTagModel.KeyValue, groupKey, sql, markers, bindNames, values, members, selected);
}
/// <summary>
@@ -183,12 +193,14 @@ public static class SqlGroupPlanner
// A where-pair wins over topByTimestamp; the parser already guarantees exactly one is populated.
if (!Blank(first.RowSelectorColumn) && first.RowSelectorValue is not null)
{
var marker = dialect.ParameterMarker(RowSelectorBaseName);
var sql = string.Concat(
"SELECT ", columns, " FROM ", table,
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", RowSelectorMarker);
" WHERE ", dialect.QuoteIdentifier(first.RowSelectorColumn!), " = ", marker);
return new SqlQueryPlan(
SqlTagModel.WideRow, groupKey, sql,
[RowSelectorMarker], [first.RowSelectorValue], members, selected);
[marker], [dialect.ParameterBindName(RowSelectorBaseName)],
[first.RowSelectorValue], members, selected);
}
if (!Blank(first.RowSelectorTopByTimestamp))
@@ -200,7 +212,7 @@ public static class SqlGroupPlanner
"SELECT ", dialect.SingleRowLimitPrefix, columns, " FROM ", table,
" ORDER BY ", dialect.QuoteIdentifier(first.RowSelectorTopByTimestamp!), " DESC",
dialect.SingleRowLimitSuffix);
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], members, selected);
return new SqlQueryPlan(SqlTagModel.WideRow, groupKey, sql, [], [], [], members, selected);
}
throw new ArgumentException(
@@ -393,13 +393,17 @@ public sealed class SqlPollReader
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
// Before any parameter is added: on ODP.NET this is what turns bind-by-position off, and
// a positionally-bound IN list feeds one tag's value to another tag's node in silence.
_dialect.ConfigureCommand(command);
command.CommandText = plan.SqlText;
command.CommandTimeout = _commandTimeoutSeconds;
for (var i = 0; i < plan.ParameterNames.Count; i++)
for (var i = 0; i < plan.ParameterBindNames.Count; i++)
{
var parameter = command.CreateParameter();
// Bound by the plan's own index pairing — never by re-deriving the marker convention.
parameter.ParameterName = plan.ParameterNames[i];
// Bound by the plan's own index pairing — never by re-deriving the marker convention,
// and never from ParameterNames: the marker and the bind name differ on ODP.NET.
parameter.ParameterName = plan.ParameterBindNames[i];
parameter.Value = plan.Parameters[i] ?? DBNull.Value;
command.Parameters.Add(parameter);
}
@@ -9,8 +9,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// per group rather than one query per tag.
/// <para><b>Injection boundary.</b> <see cref="SqlText"/> is built from dialect-quoted identifiers only;
/// every authored <em>value</em> lives in <see cref="Parameters"/> and is bound as a
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterNames"/>. There is no
/// path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
/// <see cref="DbParameter"/> named by the positionally-aligned <see cref="ParameterBindNames"/>. There is
/// no path by which a tag's <c>keyValue</c> or <c>whereValue</c> reaches the command text.</para>
/// <para><b>Markers and bind names are carried separately</b> because they are not the same string on
/// every provider: ODP.NET's text says <c>:p_k0</c> while its <see cref="DbParameter.ParameterName"/> is the
/// bare <c>p_k0</c>. Both lists are positionally aligned with <see cref="Parameters"/>, so a binding site
/// copies the pairing the planner minted rather than re-deriving a provider convention it does not own.</para>
/// <para>Produced only by <see cref="SqlGroupPlanner.Plan"/>; consumed by the poll reader, which executes
/// <see cref="SqlText"/> once and slices the rows back to per-member snapshots.</para>
/// </summary>
@@ -30,9 +34,15 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// with <see cref="Parameters"/>. Carried explicitly so the reader never has to re-derive the naming
/// convention.
/// </param>
/// <param name="ParameterBindNames">
/// The <see cref="DbParameter.ParameterName"/> to bind each value under, from
/// <see cref="ISqlDialect.ParameterBindName"/>, aligned index-for-index with <see cref="Parameters"/> and
/// with <see cref="ParameterNames"/>. Equal to the marker on every provider but ODP.NET, which wants the
/// bare name — which is exactly why it is a carried list and not a transformation of the marker.
/// </param>
/// <param name="Parameters">
/// The values to bind, aligned with <see cref="ParameterNames"/>. Captured verbatim from the authored tags
/// — a hostile value is inert here because it is data, not text.
/// The values to bind, aligned with <see cref="ParameterNames"/> and <see cref="ParameterBindNames"/>.
/// Captured verbatim from the authored tags — a hostile value is inert here because it is data, not text.
/// </param>
/// <param name="Members">
/// The tags this plan feeds, in the planner's input order. Never empty. Duplicates are preserved: two tags
@@ -47,6 +57,7 @@ public sealed record SqlQueryPlan(
object GroupKey,
string SqlText,
IReadOnlyList<string> ParameterNames,
IReadOnlyList<string> ParameterBindNames,
IReadOnlyList<object?> Parameters,
IReadOnlyList<SqlTagDefinition> Members,
IReadOnlyList<string> SelectedColumns)
@@ -57,6 +68,9 @@ public sealed record SqlQueryPlan(
/// <summary>The parameter markers in <see cref="SqlText"/>, aligned with <see cref="Parameters"/>.</summary>
public IReadOnlyList<string> ParameterNames { get; } = [.. ParameterNames];
/// <summary>The parameter names to bind under, aligned with <see cref="Parameters"/>.</summary>
public IReadOnlyList<string> ParameterBindNames { get; } = [.. ParameterBindNames];
/// <summary>The values to bind, aligned with <see cref="ParameterNames"/>.</summary>
public IReadOnlyList<object?> Parameters { get; } = [.. Parameters];
@@ -18,10 +18,13 @@
<div class="col-md-4">
<label class="form-label">Provider</label>
<InputSelect @bind-Value="_form.Provider" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@* v1 constructs only SqlServer; the other SqlProvider members are reserved (P2). *@
@* SqlServer, Postgres and Oracle construct a dialect; MySql/Odbc stay reserved
SqlProvider members and are deliberately not offered (authoring one fails the driver). *@
<option value="@SqlProvider.SqlServer">@SqlProvider.SqlServer</option>
<option value="@SqlProvider.Postgres">@SqlProvider.Postgres</option>
<option value="@SqlProvider.Oracle">@SqlProvider.Oracle</option>
</InputSelect>
<div class="form-text">Only Microsoft SQL Server is supported in v1.</div>
<div class="form-text">SQL Server, PostgreSQL and Oracle supported; MySql/Odbc reserved.</div>
</div>
<div class="col-md-8">
<label class="form-label">Connection string ref <span class="text-danger">*</span></label>
@@ -1,9 +1,16 @@
@* FOCAS address picker:
1. Static parameter group + parameter ID → axis:5 (always available).
1. Manual composition of the three addressed spaces — PMC (X0.0 / R100), CNC parameter
(PARAM:1020) and macro variable (MACRO:500) — always available.
2. (DriverOperator-gated, when the driver supports online discovery) Browse the FixedTree
device model captured by the universal DiscoveryDriverBrowser. FOCAS opens take a few
seconds longer than other drivers — its FixedTree cache fills post-connect and the browser
runs an UntilStable settle loop; the _opening spinner covers the wait. *@
device model captured by the universal DiscoveryDriverBrowser, which yields FIXED: paths.
FOCAS opens take a few seconds longer than other drivers — its FixedTree cache fills
post-connect and the browser runs an UntilStable settle loop; the _opening spinner covers
the wait.
Both branches now emit an address FocasAddress.TryParse accepts. Branch 1 used to offer
axis/spindle/program/status "parameter groups", which are not FOCAS addressing spaces, and
emitted "axis:5"; branch 2 committed the browse leaf's host-prefixed full reference. Neither
could be read (Gitea #540). *@
@implements IAsyncDisposable
@using Microsoft.AspNetCore.Authorization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@@ -14,20 +21,43 @@
@inject IAuthorizationService AuthorizationService
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Parameter group</label>
<select class="form-select form-select-sm" @bind="_group" @bind:after="OnChangedAsync">
<option value="axis">axis</option>
<option value="spindle">spindle</option>
<option value="program">program</option>
<option value="status">status</option>
<div class="col-md-3">
<label class="form-label">Address space</label>
<select class="form-select form-select-sm" @bind="_kind" @bind:after="OnChangedAsync">
<option value="@FocasManualAddressKind.Pmc">PMC</option>
<option value="@FocasManualAddressKind.Parameter">CNC parameter</option>
<option value="@FocasManualAddressKind.Macro">Macro variable</option>
</select>
</div>
@if (_kind == FocasManualAddressKind.Pmc)
{
<div class="col-md-2">
<label class="form-label">Area</label>
<select class="form-select form-select-sm" @bind="_pmcLetter" @bind:after="OnChangedAsync">
@foreach (var letter in FocasAddressBuilder.PmcLetters)
{
<option value="@letter">@letter</option>
}
</select>
</div>
}
<div class="col-md-3">
<label class="form-label">Parameter ID</label>
<label class="form-label">@(_kind == FocasManualAddressKind.Pmc ? "Byte" : "Number")</label>
<input type="number" class="form-control form-control-sm" min="0"
@bind="_parameterId" @bind:after="OnChangedAsync" />
@bind="_number" @bind:after="OnChangedAsync" />
</div>
@if (_kind != FocasManualAddressKind.Macro)
{
<div class="col-md-3">
<label class="form-label">
Bit <span class="text-muted small">(optional, @(_kind == FocasManualAddressKind.Pmc ? "0-7" : "0-31"))</span>
</label>
<input type="number" class="form-control form-control-sm" min="0"
max="@(_kind == FocasManualAddressKind.Pmc ? 7 : 31)"
placeholder="whole word"
@bind="_bitIndex" @bind:after="OnChangedAsync" />
</div>
}
</div>
@if (_canBrowse)
@@ -68,7 +98,14 @@
<div class="mt-3">
<span class="text-muted small">Result:</span>
<code class="mono ms-2">@_built</code>
@if (string.IsNullOrEmpty(_built))
{
<span class="chip chip-bad ms-2">not a valid address</span>
}
else
{
<code class="mono ms-2">@_built</code>
}
</div>
@code {
@@ -79,8 +116,10 @@
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
[Parameter] public Func<string> GetConfigJson { get; set; } = () => "{}";
private string _group = "axis";
private int _parameterId = 0;
private FocasManualAddressKind _kind = FocasManualAddressKind.Pmc;
private string _pmcLetter = "R";
private int _number = 0;
private int? _bitIndex;
private string _built = "";
private Guid _token = Guid.Empty;
@@ -94,7 +133,7 @@
var authResult = await AuthorizationService.AuthorizeAsync(auth.User, null, AdminUiPolicies.DriverOperator);
// CanBrowse constructs a throwaway driver — evaluate once, not per render.
_canBrowse = authResult.Succeeded && BrowserService.CanBrowse(DriverType, GetConfigJson() ?? "{}");
_built = FocasAddressBuilder.Build(_group, _parameterId);
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
await CurrentAddressChanged.InvokeAsync(_built);
}
@@ -125,8 +164,9 @@
private async Task OnTreeSelectAsync(BrowseNode node)
{
// Folders fire the same callback — commit only on a leaf. The captured NodeId is the
// driver FullName (FixedTree address), which the group/id builder cannot reconstruct, so
// Folders fire the same callback — commit only on a leaf. The captured NodeId is the driver
// FullName, which for a fixed-tree leaf is its canonical FIXED: address (Gitea #540); the
// manual builder composes only the three addressed spaces and cannot reconstruct it, so
// commit it directly rather than through OnChangedAsync.
if (node.Kind != BrowseNodeKind.Leaf) return;
_built = node.NodeId;
@@ -135,7 +175,7 @@
private async Task OnChangedAsync()
{
_built = FocasAddressBuilder.Build(_group, _parameterId);
_built = FocasAddressBuilder.Build(_kind, _pmcLetter, _number, _bitIndex);
await CurrentAddressChanged.InvokeAsync(_built);
}
@@ -1,16 +1,76 @@
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
/// <summary>
/// Pure static helper that converts a FOCAS parameter group + parameter ID
/// into the canonical address string (e.g. axis:5).
/// The addressing spaces the manual FOCAS picker can compose. Mirrors the parseable subset of
/// <see cref="FocasAreaKind"/>: <see cref="FocasAreaKind.Fixed"/> is deliberately absent because a
/// fixed-tree path is discovered from the machine, not composed from parts — the picker's browse
/// tree supplies those.
/// </summary>
public enum FocasManualAddressKind
{
/// <summary>PMC area — letter + byte + optional bit.</summary>
Pmc,
/// <summary>CNC parameter number + optional bit.</summary>
Parameter,
/// <summary>Macro variable number.</summary>
Macro,
}
/// <summary>
/// Pure static helper that composes a canonical FOCAS address from the manual picker's parts.
/// Extracted so unit tests can call it without bUnit.
/// </summary>
/// <remarks>
/// <para>
/// <b>It builds through <see cref="FocasAddress"/> rather than formatting a string</b>, so what
/// it emits is parseable by construction and the two cannot drift.
/// </para>
/// <para>
/// It previously emitted <c>$"{group}:{parameterId}"</c> over a group list of
/// <c>axis/spindle/program/status</c> — none of which is a FOCAS addressing space — so the
/// picker's own default value, <c>axis:5</c>, was an address the driver rejects. Together with
/// the browse-commit defect this meant every UI-assisted authoring path produced unusable tags,
/// and only hand-typed addresses worked at all (Gitea #540).
/// </para>
/// </remarks>
public static class FocasAddressBuilder
{
/// <summary>Builds the canonical FOCAS address string from a parameter group and parameter ID.</summary>
/// <param name="group">The FOCAS parameter group (e.g. <c>"axis"</c>).</param>
/// <param name="parameterId">The parameter ID within the group.</param>
/// <returns>The canonical address string in <c>group:parameterId</c> form (e.g. <c>axis:5</c>).</returns>
public static string Build(string group, int parameterId)
=> $"{group}:{parameterId}";
/// <summary>The PMC area letters the driver accepts, in the order the picker offers them.</summary>
public static IReadOnlyList<string> PmcLetters { get; } =
["X", "Y", "F", "G", "R", "D", "C", "K", "A", "E", "T"];
/// <summary>
/// Composes the canonical FOCAS address string, or an empty string when the parts do not form a
/// valid address (an out-of-range bit, a negative number, an unknown PMC letter).
/// </summary>
/// <param name="kind">Which addressing space the address is in.</param>
/// <param name="pmcLetter">The PMC area letter; ignored unless <paramref name="kind"/> is
/// <see cref="FocasManualAddressKind.Pmc"/>.</param>
/// <param name="number">The PMC byte, parameter number or macro number.</param>
/// <param name="bitIndex">Optional bit index — 0–7 for PMC, 0–31 for a parameter. Null for a
/// whole-word address; always ignored for a macro, which has no bit form.</param>
/// <returns>The canonical address, or an empty string when the parts are not addressable.</returns>
public static string Build(
FocasManualAddressKind kind, string? pmcLetter, int number, int? bitIndex)
{
FocasAddress? candidate = kind switch
{
FocasManualAddressKind.Pmc => new FocasAddress(
FocasAreaKind.Pmc, (pmcLetter ?? "").Trim().ToUpperInvariant(), number, bitIndex),
FocasManualAddressKind.Parameter => new FocasAddress(
FocasAreaKind.Parameter, null, number, bitIndex),
FocasManualAddressKind.Macro => new FocasAddress(
FocasAreaKind.Macro, null, number, null),
_ => null,
};
// Round-trip rather than trust the parts: the record accepts any values, the parser is the
// authority on which of them address anything, and an empty result is what the picker shows
// as "no address yet" instead of a plausible-looking string the driver would silently skip.
return candidate?.Canonical is { } text && FocasAddress.TryParse(text) is not null ? text : "";
}
}
@@ -47,6 +47,32 @@ public sealed class FocasTagConfigModel
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
/// <remarks>
/// <b>This runs the driver's real parser, not a non-empty check</b> (Gitea #540). It used to
/// accept any non-blank string, which is how 20 tags carrying a browse full reference
/// (<c>10.201.31.5:8193/Identity/SeriesNumber</c>) were authored, deployed and sealed green —
/// the driver then skipped every one at init and they read <c>BadNodeIdUnknown</c> forever
/// with no signal anywhere. An address that cannot be parsed cannot be polled, so the
/// rejection belongs here, where the operator is still looking at the field.
/// <para><see cref="FocasAddress"/> lives in the driver's <c>Contracts</c> assembly precisely
/// so this project can call it without referencing the runtime driver (Gitea #526).</para>
/// </remarks>
public string? Validate()
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
{
if (string.IsNullOrWhiteSpace(Address)) return "Address is required.";
return FocasAddress.TryParse(Address) is null ? DescribeUnparseableAddress(Address) : null;
}
/// <summary>
/// The operator-facing rejection message for an address the FOCAS parser will not accept,
/// naming the four legal forms. Shared with the deploy-time inspector so the modal and the
/// deploy gate say the same thing about the same string.
/// </summary>
/// <param name="address">The rejected address.</param>
/// <returns>The message.</returns>
internal static string DescribeUnparseableAddress(string address) =>
$"'{address.Trim()}' is not a FOCAS address. Expected a PMC address (X0.0, R100, F20.3), " +
"a CNC parameter (PARAM:1020, PARAM:1815/0), a macro variable (MACRO:500), or a fixed-tree " +
"path (FIXED:Identity/SeriesNumber). A browse full reference including the device host is " +
"not an address — commit the leaf from the browser rather than pasting its id.";
}
@@ -4,8 +4,8 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for an MTConnect tag's TagConfig JSON. The tag binds a single Agent
/// <c>DataItem</c> by its <c>id</c> attribute (<see cref="FullName"/> — MTConnect is read-only, so
/// there is no address encoding beyond the id); the remaining fields are probe-sourced display
/// <c>DataItem</c> by its <c>id</c> attribute (<see cref="FullName"/> — reads and writes both address
/// the DataItem by id, so there is no address encoding beyond it); the remaining fields are probe-sourced display
/// metadata plus an author-settable type override. Preserves unrecognised JSON keys across a
/// load→save.</summary>
/// <remarks>
@@ -0,0 +1,90 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// The <c>FIXED:</c> address form added by Gitea #540, and the guarantee that adding it did not
/// disturb the three forms that were already there.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasAddressFixedFormTests
{
[Theory]
[InlineData("FIXED:Identity/SeriesNumber", "Identity/SeriesNumber")]
[InlineData("FIXED:Axes/X/AbsolutePosition", "Axes/X/AbsolutePosition")]
[InlineData("FIXED:Timers/PowerOnSeconds", "Timers/PowerOnSeconds")]
[InlineData("FIXED:Spindle/S1/Load", "Spindle/S1/Load")]
[InlineData("fixed:Program/Name", "Program/Name")] // prefix is case-insensitive
[InlineData(" FIXED:Program/Name ", "Program/Name")] // outer whitespace tolerated
public void Parses_a_fixed_tree_path(string input, string expectedPath)
{
var parsed = FocasAddress.TryParse(input);
parsed.ShouldNotBeNull();
parsed.Kind.ShouldBe(FocasAreaKind.Fixed);
parsed.FixedPath.ShouldBe(expectedPath);
parsed.Canonical.ShouldBe("FIXED:" + expectedPath);
}
[Theory]
[InlineData("FIXED:")] // no path
[InlineData("FIXED: ")]
[InlineData("FIXED:/Identity/Series")] // leading separator
[InlineData("FIXED:Identity/Series/")] // trailing separator
[InlineData("FIXED:Identity//Series")] // empty segment
[InlineData("FIXED:Identity/ /Series")] // blank segment
[InlineData("FIXED:MACRO:500")] // a colon would make the grammar ambiguous
public void Rejects_a_path_that_is_not_addressable(string input)
=> FocasAddress.TryParse(input).ShouldBeNull();
/// <summary>
/// The exact strings the defect produced. Each names a real fixed-tree field but carries the
/// device host, which is the tag's Device row's job — so they must still be rejected rather
/// than quietly accepted with the host treated as a path segment.
/// </summary>
[Theory]
[InlineData("10.201.31.5:8193/Identity/SeriesNumber")]
[InlineData("focas://10.201.31.5:8193/Axes/X/AbsolutePosition")]
[InlineData("axis:5")] // the manual picker's old default
public void Still_rejects_what_the_defect_authored(string input)
=> FocasAddress.TryParse(input).ShouldBeNull();
/// <summary>
/// <c>F</c> is a valid PMC area letter, so the <c>FIXED:</c> prefix has to be tested before
/// the PMC branch. Both forms must survive that ordering.
/// </summary>
[Fact]
public void The_fixed_prefix_does_not_shadow_the_pmc_F_area()
{
FocasAddress.TryParse("F20.3")!.Kind.ShouldBe(FocasAreaKind.Pmc);
FocasAddress.TryParse("F20.3")!.PmcLetter.ShouldBe("F");
FocasAddress.TryParse("FIXED:Program/Name")!.Kind.ShouldBe(FocasAreaKind.Fixed);
}
[Theory]
[InlineData("R100")]
[InlineData("X0.0")]
[InlineData("PARAM:1020")]
[InlineData("PARAM:1815/0")]
[InlineData("MACRO:500")]
public void The_pre_existing_forms_still_round_trip(string input)
{
var parsed = FocasAddress.TryParse(input);
parsed.ShouldNotBeNull();
parsed.Kind.ShouldNotBe(FocasAreaKind.Fixed);
parsed.Canonical.ShouldBe(input);
}
/// <summary>The one place the <c>FIXED:</c> spelling is built, so emitter and parser cannot drift.</summary>
[Fact]
public void ForFixedPath_produces_what_TryParse_accepts()
{
var built = FocasAddress.ForFixedPath("Identity/SeriesNumber");
built.ShouldBe("FIXED:Identity/SeriesNumber");
FocasAddress.TryParse(built)!.FixedPath.ShouldBe("Identity/SeriesNumber");
}
}
@@ -0,0 +1,275 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Gitea #540 — authoring a fixed-tree tag through <c>/raw</c> → Browse device… produced a tag
/// the driver could never read. The browse leaf's full reference was <c>{deviceHost}/{path}</c>,
/// the commit mapper wrote it verbatim into <c>TagConfig.address</c>, and
/// <see cref="FocasAddress.TryParse"/> accepts no such form — so the driver skipped the tag at
/// init and every read returned <c>BadNodeIdUnknown</c>, indefinitely, while the deploy sealed
/// green and driver health stayed Healthy with zero errors.
/// <para>
/// Found on a real Makino Pro 5 / Fanuc 30i: 0 of 20 browse-committed tags read, while two
/// hand-authored <c>MACRO:</c> tags on the same device read Good.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasFixedTreeAddressingTests
{
private const string Host = "focas://10.0.0.5:8193";
private static FocasDriver NewDriver(
FakeFocasClientFactory factory,
bool fixedTreeEnabled,
params FocasTagDefinition[] tags) =>
new(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)],
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions { Enabled = fixedTreeEnabled },
RawTags = FocasRawTags.Entries(tags),
}, "drv-1", factory);
private static async Task<bool> WaitForAsync(Func<bool> predicate, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (predicate()) return true;
await Task.Delay(25, TestContext.Current.CancellationToken);
}
return predicate();
}
/// <summary>
/// The headline regression, stated over the WHOLE emitted tree rather than a sample: nothing
/// the browser can offer is unauthorable. Sampling is what let this ship — the tree looked
/// right, and no test ever asked whether the strings hanging off it were addresses.
/// </summary>
[Fact]
public async Task Every_browse_emitted_leaf_carries_an_address_the_driver_can_parse()
{
var drv = NewDriver(new FakeFocasClientFactory(), fixedTreeEnabled: true);
await drv.InitializeAsync("{}", CancellationToken.None);
(await WaitForAsync(
() => drv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5)))
.ShouldBeTrue("the fixed-tree cache never filled, so the tree under test was empty");
var builder = new RecordingBuilder();
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.ShouldNotBeEmpty();
foreach (var (browseName, info) in builder.Variables)
{
FocasAddress.TryParse(info.FullName)
.ShouldNotBeNull($"leaf '{browseName}' emitted '{info.FullName}', which is not an address");
// The specific shape that broke it: the device host leaking into the address, where it
// is both unparseable and a second routing channel competing with the tag's Device row.
info.FullName.ShouldNotContain("10.0.0.5");
}
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// The end-to-end claim the issue is about: take what the browser offers, author it as a tag,
/// and read it. Anything short of this passed before the fix.
/// </summary>
[Fact]
public async Task A_leaf_authored_from_the_browse_tree_reads_its_value()
{
// 1. Browse.
var browseDrv = NewDriver(new FakeFocasClientFactory(), fixedTreeEnabled: true);
await browseDrv.InitializeAsync("{}", CancellationToken.None);
(await WaitForAsync(
() => browseDrv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5)))
.ShouldBeTrue();
var builder = new RecordingBuilder();
await browseDrv.DiscoverAsync(builder, CancellationToken.None);
await browseDrv.ShutdownAsync(CancellationToken.None);
var seriesLeaf = builder.Variables.Single(v => v.Info.FullName == "FIXED:Identity/SeriesNumber");
// 2. Commit it as a raw tag, exactly as the browse-commit mapper does — the leaf's full
// reference becomes the tag's address, and its RawPath is where the operator put it.
var runDrv = NewDriver(
new FakeFocasClientFactory(),
fixedTreeEnabled: true,
new FocasTagDefinition(
"focas-cnc/cnc/FOCAS/cnc/Identity/SeriesNumber",
Host,
seriesLeaf.Info.FullName,
FocasDataType.String,
Writable: false));
await runDrv.InitializeAsync("{}", CancellationToken.None);
(await WaitForAsync(
() => runDrv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5)))
.ShouldBeTrue();
// 3. Read it by RawPath, which is the reference the v3 server uses.
var read = await runDrv.ReadAsync(["focas-cnc/cnc/FOCAS/cnc/Identity/SeriesNumber"], CancellationToken.None);
read[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
read[0].Value.ShouldBe("30i"); // FakeFocasClient.SysInfo.Series
await runDrv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// <c>FixedTree.Enabled</c> defaults to false and the universal browser force-enables it only
/// for the duration of a browse. Without this, an operator would browse the tree, commit
/// leaves, deploy, and land on a driver whose poll loop never starts — every tag Bad, with a
/// perfectly correct address. Authoring a FIXED: tag is the request for the tree.
/// </summary>
[Fact]
public async Task An_authored_fixed_tag_arms_the_poll_loop_with_the_config_flag_off()
{
var drv = NewDriver(
new FakeFocasClientFactory(),
fixedTreeEnabled: false,
new FocasTagDefinition("raw/Program", Host, "FIXED:Program/Name", FocasDataType.String, Writable: false));
await drv.InitializeAsync("{}", CancellationToken.None);
(await WaitForAsync(
() => drv.GetDeviceState(Host)?.FixedTreeCache is not null, TimeSpan.FromSeconds(5)))
.ShouldBeTrue("an authored FIXED: tag must start the fixed-tree loop on its own");
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A driver with no fixed-tree tags and the flag off must still not start the loop.</summary>
[Fact]
public async Task An_ordinary_tag_does_not_arm_the_poll_loop()
{
var drv = NewDriver(
new FakeFocasClientFactory(),
fixedTreeEnabled: false,
new FocasTagDefinition("raw/Run", Host, "R100", FocasDataType.Byte));
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(300, TestContext.Current.CancellationToken);
drv.GetDeviceState(Host)!.FixedTreeCache.ShouldBeNull();
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// A path this machine does not expose is a genuine <c>BadNodeIdUnknown</c> — the address
/// parsed, it simply names nothing here (a capability the series lacks, a renamed axis).
/// </summary>
[Fact]
public async Task A_fixed_path_the_machine_does_not_expose_reads_BadNodeIdUnknown()
{
var drv = NewDriver(
new FakeFocasClientFactory(),
fixedTreeEnabled: true,
new FocasTagDefinition("raw/Ghost", Host, "FIXED:Axes/Q/AbsolutePosition", FocasDataType.Float64, Writable: false));
await drv.InitializeAsync("{}", CancellationToken.None);
(await WaitForAsync(
() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(5)))
.ShouldBeTrue();
var read = await drv.ReadAsync(["raw/Ghost"], CancellationToken.None);
read[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// Nothing in the fixed tree is a writable location on the CNC — it is a read-out of machine
/// state. Refused before a connection is opened.
/// </summary>
[Fact]
public async Task A_fixed_tree_tag_refuses_writes()
{
var drv = NewDriver(
new FakeFocasClientFactory(),
fixedTreeEnabled: true,
// Writable:true on purpose — the refusal must come from the address kind, not from the
// tag's own flag, or an operator could author their way past it.
new FocasTagDefinition("raw/Series", Host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: true));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("raw/Series", "nope")], CancellationToken.None);
results[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// A dead CNC must not keep serving its last fixed-tree values as Good. The poll loop
/// swallows a failed tick by design (LogDebug + retry), so nothing else marks the caches
/// stale — and unlike an area read, a fixed-tree read issues no traffic that could fail.
/// This was harmless while nothing could bind to the tree; it is not harmless now.
/// </summary>
[Fact]
public async Task Fixed_tree_values_go_bad_once_the_device_stops_answering()
{
var drv = NewDriver(
new FakeFocasClientFactory(),
fixedTreeEnabled: true,
new FocasTagDefinition("raw/Series", Host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: false));
await drv.InitializeAsync("{}", CancellationToken.None);
(await WaitForAsync(
() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(5)))
.ShouldBeTrue();
(await drv.ReadAsync(["raw/Series"], CancellationToken.None))[0]
.StatusCode.ShouldBe(FocasStatusMapper.Good);
// Age the last answered poll past the staleness budget. Reaching into DeviceState rather
// than sleeping keeps this deterministic and instant; the budget itself is asserted below.
var state = drv.GetDeviceState(Host)!;
state.FixedTreeCts?.Cancel();
state.LastFixedPollUtc = DateTime.UtcNow
- FocasDriver.FixedTreeStaleAfter(TimeSpan.FromMilliseconds(250))
- TimeSpan.FromSeconds(1);
var stale = await drv.ReadAsync(["raw/Series"], CancellationToken.None);
stale[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// The staleness budget tracks the poll cadence but never drops below 30 s, so an ordinarily
/// slow CNC on the sub-second default cadence does not flap between Good and Bad.
/// </summary>
[Theory]
[InlineData(250, 30_000)] // default cadence — the 30 s floor wins
[InlineData(1_000, 30_000)] // 8 s of cadence still under the floor
[InlineData(10_000, 80_000)] // slow cadence — 8x the interval wins
public void Stale_budget_is_eight_cadences_floored_at_thirty_seconds(int pollMs, int expectedMs)
=> FocasDriver.FixedTreeStaleAfter(TimeSpan.FromMilliseconds(pollMs))
.ShouldBe(TimeSpan.FromMilliseconds(expectedMs));
/// <summary>Records what <c>DiscoverAsync</c> emits, flattening the folder tree.</summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = [];
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string name, DriverDataType type, object? value) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -25,12 +25,20 @@
EVENT, PART_COUNT -> Int64 fixture_partcount (the regression case)
EVENT, LINE_NUMBER -> Int64 fixture_linenumber
EVENT, controlled vocabulary -> String fixture_execution, fixture_avail, fixture_mode
EVENT, free text -> String fixture_program, fixture_block (never fed)
EVENT, free text -> String fixture_program, fixture_block (never fed),
fixture_writeback (never fed; write target)
EVENT, DATA_SET representation -> String fixture_varset
CONDITION (any type) -> String + IsAlarm fixture_x_travel, fixture_logic
fixture_block is deliberately NEVER fed by the adapter, so the Agent reports it
UNAVAILABLE forever — the live half of the UNAVAILABLE -> BadNoCommunication mapping.
fixture_writeback is likewise never fed, and exists for a different reason: it is the
target the write legs POST to (agent.cfg has AllowPut = true). It is a SEPARATE item from
fixture_block on purpose — the first successful write would take fixture_block off
UNAVAILABLE for the life of the container and quietly rob the mapping test above of its
subject. Because no adapter ever writes fixture_writeback, the value a write leg reads back
out of /current can only have come from that write.
-->
<MTConnectDevices
xmlns="urn:mtconnect.org:MTConnectDevices:2.0"
@@ -99,6 +107,12 @@
<DataItem category="EVENT" id="fixture_program" name="Pprogram" type="PROGRAM"/>
<!-- Free-text EVENT the adapter NEVER feeds -> stays UNAVAILABLE. -->
<DataItem category="EVENT" id="fixture_block" name="Pblock" type="BLOCK"/>
<!--
The write-back target (MTCONNECT_WRITE_TARGET overrides the id when the suite is
pointed at a real Agent). Free-text EVENT, so any string a write leg sends is a
legal value, and never fed by the adapter, so nothing races the assertion.
-->
<DataItem category="EVENT" id="fixture_writeback" name="Pwriteback" type="PROGRAM_COMMENT"/>
<!-- DATA_SET representation demotes to String even though the Agent sends key=value entries. -->
<DataItem category="EVENT" id="fixture_varset" name="Pvars" type="VARIABLE" representation="DATA_SET"/>
<DataItem category="CONDITION" id="fixture_logic" name="Plogic" type="LOGIC_PROGRAM"/>
@@ -22,8 +22,18 @@ BufferSize = 14
CheckpointFrequency = 1000
MaxAssets = 128
# MTConnect is a read-only source for this driver; the Agent must not accept writes.
AllowPut = false
# Writes ARE in scope for this fixture, because the driver now implements IWritable (see
# docs/plans/2026-08-05-mtconnect-writeback-design.md). The mechanism is this very key: with
# AllowPut on, POST {agent}/{device}?{dataItemId}={value} sets what the Agent REPORTS for that
# observation. It is injection into the Agent's buffer -- visible to every consumer of the
# Agent, overwritten by the next SHDR update for the same DataItem -- and never a command to a
# machine. The integration suite's write legs (round-trip, agent refusal, disabled gate) need
# it on; with the driver's own write.enabled off, the shipped read-only behaviour is unchanged.
#
# No AllowPutFrom is set, so any host that can reach the port may write. Acceptable for a
# throwaway test Agent on the fixture network; NOT acceptable for a plant Agent, where the
# allow-list is the second half of the gate.
AllowPut = true
SchemaVersion = 2.0
MonitorConfigFiles = false
@@ -55,14 +55,23 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
/// PART_COUNT) <c>Assert.Skip</c>s rather than passing vacuously.
/// </para>
/// <para>
/// <b>Two things this suite deliberately does NOT cover, with reasons.</b>
/// (a) <b><c>MTConnectError</c> under HTTP 200.</b> The driver handles it and canned tests
/// <b>One thing this suite deliberately does NOT cover, with a reason.</b>
/// <b><c>MTConnectError</c> under HTTP 200.</b> The driver handles it and canned tests
/// pin it, but a modern Agent cannot be made to produce it: <c>mtconnect/agent</c> 2.7
/// answers an unknown device with <b>404</b> and an out-of-range sequence with <b>400</b>,
/// each carrying a well-formed <c>MTConnectError</c> body (verified live). The under-200
/// shape belongs to older/third-party Agents and stays canned-only.
/// (b) <b>Writes.</b> MTConnect is a read-only source protocol and the driver implements no
/// <c>IWritable</c>; the fixture Agent runs <c>AllowPut = false</c> to match.
/// </para>
/// <para>
/// <b>Writes ARE covered now</b> — the driver gained <c>IWritable</c> and the fixture Agent
/// runs <c>AllowPut = true</c> to match (<c>Docker/agent.cfg</c>). Three legs: the round trip
/// (a driver write the Agent then reports back), the Agent's own refusal of an undeclared
/// DataItem mapping to <c>BadCommunicationError</c>, and the <c>write.enabled</c> gate
/// refusing with <c>BadNotWritable</c> without touching the Agent. The injected target is
/// <c>fixture_writeback</c>, a DataItem the fixture's adapter never feeds, so an observed
/// value can only have come from the write under test. A write is <b>observation
/// injection</b>, not machine command: it sets what the Agent reports, which is exactly why
/// a live Agent is the only thing that can prove it.
/// </para>
/// </remarks>
[Collection(MTConnectAgentCollection.Name)]
@@ -83,9 +92,40 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
private const uint BadNoCommunication = 0x80310000u;
/// <summary>The Agent answered and refused the write (non-2xx, or an <c>MTConnectError</c> under 200).</summary>
private const uint BadCommunicationError = 0x80050000u;
/// <summary>No DataItem this driver serves answers to the reference — decided locally, before the wire.</summary>
private const uint BadNodeIdUnknown = 0x80340000u;
/// <summary><c>write.enabled</c> is off (or the DataItem is CONDITION-category): nothing is attempted.</summary>
private const uint BadNotWritable = 0x803B0000u;
/// <summary>
/// Overrides the DataItem the write legs inject into, for a run pointed at a real Agent whose
/// model has no <c>fixture_writeback</c>.
/// </summary>
private const string WriteTargetEnvVar = "MTCONNECT_WRITE_TARGET";
/// <summary>
/// The fixture's dedicated write target: a free-text EVENT the SHDR adapter NEVER feeds, so a
/// value observed on it can only have come from the write under test. Deliberately NOT
/// <c>fixture_block</c> — the first successful write would take that item off
/// <c>UNAVAILABLE</c> for the life of the container and rob
/// <see cref="Read_maps_an_unavailable_observation_to_BadNoCommunication"/> of its subject.
/// </summary>
private const string DefaultWriteTargetId = "fixture_writeback";
/// <summary>How long a subscription test waits for the live <c>/sample</c> stream to move a value.</summary>
private static readonly TimeSpan StreamWait = TimeSpan.FromSeconds(45);
/// <summary>
/// How long a write leg waits for the Agent to report the injected observation on
/// <c>/current</c>. A PUT lands in the Agent's buffer synchronously, so this is slack for the
/// HTTP round trips rather than for any processing the Agent has to schedule.
/// </summary>
private static readonly TimeSpan WriteVisibleWait = TimeSpan.FromSeconds(15);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
// ---- reachability ----
@@ -537,6 +577,169 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
}
}
// ---- writes: the Agent's AllowPut surface ----
/// <summary>
/// <b>The write round trip, and the only thing that can prove the PUT surface at all.</b> A
/// driver write must be accepted by the Agent and must then be what the Agent
/// <i>reports</i> for that DataItem — read back off <c>/current</c> over plain HTTP (not
/// through the parser under test), and then through the driver's own read path.
/// </summary>
/// <remarks>
/// <para>
/// <b>The value is unique per run</b> (a <see cref="Environment.TickCount64"/> suffix).
/// The Agent keeps an injected observation for the life of the container, so a constant
/// would be satisfied by a previous run's leftover and the test would pass against a
/// driver whose write never left the process.
/// </para>
/// <para>
/// <b>The target is a DataItem the SHDR adapter never feeds</b>, so no observation but
/// this write can put a value on it. Ring-buffer eviction is not a hazard here even
/// though the fixture's stream is busy: <c>BufferSize = 14</c> in <c>agent.cfg</c> is an
/// exponent (2^14 = 16384 observations), and <c>/current</c> is answered from the
/// Agent's checkpoint — the newest observation per DataItem — which survives the ring
/// wrapping past it.
/// </para>
/// </remarks>
[Fact(Timeout = TestTimeoutMs)]
public async Task Write_injects_an_observation_the_agent_then_reports()
{
SkipIfDown();
var target = ResolveWriteTarget();
if (target is null)
{
Assert.Skip(NoWriteTargetReason());
}
var (dataItemId, deviceName) = target!.Value;
var value = $"OTOPCUA-WRITE-{Environment.TickCount64:X}";
await using var live = await LiveDriverAsync([Tag(dataItemId, DriverDataType.String)], writeEnabled: true);
var results = await live.Driver.WriteAsync([new WriteRequest(dataItemId, value)], Ct);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(
Good,
$"the Agent at {agent.AgentUri} must accept a write to DataItem '{dataItemId}' on device " +
$"'{deviceName}'. A refusal here is most often AllowPut still false in the Agent's config.");
var observed = await WaitForObservationAsync(dataItemId, value, WriteVisibleWait);
observed.ShouldBe(
value,
$"the Agent must REPORT the injected value on /current within {WriteVisibleWait.TotalSeconds:F0}s; " +
"a Good write whose value never appears is a write that went nowhere");
var reads = await live.Driver.ReadAsync([dataItemId], Ct);
reads.Count.ShouldBe(1);
reads[0].StatusCode.ShouldBe(Good, "the injected observation must read back through the driver's own path");
reads[0].Value.ShouldBe(value);
}
/// <summary>
/// <b>The Agent's own refusal, mapped to <c>BadCommunicationError</c>.</b> A write naming a
/// DataItem the Agent does not declare reaches the wire and comes back refused.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this reaches the Agent at all, when an unknown reference is normally answered
/// locally.</b> <c>BuildWriteTargets</c> derives a write target for every DataItem the
/// <i>binding</i> serves — i.e. every authored tag — resolving the device from the probe
/// model and falling back to the configured <c>deviceName</c>. So an authored tag naming
/// an id the Agent never declared, on a driver whose <c>deviceName</c> IS configured,
/// resolves to a real URL and is POSTed. That is precisely the shape a stale tag has after
/// a machine's DataItem is renamed, and the Agent's answer to it is the refusal this leg
/// pins.
/// </para>
/// <para>
/// <b>The second half is the falsifiability control</b>, and without it the leg proves
/// nothing: the SAME unknown id on a driver with NO configured device name has no URL to
/// POST to and must be refused locally as <c>BadNodeIdUnknown</c>. Two different codes for
/// the same id is what shows the first write actually went to the Agent rather than being
/// short-circuited into a look-alike failure.
/// </para>
/// </remarks>
[Fact(Timeout = TestTimeoutMs)]
public async Task Write_to_a_data_item_the_agent_does_not_declare_reports_the_agents_refusal()
{
SkipIfDown();
var target = ResolveWriteTarget();
if (target is null)
{
Assert.Skip(NoWriteTargetReason());
}
var deviceName = target!.Value.DeviceName;
var unknownId = $"otopcua_no_such_data_item_{Environment.TickCount64:X}";
await using (var live = await LiveDriverAsync(
[Tag(unknownId, DriverDataType.String)], writeEnabled: true, deviceName: deviceName))
{
var refused = await live.Driver.WriteAsync([new WriteRequest(unknownId, "refuse-me")], Ct);
refused.Count.ShouldBe(1);
refused[0].StatusCode.ShouldBe(
BadCommunicationError,
$"the Agent at {agent.AgentUri} must refuse a write to DataItem '{unknownId}', which device " +
$"'{deviceName}' does not declare, and the driver must report that refusal as an Agent " +
"failure rather than as success");
}
await using var noDevice = await LiveDriverAsync([Tag(unknownId, DriverDataType.String)], writeEnabled: true);
var local = await noDevice.Driver.WriteAsync([new WriteRequest(unknownId, "refuse-me")], Ct);
local.Count.ShouldBe(1);
local[0].StatusCode.ShouldBe(
BadNodeIdUnknown,
"with no device resolvable there is no URL to POST to, so the refusal must be decided locally — " +
"and a run where BOTH writes report the same code proves nothing about the Agent");
}
/// <summary>
/// <b>The driver-side gate, which is the half an operator controls.</b> With
/// <c>write.enabled</c> absent the same write is refused <c>BadNotWritable</c> — and the
/// Agent's <c>/current</c> is untouched, which is the assertion that distinguishes "refused"
/// from "reported as refused and sent anyway".
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Write_is_refused_without_touching_the_agent_when_write_is_not_enabled()
{
SkipIfDown();
var target = ResolveWriteTarget();
if (target is null)
{
Assert.Skip(NoWriteTargetReason());
}
var dataItemId = target!.Value.DataItemId;
var value = $"OTOPCUA-DISABLED-{Environment.TickCount64:X}";
var before = await ObservationValueAsync(dataItemId);
// No writeEnabled: the driver's own gate, with the Agent's AllowPut left on.
await using var live = await LiveDriverAsync([Tag(dataItemId, DriverDataType.String)]);
var results = await live.Driver.WriteAsync([new WriteRequest(dataItemId, value)], Ct);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(
BadNotWritable,
"write.enabled is absent, so the honest answer is 'this is not writable' — not a comms failure");
// Long enough that a write which HAD been dispatched would be visible: the round-trip leg
// observes one inside WriteVisibleWait, and this waits for the same value to appear.
var after = await WaitForObservationAsync(dataItemId, value, TimeSpan.FromSeconds(3));
after.ShouldNotBe(value, "a gated write must never reach the Agent's observation buffer");
after.ShouldBe(
before,
$"nothing else feeds DataItem '{dataItemId}', so its reported value must be exactly what it was " +
"before the gated write");
}
// ---- helpers ----
private void SkipIfDown()
@@ -569,13 +772,17 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
return port;
}
private MTConnectDriverOptions LiveOptions(string? deviceName = null) =>
private MTConnectDriverOptions LiveOptions(string? deviceName = null, bool writeEnabled = false) =>
new()
{
AgentUri = agent.AgentUri,
DeviceName = deviceName,
RequestTimeoutMs = 15_000,
// Off unless a write leg asks for it: every other test in this suite must run against the
// shipped read-only driver, whatever the fixture Agent's AllowPut says.
Write = new MTConnectWriteOptions { Enabled = writeEnabled },
// Faster than the 1000 ms default so a subscription sees several distinct chunks inside
// StreamWait; the heartbeat stays well under it so a silent Agent is caught, not waited on.
SampleIntervalMs = 200,
@@ -597,6 +804,10 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
sampleCount = options.SampleCount,
heartbeatMs = options.HeartbeatMs,
probe = new { enabled = false },
// Sourced from the options rather than a separate parameter, so the config the driver
// parses can never disagree with the options object a test thinks it built.
write = new { enabled = options.Write.Enabled },
tags = (tags ?? []).Select(t => new
{
fullName = t.FullName,
@@ -607,9 +818,16 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
private static MTConnectTagDefinition Tag(string dataItemId, DriverDataType type) => new(dataItemId, type);
/// <summary>Initializes a driver against the live Agent with the supplied authored tags.</summary>
private async Task<LiveDriver> LiveDriverAsync(IEnumerable<MTConnectTagDefinition> tags)
/// <param name="tags">The authored tags, by DataItem id.</param>
/// <param name="writeEnabled">The driver's own <c>write.enabled</c> gate; off by default.</param>
/// <param name="deviceName">
/// The configured device scope, and the write path's device fallback for a DataItem the probe
/// model does not declare. Null (the default) leaves the driver unscoped.
/// </param>
private async Task<LiveDriver> LiveDriverAsync(
IEnumerable<MTConnectTagDefinition> tags, bool writeEnabled = false, string? deviceName = null)
{
var options = LiveOptions();
var options = LiveOptions(deviceName, writeEnabled);
var driver = new MTConnectDriver(options, "mtconnect-live");
await driver.InitializeAsync(ConfigJson(options, tags), Ct);
@@ -650,6 +868,112 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
return XDocument.Parse(body);
}
/// <summary>
/// The DataItem the write legs inject into, and the device that owns it — or <c>null</c> when
/// this Agent declares nothing suitable, which is a skip rather than a failure.
/// </summary>
/// <remarks>
/// Resolved from the Agent's own <c>/probe</c> like every other expectation in this suite, and
/// overridable via <see cref="WriteTargetEnvVar"/> so a run pointed at a real machine tool can
/// name a DataItem its operator is willing to have injected. CONDITION-category items and the
/// TIME_SERIES / DATA_SET / TABLE representations are excluded because <c>WriteAsync</c>
/// itself refuses them — pointing a write leg at one would assert the wrong refusal.
/// </remarks>
private (string DataItemId, string DeviceName)? ResolveWriteTarget()
{
var id = (Environment.GetEnvironmentVariable(WriteTargetEnvVar) ?? DefaultWriteTargetId).Trim();
var declared = agent.DeclaredDataItems
.FirstOrDefault(d => string.Equals(d.Id, id, StringComparison.Ordinal));
if (declared is null ||
Is(declared.Category, "CONDITION") ||
Is(declared.Representation, "TIME_SERIES") ||
Is(declared.Representation, "DATA_SET") ||
Is(declared.Representation, "TABLE"))
{
return null;
}
var device = OwningDeviceName(id);
return device is null ? null : (id, device);
}
/// <summary>The skip message for a run whose Agent declares no usable write target.</summary>
private string NoWriteTargetReason() =>
$"The Agent at {agent.AgentUri} declares no writable DataItem " +
$"'{Environment.GetEnvironmentVariable(WriteTargetEnvVar) ?? DefaultWriteTargetId}' " +
$"(a non-CONDITION, scalar item under a Device). Set {WriteTargetEnvVar} to a DataItem this " +
"Agent declares and is safe to inject an observation into, or run against the fixture's own " +
"Devices.xml, which carries a never-fed one for exactly this purpose.";
/// <summary>
/// The device a DataItem is declared under, addressed the way the Agent's PUT URL addresses
/// it: by <c>name</c>, falling back to <c>id</c> for a nameless device.
/// </summary>
private string? OwningDeviceName(string dataItemId)
{
var element = agent.ProbeDocument?
.Descendants()
.FirstOrDefault(e =>
e.Name.LocalName == "DataItem" &&
string.Equals((string?)e.Attribute("id"), dataItemId, StringComparison.Ordinal));
var device = element?.Ancestors().FirstOrDefault(a => a.Name.LocalName == "Device");
var name = (string?)device?.Attribute("name") ?? (string?)device?.Attribute("id");
return string.IsNullOrWhiteSpace(name) ? null : name;
}
/// <summary>
/// What the Agent currently reports for one DataItem, straight off <c>/current</c> — the raw
/// observation text (<c>UNAVAILABLE</c> included), or <c>null</c> when the snapshot carries no
/// observation for it at all.
/// </summary>
private async Task<string?> ObservationValueAsync(string dataItemId) =>
(await CurrentAsync())
.Descendants()
.Where(e => !e.HasElements)
.Where(e => string.Equals((string?)e.Attribute("dataItemId"), dataItemId, StringComparison.Ordinal))
.Select(e => e.Value.Trim())
.LastOrDefault();
/// <summary>
/// Polls <c>/current</c> until the DataItem reports <paramref name="expected"/> or the wait
/// runs out, returning whatever it last observed either way.
/// </summary>
/// <remarks>
/// Returning the last observation rather than a bool is deliberate: a failing assertion can
/// then print what the Agent WAS reporting, which is the difference between "the write never
/// landed" and "the write landed as something else" (a formatting defect).
/// </remarks>
private async Task<string?> WaitForObservationAsync(string dataItemId, string expected, TimeSpan timeout)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Ct);
deadline.CancelAfter(timeout);
string? observed;
do
{
observed = await ObservationValueAsync(dataItemId);
if (string.Equals(observed, expected, StringComparison.Ordinal))
{
return observed;
}
try
{
await Task.Delay(TimeSpan.FromMilliseconds(500), deadline.Token);
}
catch (OperationCanceledException) when (!Ct.IsCancellationRequested)
{
return observed;
}
}
while (true);
}
/// <summary>
/// The DataItem ids the Agent is currently reporting a parseable number for: the live subset
/// a read or subscription can make a non-vacuous statement about.
@@ -355,6 +355,32 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
}
}
/// <summary>
/// Every write this Agent was asked to take, in order — the record a driver-level write test
/// asserts against (which DataItem, on which device, carrying what).
/// </summary>
public ConcurrentQueue<(string DeviceName, string DataItemId, string Value)> Writes { get; } = new();
/// <summary>
/// What <see cref="WriteObservationAsync"/> answers with. Defaults to acceptance; set it to a
/// refusal to script the Agent's <c>MTConnectError</c> leg.
/// </summary>
public MTConnectWriteOutcome WriteOutcome { get; set; } = new(Accepted: true, AgentError: null);
/// <summary>When set, a write throws this instead of answering — the transport-failure leg.</summary>
public Exception? WriteFailure { get; set; }
/// <inheritdoc/>
public Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
cancellationToken.ThrowIfCancellationRequested();
Writes.Enqueue((deviceName, dataItemId, value));
return WriteFailure is null ? Task.FromResult(WriteOutcome) : throw WriteFailure;
}
/// <summary>
/// Queues a chunk onto the scripted <c>/sample</c> stream and waits until the consumer has
/// processed it. This is the deterministic replacement for "wait a bit and hope the pump
@@ -0,0 +1,356 @@
using System.Net;
using System.Text;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// The wire contract of <see cref="MTConnectAgentClient.WriteObservationAsync"/> — the Agent's
/// <c>AllowPut</c> surface. Everything here runs through a stubbed
/// <see cref="HttpMessageHandler"/> via the internal test-seam constructor, so the verb, the
/// exact request URI, and each response shape are asserted with no socket and no Agent.
/// </summary>
/// <remarks>
/// <para>
/// <b>The URI assertions are the point of this class, not incidental detail.</b> A write is
/// nothing but a URL: get the escaping or the device segment wrong and the Agent still
/// answers 200, having stored a value under a DataItem id nobody asked about — a corruption
/// with no error anywhere, visible only on a later read-back. There is no encoder to unit
/// test, so this is the only place that shape is pinned.
/// </para>
/// <para>
/// <b>The refusal legs pin the split the seam contract rests on:</b> an Agent that answered
/// and said no is a returned outcome, while an Agent that could not be reached throws. The
/// 200-carrying-an-<c>MTConnectError</c> case is why the status code alone can never decide
/// it — that is the shape a real cppagent uses to report every objection.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class MTConnectAgentClientWriteTests
{
// ── stub handler ─────────────────────────────────────────────────────────
/// <summary>Answers every request with a canned response (or throws) via a delegate, with no socket.</summary>
private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
respond(request, ct);
}
/// <summary>Records the requests it is handed, then answers each with <paramref name="respond"/>.</summary>
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public List<(HttpMethod Method, Uri Uri)> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
Requests.Add((request.Method, request.RequestUri!));
return Task.FromResult(respond(request));
}
}
private static MTConnectDriverOptions Options(string agentUri = "http://stub-agent:5000") =>
new() { AgentUri = agentUri };
private static HttpResponseMessage Ok(string body = "") => new(HttpStatusCode.OK)
{
Content = new StringContent(body, Encoding.UTF8, "text/plain"),
};
private static HttpResponseMessage XmlOk(string xml) => new(HttpStatusCode.OK)
{
Content = new StringContent(xml, Encoding.UTF8, "text/xml"),
};
/// <summary>Exactly the shape a cppagent uses to refuse: an error document served under HTTP 200.</summary>
private const string MTConnectErrorXml =
"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
<Header creationTime="2026-08-05T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
<Errors>
<Error errorCode="UNSUPPORTED">Cannot find device data item: nosuchitem</Error>
</Errors>
</MTConnectError>
""";
// ── verb + URI ───────────────────────────────────────────────────────────
[Fact]
public async Task A_write_is_issued_as_a_POST()
{
var handler = new RecordingHandler(_ => Ok());
using var client = new MTConnectAgentClient(Options(), handler);
await client.WriteObservationAsync("StubDevice", "dev1_msg", "hello", CancellationToken.None);
handler.Requests.ShouldHaveSingleItem().Method.ShouldBe(HttpMethod.Post);
}
/// <summary>
/// The device is a <b>path segment</b> and the DataItem id/value are a <b>query pair</b> —
/// the Agent's documented AllowPut shape. Asserted as the whole URI rather than in parts,
/// because a fragment-wise assertion passes on a URI that is assembled in the wrong order.
/// </summary>
[Fact]
public async Task The_request_uri_is_the_agents_AllowPut_shape()
{
var handler = new RecordingHandler(_ => Ok());
using var client = new MTConnectAgentClient(Options(), handler);
await client.WriteObservationAsync("StubDevice", "dev1_msg", "hello", CancellationToken.None);
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
.ShouldBe("http://stub-agent:5000/StubDevice?dev1_msg=hello");
}
/// <summary>
/// A value carrying a space, an <c>&amp;</c> and a <c>=</c> — each of which silently ends the
/// query pair early if it reaches the wire raw — plus a DataItem id needing the same
/// treatment. Unescaped, the Agent would take a 200 and store the truncated value.
/// </summary>
[Fact]
public async Task The_dataItemId_and_value_are_percent_encoded()
{
var handler = new RecordingHandler(_ => Ok());
using var client = new MTConnectAgentClient(Options(), handler);
await client.WriteObservationAsync(
"Stub Device", "dev1 msg&x", "Coolant low = 20% & rising", CancellationToken.None);
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri.ShouldBe(
"http://stub-agent:5000/Stub%20Device?dev1%20msg%26x=Coolant%20low%20%3D%2020%25%20%26%20rising");
}
/// <summary>
/// The base URI's own path is preserved (an Agent behind a reverse-proxy prefix) and a
/// trailing slash does not double up — the same left-part rule the read paths use.
/// </summary>
[Fact]
public async Task A_based_agent_uri_keeps_its_path_prefix_and_does_not_double_its_slash()
{
var handler = new RecordingHandler(_ => Ok());
using var client = new MTConnectAgentClient(Options("http://stub-agent:5000/mtconnect/"), handler);
await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
.ShouldBe("http://stub-agent:5000/mtconnect/StubDevice?dev1_msg=hi");
}
/// <summary>
/// The <c>deviceName</c> argument decides the path — <b>not</b> the options' device scope. A
/// multi-device Agent is addressed per write, so an implementation that reused the read
/// paths' scoped URI would write every DataItem onto one device.
/// </summary>
[Fact]
public async Task The_device_segment_comes_from_the_argument_not_the_configured_device_scope()
{
var handler = new RecordingHandler(_ => Ok());
using var client = new MTConnectAgentClient(
new MTConnectDriverOptions { AgentUri = "http://stub-agent:5000", DeviceName = "ScopedDevice" },
handler);
await client.WriteObservationAsync("OtherDevice", "dev2_msg", "hi", CancellationToken.None);
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
.ShouldBe("http://stub-agent:5000/OtherDevice?dev2_msg=hi");
}
// ── acceptance ───────────────────────────────────────────────────────────
/// <summary>
/// An empty 2xx body is what a real cppagent sends on success. Treating a body-less answer
/// as anything but acceptance would refuse every write that actually worked.
/// </summary>
[Fact]
public async Task An_empty_200_is_accepted()
{
using var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok())));
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
outcome.Accepted.ShouldBeTrue();
outcome.AgentError.ShouldBeNull();
}
/// <summary>A 200 carrying a non-MTConnect body is still acceptance — only an error document refuses.</summary>
[Fact]
public async Task A_200_whose_body_is_not_an_error_document_is_accepted()
{
using var client = new MTConnectAgentClient(
Options(), new StubHandler((_, _) => Task.FromResult(Ok("<html><body>OK</body></html>"))));
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
outcome.Accepted.ShouldBeTrue();
outcome.AgentError.ShouldBeNull();
}
// ── refusal ──────────────────────────────────────────────────────────────
/// <summary>
/// The load-bearing refusal: HTTP 200 with an <c>MTConnectError</c> body.
/// <c>EnsureSuccessStatusCode</c> never fires here, so an implementation that trusted the
/// status code would report every rejected write as a success.
/// </summary>
[Fact]
public async Task An_MTConnectError_under_200_is_a_refusal_carrying_the_agents_own_text()
{
using var client = new MTConnectAgentClient(
Options(), new StubHandler((_, _) => Task.FromResult(XmlOk(MTConnectErrorXml))));
var outcome = await client.WriteObservationAsync(
"StubDevice", "nosuchitem", "hi", CancellationToken.None);
outcome.Accepted.ShouldBeFalse();
outcome.AgentError.ShouldNotBeNull();
outcome.AgentError.ShouldContain("UNSUPPORTED");
outcome.AgentError.ShouldContain("Cannot find device data item: nosuchitem");
}
/// <summary>
/// An <c>MTConnectError</c> document carrying no readable <c>&lt;Error&gt;</c> detail is
/// still a refusal. The Agent said no; that it said so unhelpfully changes nothing. The trap
/// this pins is that the natural implementation — "return the joined error text, or null if
/// there is none" — hands back the same <c>null</c> that means "this body was not an error
/// document at all", silently converting the refusal into an acceptance.
/// </summary>
[Fact]
public async Task An_error_document_with_no_readable_detail_is_still_a_refusal()
{
const string HollowError =
"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
<Header creationTime="2026-08-05T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
<Errors/>
</MTConnectError>
""";
using var client = new MTConnectAgentClient(
Options(), new StubHandler((_, _) => Task.FromResult(XmlOk(HollowError))));
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
outcome.Accepted.ShouldBeFalse();
outcome.AgentError.ShouldNotBeNullOrWhiteSpace();
}
/// <summary>The same, for an <c>&lt;Error&gt;</c> element that is present but blank.</summary>
[Fact]
public async Task An_error_document_whose_error_element_is_blank_is_still_a_refusal()
{
const string BlankError =
"""
<?xml version="1.0" encoding="UTF-8"?>
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
<Errors><Error> </Error></Errors>
</MTConnectError>
""";
using var client = new MTConnectAgentClient(
Options(), new StubHandler((_, _) => Task.FromResult(XmlOk(BlankError))));
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
outcome.Accepted.ShouldBeFalse();
outcome.AgentError.ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public async Task A_non_success_status_is_a_refusal_naming_the_status()
{
using var client = new MTConnectAgentClient(
Options(),
new StubHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
{
Content = new StringContent(string.Empty),
})));
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
outcome.Accepted.ShouldBeFalse();
outcome.AgentError.ShouldNotBeNull();
outcome.AgentError.ShouldContain("404");
}
// ── transport failure throws, never a false outcome ──────────────────────
/// <summary>
/// "Unreachable" must stay distinguishable from "rejected". Returning
/// <c>Accepted = false</c> here would collapse a comms fault into a per-tag refusal, which
/// the driver maps to a different status code and an operator resolves differently.
/// </summary>
[Fact]
public async Task A_transport_failure_propagates_rather_than_becoming_a_refusal()
{
var boom = new HttpRequestException("dial failed");
using var client = new MTConnectAgentClient(
Options(), new StubHandler((_, _) => Task.FromException<HttpResponseMessage>(boom)));
var thrown = await Should.ThrowAsync<HttpRequestException>(
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
thrown.ShouldBeSameAs(boom);
}
/// <summary>
/// A frozen Agent must surface as a bounded <see cref="TimeoutException"/> naming the URI,
/// not park the caller — the same per-call deadline discipline the read legs carry
/// (arch-review R2-01).
/// </summary>
[Fact(Timeout = 10000)]
public async Task A_frozen_agent_times_out_within_the_per_call_deadline()
{
using var client = new MTConnectAgentClient(
new MTConnectDriverOptions { AgentUri = "http://stub-agent:5000", RequestTimeoutMs = 300 },
new StubHandler(async (_, ct) =>
{
await Task.Delay(Timeout.Infinite, ct);
throw new UnreachableException();
}));
await Should.ThrowAsync<TimeoutException>(
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
}
/// <summary>Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis.</summary>
private sealed class UnreachableException : Exception;
/// <summary>Cancelling is cancellation, never a refusal — the caller's own token wins over the deadline.</summary>
[Fact]
public async Task A_cancelled_token_surfaces_as_cancellation_not_as_a_refusal()
{
using var cts = new CancellationTokenSource();
using var client = new MTConnectAgentClient(
Options(),
new StubHandler(async (_, ct) =>
{
await Task.Delay(Timeout.Infinite, ct);
throw new UnreachableException();
}));
var write = client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", cts.Token);
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(() => write);
}
// ── disposal ─────────────────────────────────────────────────────────────
[Fact]
public async Task Writing_through_a_disposed_client_throws_ObjectDisposedException()
{
var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok())));
client.Dispose();
await Should.ThrowAsync<ObjectDisposedException>(
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
}
}
@@ -45,12 +45,16 @@ public sealed class MTConnectDiscoverTests
"dev1_system_cond",
];
private static MTConnectDriverOptions Opts(string? deviceName = null) =>
private static MTConnectDriverOptions Opts(string? deviceName = null, bool writeEnabled = false) =>
new()
{
AgentUri = AgentUri,
DeviceName = deviceName,
// Off unless a test says otherwise — the shipped default, and what every pre-write-back
// expectation in this file was written against.
Write = new MTConnectWriteOptions { Enabled = writeEnabled },
// Discovery is a pure read of the /probe device model: it must enumerate what the Agent
// declares, NOT what an operator has already authored. Left empty on purpose so a driver
// that streamed its authored tag table instead could not pass.
@@ -324,10 +328,10 @@ public sealed class MTConnectDiscoverTests
}
/// <summary>
/// MTConnect is a read-only protocol and this driver implements no <c>IWritable</c>, so every
/// browsed leaf is <see cref="SecurityClassification.ViewOnly"/> — the tier that is read-only
/// from OPC UA. Nothing is historized by discovery either: historization is authored per tag,
/// never inferred from a device model.
/// Write-back is off by default, so every browsed leaf is
/// <see cref="SecurityClassification.ViewOnly"/> — the tier that is read-only from OPC UA, and
/// exactly what this driver shipped. Nothing is historized by discovery either: historization
/// is authored per tag, never inferred from a device model.
/// </summary>
[Fact]
public async Task Discover_streams_every_leaf_view_only_and_not_historized()
@@ -341,6 +345,47 @@ public sealed class MTConnectDiscoverTests
cap.Variables.ShouldAllBe(v => v.Attr.Source == NodeSourceKind.Driver);
}
/// <summary>
/// With <c>write.enabled</c> on, the same probe model browses <see cref="SecurityClassification.Operate"/>
/// — but only for the leaves <c>WriteAsync</c> would actually accept. A browse that advertised a
/// tier the write path refuses is the "silent no-op in a new costume" failure MQTT's
/// <c>CanWrite</c> pins against; the flag is the ONLY difference between this test and the one
/// above, so a driver that ignored it could not pass both.
/// </summary>
[Fact]
public async Task Discover_streams_writable_leaves_as_operate_when_write_is_enabled()
{
var (driver, _) = await InitializedDriverAsync(Opts(writeEnabled: true));
var cap = await DiscoverAsync(driver);
var writable = cap.Variables.Where(v => !v.Attr.IsAlarm).ToList();
// Counted, not just ShouldAllBe: an empty sequence satisfies ShouldAllBe vacuously, so a
// discovery that streamed nothing at all would otherwise pass this test.
writable.Count.ShouldBe(FixtureDataItemIds.Length - 1);
writable.ShouldAllBe(v => v.Attr.SecurityClass == SecurityClassification.Operate);
}
/// <summary>
/// A CONDITION data item stays <see cref="SecurityClassification.ViewOnly"/> in <b>both</b>
/// modes: a condition PUT carries alarm semantics and a different <c>level|text</c> wire
/// format, so the write path refuses it and the browse must say so.
/// </summary>
/// <param name="writeEnabled">Whether the driver's write-back block is switched on.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Discover_streams_a_condition_leaf_view_only_whether_or_not_write_is_enabled(bool writeEnabled)
{
var (driver, _) = await InitializedDriverAsync(Opts(writeEnabled: writeEnabled));
var cap = await DiscoverAsync(driver);
cap.Variables.Single(v => v.Attr.FullName == "dev1_system_cond")
.Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
// ---- browse names ----
/// <summary>
@@ -56,6 +56,20 @@ public sealed class MTConnectDriverOptionsTests
reconnect.BackoffMultiplier.ShouldBeGreaterThan(1.0);
}
/// <summary>
/// Write-back is <b>off unless authored</b>: the driver shipped read-only, and the agent's
/// PUT surface is itself off by default, so a deployment that never mentions <c>write</c>
/// must keep exactly today's behaviour.
/// </summary>
[Fact]
public void Write_defaults_to_disabled()
{
var write = new MTConnectDriverOptions { AgentUri = "http://agent:5000" }.Write;
write.ShouldNotBeNull();
write.Enabled.ShouldBeFalse();
}
/// <summary>
/// <see cref="MTConnectDriverOptions.Tags"/> defaults to an <i>empty</i> collection, never
/// <c>null</c> — a null collection here is an operator-authorable NullReferenceException at
@@ -207,19 +207,25 @@ public sealed class MTConnectFactoryTests
}
/// <summary>
/// The other half of the capability pin: MTConnect's Agent surface is read-only, so the
/// instance must NOT be <see cref="IWritable"/>. If it ever became writable, the write gate
/// would start offering an operation the protocol cannot honour.
/// The other half of the capability pin: the instance IS <see cref="IWritable"/>, so the
/// runtime's write gate dispatches to it. This is the seam the concrete return type exists for
/// — the capability was added to the driver with no change to the factory, and a narrowed
/// return would have left it implemented but never dispatched.
/// </summary>
/// <remarks>
/// <b>Dispatchable is not enabled.</b> The config below authors no <c>write</c> block, so this
/// instance refuses every write with <c>BadNotWritable</c> — and the Agent's own
/// <c>AllowPut</c> is a second, independent gate. See <c>MTConnectWriteTests</c>.
/// </remarks>
[Fact]
public void Registered_delegate_returns_an_instance_that_is_not_writable()
public void Registered_delegate_returns_an_instance_that_dispatches_writes()
{
var registry = new DriverFactoryRegistry();
MTConnectDriverFactoryExtensions.Register(registry);
IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");
(driver is IWritable).ShouldBeFalse("the MTConnect Agent surface is read-only");
(driver is IWritable).ShouldBeTrue("MTConnect must dispatch writes (the Agent AllowPut surface)");
}
/// <summary>Registering into a null registry is a wiring bug, not a silent no-op.</summary>
@@ -349,4 +355,51 @@ public sealed class MTConnectFactoryTests
// above) hold for both entry points.
MTConnectDriverFactoryExtensions.CreateInstance("mt1", json).DriverInstanceId.ShouldBe("mt1");
}
/// <summary>
/// The <c>write</c> block reaches the options — the opt-in half of the two-layer gate on
/// write-back (the agent's own <c>AllowPut</c> is the other). Authored <c>true</c> arms it;
/// an authored <c>false</c> and an omitted block are the same thing, and both leave the
/// driver read-only.
/// </summary>
[Fact]
public void Write_section_round_trips_and_defaults_to_disabled_when_absent()
{
MTConnectDriver.ParseOptions(
"mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": true } }""")
.Write.Enabled.ShouldBeTrue();
MTConnectDriver.ParseOptions(
"mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": false } }""")
.Write.Enabled.ShouldBeFalse();
MTConnectDriver.ParseOptions("mt1", """{ "agentUri": "http://x:5000" }""")
.Write.Enabled.ShouldBeFalse();
// The factory shares that parse, so an armed instance is authorable through it too.
MTConnectDriverFactoryExtensions.CreateInstance(
"mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": true } }""")
.DriverInstanceId.ShouldBe("mt1");
}
/// <summary>
/// A malformed <c>write</c> block fails <b>exactly as its siblings do</b> — the whole
/// document is one <c>JsonSerializer.Deserialize</c>, so a non-boolean <c>enabled</c> faults
/// the parse rather than silently defaulting to off. The <c>probe</c> leg is the control: it
/// pins that this is the surrounding convention and not stricter behaviour invented here.
/// </summary>
[Fact]
public void Malformed_write_block_faults_exactly_like_the_sibling_probe_block()
{
Should.Throw<InvalidOperationException>(() => MTConnectDriver.ParseOptions(
"mt1", """{ "agentUri": "http://x:5000", "probe": { "enabled": "yes" } }"""));
Should.Throw<InvalidOperationException>(() => MTConnectDriver.ParseOptions(
"mt1", """{ "agentUri": "http://x:5000", "write": { "enabled": "yes" } }"""));
// A null block is not malformed — the same "omitted means default" reading the sibling
// blocks get, since every DTO property is nullable.
MTConnectDriver.ParseOptions("mt1", """{ "agentUri": "http://x:5000", "write": null }""")
.Write.Enabled.ShouldBeFalse();
}
}
@@ -0,0 +1,425 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// <see cref="MTConnectDriver"/>'s <see cref="IWritable"/> leg — the batch contract, the per-slot
/// refusals, and the one thing the driver has to get right before the Agent is even dialled: which
/// device a DataItem belongs to.
/// </summary>
/// <remarks>
/// <para>
/// <b>The write itself is the live gate's job.</b> Everything below stops at the
/// <see cref="IMTConnectAgentClient"/> seam and asserts what the driver <i>asked</i> the Agent
/// for (<see cref="CannedAgentClient.Writes"/>), never that a fake was called for its own sake —
/// the same philosophy as <c>MqttDriverWriteTests</c>. What IS worth pinning offline is
/// everything that decides whether a write should reach an Agent at all, because that is where
/// a silent no-op would hide.
/// </para>
/// <para>
/// <b>A write is observation injection, not machine command</b> (design §2.2): it sets what the
/// Agent reports for a DataItem, and the next adapter update overwrites it. That is why the
/// feature is off at two independent gates — the Agent's <c>AllowPut</c>, and this driver's
/// <c>write.enabled</c> — and why the disabled leg below is the first test in the file.
/// </para>
/// </remarks>
public sealed class MTConnectWriteTests
{
private const string AgentUri = "http://fixture-agent:5000";
/// <summary>The device the canned <c>Fixtures/probe.xml</c> declares — <c>id="dev1" name="VMC-3Axis"</c>.</summary>
private const string FixtureDeviceName = "VMC-3Axis";
private const string PositionDataItemId = "dev1_pos";
private const string ConditionDataItemId = "dev1_system_cond";
private const string PositionRawPath = "Plant/MTConnect/dev1/Position";
private const string ConditionRawPath = "Plant/MTConnect/dev1/SystemCondition";
private const string UnknownRawPath = "Plant/MTConnect/dev1/NotATag";
private const uint Good = 0x00000000u;
private const uint BadNodeIdUnknown = 0x80340000u;
private const uint BadNotWritable = 0x803B0000u;
private const uint BadTypeMismatch = 0x80740000u;
private const uint BadCommunicationError = 0x80050000u;
private const uint BadNotConnected = 0x808A0000u;
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>One authored raw tag bound to a DataItem id, as the browse-commit flow writes it.</summary>
private static RawTagEntry Entry(string rawPath, string dataItemId) =>
new(rawPath, $$"""{"fullName":"{{dataItemId}}"}""", WriteIdempotent: false, DeviceName: "dev1");
private static MTConnectDriverOptions Opts(
bool writeEnabled = true, string? deviceName = null, params RawTagEntry[] rawTags) =>
new()
{
AgentUri = AgentUri,
DeviceName = deviceName,
Tags = [],
RawTags = rawTags,
Write = new MTConnectWriteOptions { Enabled = writeEnabled },
};
private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
MTConnectDriverOptions options, RecordingDriverLogger? logger = null)
{
var client = CannedAgentClient.FromFixtures();
return (new MTConnectDriver(options, "mt1", _ => client, logger), client);
}
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedAsync(
MTConnectDriverOptions options, RecordingDriverLogger? logger = null)
{
var (driver, client) = NewDriver(options, logger);
await driver.InitializeAsync("{}", Ct);
return (driver, client);
}
/// <summary>The ordinary rig: write on, one writable position tag and the fixture's CONDITION item.</summary>
private static Task<(MTConnectDriver Driver, CannedAgentClient Client)> WritableDriverAsync(
RecordingDriverLogger? logger = null) =>
InitializedAsync(
Opts(
writeEnabled: true,
deviceName: null,
Entry(PositionRawPath, PositionDataItemId),
Entry(ConditionRawPath, ConditionDataItemId)),
logger);
// ---- the capability itself ----
[Fact]
public void MTConnectDriver_implements_IWritable()
// The interface's presence IS the feature: without it the runtime's capability dispatch never
// offers this driver a write, whatever a node advertises.
=> NewDriver(Opts()).Driver.ShouldBeAssignableTo<IWritable>();
// ---- the two independent gates ----
[Fact]
public async Task A_driver_with_write_disabled_refuses_every_slot_without_dialling_the_agent()
{
// The default. An existing deployment that never authors the block keeps today's read-only
// behaviour EXACTLY — including making no request an Agent could log or accept.
var (driver, client) = await InitializedAsync(
Opts(writeEnabled: false, deviceName: null, Entry(PositionRawPath, PositionDataItemId)));
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
results.Count.ShouldBe(1);
results[0].StatusCode.ShouldBe(BadNotWritable);
client.Writes.ShouldBeEmpty();
}
[Fact]
public async Task A_write_before_initialize_reports_not_connected()
{
// Matches ReadAsync: "this driver is not running" is the only true statement available, and it
// is NOT the same operator problem as an Agent that answered and refused.
var (driver, _) = NewDriver(Opts(true, null, Entry(PositionRawPath, PositionDataItemId)));
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
results[0].StatusCode.ShouldBe(BadNotConnected);
}
[Fact]
public async Task A_write_after_shutdown_reports_not_connected()
{
var (driver, _) = await WritableDriverAsync();
await driver.ShutdownAsync(Ct);
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
results[0].StatusCode.ShouldBe(BadNotConnected);
}
// ---- per-slot refusals ----
[Fact]
public async Task An_unknown_reference_is_refused_in_its_own_slot()
{
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync([new WriteRequest(UnknownRawPath, 1.0)], Ct);
results[0].StatusCode.ShouldBe(BadNodeIdUnknown);
client.Writes.ShouldBeEmpty();
}
[Fact]
public async Task A_condition_data_item_is_refused_as_not_writable()
{
// CONDITION PUTs carry alarm semantics and a different level|text wire format — excluded from
// v1. BadNotWritable, not BadTypeMismatch: the value was never the problem.
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync([new WriteRequest(ConditionRawPath, "NORMAL")], Ct);
results[0].StatusCode.ShouldBe(BadNotWritable);
client.Writes.ShouldBeEmpty();
}
[Fact]
public async Task A_null_value_is_refused_as_a_type_mismatch()
{
// A write IS a URL query value; there is no null to send. Refusing beats writing the empty
// string, which the Agent would accept as a real observation.
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, null)], Ct);
results[0].StatusCode.ShouldBe(BadTypeMismatch);
client.Writes.ShouldBeEmpty();
}
[Fact]
public async Task An_array_value_is_refused_as_a_type_mismatch()
{
// Convert.ToString would happily render "System.Double[]" and the Agent would happily store it:
// a silent corruption visible only on read-back. Array/TIME_SERIES writes are out of scope.
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, new[] { 1.0, 2.0 })], Ct);
results[0].StatusCode.ShouldBe(BadTypeMismatch);
client.Writes.ShouldBeEmpty();
}
[Fact]
public async Task A_value_whose_own_formatter_throws_costs_its_slot_and_nothing_more()
{
// Convert.ToString runs the value's OWN ToString — arbitrary code that may throw anything —
// and it runs OUTSIDE WriteAsync's per-slot try. A narrow catch filter let every exception
// other than Format/InvalidCast/Overflow escape WriteAsync and fail the WHOLE batch, breaking
// the never-throws contract. The load-bearing half of this test is the second slot: the good
// write still happens.
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync(
[
new WriteRequest(PositionRawPath, new ThrowingFormatter()),
new WriteRequest(PositionRawPath, 42.5),
],
Ct);
results.Count.ShouldBe(2);
results[0].StatusCode.ShouldBe(BadTypeMismatch);
results[1].StatusCode.ShouldBe(Good);
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("42.5");
}
/// <summary>A value whose <see cref="ToString"/> throws something no conversion filter would name.</summary>
private sealed class ThrowingFormatter
{
public override string ToString() =>
throw new InvalidOperationException("this formatter is broken");
}
// ---- what actually reaches the seam ----
[Fact]
public async Task An_accepted_write_reports_good_and_names_the_owning_device_and_data_item()
{
// The device NAME is resolved from the /probe model, not from config: the Agent's PUT surface
// is addressed per device, and a multi-device Agent must be addressed correctly with no extra
// authoring. The RawPath is translated to the DataItem id, exactly as the read path does.
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
results[0].StatusCode.ShouldBe(Good);
client.Writes.ShouldHaveSingleItem()
.ShouldBe((FixtureDeviceName, PositionDataItemId, "42.5"));
}
[Fact]
public async Task A_value_is_rendered_with_the_invariant_culture()
{
// A comma decimal separator would be accepted by the Agent and read back as a different number.
var (driver, client) = await WritableDriverAsync();
await driver.WriteAsync([new WriteRequest(PositionRawPath, 1234.5678d)], Ct);
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("1234.5678");
}
[Fact]
public async Task A_boolean_is_rendered_as_its_invariant_true_false_string()
{
// Boolean.ToString is culture-independent, so the invariant rendering is "True"/"False" — NOT
// "1"/"0". Documented because it is the value an MTConnect consumer will see verbatim.
var (driver, client) = await WritableDriverAsync();
await driver.WriteAsync([new WriteRequest(PositionRawPath, true)], Ct);
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("True");
}
// ---- device-name resolution ----
[Fact]
public async Task A_data_item_the_probe_does_not_declare_falls_back_to_the_configured_device()
{
// The dataItemId-keyed authoring surface (the driver CLI) and any tag whose DataItem the Agent
// has since dropped: config is the only device name available, and it is a real one.
var (driver, client) = await InitializedAsync(
Opts(writeEnabled: true, deviceName: "ConfiguredDevice", Entry(PositionRawPath, "not_in_probe")));
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 7)], Ct);
results[0].StatusCode.ShouldBe(Good);
client.Writes.ShouldHaveSingleItem().ShouldBe(("ConfiguredDevice", "not_in_probe", "7"));
}
[Fact]
public async Task A_data_item_with_no_resolvable_device_is_refused_as_an_unknown_node()
{
// Neither the probe model nor config names a device, so there is no URL to POST to. Refused
// rather than guessed: a wrong device path is a 404 the operator reads as "the Agent is broken".
var (driver, client) = await InitializedAsync(
Opts(writeEnabled: true, deviceName: null, Entry(PositionRawPath, "not_in_probe")));
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 7)], Ct);
results[0].StatusCode.ShouldBe(BadNodeIdUnknown);
client.Writes.ShouldBeEmpty();
}
// ---- how the Agent's own answers map ----
[Fact]
public async Task An_agent_refusal_maps_to_a_communication_error_and_logs_the_agents_own_text()
{
var logger = new RecordingDriverLogger();
var (driver, client) = await WritableDriverAsync(logger);
client.WriteOutcome = new MTConnectWriteOutcome(Accepted: false, AgentError: "UNSUPPORTED: no such device");
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
results[0].StatusCode.ShouldBe(BadCommunicationError);
logger.WarningsSnapshot().ShouldContain(w => w.Contains("UNSUPPORTED: no such device", StringComparison.Ordinal));
}
[Fact]
public async Task An_agent_refusal_never_reaches_the_health_LastError()
{
// Type-only discipline: LastError carries an exception TYPE, never Agent-authored text, and a
// refused write is not evidence the driver's read path is degraded either.
var (driver, client) = await WritableDriverAsync();
var before = driver.GetHealth();
client.WriteOutcome = new MTConnectWriteOutcome(Accepted: false, AgentError: "UNSUPPORTED: no such device");
await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
var after = driver.GetHealth();
after.LastError.ShouldBe(before.LastError);
after.State.ShouldBe(before.State);
}
[Fact]
public async Task A_transport_failure_maps_to_a_communication_error_without_throwing()
{
var (driver, client) = await WritableDriverAsync();
client.WriteFailure = new HttpRequestException("connection refused");
var results = await driver.WriteAsync([new WriteRequest(PositionRawPath, 42.5)], Ct);
results[0].StatusCode.ShouldBe(BadCommunicationError);
}
// ---- batch contract ----
[Fact]
public async Task Cancellation_fills_the_remaining_slots_and_returns_the_batch()
{
// Never throws, even cancelled: the caller gets one status per request or the node-manager
// cannot tell which writes were attempted.
var (driver, client) = await WritableDriverAsync();
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
var results = await driver.WriteAsync(
[new WriteRequest(PositionRawPath, 1.0), new WriteRequest(PositionRawPath, 2.0)], cts.Token);
results.Count.ShouldBe(2);
results.ShouldAllBe(r => r.StatusCode == BadCommunicationError);
client.Writes.ShouldBeEmpty();
}
[Fact]
public async Task Cancellation_mid_batch_leaves_the_slots_already_written_with_their_real_outcomes()
{
// The fill must start at the cancelled slot, not overwrite the batch. Slot 0 completed against
// the Agent before the token was cancelled, and reporting it BadCommunicationError would tell
// an operator a write failed that actually landed — the one thing worse than reporting a
// failure. Cancellation is injected from inside slot 1's own formatting, so the ordering is a
// fact of the run rather than a timing hope.
var (driver, client) = await WritableDriverAsync();
using var cts = new CancellationTokenSource();
var results = await driver.WriteAsync(
[
new WriteRequest(PositionRawPath, 42.5),
new WriteRequest(PositionRawPath, new CancellingFormatter(cts)),
new WriteRequest(PositionRawPath, 7.5),
],
cts.Token);
results.Count.ShouldBe(3);
results[0].StatusCode.ShouldBe(Good);
results[1].StatusCode.ShouldBe(BadCommunicationError);
results[2].StatusCode.ShouldBe(BadCommunicationError);
// Exactly the pre-cancellation slot reached the Agent.
client.Writes.ShouldHaveSingleItem().Value.ShouldBe("42.5");
}
/// <summary>Cancels the batch from inside its own rendering, then renders normally.</summary>
private sealed class CancellingFormatter(CancellationTokenSource cts)
{
public override string ToString()
{
cts.Cancel();
return "99";
}
}
[Fact]
public async Task One_bad_request_does_not_fail_the_others_in_the_batch()
{
// Mirrors ReadAsync: failing the whole call would let one stale reference reject every other
// write in the same OPC UA request.
var (driver, client) = await WritableDriverAsync();
var results = await driver.WriteAsync(
[
new WriteRequest(UnknownRawPath, 1.0),
new WriteRequest(ConditionRawPath, "NORMAL"),
new WriteRequest(PositionRawPath, 42.5),
],
Ct);
// Order-preserving, one status per request, each naming its own distinct cause.
results.Count.ShouldBe(3);
results[0].StatusCode.ShouldBe(BadNodeIdUnknown);
results[1].StatusCode.ShouldBe(BadNotWritable);
results[2].StatusCode.ShouldBe(Good);
client.Writes.ShouldHaveSingleItem().DataItemId.ShouldBe(PositionDataItemId);
}
[Fact]
public async Task An_empty_batch_is_answered_with_an_empty_result()
=> (await NewDriver(Opts()).Driver.WriteAsync([], Ct)).ShouldBeEmpty();
[Fact]
public async Task WriteAsync_rejects_a_null_batch()
=> await Should.ThrowAsync<ArgumentNullException>(
async () => await NewDriver(Opts()).Driver.WriteAsync(null!, Ct));
}
@@ -371,4 +371,31 @@ public sealed class SqlBrowseSessionTests
await session.AttributesAsync(columnNodeId, CancellationToken.None);
session.LastUsedUtc.ShouldBeGreaterThan(afterExpand);
}
/// <summary>
/// The browse path binds the <em>dialect's</em> parameter names, not a hardcoded <c>"@schema"</c>.
/// Driven through <see cref="ColonMarkerDialect"/> (markers <c>:schema</c>/<c>:table</c>, bare bind
/// names) — ODP.NET's shape, and the only place the two spellings differ.
/// <para>Discriminating in two independent ways: a session still binding <c>"@schema"</c> leaves the
/// colon marker unbound (the expand yields nothing, or the provider refuses the command), and the
/// recorded names would carry the prefix.</para>
/// </summary>
[Fact]
public async Task TheCatalogParameters_areBoundUnderTheDialectsBindNames()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var dialect = new ColonMarkerDialect();
await using var session = new SqlBrowseSession(db.Connection, dialect);
var tableNode = (await session.ExpandAsync(SchemaNodeId, CancellationToken.None))
.First(n => n.DisplayName == SqliteBrowseFixture.KeyValueTable);
var columns = await session.ExpandAsync(tableNode.NodeId, CancellationToken.None);
columns.ShouldContain(c => c.DisplayName == SqliteBrowseFixture.RealColumn);
dialect.BoundParameterNames.ShouldContain("schema");
dialect.BoundParameterNames.ShouldContain("table");
dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':'));
// Every catalog command went through the provider hook (Oracle's BindByName lives there).
dialect.ConfiguredCommands.ShouldBe(2);
}
}
@@ -45,12 +45,21 @@ public sealed class SqlDriverBrowserTests
new SqlDriverBrowser().DriverType.ShouldBe(SqlDriver.DriverTypeName);
}
/// <summary>
/// The browse-side gate, asserted as a partition of the whole enum: the three carried providers each
/// resolve to <b>their own</b> dialect, and every remaining member resolves to <see langword="null"/>.
/// Enumerating <see cref="Enum.GetValues{TEnum}"/> for the reserved half is what makes a future enum
/// member fail here rather than silently browse as SQL Server.
/// </summary>
[Fact]
public void DefaultDialectSelector_carriesSqlServerOnly()
public void DefaultDialectSelector_carriesSqlServerPostgresAndOracle()
{
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.SqlServer).ShouldBeOfType<SqlServerDialect>();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Postgres).ShouldBeOfType<PostgresDialect>();
SqlDriverBrowser.DefaultDialectSelector(SqlProvider.Oracle).ShouldBeOfType<OracleDialect>();
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => p != SqlProvider.SqlServer))
SqlProvider[] carried = [SqlProvider.SqlServer, SqlProvider.Postgres, SqlProvider.Oracle];
foreach (var provider in Enum.GetValues<SqlProvider>().Where(p => !carried.Contains(p)))
SqlDriverBrowser.DefaultDialectSelector(provider).ShouldBeNull();
}
@@ -178,6 +187,10 @@ public sealed class SqlDriverBrowserTests
// ---- provider availability ----
/// <summary>
/// The falsifiability control for the widened browse gate: <see cref="SqlProvider.MySql"/> is still
/// reserved and must be reported as unavailable, naming itself.
/// </summary>
[Fact]
public async Task Open_providerThisBuildDoesNotCarry_saysNotAvailableInThisBuild()
{
@@ -185,15 +198,15 @@ public sealed class SqlDriverBrowserTests
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("Postgres", connectionStringRef: "MesStaging"), CancellationToken.None));
ConfigJson("MySql", connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldContain("Postgres");
ex.Message.ShouldContain("MySql");
ex.Message.ShouldContain("not available in this build");
}
/// <summary>
/// Provider availability is answered before the environment is touched: "this build cannot browse
/// Postgres" is true whatever the ref resolves to, and the two failures must not mask each other.
/// MySql" is true whatever the ref resolves to, and the two failures must not mask each other.
/// </summary>
[Fact]
public async Task Open_unavailableProviderAndUnresolvableRef_reportsTheProvider()
@@ -203,12 +216,34 @@ public sealed class SqlDriverBrowserTests
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson("Oracle", connectionStringRef: "MesStaging"), CancellationToken.None));
ConfigJson("Odbc", connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldContain("not available in this build");
reads.ShouldBeEmpty();
}
/// <summary>
/// A carried provider must get <b>past</b> the provider gate and on to the environment, so that a
/// Postgres or Oracle browse fails (if at all) on the connection string rather than on availability.
/// Asserted through the reads list: reaching the environment reader is the observable proof the gate
/// let the provider through.
/// </summary>
[Theory]
[InlineData("Postgres")]
[InlineData("Oracle")]
public async Task Open_aCarriedProvider_passesTheGateAndResolvesTheRef(string provider)
{
var reads = new List<string>();
var browser = new SqlDriverBrowser(environmentReader: name => { reads.Add(name); return null; });
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
browser.OpenAsync(
ConfigJson(provider, connectionStringRef: "MesStaging"), CancellationToken.None));
ex.Message.ShouldNotContain("not available in this build");
reads.ShouldContain(SqlDriverBrowser.EnvironmentVariableFor("MesStaging"));
}
// ---- malformed configuration ----
[Theory]
@@ -45,6 +45,13 @@
— see its class docs.
-->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs"/>
<!--
Linked for the same reason and on the same terms: ColonMarkerDialect is the control for
dialect-owned parameter naming (":schema" in the text, bare "schema" on the parameter — ODP.NET's
shape), and the browse session binds those names. It decorates SqliteDialect, which is already
linked above, so the pair travels together.
-->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests\ColonMarkerDialect.cs" Link="ColonMarkerDialect.cs"/>
</ItemGroup>
</Project>
@@ -0,0 +1,81 @@
# Dedicated, DISPOSABLE Oracle Database Free for the Sql driver's Oracle live gate
# (OracleReadTests).
#
# ── SAFETY ─────────────────────────────────────────────────────────────────────
# This stack owns its OWN container, `otopcua-sql-oracle`, on host port 11521 — deliberately not
# the default 1521, so it can never be confused with a real Oracle listener on the rig. `restart:
# "no"` so a killed container stays down rather than respawning.
#
# ── DEPLOY (docker runs on the shared Linux host, 10.100.0.35) ──────────────────
# # from a Windows VM:
# lmxopcua-fix sync sql-oracle # rsync this dir → /opt/otopcua-sql-oracle/
# lmxopcua-fix up sql-oracle # single-service stack (+ one-shot seed), no profile arg
#
# # from macOS (there is no lmxopcua-fix there — drive the host over SSH):
# rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/oracle/ \
# dohertj2@10.100.0.35:/opt/otopcua-sql-oracle/
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-sql-oracle && SQL_ORACLE_PASSWORD=... docker compose up -d'
#
# ⚠️ FIRST START TAKES MINUTES, not seconds — the image creates the database on first boot. The
# healthcheck's start_period reflects that; the seed one-shot simply waits for it.
#
# ── RUN THE GATE ───────────────────────────────────────────────────────────────
# export SQL_ORACLE_ENDPOINT=10.100.0.35:11521
# export SQL_ORACLE_USER=otopcua # optional; this is the default
# export SQL_ORACLE_PASSWORD=... # the same value this compose was brought up with
# export SQL_ORACLE_SERVICE=FREEPDB1 # optional; this is the default (the image's PDB)
# dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \
# --filter "FullyQualifiedName~OracleReadTests"
#
# The one-shot `oracle-seed` service applies seed-oracle.sql once the server is healthy. The
# gvenzl images also auto-run scripts mounted at /container-entrypoint-initdb.d, but that path
# runs ONLY on the very first boot of an empty data volume — a re-seed after editing the script
# would silently not happen. The one-shot service matches the mssql fixture and re-runs every
# `docker compose up`, which is why it is used here.
name: otopcua-sql-oracle
services:
oracle:
image: gvenzl/oracle-free:23-slim
container_name: otopcua-sql-oracle
labels:
project: lmxopcua
restart: "no"
environment:
# SYS/SYSTEM password. Dev-only sandbox value for a throwaway container.
ORACLE_PASSWORD: "${SQL_ORACLE_PASSWORD:-Oracle_dev_pw1}"
# The image creates this non-SYS user (and its schema) inside the FREEPDB1 pluggable
# database. It is the principal the suite connects as — nothing here ever uses SYS.
APP_USER: otopcua
APP_USER_PASSWORD: "${SQL_ORACLE_PASSWORD:-Oracle_dev_pw1}"
ports:
- "11521:1521"
healthcheck:
# healthcheck.sh ships in the image and reports the database OPEN, not merely the listener up.
test: ["CMD", "healthcheck.sh"]
interval: 15s
timeout: 10s
retries: 40
start_period: 180s
# One-shot seed: waits for the database to pass its healthcheck, applies seed-oracle.sql, exits.
# Reuses the server image purely because it already carries sqlplus.
oracle-seed:
image: gvenzl/oracle-free:23-slim
labels:
project: lmxopcua
restart: "no"
depends_on:
oracle:
condition: service_healthy
environment:
APP_USER_PASSWORD: "${SQL_ORACLE_PASSWORD:-Oracle_dev_pw1}"
volumes:
- ./seed-oracle.sql:/seed.sql:ro
entrypoint:
- "/bin/bash"
- "-c"
# -S silent, -L single login attempt (never prompt). The script's WHENEVER SQLERROR EXIT
# makes a broken seed fail this service rather than leave a half-seeded schema behind.
- "sqlplus -S -L otopcua/\"$${APP_USER_PASSWORD}\"@//oracle:1521/FREEPDB1 @/seed.sql"
@@ -0,0 +1,76 @@
-- Seed for the Sql driver's Oracle live gate (OracleReadTests), applied once by the compose
-- `oracle-seed` one-shot AS THE APPLICATION USER (otopcua) against the OWNED `otopcua-sql-oracle`
-- container. Oracle equates a schema with a user, so everything below lands in the OTOPCUA schema.
--
-- The table shapes mirror the SQL Server fixture (SqlPollServerFixture) row for row — a present
-- value, a present row with a NULL cell, and a wide-row table whose NEWEST row is deliberately not
-- its first — so the same assertions can be made against both backends and a divergence between
-- the two provider paths shows up as a failing assertion rather than as two suites that quietly
-- test different things.
--
-- ⚠️ EVERY IDENTIFIER HERE IS UNQUOTED, AND THAT IS THE POINT. Oracle upper-cases an unquoted
-- identifier, so the catalog stores OTOPCUA / TAG_VALUES / TAG_NAME while an operator authoring a
-- tag in the AdminUI naturally types lower case. The suite's case-folding leg authors the lower-case
-- spelling and expects `SqlCatalogGate` to resolve it through `SqlCatalog.TryMatch` (exact-ordinal
-- first, then a UNIQUE case-insensitive match) and substitute the catalog's own UPPER-case spelling
-- before `OracleDialect.QuoteIdentifier` double-quotes it — which is load-bearing, because a quoted
-- "tag_values" and a quoted "TAG_VALUES" are DIFFERENT objects to Oracle. Quoting a name here would
-- make that leg meaningless.
WHENEVER SQLERROR EXIT SQL.SQLCODE
SET FEEDBACK OFF
SET DEFINE OFF
-- Drop-and-recreate rather than merge-or-insert: the seed is an assertion contract, and a leftover
-- row from an older revision of this file would make a passing test meaningless. ORA-00942 (table
-- does not exist) is the expected outcome on a first run and is the only error swallowed.
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE tag_values PURGE';
EXCEPTION
WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF;
END;
/
BEGIN
EXECUTE IMMEDIATE 'DROP TABLE latest_status PURGE';
EXCEPTION
WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF;
END;
/
-- Key-value (EAV) source: tag_name → num_value, with a source timestamp.
-- num_value is NUMBER, Oracle's one numeric family — which OracleDialect.MapColumnType folds to
-- Float64 (the documented under-specification; see that method's remarks).
CREATE TABLE tag_values (
tag_name VARCHAR2(128) NOT NULL,
num_value NUMBER,
sample_ts TIMESTAMP(3) NOT NULL
);
INSERT INTO tag_values (tag_name, num_value, sample_ts)
VALUES ('Line1.Speed', 42, TIMESTAMP '2026-07-24 10:00:00');
INSERT INTO tag_values (tag_name, num_value, sample_ts)
VALUES ('Line1.Temp', NULL, TIMESTAMP '2026-07-24 10:00:01');
INSERT INTO tag_values (tag_name, num_value, sample_ts)
VALUES ('Line1.Pressure', 3.5, TIMESTAMP '2026-07-24 10:00:02');
-- Wide-row source: one row per station, several value columns. Station 8 is inserted LAST and is
-- also the newest sample, while station 7 is first — so a topByTimestamp plan that lost its
-- ORDER BY … DESC (or its FETCH FIRST 1 ROWS ONLY) returns a different, wrong value rather than a
-- coincidentally right one.
CREATE TABLE latest_status (
station_id NUMBER(10) NOT NULL,
oven_temp NUMBER,
pressure NUMBER,
sample_ts TIMESTAMP(3) NOT NULL
);
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts)
VALUES (7, 180.5, 1.2, TIMESTAMP '2026-07-24 10:00:00');
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts)
VALUES (9, NULL, 1.6, TIMESTAMP '2026-07-24 10:00:01');
INSERT INTO latest_status (station_id, oven_temp, pressure, sample_ts)
VALUES (8, 210.25, 1.4, TIMESTAMP '2026-07-24 10:00:02');
COMMIT;
EXIT;
@@ -0,0 +1,78 @@
# Dedicated, DISPOSABLE PostgreSQL for the Sql driver's Postgres live gate (PostgresReadTests).
#
# ── SAFETY ─────────────────────────────────────────────────────────────────────
# This stack owns its OWN postgres container, `otopcua-sql-pg`, on host port 15432.
# Nothing else on the rig runs PostgreSQL, so there is no shared-server hazard of the
# kind the mssql blackhole fixture guards against — but the same discipline applies:
# a distinct container_name and a distinct host port, and `restart: "no"` so a killed
# container stays down rather than respawning.
#
# ── DEPLOY (docker runs on the shared Linux host, 10.100.0.35) ──────────────────
# # from a Windows VM:
# lmxopcua-fix sync sql-pg # rsync this dir → /opt/otopcua-sql-pg/
# lmxopcua-fix up sql-pg # single-service stack (+ one-shot seed), no profile arg
#
# # from macOS (there is no lmxopcua-fix there — drive the host over SSH):
# rsync -av tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/Docker/postgres/ \
# dohertj2@10.100.0.35:/opt/otopcua-sql-pg/
# ssh dohertj2@10.100.0.35 'cd /opt/otopcua-sql-pg && SQL_PG_PASSWORD=... docker compose up -d'
#
# ── RUN THE GATE ───────────────────────────────────────────────────────────────
# export SQL_PG_ENDPOINT=10.100.0.35:15432
# export SQL_PG_USER=otopcua # optional; this is the default
# export SQL_PG_PASSWORD=... # the same value this compose was brought up with
# dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests \
# --filter "FullyQualifiedName~PostgresReadTests"
#
# The one-shot `postgres-seed` service applies seed-pg.sql once the server is healthy, so
# the stack is self-seeding. Unlike the mssql fixture the suite does NOT seed defensively
# on connect: the seed creates ROLES (an owner role that is not the connecting principal),
# which the read-only fixture login deliberately cannot do — see seed-pg.sql.
name: otopcua-sql-pg
services:
postgres:
image: postgres:17-alpine
container_name: otopcua-sql-pg
labels:
project: lmxopcua
restart: "no"
environment:
# Dev-only sandbox password for a throwaway container; the seed gives the unprivileged
# `otopcua` login the SAME value, which is what the suite reads from SQL_PG_PASSWORD.
POSTGRES_PASSWORD: "${SQL_PG_PASSWORD:-Sqlpg_dev_pw1}"
POSTGRES_DB: sqlpollfixture
ports:
- "15432:5432"
healthcheck:
# pg_isready against the fixture database: the server accepting a connection to the
# database the seed targets is the readiness signal, not merely a listening socket.
test:
- "CMD-SHELL"
- "pg_isready -U postgres -d sqlpollfixture || exit 1"
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
# One-shot seed: waits for the server to pass its healthcheck, applies seed-pg.sql, exits.
postgres-seed:
image: postgres:17-alpine
labels:
project: lmxopcua
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: "${SQL_PG_PASSWORD:-Sqlpg_dev_pw1}"
APP_PASSWORD: "${SQL_PG_PASSWORD:-Sqlpg_dev_pw1}"
volumes:
- ./seed-pg.sql:/seed.sql:ro
entrypoint:
- "/bin/sh"
- "-c"
# ON_ERROR_STOP=1 so a broken seed fails the service rather than leaving a half-seeded
# database that makes a later test failure look like a driver bug.
- "psql -v ON_ERROR_STOP=1 -v app_password=\"$${APP_PASSWORD}\" -h postgres -U postgres -d sqlpollfixture -f /seed.sql"
@@ -0,0 +1,87 @@
-- Seed for the Sql driver's PostgreSQL live gate (PostgresReadTests), applied once by the
-- compose `postgres-seed` one-shot as the superuser against the OWNED `otopcua-sql-pg` container.
--
-- The table shapes mirror the SQL Server fixture (SqlPollServerFixture) row for row — a present
-- value, a present row with a NULL cell, and a wide-row table whose NEWEST row is deliberately not
-- its first — so the same assertions can be made against both backends and a divergence between
-- the two provider paths shows up as a failing assertion rather than as two suites that quietly
-- test different things.
--
-- ── TWO THINGS HERE ARE LOAD-BEARING, NOT SCAFFOLDING ─────────────────────────────────────────
--
-- 1. The schema is owned by `sqlpoll_owner` and the suite connects as `otopcua`, which holds
-- USAGE + SELECT and nothing else. That is the exact provisioning `PostgresDialect.ListSchemasSql`
-- was written for: `information_schema.schemata` lists only schemas the current role OWNS, so a
-- granted-not-owning read-only poller would see an empty schema list, `SqlCatalogLoader` would
-- throw its "the catalog returned no schemas at all" grant diagnosis, and every tag would be
-- rejected. Sourcing the schema list from `information_schema.tables` instead is what makes this
-- work — and a fixture where the poller owned its own schema would pass either way, proving
-- nothing. Do not "simplify" this by creating the tables as `otopcua`.
--
-- 2. Every identifier is created UNQUOTED, so Postgres folds it to lower case in the catalog. The
-- suite's case-folding leg authors the same names in UPPER case and expects them to resolve
-- through `SqlCatalog.TryMatch` (exact-ordinal first, then a UNIQUE case-insensitive match, which
-- substitutes the catalog's own spelling). Quoting a name here would make the catalog spelling
-- mixed case and that leg would stop meaning anything.
\set ON_ERROR_STOP on
-- ── roles ─────────────────────────────────────────────────────────────────────────────────────
-- \gexec runs the row the SELECT produced, and produces no row when the role already exists —
-- the idempotent CREATE ROLE idiom. A DO block cannot be used for the login role: psql does not
-- interpolate :'app_password' inside a dollar-quoted body.
SELECT 'CREATE ROLE sqlpoll_owner NOLOGIN'
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'sqlpoll_owner')
\gexec
SELECT 'CREATE ROLE otopcua LOGIN'
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'otopcua')
\gexec
-- Set (or re-set) the password every run, so re-seeding with a different SQL_PG_PASSWORD converges
-- rather than leaving the suite unable to log in.
ALTER ROLE otopcua LOGIN PASSWORD :'app_password';
-- ── schema ────────────────────────────────────────────────────────────────────────────────────
-- Drop-and-recreate rather than merge-or-insert: the seed is an assertion contract, and a leftover
-- row from an older revision of this file would make a passing test meaningless. The blast radius
-- is bounded to this fixture-owned schema.
DROP SCHEMA IF EXISTS sqlpoll CASCADE;
CREATE SCHEMA sqlpoll AUTHORIZATION sqlpoll_owner;
-- Everything below is created AS THE OWNER, not as the superuser — see note 1 above.
SET ROLE sqlpoll_owner;
-- Key-value (EAV) source: tag_name → num_value, with a source timestamp.
CREATE TABLE sqlpoll.tag_values (
tag_name varchar(128) NOT NULL,
num_value double precision NULL,
sample_ts timestamp(3) NOT NULL
);
INSERT INTO sqlpoll.tag_values (tag_name, num_value, sample_ts)
VALUES ('Line1.Speed', 42.0, TIMESTAMP '2026-07-24 10:00:00'),
('Line1.Temp', NULL, TIMESTAMP '2026-07-24 10:00:01'),
('Line1.Pressure', 3.5, TIMESTAMP '2026-07-24 10:00:02');
-- Wide-row source: one row per station, several value columns. Station 8 is inserted LAST and is
-- also the newest sample, while station 7 is first — so a topByTimestamp plan that lost its
-- ORDER BY … DESC (or its LIMIT 1) returns a different, wrong value rather than a coincidentally
-- right one.
CREATE TABLE sqlpoll.latest_status (
station_id integer NOT NULL,
oven_temp double precision NULL,
pressure double precision NULL,
sample_ts timestamp(3) NOT NULL
);
INSERT INTO sqlpoll.latest_status (station_id, oven_temp, pressure, sample_ts)
VALUES (7, 180.5, 1.2, TIMESTAMP '2026-07-24 10:00:00'),
(9, NULL, 1.6, TIMESTAMP '2026-07-24 10:00:01'),
(8, 210.25, 1.4, TIMESTAMP '2026-07-24 10:00:02');
RESET ROLE;
-- ── grants: USAGE + SELECT and nothing else ───────────────────────────────────────────────────
GRANT USAGE ON SCHEMA sqlpoll TO otopcua;
GRANT SELECT ON ALL TABLES IN SCHEMA sqlpoll TO otopcua;
@@ -0,0 +1,545 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// The <c>Sql</c> driver against a <b>real Oracle Database</b>, over the fully managed ODP.NET provider
/// (<c>Oracle.ManagedDataAccess.Core</c>) <c>OracleDialect</c> ships. Four things are exercised nowhere
/// else in this driver's estate: the ODP.NET ADO.NET path, <c>OracleCommand.BindByName</c> against a
/// real multi-parameter statement, the <c>ALL_*</c> catalog views that stand in for the
/// <c>INFORMATION_SCHEMA</c> Oracle does not have, and <c>FETCH FIRST 1 ROWS ONLY</c> row limiting.
/// <para><b><c>BindByName</c> is the sharpest edge, and the browse leg below is what proves it live.</b>
/// ODP.NET binds by <em>position</em> by default, and <c>OracleDialect.ListTablesSql</c> names
/// <c>:schema</c> twice (once per branch of its <c>UNION ALL</c>) while binding it once — under
/// positional binding that statement cannot execute at all. The unit suite asserts the flag is set on a
/// real <c>OracleCommand</c>; this asserts the statement it protects actually runs.</para>
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="OracleFixture"/> for the gate.
/// With it unset nothing here opens a socket, so the suite is instant and green offline.</para>
/// <para><b>Deliberately narrower than <see cref="SqlServerReadTests"/></b>, for the reason
/// <see cref="PostgresReadTests"/> gives: the provider-agnostic read semantics are pinned once against
/// SQL Server and by the SQLite suites, and only what a <em>dialect</em> can get wrong is repeated
/// per provider.</para>
/// </summary>
[Collection(OracleCollection.Name)]
public sealed class OracleReadTests
{
private readonly OracleFixture _ora;
/// <summary>Initializes a new instance of the <see cref="OracleReadTests"/> class.</summary>
/// <param name="ora">The seeded live-server fixture (collection-scoped).</param>
public OracleReadTests(OracleFixture ora) => _ora = ora;
// ---- lifecycle: liveness + catalog load ----
/// <summary>
/// Initialize's liveness check — <c>SELECT 1 FROM DUAL</c>, not the bare <c>SELECT 1</c> every other
/// dialect uses — and its catalog load both succeed, and the driver reports Healthy with the endpoint
/// described credential-free.
/// <para>The catalog load is asserted indirectly but unambiguously: <c>SqlCatalogLoader</c> throws
/// when the principal sees zero schemas, and a tag whose table did not resolve publishes
/// <c>BadNodeIdUnknown</c> instead of a value — so the Good read below is only reachable once
/// <c>ALL_USERS</c> / <c>ALL_TABLES</c> / <c>ALL_TAB_COLUMNS</c> have all answered the shape the
/// loader expects.</para>
/// </summary>
[Fact]
public async Task InitializeAsync_realOracle_reportsHealthyAndDescribesTheEndpoint()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead.ShouldNotBeNull();
// The endpoint description is what every log line and the Admin UI show. Oracle's connection string
// names the database inside Data Source (host:port/service), so the service is what must survive —
// and the credential must not.
driver.Endpoint.ShouldContain(_ora.ServiceName);
driver.Endpoint.ShouldNotContain("password", Case.Insensitive);
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
}
// ---- key-value model ----
/// <summary>
/// A seeded key-value row round-trips as a Good snapshot with its source timestamp.
/// <para>The value column is a plain <c>NUMBER</c> — Oracle's single numeric family — which
/// <c>OracleDialect.MapColumnType</c> folds to <see cref="DriverDataType.Float64"/>. This is the
/// live confirmation of that documented under-specification: an integral-looking seeded value comes
/// back as a floating-point node, and the escape hatch for an operator who needs otherwise is an
/// explicit authored <c>"type"</c>.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realOracle_roundTripsSeededKeyValue()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey));
var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken);
var snapshot = snapshots.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(OracleFixture.PresentValue);
snapshot.SourceTimestampUtc.ShouldBe(OracleFixture.PresentKeyTimestampUtc);
}
/// <summary>
/// Two keys on the same table fold into ONE query — <c>… WHERE "TAG_NAME" IN (:k0, :k1)</c> — and are
/// sliced back to the right tags.
/// <para><b>This is the live falsification of positional binding.</b> With
/// <c>OracleCommand.BindByName</c> unset, ODP.NET fills the markers in statement order from the
/// parameter collection regardless of their names, and the failure mode is not an exception but a
/// <em>successful query over the wrong rows</em> — one tag's value published onto another tag's node.
/// The two seeded values differ, so a swap fails the assertion rather than passing silently.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realOracle_foldsTwoKeysIntoOneGroupAndSlicesBackCorrectly()
{
SkipUnlessLive();
await using var driver = await StartAsync(
KeyValue("Sql/Line1/Speed", OracleFixture.PresentKey),
KeyValue("Sql/Line1/Pressure", OracleFixture.SecondPresentKey));
var snapshots = await driver.ReadAsync(
["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(2);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshots[0].Value, CultureInfo.InvariantCulture)
.ShouldBe(OracleFixture.PresentValue);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshots[1].Value, CultureInfo.InvariantCulture)
.ShouldBe(OracleFixture.SecondPresentValue);
}
// ---- wide-row model: the Oracle-specific row-limit syntax ----
/// <summary>
/// The <c>topByTimestamp</c> selector resolves to the newest row.
/// <para><b>This is the Oracle-specific leg.</b> The planner spells the row limit through the
/// dialect's <c>SingleRowLimitSuffix</c> — a trailing <c>FETCH FIRST 1 ROWS ONLY</c> (the 12.1+
/// ANSI clause), deliberately not <c>WHERE ROWNUM = 1</c>, which is assigned before
/// <c>ORDER BY</c> runs and would take an arbitrary row and then sort it. The seed makes the newest
/// row neither the first-inserted nor the lowest-keyed one, so that exact mistake returns a
/// different, wrong value here rather than a coincidentally-right one.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realOracle_topByTimestampSelectsTheNewestRow()
{
SkipUnlessLive();
await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", OracleFixture.OvenTempColumn));
var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture)
.ShouldBe(OracleFixture.NewestStationOvenTemp);
}
// ---- catalog case-folding (design §8.1 gate, against the real ALL_* views) ----
/// <summary>
/// An operator who authors <c>otopcua.tag_values</c> against a catalog holding
/// <c>OTOPCUA.TAG_VALUES</c> still reads. <b>Pinned as accepted, not rejected</b> — that is what
/// <c>SqlCatalog.TryMatch</c> actually does: an exact <em>ordinal</em> hit wins, and failing that a
/// <b>unique</b> case-insensitive hit is accepted and the catalog's own spelling substituted before
/// anything is quoted into SQL.
/// <para><b>On Oracle this is not tolerance, it is the only thing that works.</b> Oracle upper-cases
/// an unquoted identifier, so the catalog holds <c>TAG_VALUES</c>; but
/// <c>OracleDialect.QuoteIdentifier</c> emits a double-quoted — i.e. case-<em>sensitive</em> — name,
/// and <c>"tag_values"</c> and <c>"TAG_VALUES"</c> are different objects to Oracle. Without the
/// gate's substitution the authored lower-case spelling would reach the server naming an object that
/// does not exist. Every AdminUI-authored tag would be affected, since nobody types SHOUTING
/// identifiers by hand.</para>
/// <para>The gate refuses only an <em>ambiguous</em> fold — two catalog names differing solely in
/// case, which Oracle can only produce from quoted DDL — and that cannot arise from this seed; it is
/// pinned offline in the unit suites.</para>
/// </summary>
[Fact]
public async Task CatalogGate_realOracle_foldsALowerCasedAuthoredNameOntoTheUpperCaseCatalogSpelling()
{
SkipUnlessLive();
var lower = Tag("Sql/Line1/Speed", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = OracleFixture.Qualified(OracleFixture.KeyValueTable).ToLowerInvariant(),
["keyColumn"] = OracleFixture.KeyColumn.ToLowerInvariant(),
["keyValue"] = OracleFixture.PresentKey,
["valueColumn"] = OracleFixture.ValueColumn.ToLowerInvariant(),
["timestampColumn"] = OracleFixture.TimestampColumn.ToLowerInvariant(),
});
await using var driver = await StartAsync(lower);
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(OracleFixture.PresentValue);
}
// ---- browse session: the AdminUI's schema → table → column walk ----
/// <summary>
/// The AdminUI picker's walk — <c>RootAsync</c> (schemas) → <c>ExpandAsync</c> (tables) →
/// <c>ExpandAsync</c> (columns) → <c>AttributesAsync</c> (the side panel) — sees the seeded table
/// over a real database, and the leaf's mapped driver type is the one
/// <c>OracleDialect.MapColumnType</c> promises for <c>NUMBER</c>.
/// <para><b>The table expand is the <c>BindByName</c> proof.</b> It runs
/// <c>OracleDialect.ListTablesSql</c>, whose <c>UNION ALL</c> names <c>:schema</c> twice against a
/// single bound parameter — a statement that cannot execute under ODP.NET's default positional
/// binding. Reaching a non-empty table list here is therefore evidence that
/// <c>ConfigureCommand</c> ran at every command site, not just in the unit test.</para>
/// <para>Unlike the Postgres walk, the schema list is NOT asserted to be a single item:
/// <c>ALL_USERS</c> lists every user in the instance (Oracle equates a schema with a user), so the
/// seeded schema is one of many.</para>
/// </summary>
[Fact]
public async Task Browse_realOracle_walksSchemaToTableToColumnAndTypesTheLeaf()
{
SkipUnlessLive();
var config = new JsonObject
{
["provider"] = nameof(SqlProvider.Oracle),
["connectionString"] = _ora.ConnectionString,
}.ToJsonString();
await using var session = await new SqlDriverBrowser()
.OpenAsync(config, TestContext.Current.CancellationToken);
// Asserted on the NAMES rather than with a predicate so a miss prints the list the catalog did
// return, which is the only useful diagnostic when a browse walk comes back short.
var schemas = await session.RootAsync(TestContext.Current.CancellationToken);
schemas.Select(s => s.DisplayName).ShouldContain(OracleFixture.Schema);
var schema = schemas.First(s => s.DisplayName == OracleFixture.Schema);
var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
tables.Select(t => t.DisplayName).ShouldContain(OracleFixture.KeyValueTable);
var table = tables.First(t => t.DisplayName == OracleFixture.KeyValueTable);
var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken);
columns.Select(c => c.DisplayName).ShouldBe(
[OracleFixture.KeyColumn, OracleFixture.ValueColumn, OracleFixture.TimestampColumn]);
columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf);
// The side panel is what the browse-commit builds a tag from: the table has to travel with the
// column, or the commit falls through to a generic blob the typed editor cannot read.
var value = columns.Single(c => c.DisplayName == OracleFixture.ValueColumn);
var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // NUMBER
var address = attribute.AddressFields.ShouldNotBeNull();
address["schema"].ShouldBe(OracleFixture.Schema);
address["table"].ShouldBe(OracleFixture.KeyValueTable);
address["columnName"].ShouldBe(OracleFixture.ValueColumn);
}
// ---- helpers ----
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
private void SkipUnlessLive()
{
if (_ora.SkipReason is { } reason) Assert.Skip(reason);
}
/// <summary>
/// Builds and initializes a driver over the seeded schema, with no injected factory — which is
/// precisely what makes this suite exercise <c>OracleClientFactory.Instance</c>, the provider the
/// dialect ships.
/// </summary>
private async Task<SqlDriver> StartAsync(params RawTagEntry[] tags)
{
var options = new SqlDriverOptions
{
RawTags = tags,
CommandTimeout = TimeSpan.FromSeconds(15),
OperationTimeout = TimeSpan.FromSeconds(20),
};
var driver = new SqlDriver(
options,
driverInstanceId: "sql-oracle-integration",
dialect: new OracleDialect(),
connectionString: _ora.ConnectionString);
try
{
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
}
catch
{
await driver.DisposeAsync();
throw;
}
return driver;
}
/// <summary>A key-value tag over the seeded EAV table, authored in the catalog's own spelling.</summary>
private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = OracleFixture.Qualified(OracleFixture.KeyValueTable),
["keyColumn"] = OracleFixture.KeyColumn,
["keyValue"] = key,
["valueColumn"] = OracleFixture.ValueColumn,
["timestampColumn"] = OracleFixture.TimestampColumn,
});
/// <summary>A wide-row tag selected by newest timestamp.</summary>
private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "WideRow",
["table"] = OracleFixture.Qualified(OracleFixture.WideRowTable),
["columnName"] = column,
["timestampColumn"] = OracleFixture.TimestampColumn,
["rowSelector"] = new JsonObject
{
["topByTimestamp"] = OracleFixture.TimestampColumn,
},
});
/// <summary>
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry. Composed as a
/// <see cref="JsonObject"/> rather than as an interpolated string literal for the reason
/// <see cref="SqlServerReadTests"/> gives.
/// </summary>
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
}
/// <summary>
/// A real, seeded <b>Oracle Database</b> schema standing in for the source the <c>Sql</c> driver polls —
/// the Oracle counterpart of <see cref="SqlPollServerFixture"/>.
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
/// nothing unless <see cref="EndpointEnvVar"/> and <see cref="PasswordEnvVar"/> are both set; absent, it
/// records <see cref="SkipReason"/> and every test calls <c>Assert.Skip</c>.</para>
/// <para><b>This fixture does not seed — the compose stack does</b>
/// (<c>Docker/oracle/docker-compose.yml</c> + <c>seed-oracle.sql</c>). Oracle equates a schema with a
/// user, so the seed runs as the same <c>otopcua</c> principal the suite connects as and its objects
/// land in the <c>OTOPCUA</c> schema.</para>
/// <para><b>Every identifier constant below is UPPER case, and that is the catalog's spelling, not a
/// style choice.</b> The seed's DDL is unquoted, so Oracle folds it up; the case-folding leg is the one
/// place that deliberately authors the lower-case form an operator would actually type.</para>
/// </summary>
public sealed class OracleFixture : IAsyncLifetime
{
/// <summary>Listener endpoint in <c>host:port</c> form (e.g. <c>10.100.0.35:11521</c>). Absent ⇒ skip.</summary>
public const string EndpointEnvVar = "SQL_ORACLE_ENDPOINT";
/// <summary>The login. Defaults to <see cref="DefaultUser"/>, the user the compose stack creates.</summary>
public const string UserEnvVar = "SQL_ORACLE_USER";
/// <summary>The password. Required — never defaulted (see <see cref="ResolveConnectionString"/>).</summary>
public const string PasswordEnvVar = "SQL_ORACLE_PASSWORD";
/// <summary>
/// The service name the EZ-Connect descriptor addresses. Optional: defaults to
/// <see cref="DefaultService"/>, the pluggable database the <c>gvenzl/oracle-free</c> image creates.
/// </summary>
public const string ServiceEnvVar = "SQL_ORACLE_SERVICE";
/// <summary>The application user the compose stack creates (<c>APP_USER</c>).</summary>
public const string DefaultUser = "otopcua";
/// <summary>The image's pluggable-database service name.</summary>
public const string DefaultService = "FREEPDB1";
/// <summary>Oracle's default listener port, used when <see cref="EndpointEnvVar"/> carries no port.</summary>
public const int DefaultPort = 1521;
/// <summary>The schema the seed's objects live in — Oracle folds the unquoted user name up.</summary>
public const string Schema = "OTOPCUA";
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
private const int ProbeConnectTimeoutSeconds = 10;
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>TAG_NAME VARCHAR2(128), NUM_VALUE NUMBER, SAMPLE_TS TIMESTAMP(3)</c>.</summary>
public const string KeyValueTable = "TAG_VALUES";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "TAG_NAME";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "NUM_VALUE";
/// <summary>The source-timestamp column carried by every seeded table.</summary>
public const string TimestampColumn = "SAMPLE_TS";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
public const string SecondPresentKey = "Line1.Pressure";
/// <summary>The value seeded for <see cref="SecondPresentKey"/> — different from
/// <see cref="PresentValue"/>, so a positional-binding swap is visible.</summary>
public const double SecondPresentValue = 3.5;
/// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
// ---- wide-row source ----
/// <summary>The wide-row table: <c>STATION_ID NUMBER(10), OVEN_TEMP/PRESSURE NUMBER, SAMPLE_TS TIMESTAMP(3)</c>.</summary>
public const string WideRowTable = "LATEST_STATUS";
/// <summary>The wide-row table's value column the <c>topByTimestamp</c> leg reads.</summary>
public const string OvenTempColumn = "OVEN_TEMP";
/// <summary>
/// The <see cref="OvenTempColumn"/> of the row holding the <b>newest</b> <c>SAMPLE_TS</c> — what a
/// <c>topByTimestamp</c> selector must resolve to. Its row is neither the first inserted nor the
/// lowest-keyed, so a plan that lost its <c>ORDER BY … DESC</c> or its
/// <c>FETCH FIRST 1 ROWS ONLY</c> picks a different value and the test says so.
/// </summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>Why the suite is skipping, or <see langword="null"/> when the server is reachable.</summary>
public string? SkipReason { get; private set; }
/// <summary>The connection string to hand the code under test. Empty while <see cref="SkipReason"/> is set.</summary>
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>The resolved service name, for the endpoint-description assertion.</summary>
public string ServiceName { get; private set; } = DefaultService;
/// <summary>The listener the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
public string Server { get; private set; } = string.Empty;
/// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
/// <param name="table">The bare table or view name.</param>
/// <returns>The <c>schema.table</c> name.</returns>
public static string Qualified(string table) => $"{Schema}.{table}";
/// <summary>
/// Resolves the gate and probes the server with the dialect's own <c>LivenessSql</c>. A failure to
/// connect is the offline case and becomes a <see cref="SkipReason"/>; nothing is created or seeded
/// here (the compose stack owns that).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
if (ResolveConnectionString() is not { } resolved)
{
SkipReason =
$"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, " +
$"default '{DefaultUser}', and {ServiceEnvVar}, default '{DefaultService}') to run the " +
"Sql driver's live Oracle suite. Bring the fixture up from " +
"Docker/oracle/docker-compose.yml first. No connection is attempted without these.";
return;
}
ConnectionString = resolved;
var dialect = new OracleDialect();
try
{
var connection = dialect.Factory.CreateConnection()
?? throw new InvalidOperationException("ODP.NET returned no connection object.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = ConnectionString;
await connection.OpenAsync().ConfigureAwait(false);
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
dialect.ConfigureCommand(command);
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync().ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
SkipReason =
$"Oracle at '{Server}' (service '{ServiceName}') was not reachable within " +
$"{ProbeConnectTimeoutSeconds}s ({ex.GetType().Name}: {ex.Message}). Check " +
$"{EndpointEnvVar}/{UserEnvVar}/{PasswordEnvVar}/{ServiceEnvVar} and that the fixture " +
"stack is up (its first boot takes minutes), or leave them unset to skip.";
}
}
/// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
/// Reads the gate and composes the connection string <b>through the dialect's own provider
/// factory</b> (<see cref="DbProviderFactory.CreateConnectionStringBuilder"/>), setting keys via the
/// abstract <see cref="DbConnectionStringBuilder"/> indexer.
/// <para>That indirection is deliberate: it keeps this project free of a direct ODP.NET
/// <c>PackageReference</c>, so the suite can only ever exercise the provider version the driver
/// actually ships (see the csproj's no-direct-provider-reference note). Every key used here —
/// <c>Data Source</c>, <c>User Id</c>, <c>Password</c>, <c>Connection Timeout</c> — was verified to
/// be accepted through that indexer, so no provider-specific property is needed.</para>
/// <para>The data source is an <b>EZ-Connect</b> descriptor (<c>host:port/service</c>), which needs
/// no <c>tnsnames.ora</c> — deliberate, since the managed provider is meant to run with no client
/// install and no Oracle configuration files anywhere near the test host.</para>
/// </summary>
/// <returns>The connection string, or <see langword="null"/> — touching no socket — when unconfigured.</returns>
private string? ResolveConnectionString()
{
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) return null;
// A password is required rather than defaulted: guessing one would turn a misconfiguration into a
// failed login, and Oracle locks an account after a handful of those.
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null;
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named
? named
: DefaultUser;
ServiceName = Environment.GetEnvironmentVariable(ServiceEnvVar) is { Length: > 0 } service
? service
: DefaultService;
var (host, port) = PostgresFixture.SplitEndpoint(endpoint, DefaultPort);
Server = $"{host}:{port}";
var builder = new OracleDialect().Factory.CreateConnectionStringBuilder()
?? throw new InvalidOperationException("ODP.NET returned no connection-string builder.");
builder["Data Source"] = $"{host}:{port}/{ServiceName}";
builder["User Id"] = user;
builder["Password"] = password;
builder["Connection Timeout"] = ProbeConnectTimeoutSeconds;
return builder.ConnectionString;
}
}
/// <summary>
/// Collection definition so the gate is resolved once per test session rather than once per test
/// class — the same shape as <see cref="SqlPollServerCollection"/>.
/// </summary>
[CollectionDefinition(Name)]
public sealed class OracleCollection : ICollectionFixture<OracleFixture>
{
/// <summary>The collection name test classes reference.</summary>
public const string Name = "SqlOracleServer";
}
@@ -0,0 +1,511 @@
using System.Data.Common;
using System.Globalization;
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;
/// <summary>
/// The <c>Sql</c> driver against a <b>real PostgreSQL</b>, over the <c>Npgsql</c> provider
/// <c>PostgresDialect</c> ships. The offline suites all run on SQLite, so three things are exercised
/// nowhere else: the Npgsql ADO.NET path, Postgres' own row-limit syntax (a trailing <c>LIMIT 1</c>
/// where T-SQL emits <c>SELECT TOP 1</c>), and <c>PostgresDialect</c>'s <c>information_schema</c>
/// catalog SQL — including the deliberate choice to derive the schema list from
/// <c>information_schema.tables</c> rather than <c>.schemata</c>, which only a
/// granted-but-not-owning principal can prove.
/// <para><b>Env-gated; skipping is the normal outcome.</b> See <see cref="PostgresFixture"/> for the
/// gate. With it unset nothing here opens a socket, so the suite is instant and green offline — which
/// is the point: a live test that goes red on a laptop teaches people to ignore red.</para>
/// <para><b>Deliberately narrower than <see cref="SqlServerReadTests"/>.</b> The provider-agnostic
/// read semantics (NULL cells, absent keys, declared-type overrides, pooling, cancellation) are pinned
/// once against SQL Server and by the SQLite suites; repeating all of them per provider would buy
/// runtime rather than coverage. What is repeated here is exactly what a <em>dialect</em> can get
/// wrong: liveness, the catalog SQL, the row-limit syntax, identifier case-folding, and the browse
/// walk an operator authors through.</para>
/// </summary>
[Collection(PostgresCollection.Name)]
public sealed class PostgresReadTests
{
private readonly PostgresFixture _pg;
/// <summary>Initializes a new instance of the <see cref="PostgresReadTests"/> class.</summary>
/// <param name="pg">The seeded live-server fixture (collection-scoped).</param>
public PostgresReadTests(PostgresFixture pg) => _pg = pg;
// ---- lifecycle: liveness + catalog load ----
/// <summary>
/// Initialize's liveness check (<c>SELECT 1</c>) and its catalog load both succeed, and the driver
/// reports Healthy with the endpoint described credential-free.
/// <para><b>The catalog load is the interesting half, and it is asserted indirectly but
/// unambiguously.</b> <c>SqlCatalogLoader</c> throws when the connecting principal sees zero
/// schemas, and a tag whose table did not resolve is dropped from the polled set and publishes
/// <c>BadNodeIdUnknown</c>. So a Good read (below) is only reachable when
/// <c>PostgresDialect.ListSchemasSql</c> returned the fixture schema for a role that merely holds
/// <c>USAGE</c> + <c>SELECT</c> on it — the provisioning that <c>information_schema.schemata</c>
/// would have answered empty for.</para>
/// </summary>
[Fact]
public async Task InitializeAsync_realPostgres_reportsHealthyAndDescribesTheEndpoint()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.PresentKey));
var health = driver.GetHealth();
health.State.ShouldBe(DriverState.Healthy);
health.LastSuccessfulRead.ShouldNotBeNull();
// The endpoint description is what every log line and the Admin UI show. It must name the
// database and must not carry the credential that reached the provider.
driver.Endpoint.ShouldContain(PostgresFixture.FixtureDatabase);
driver.Endpoint.ShouldNotContain("password", Case.Insensitive);
driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running);
}
// ---- key-value model ----
/// <summary>A seeded key-value row round-trips as a Good snapshot with its source timestamp.</summary>
[Fact]
public async Task ReadAsync_realPostgres_roundTripsSeededKeyValue()
{
SkipUnlessLive();
await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", PostgresFixture.PresentKey));
var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken);
var snapshot = snapshots.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue);
// `timestamp without time zone` comes back Unspecified; the reader stamps it UTC rather than
// reinterpreting it through the host's zone — the behaviour this asserts against a real column type.
snapshot.SourceTimestampUtc.ShouldBe(PostgresFixture.PresentKeyTimestampUtc);
}
// ---- wide-row model: the Postgres-specific row-limit syntax ----
/// <summary>
/// The <c>topByTimestamp</c> selector resolves to the newest row.
/// <para><b>This is the Postgres-specific leg.</b> The planner spells the row limit through the
/// dialect's <c>SingleRowLimitSuffix</c> — a trailing <c>LIMIT 1</c> — where the SQL Server suite
/// exercises a leading <c>SELECT TOP 1</c>. The seed makes the newest row neither the
/// first-inserted nor the lowest-keyed one, so losing either the limit or the
/// <c>ORDER BY … DESC</c> yields a different, wrong value rather than a coincidentally-right one.</para>
/// </summary>
[Fact]
public async Task ReadAsync_realPostgres_topByTimestampSelectsTheNewestRow()
{
SkipUnlessLive();
await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", "oven_temp"));
var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good);
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture)
.ShouldBe(PostgresFixture.NewestStationOvenTemp);
}
// ---- catalog case-folding (design §8.1 gate, against a real information_schema) ----
/// <summary>
/// An operator who authors <c>SQLPOLL.TAG_VALUES</c> against a catalog holding
/// <c>sqlpoll.tag_values</c> still reads. <b>Pinned as accepted, not rejected</b> — that is what
/// <c>SqlCatalog.TryMatch</c> actually does: an exact <em>ordinal</em> hit wins, and failing that a
/// <b>unique</b> case-insensitive hit is accepted and the catalog's own spelling substituted before
/// anything is quoted into SQL.
/// <para><b>Why this matters more on Postgres than it looks.</b> Postgres folds an unquoted
/// identifier to <em>lower</em> case, and <c>PostgresDialect.QuoteIdentifier</c> emits a
/// double-quoted — i.e. case-<em>sensitive</em> — name. Without the gate's substitution, the
/// authored <c>"TAG_VALUES"</c> would reach the server as a quoted identifier naming an object that
/// does not exist, and the tag would fail at query time instead of resolving. So this leg is not
/// cosmetic tolerance; it is the only thing making mixed-case authoring work at all.</para>
/// <para>The gate refuses only an <em>ambiguous</em> fold — two catalog columns differing solely in
/// case — which cannot be produced from this seed and is pinned offline in the unit suites.</para>
/// </summary>
[Fact]
public async Task CatalogGate_realPostgres_foldsAnUpperCasedAuthoredNameOntoTheLowerCaseCatalogSpelling()
{
SkipUnlessLive();
var upper = Tag("Sql/Line1/Speed", new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable).ToUpperInvariant(),
["keyColumn"] = PostgresFixture.KeyColumn.ToUpperInvariant(),
["keyValue"] = PostgresFixture.PresentKey,
["valueColumn"] = PostgresFixture.ValueColumn.ToUpperInvariant(),
["timestampColumn"] = PostgresFixture.TimestampColumn.ToUpperInvariant(),
});
await using var driver = await StartAsync(upper);
var snapshot = (await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
Convert.ToDouble(snapshot.Value, CultureInfo.InvariantCulture).ShouldBe(PostgresFixture.PresentValue);
}
// ---- browse session: the AdminUI's schema → table → column walk ----
/// <summary>
/// The AdminUI picker's walk — <c>RootAsync</c> (schemas) → <c>ExpandAsync</c> (tables) →
/// <c>ExpandAsync</c> (columns) → <c>AttributesAsync</c> (the side panel) — sees the seeded table
/// over a real database, and the leaf's mapped driver type is the one
/// <c>PostgresDialect.MapColumnType</c> promises for <c>double precision</c>.
/// <para>Driven through <see cref="SqlDriverBrowser"/> rather than the internal session, because
/// that is the seam the AdminUI actually calls: it also proves the provider gate admits
/// <c>Postgres</c> and that the dialect's <em>quoted</em> catalog aliases
/// (<c>AS "TABLE_NAME"</c>) survive Postgres' lower-casing of unquoted output columns — the
/// browse session reads every catalog column by name, so an unquoted alias would fold to
/// <c>table_name</c> and the walk would throw on <c>GetOrdinal</c> rather than return an empty
/// list.</para>
/// </summary>
[Fact]
public async Task Browse_realPostgres_walksSchemaToTableToColumnAndTypesTheLeaf()
{
SkipUnlessLive();
var config = new JsonObject
{
["provider"] = nameof(SqlProvider.Postgres),
["connectionString"] = _pg.ConnectionString,
}.ToJsonString();
await using var session = await new SqlDriverBrowser()
.OpenAsync(config, TestContext.Current.CancellationToken);
var schemas = await session.RootAsync(TestContext.Current.CancellationToken);
var schema = schemas.ShouldHaveSingleItem(
"the fixture login can SELECT in exactly one schema, so exactly one is browsable");
schema.DisplayName.ShouldBe(PostgresFixture.Schema);
var tables = await session.ExpandAsync(schema.NodeId, TestContext.Current.CancellationToken);
// Asserted on the NAMES rather than with a predicate so a miss prints the list the catalog did
// return, which is the only useful diagnostic when a browse walk comes back short.
tables.Select(t => t.DisplayName).ShouldContain(PostgresFixture.KeyValueTable);
var table = tables.First(t => t.DisplayName == PostgresFixture.KeyValueTable);
var columns = await session.ExpandAsync(table.NodeId, TestContext.Current.CancellationToken);
columns.Select(c => c.DisplayName).ShouldBe(
[PostgresFixture.KeyColumn, PostgresFixture.ValueColumn, PostgresFixture.TimestampColumn]);
columns.ShouldAllBe(c => c.Kind == BrowseNodeKind.Leaf);
// The side panel is what the browse-commit builds a tag from: the table has to travel with the
// column, or the commit falls through to a generic blob the typed editor cannot read.
var value = columns.Single(c => c.DisplayName == PostgresFixture.ValueColumn);
var attribute = (await session.AttributesAsync(value.NodeId, TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
attribute.DriverDataType.ShouldBe(nameof(DriverDataType.Float64)); // double precision
var address = attribute.AddressFields.ShouldNotBeNull();
address["schema"].ShouldBe(PostgresFixture.Schema);
address["table"].ShouldBe(PostgresFixture.KeyValueTable);
address["columnName"].ShouldBe(PostgresFixture.ValueColumn);
}
// ---- helpers ----
/// <summary>Skips the calling test when the live-server gate is not configured or not reachable.</summary>
private void SkipUnlessLive()
{
if (_pg.SkipReason is { } reason) Assert.Skip(reason);
}
/// <summary>
/// Builds and initializes a driver over the seeded database, with no injected factory — which is
/// precisely what makes this suite exercise <c>NpgsqlFactory.Instance</c>, the provider the dialect
/// ships.
/// </summary>
private async Task<SqlDriver> StartAsync(params RawTagEntry[] tags)
{
var options = new SqlDriverOptions
{
RawTags = tags,
CommandTimeout = TimeSpan.FromSeconds(10),
OperationTimeout = TimeSpan.FromSeconds(15),
};
var driver = new SqlDriver(
options,
driverInstanceId: "sql-postgres-integration",
dialect: new PostgresDialect(),
connectionString: _pg.ConnectionString);
try
{
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
}
catch
{
await driver.DisposeAsync();
throw;
}
return driver;
}
/// <summary>A key-value tag over the seeded EAV table.</summary>
private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "KeyValue",
["table"] = PostgresFixture.Qualified(PostgresFixture.KeyValueTable),
["keyColumn"] = PostgresFixture.KeyColumn,
["keyValue"] = key,
["valueColumn"] = PostgresFixture.ValueColumn,
["timestampColumn"] = PostgresFixture.TimestampColumn,
});
/// <summary>A wide-row tag selected by newest timestamp.</summary>
private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject
{
["driver"] = "Sql",
["model"] = "WideRow",
["table"] = PostgresFixture.Qualified(PostgresFixture.WideRowTable),
["columnName"] = column,
["timestampColumn"] = PostgresFixture.TimestampColumn,
["rowSelector"] = new JsonObject
{
["topByTimestamp"] = PostgresFixture.TimestampColumn,
},
});
/// <summary>
/// Wraps an authored <c>TagConfig</c> object as a raw tag entry. Composed as a
/// <see cref="JsonObject"/> rather than as an interpolated string literal for the reason
/// <see cref="SqlServerReadTests"/> gives: a hand-written literal that loses a brace produces a
/// config the parser silently rejects, which surfaces as a plausible-looking driver failure.
/// </summary>
private static RawTagEntry Tag(string rawPath, JsonObject config) =>
new(rawPath, config.ToJsonString(), WriteIdempotent: false);
}
/// <summary>
/// A real, seeded <b>PostgreSQL</b> database standing in for the source the <c>Sql</c> driver polls —
/// the Postgres counterpart of <see cref="SqlPollServerFixture"/>.
/// <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
/// nothing unless <see cref="EndpointEnvVar"/> and <see cref="PasswordEnvVar"/> are both set; absent,
/// it records <see cref="SkipReason"/> and every test calls <c>Assert.Skip</c>. That is what keeps
/// <c>dotnet test</c> genuinely green (and instant) on the macOS dev box this is usually run from.</para>
/// <para><b>This fixture does not seed — the compose stack does</b>
/// (<c>Docker/postgres/docker-compose.yml</c> + <c>seed-pg.sql</c>), and that is deliberate rather
/// than a shortcut. The seed's whole point is that the schema is owned by a role the suite is
/// <em>not</em>: the connecting principal holds <c>USAGE</c> + <c>SELECT</c> and could not create
/// roles, schemas or tables even if asked to. A fixture that seeded itself would have to connect as a
/// superuser, and the granted-not-owning provisioning that
/// <c>PostgresDialect.ListSchemasSql</c> exists for would never be exercised.</para>
/// </summary>
public sealed class PostgresFixture : IAsyncLifetime
{
/// <summary>Server endpoint in <c>host:port</c> form (e.g. <c>10.100.0.35:15432</c>). Absent ⇒ skip.</summary>
public const string EndpointEnvVar = "SQL_PG_ENDPOINT";
/// <summary>The login. Defaults to <see cref="DefaultUser"/>, the user the seed grants.</summary>
public const string UserEnvVar = "SQL_PG_USER";
/// <summary>The password. Required — never defaulted (see <see cref="ResolveConnectionString"/>).</summary>
public const string PasswordEnvVar = "SQL_PG_PASSWORD";
/// <summary>The unprivileged login the seed creates and grants USAGE + SELECT to.</summary>
public const string DefaultUser = "otopcua";
/// <summary>Postgres' default port, used when <see cref="EndpointEnvVar"/> carries no port.</summary>
public const int DefaultPort = 5432;
/// <summary>The database the compose stack creates (<c>POSTGRES_DB</c>) and the seed targets.</summary>
public const string FixtureDatabase = "sqlpollfixture";
/// <summary>
/// The schema every seeded object lives in — <b>not</b> <c>public</c>, so every authored
/// <c>table</c> is a two-part name and the planner's qualified quoting is exercised, and so the
/// seed's drop-and-recreate is bounded to objects this fixture owns.
/// </summary>
public const string Schema = "sqlpoll";
/// <summary>The <c>Application Name</c> the driver's connections carry, for server-side attribution.</summary>
public const string DriverApplicationName = "OtOpcUa.Sql.ITs.pg";
/// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
private const int ProbeConnectTimeoutSeconds = 5;
// ---- key-value (EAV) source ----
/// <summary>The key-value table: <c>tag_name varchar(128), num_value double precision NULL, sample_ts timestamp(3)</c>.</summary>
public const string KeyValueTable = "tag_values";
/// <summary>The key-value table's key column.</summary>
public const string KeyColumn = "tag_name";
/// <summary>The key-value table's value column.</summary>
public const string ValueColumn = "num_value";
/// <summary>The source-timestamp column carried by every seeded table.</summary>
public const string TimestampColumn = "sample_ts";
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
public const string PresentKey = "Line1.Speed";
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
public const double PresentValue = 42.0;
/// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
public static readonly DateTime PresentKeyTimestampUtc = new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);
// ---- wide-row source ----
/// <summary>The wide-row table: <c>station_id integer, oven_temp/pressure double precision NULL, sample_ts timestamp(3)</c>.</summary>
public const string WideRowTable = "latest_status";
/// <summary>
/// The <c>oven_temp</c> of the row holding the <b>newest</b> <c>sample_ts</c> — what a
/// <c>topByTimestamp</c> selector must resolve to. Its row is neither the first inserted nor the
/// lowest-keyed, so a plan that lost its <c>ORDER BY … DESC</c> or its <c>LIMIT 1</c> picks a
/// different value and the test says so.
/// </summary>
public const double NewestStationOvenTemp = 210.25;
/// <summary>Why the suite is skipping, or <see langword="null"/> when the server is reachable.</summary>
public string? SkipReason { get; private set; }
/// <summary>The connection string to hand the code under test. Empty while <see cref="SkipReason"/> is set.</summary>
public string ConnectionString { get; private set; } = string.Empty;
/// <summary>The server the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
public string Server { get; private set; } = string.Empty;
/// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
/// <param name="table">The bare table or view name.</param>
/// <returns>The <c>schema.table</c> name.</returns>
public static string Qualified(string table) => $"{Schema}.{table}";
/// <summary>
/// Resolves the gate and probes the server with the dialect's own <c>LivenessSql</c>. A failure to
/// connect is the offline case and becomes a <see cref="SkipReason"/>; nothing is created or
/// seeded here (the compose stack owns that).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
if (ResolveConnectionString() is not { } resolved)
{
SkipReason =
$"Set {EndpointEnvVar} (host:port) and {PasswordEnvVar} (and optionally {UserEnvVar}, " +
$"default '{DefaultUser}') to run the Sql driver's live PostgreSQL suite. Bring the " +
"fixture up from Docker/postgres/docker-compose.yml first. No connection is attempted " +
"without these.";
return;
}
ConnectionString = resolved;
var dialect = new PostgresDialect();
try
{
var connection = dialect.Factory.CreateConnection()
?? throw new InvalidOperationException("Npgsql returned no connection object.");
await using (connection.ConfigureAwait(false))
{
connection.ConnectionString = ConnectionString;
await connection.OpenAsync().ConfigureAwait(false);
var command = connection.CreateCommand();
await using (command.ConfigureAwait(false))
{
command.CommandText = dialect.LivenessSql;
await command.ExecuteScalarAsync().ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
SkipReason =
$"PostgreSQL at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " +
$"({ex.GetType().Name}: {ex.Message}). Check {EndpointEnvVar}/{UserEnvVar}/" +
$"{PasswordEnvVar} and that the fixture stack is up, or leave them unset to skip.";
}
}
/// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
/// Reads the gate and composes the connection string <b>through the dialect's own provider
/// factory</b> (<see cref="DbProviderFactory.CreateConnectionStringBuilder"/>), setting keys via
/// the abstract <see cref="DbConnectionStringBuilder"/> indexer.
/// <para>That indirection is deliberate: it keeps this project free of a direct Npgsql
/// <c>PackageReference</c>, so the suite can only ever exercise the provider version the driver
/// actually ships (see the csproj's no-direct-provider-reference note). Every key used here —
/// <c>Host</c>, <c>Port</c>, <c>Database</c>, <c>Username</c>, <c>Password</c>, <c>Timeout</c>,
/// <c>Application Name</c> — was verified to be accepted through that indexer, so no
/// provider-specific property is needed.</para>
/// </summary>
/// <returns>The connection string, or <see langword="null"/> — touching no socket — when unconfigured.</returns>
private string? ResolveConnectionString()
{
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) return null;
// A password is required rather than defaulted: guessing one would turn a misconfiguration into a
// failed login, which is how accounts get locked out on a server that is not a throwaway.
if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) return null;
var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named
? named
: DefaultUser;
var (host, port) = SplitEndpoint(endpoint, DefaultPort);
Server = $"{host}:{port}";
var builder = new PostgresDialect().Factory.CreateConnectionStringBuilder()
?? throw new InvalidOperationException("Npgsql returned no connection-string builder.");
builder["Host"] = host;
builder["Port"] = port;
builder["Database"] = FixtureDatabase;
builder["Username"] = user;
builder["Password"] = password;
builder["Timeout"] = ProbeConnectTimeoutSeconds;
builder["Application Name"] = DriverApplicationName;
return builder.ConnectionString;
}
/// <summary>
/// Splits a <c>host:port</c> endpoint, defaulting the port when absent or unparseable. Split on the
/// LAST colon so a bracketed IPv6 literal keeps its own colons.
/// </summary>
/// <param name="endpoint">The authored endpoint.</param>
/// <param name="defaultPort">The port to use when none is carried.</param>
/// <returns>The host and port.</returns>
internal static (string Host, int Port) SplitEndpoint(string endpoint, int defaultPort)
{
var separator = endpoint.LastIndexOf(':');
if (separator <= 0 || separator == endpoint.Length - 1) return (endpoint, defaultPort);
var host = endpoint[..separator];
return int.TryParse(
endpoint[(separator + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var port)
? (host, port)
: (endpoint, defaultPort);
}
}
/// <summary>
/// Collection definition so the gate is resolved once per test session rather than once per test
/// class — the same shape as <see cref="SqlPollServerCollection"/>.
/// </summary>
[CollectionDefinition(Name)]
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>
{
/// <summary>The collection name test classes reference.</summary>
public const string Name = "SqlPostgresServer";
}
@@ -27,12 +27,20 @@
</ItemGroup>
<!--
No Microsoft.Data.SqlClient PackageReference here on purpose: this suite must exercise EXACTLY the
provider the driver ships, and it arrives transitively through Driver.Sql. Adding it here would let
the two versions drift and the suite would then prove something about a provider nobody deploys.
No Microsoft.Data.SqlClient / Npgsql / Oracle.ManagedDataAccess.Core PackageReference here on purpose:
this suite must exercise EXACTLY the providers the driver ships, and they arrive transitively through
Driver.Sql. Adding one here would let the two versions drift and the suite would then prove something
about a provider nobody deploys. The Postgres and Oracle suites therefore never name a provider type:
they compose their connection strings through `dialect.Factory.CreateConnectionStringBuilder()` and the
abstract DbConnectionStringBuilder indexer.
Driver.Sql.Browser is referenced for the browse-session walk (schema → table → column) the Postgres and
Oracle suites drive through the AdminUI's real seam, SqlDriverBrowser — the only place the dialects'
catalog SQL is exercised as the picker actually calls it.
-->
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
</ItemGroup>
@@ -0,0 +1,116 @@
using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// A <b>test-only</b> dialect that spells parameters the ODP.NET way — <c>:name</c> in the SQL text and a
/// <b>bare</b> <c>name</c> as the <see cref="DbParameter.ParameterName"/> — over the SQLite fixture. It is
/// the falsifiability control for dialect-owned parameter naming: every marker in this repo was hardcoded
/// <c>"@"</c> before, and a planner or loader that still hardcodes it emits <c>@k0</c> against this
/// dialect and fails loudly.
/// <para><b>Why the two halves must differ.</b> A dialect whose marker and bind name are the same string
/// (SQL Server, Npgsql, SQLite) cannot tell a call site that <em>copies</em> the plan's bind name apart
/// from one that reuses the marker. Oracle is the provider where they genuinely diverge, so this fake
/// reproduces that divergence rather than merely changing the prefix.</para>
/// <para><b>It really executes.</b> Everything but the parameter naming (and the catalog SQL's markers)
/// delegates to <see cref="SqliteDialect"/>, and Microsoft.Data.Sqlite accepts both a <c>:</c>-prefixed
/// marker and a prefix-less parameter name — so a poll or a catalog load driven through this dialect runs
/// against the real fixture database rather than being asserted on as text.</para>
/// <para>A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and
/// <c>new</c>-hiding a member would leave interface dispatch calling the original — the substitution
/// would silently not happen and the control would pass for the wrong reason.</para>
/// </summary>
public sealed class ColonMarkerDialect : ISqlDialect
{
private static readonly SqliteDialect Inner = new();
/// <summary>Every command handed to <see cref="ConfigureCommand"/>, kept so its binds can be read back.</summary>
private readonly List<DbCommand> _commands = [];
/// <summary>How many times <see cref="ConfigureCommand"/> has been called on this instance.</summary>
/// <remarks>
/// Oracle's <c>BindByName</c> is set here, and a command created without it mis-binds by position
/// rather than failing — so "was every command configured" has to be observable to be testable.
/// </remarks>
public int ConfiguredCommands
{
get { lock (_commands) return _commands.Count; }
}
/// <summary>
/// The <see cref="DbParameter.ParameterName"/> of every parameter bound on every configured command,
/// read after the fact.
/// <para><b>Why this and not the query's own success.</b> Microsoft.Data.Sqlite resolves a parameter
/// whether it is named <c>:k0</c> or bare <c>k0</c>, so a poll that returns the right values proves
/// the SQL parsed — not that the caller bound the dialect's bind name. Only the name actually put on
/// the parameter distinguishes the two, and on ODP.NET the wrong one is a hard bind failure.</para>
/// </summary>
public IReadOnlyList<string> BoundParameterNames
{
get
{
lock (_commands)
return _commands
.SelectMany(c => c.Parameters.Cast<DbParameter>())
.Select(p => p.ParameterName)
.ToList();
}
}
/// <inheritdoc/>
public SqlProvider Provider => Inner.Provider;
/// <inheritdoc/>
public DbProviderFactory Factory => Inner.Factory;
/// <inheritdoc/>
public string LivenessSql => Inner.LivenessSql;
/// <inheritdoc/>
public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;
/// <inheritdoc/>
public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;
/// <inheritdoc/>
public string ListSchemasSql => Colonize(Inner.ListSchemasSql);
/// <inheritdoc/>
public string DefaultSchemaSql => Colonize(Inner.DefaultSchemaSql);
/// <inheritdoc/>
public string ListTablesSql => Colonize(Inner.ListTablesSql);
/// <inheritdoc/>
public string ListColumnsSql => Colonize(Inner.ListColumnsSql);
/// <inheritdoc/>
public string ParameterMarker(string baseName) => ":" + baseName;
/// <summary>The bare name, with no prefix — ODP.NET's convention, and deliberately NOT the marker.</summary>
/// <param name="baseName">The planner's/loader's base name.</param>
/// <returns>The bind name.</returns>
public string ParameterBindName(string baseName) => baseName;
/// <inheritdoc/>
public void ConfigureCommand(DbCommand command)
{
ArgumentNullException.ThrowIfNull(command);
lock (_commands) _commands.Add(command);
}
/// <inheritdoc/>
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
/// <inheritdoc/>
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
/// <summary>
/// Rewrites the SQLite dialect's <c>@</c> markers as <c>:</c> markers. The catalog SQL is dialect-owned
/// text, so a colon-marker dialect must carry colon markers in it — that is precisely the coupling the
/// catalog loader's bind names have to stay in step with.
/// </summary>
private static string Colonize(string sql) => sql.Replace('@', ':');
}
@@ -0,0 +1,298 @@
using Microsoft.Data.Sqlite;
using Oracle.ManagedDataAccess.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Golden tests for <see cref="OracleDialect"/>, mirroring <see cref="SqlServerDialectTests"/>: the
/// quoting/injection boundary, the exact catalog SQL, the type-family map — plus the two tests that only
/// Oracle needs, pinning <c>OracleCommand.BindByName</c>.
/// </summary>
public class OracleDialectTests
{
// ---- the load-bearing pair: ODP.NET binds by POSITION unless told otherwise ----
/// <summary>
/// The sharpest correctness edge on the whole provider seam. A real <see cref="OracleCommand"/> — no
/// connection needed — must report <c>BindByName == false</c> before
/// <see cref="OracleDialect.ConfigureCommand"/> touches it and <c>true</c> afterwards.
/// <para><b>The false-by-default half is the falsifiability control.</b> Asserting only the post-state
/// would pass identically against an ODP.NET release that had flipped the default — i.e. it would prove
/// nothing about whether this dialect's call is load-bearing. Pinning both halves means the day the
/// provider default changes, this test says so instead of quietly becoming a tautology.</para>
/// </summary>
[Fact]
public void ConfigureCommand_turnsOnBindByName_whichOdpNetLeavesOffByDefault()
{
using var command = new OracleCommand();
command.BindByName.ShouldBeFalse(
"ODP.NET binds by position by default — if this ever changes, the dialect's ConfigureCommand " +
"is no longer what makes named binding work and this test must be re-reasoned, not deleted.");
new OracleDialect().ConfigureCommand(command);
command.BindByName.ShouldBeTrue();
}
/// <summary>
/// A command from another provider passes through untouched and, crucially, does <b>not</b> throw:
/// the dialect can legitimately meet a foreign <c>DbCommand</c> (an instrumented or substituted
/// factory in a shared test harness), and an <c>InvalidCastException</c> there would fail a poll for a
/// reason that has nothing to do with the database.
/// </summary>
[Fact]
public void ConfigureCommand_leavesAForeignCommandUntouched()
{
using var command = new SqliteCommand("SELECT 1");
Should.NotThrow(() => new OracleDialect().ConfigureCommand(command));
command.CommandText.ShouldBe("SELECT 1");
command.Parameters.Count.ShouldBe(0);
}
[Fact]
public void ParameterMarker_isColonPrefixed_andTheBindNameIsTheSameNameWithoutTheColon()
{
var d = new OracleDialect();
// The whole reason ISqlDialect models these as two members: on ODP.NET they are different strings.
d.ParameterMarker("k0").ShouldBe(":p_k0");
d.ParameterBindName("k0").ShouldBe("p_k0");
d.ParameterMarker("schema").ShouldBe(":p_schema");
d.ParameterBindName("schema").ShouldBe("p_schema");
}
/// <summary>
/// No bind name may be spelled as its bare base name — the <c>p_</c> namespace is what stops one
/// colliding with an Oracle reserved word.
/// </summary>
/// <remarks>
/// <b>This is a regression test for a live-found, provider-fatal defect.</b> The catalog readers bind
/// a parameter whose base name is <c>table</c>, and Oracle rejects a bind variable named after a
/// reserved word outright: <c>ORA-01745: invalid host/bind variable name</c>. With the un-prefixed
/// names this dialect first shipped with, <c>ListColumnsSql</c> could not execute at all — so every
/// Oracle driver instance failed its Initialize-time catalog load and rejected every authored tag, and
/// every AdminUI browse of a table's columns threw. It was invisible to every offline test (only a
/// server parses the SQL) and was caught by <c>OracleReadTests</c> against a real database.
/// <para><c>table</c> is asserted explicitly because it is the one base name in use today that is
/// actually reserved; the others guard the general rule.</para>
/// </remarks>
[Theory]
[InlineData("table")]
[InlineData("schema")]
[InlineData("k0")]
public void ParameterBindName_neverSurfacesTheBareBaseName_soItCannotBeAReservedWord(string baseName)
{
var d = new OracleDialect();
d.ParameterBindName(baseName).ShouldNotBe(baseName);
d.ParameterMarker(baseName).ShouldNotBe(":" + baseName);
// The two must stay derived from one another, or a statement's marker and its bind drift apart and
// ODP.NET silently leaves the parameter unbound.
d.ParameterMarker(baseName).ShouldBe(":" + d.ParameterBindName(baseName));
}
// ---- identity ----
[Fact]
public void Dialect_identifiesItselfAsOracle_andExposesTheAbstractFactory()
{
var d = new OracleDialect();
d.Provider.ShouldBe(SqlProvider.Oracle);
d.Factory.ShouldNotBeNull();
d.Factory.ShouldBeOfType<OracleClientFactory>();
}
// ---- quoting / injection boundary ----
[Fact]
public void QuoteIdentifier_doubleQuotesAndEscapesEmbeddedQuotes()
{
var d = new OracleDialect();
d.QuoteIdentifier("SPEED").ShouldBe("\"SPEED\"");
d.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\""); // " doubled, per Oracle's quoted-identifier rule
}
[Fact]
public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButAQuote()
=> new OracleDialect().QuoteIdentifier("\"").ShouldBe("\"\"\"\"");
[Fact]
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
{
// The only metacharacter that could terminate a quoted identifier early is ", and it is doubled.
// The result is a single (nonexistent) identifier, never executable SQL.
new OracleDialect().QuoteIdentifier("x\" ; DROP TABLE Users --")
.ShouldBe("\"x\"\" ; DROP TABLE Users --\"");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
=> Should.Throw<ArgumentException>(() => new OracleDialect().QuoteIdentifier(ident!));
// NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category.
[Theory]
[InlineData("x\0y")]
[InlineData("x\u0001y")] // C0 SOH
[InlineData("x\u001By")] // C0 ESC
[InlineData("x\u007Fy")] // DEL
[InlineData("x\u0085y")] // C1 NEL
[InlineData("x\ty")]
[InlineData("x\ny")]
public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(string ident)
=> Should.Throw<ArgumentException>(() => new OracleDialect().QuoteIdentifier(ident));
// Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all of
// these. They cannot escape the quotes, but they spoof rendered log/AdminUI text while comparing
// byte-different from the real catalog name (Trojan Source, CVE-2021-42574).
[Theory]
[InlineData("SP\u200BEED")] // zero-width space
[InlineData("\u202Ediop.selbat")] // right-to-left override
[InlineData("x\u2066y\u2069")] // bidi isolates
[InlineData("x\uFEFFy")] // BOM / zero-width no-break space
[InlineData("x\u00ADy")] // soft hyphen
public void QuoteIdentifier_rejectsUnicodeFormatCharacters(string ident)
=> Should.Throw<ArgumentException>(() => new OracleDialect().QuoteIdentifier(ident));
[Fact]
public void QuoteIdentifier_acceptsExactly128Chars_andRejects129()
{
var d = new OracleDialect();
d.QuoteIdentifier(new string('a', 128)).ShouldBe("\"" + new string('a', 128) + "\"");
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 129)));
}
// ---- liveness + row limit ----
[Fact]
public void LivenessSql_selectsFromDual()
=> new OracleDialect().LivenessSql.ShouldBe("SELECT 1 FROM DUAL");
[Fact]
public void SingleRowLimit_isFetchFirst_inTheSuffixPosition_withItsOwnLeadingSpace()
{
var d = new OracleDialect();
// Oracle 12c+ spells the limit at the END of the statement, so the prefix must be empty — a
// non-empty one would land between SELECT and the column list and produce invalid SQL.
d.SingleRowLimitPrefix.ShouldBe("");
d.SingleRowLimitSuffix.ShouldBe(" FETCH FIRST 1 ROWS ONLY");
string.Concat("SELECT ", d.SingleRowLimitPrefix, "\"A\" FROM \"T\"", d.SingleRowLimitSuffix)
.ShouldBe("SELECT \"A\" FROM \"T\" FETCH FIRST 1 ROWS ONLY");
}
// ---- catalog SQL ----
[Fact]
public void CatalogSql_readsTheAllViews_neverInformationSchema()
{
var d = new OracleDialect();
// Oracle has no INFORMATION_SCHEMA at all; the ALL_* views are the portable equivalent.
d.ListSchemasSql.ShouldContain("ALL_USERS");
d.ListTablesSql.ShouldContain("ALL_TABLES");
d.ListTablesSql.ShouldContain("ALL_VIEWS"); // INFORMATION_SCHEMA.TABLES includes views
d.ListColumnsSql.ShouldContain("ALL_TAB_COLUMNS");
d.DefaultSchemaSql.ShouldBe("SELECT sys_context('userenv','current_schema') FROM dual");
d.ListSchemasSql.ShouldNotContain("INFORMATION_SCHEMA");
d.ListTablesSql.ShouldNotContain("INFORMATION_SCHEMA");
d.ListColumnsSql.ShouldNotContain("INFORMATION_SCHEMA");
}
[Fact]
public void CatalogSql_isParameterizedWithColonMarkers_matchingParameterMarker()
{
var d = new OracleDialect();
d.ListTablesSql.ShouldContain(d.ParameterMarker("schema")); // never string-interpolated
d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema"));
d.ListColumnsSql.ShouldContain(d.ParameterMarker("table"));
// A leftover @ marker would bind on SqlClient and silently be a literal here.
d.ListTablesSql.ShouldNotContain("@");
d.ListColumnsSql.ShouldNotContain("@");
}
/// <summary>
/// <c>SqlCatalogLoader</c>/<c>SqlBrowseSession</c> project catalog columns by the
/// <c>INFORMATION_SCHEMA</c> names, so every dialect must alias its columns to them or
/// <c>GetOrdinal</c> throws at runtime — a failure the SQL text alone would not reveal.
/// </summary>
[Fact]
public void CatalogSql_aliasesItsColumnsToTheInformationSchemaNamesTheReadersProjectBy()
{
var d = new OracleDialect();
d.ListSchemasSql.ShouldContain("TABLE_SCHEMA");
d.ListTablesSql.ShouldContain("TABLE_NAME");
d.ListTablesSql.ShouldContain("TABLE_TYPE");
d.ListColumnsSql.ShouldContain("COLUMN_NAME");
d.ListColumnsSql.ShouldContain("DATA_TYPE");
// SqlBrowseSession compares TABLE_TYPE against the literal "VIEW" to mark a view node.
d.ListTablesSql.ShouldContain("'VIEW'");
d.ListTablesSql.ShouldContain("'BASE TABLE'");
}
// ---- type map ----
[Theory]
[InlineData("NUMBER", DriverDataType.Float64)]
[InlineData("FLOAT", DriverDataType.Float64)]
[InlineData("BINARY_DOUBLE", DriverDataType.Float64)]
[InlineData("BINARY_FLOAT", DriverDataType.Float32)]
[InlineData("INTEGER", DriverDataType.Float64)]
[InlineData("VARCHAR2", DriverDataType.String)]
[InlineData("NVARCHAR2", DriverDataType.String)]
[InlineData("CHAR", DriverDataType.String)]
[InlineData("NCHAR", DriverDataType.String)]
[InlineData("CLOB", DriverDataType.String)]
[InlineData("NCLOB", DriverDataType.String)]
[InlineData("DATE", DriverDataType.DateTime)]
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
=> new OracleDialect().MapColumnType(sql).ShouldBe(expected);
/// <summary>
/// <c>ALL_TAB_COLUMNS.DATA_TYPE</c> carries the fractional-seconds precision and the time-zone
/// qualifier <em>inside</em> the family name, unlike <c>INFORMATION_SCHEMA</c>. An exact-match table
/// would send every one of these to the String fallback.
/// </summary>
[Theory]
[InlineData("TIMESTAMP")]
[InlineData("TIMESTAMP(6)")]
[InlineData("TIMESTAMP(3)")]
[InlineData("TIMESTAMP(6) WITH TIME ZONE")]
[InlineData("TIMESTAMP(6) WITH LOCAL TIME ZONE")]
public void MapColumnType_matchesTheTimestampFamilyByPrefix(string sql)
=> new OracleDialect().MapColumnType(sql).ShouldBe(DriverDataType.DateTime);
[Theory]
[InlineData("number", DriverDataType.Float64)]
[InlineData("VarChar2", DriverDataType.String)]
[InlineData("timestamp(6)", DriverDataType.DateTime)]
public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected)
=> new OracleDialect().MapColumnType(sql).ShouldBe(expected);
[Theory]
[InlineData("RAW")] // binary, like SQL Server's varbinary
[InlineData("BLOB")]
[InlineData("LONG RAW")]
[InlineData("ROWID")]
[InlineData("XMLTYPE")]
[InlineData("INTERVAL DAY(3) TO SECOND(6)")] // a duration, deliberately NOT a DateTime
[InlineData("")]
[InlineData(null)]
public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
=> new OracleDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
}
@@ -0,0 +1,229 @@
using Microsoft.Data.Sqlite;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
/// <summary>
/// Golden tests for <see cref="PostgresDialect"/>, mirroring <see cref="SqlServerDialectTests"/> rule for
/// rule: the same injection boundary (values bound, identifiers quoted), the same rejection discipline,
/// and the exact catalog SQL. Where Postgres deliberately differs from T-SQL — double-quote escaping, a
/// 63-character identifier ceiling, the end-of-statement <c>LIMIT 1</c>, and <c>bit</c> being a bit string
/// rather than a boolean — the divergence is pinned here rather than left to reading.
/// </summary>
public class PostgresDialectTests
{
[Fact]
public void QuoteIdentifier_doubleQuotesAndEscapesEmbeddedQuotes()
{
var d = new PostgresDialect();
d.QuoteIdentifier("Speed").ShouldBe("\"Speed\"");
// " doubled, per the SQL-standard rule Postgres follows.
d.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\"");
}
[Fact]
public void QuoteIdentifier_rejectsControlCharsAndNul()
=> Should.Throw<ArgumentException>(() => new PostgresDialect().QuoteIdentifier("x\0y"));
// NUL alone would still pass against a `Contains('\0')` regression, so pin the whole Cc category. The
// code points are spelled numerically so the offending characters never appear raw in this source file.
[Theory]
[InlineData(0x0001)] // C0 SOH
[InlineData(0x001B)] // C0 ESC
[InlineData(0x007F)] // DEL
[InlineData(0x0085)] // C1 NEL
[InlineData(0x0009)] // TAB
[InlineData(0x000A)] // LF
public void QuoteIdentifier_rejectsEveryControlCharacter_notJustNul(int codePoint)
=> Should.Throw<ArgumentException>(
() => new PostgresDialect().QuoteIdentifier("x" + (char)codePoint + "y"));
// Unicode Format (Cf) is a distinct category from Control (Cc): char.IsControl returns false for all of
// these. They cannot escape the quotes, but they spoof rendered log/AdminUI text while comparing
// byte-different from the real catalog name (Trojan Source, CVE-2021-42574).
[Theory]
[InlineData(0x200B)] // zero-width space
[InlineData(0x202E)] // right-to-left override
[InlineData(0x2066)] // left-to-right isolate
[InlineData(0xFEFF)] // BOM / zero-width no-break space
[InlineData(0x00AD)] // soft hyphen
public void QuoteIdentifier_rejectsUnicodeFormatCharacters(int codePoint)
=> Should.Throw<ArgumentException>(
() => new PostgresDialect().QuoteIdentifier("Sp" + (char)codePoint + "eed"));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\t")]
public void QuoteIdentifier_rejectsNullEmptyAndWhitespace(string? ident)
=> Should.Throw<ArgumentException>(() => new PostgresDialect().QuoteIdentifier(ident!));
[Fact]
public void QuoteIdentifier_escapesAnIdentifierThatIsNothingButAQuote()
=> new PostgresDialect().QuoteIdentifier("\"").ShouldBe("\"\"\"\""); // the 1-char name "
// NAMEDATALEN - 1: a longer string cannot name a real Postgres object (the server truncates it), so it
// is a config mistake rather than something to quote and send.
[Fact]
public void QuoteIdentifier_acceptsExactly63Chars_andRejects64()
{
var d = new PostgresDialect();
d.QuoteIdentifier(new string('a', 63)).ShouldBe("\"" + new string('a', 63) + "\"");
Should.Throw<ArgumentException>(() => d.QuoteIdentifier(new string('a', 64)));
}
[Fact]
public void QuoteIdentifier_neutralisesAClassicInjectionPayload()
{
// A hostile "column name" cannot escape the quotes: the only metacharacter that could is ", and it
// is doubled. The result is a single (nonexistent) identifier, never executable SQL.
new PostgresDialect().QuoteIdentifier("x\" ; DROP TABLE users --")
.ShouldBe("\"x\"\" ; DROP TABLE users --\"");
}
[Theory]
[InlineData("boolean", DriverDataType.Boolean)]
[InlineData("smallint", DriverDataType.Int16)]
[InlineData("integer", DriverDataType.Int32)]
[InlineData("bigint", DriverDataType.Int64)]
[InlineData("real", DriverDataType.Float32)]
[InlineData("double precision", DriverDataType.Float64)]
[InlineData("numeric", DriverDataType.Float64)]
[InlineData("text", DriverDataType.String)]
[InlineData("character varying", DriverDataType.String)]
[InlineData("character", DriverDataType.String)]
[InlineData("uuid", DriverDataType.String)]
[InlineData("timestamp without time zone", DriverDataType.DateTime)]
[InlineData("timestamp with time zone", DriverDataType.DateTime)]
[InlineData("date", DriverDataType.DateTime)]
public void MapColumnType_mapsFamilies(string sql, DriverDataType expected)
=> new PostgresDialect().MapColumnType(sql).ShouldBe(expected);
[Theory]
[InlineData("BOOLEAN", DriverDataType.Boolean)]
[InlineData("Double Precision", DriverDataType.Float64)]
public void MapColumnType_isCaseInsensitive(string sql, DriverDataType expected)
=> new PostgresDialect().MapColumnType(sql).ShouldBe(expected);
[Theory]
[InlineData("bytea")] // binary, exactly as SQL Server's varbinary falls back
[InlineData("bit")] // a Postgres BIT STRING, deliberately NOT a boolean
[InlineData("bit varying")]
[InlineData("time without time zone")] // a time of day, not a point in time (mirrors T-SQL `time`)
[InlineData("interval")]
[InlineData("json")]
[InlineData("ARRAY")]
[InlineData("USER-DEFINED")]
[InlineData("")]
[InlineData(null)]
public void MapColumnType_unknownFamilyFallsBackToString_andNeverThrows(string? sql)
=> new PostgresDialect().MapColumnType(sql!).ShouldBe(DriverDataType.String);
[Fact]
public void CatalogSql_isInformationSchemaAndParameterized()
{
var d = new PostgresDialect();
d.LivenessSql.ShouldBe("SELECT 1");
d.DefaultSchemaSql.ShouldBe("SELECT current_schema()");
d.ListTablesSql.ShouldContain("information_schema.tables");
d.ListColumnsSql.ShouldContain("information_schema.columns");
d.ListTablesSql.ShouldContain("@schema"); // never string-interpolated
d.ListColumnsSql.ShouldContain("@schema");
d.ListColumnsSql.ShouldContain("@table");
}
// The markers baked into the catalog SQL text and the ones ParameterMarker hands the loader must be the
// same string, or the loader binds a name the statement never mentions.
[Fact]
public void CatalogSql_markersAgreeWithParameterMarker()
{
var d = new PostgresDialect();
d.ListTablesSql.ShouldContain(d.ParameterMarker("schema"));
d.ListColumnsSql.ShouldContain(d.ParameterMarker("schema"));
d.ListColumnsSql.ShouldContain(d.ParameterMarker("table"));
}
[Fact]
public void ListSchemasSql_excludesTheSystemSchemas()
{
var sql = new PostgresDialect().ListSchemasSql;
sql.ShouldContain("'pg_catalog'");
sql.ShouldContain("'information_schema'");
// The prefix families are matched with LIKE, where a bare _ is the single-character wildcard — so
// they carry an explicit escape. Asserting the escaped spelling is what makes this test able to fail
// if someone "tidies" the backslash away and silently widens the exclusion.
sql.ShouldContain("'pg\\_toast%' ESCAPE '\\'");
sql.ShouldContain("'pg\\_temp%' ESCAPE '\\'");
}
// The loader and the browse session read every catalog column BY ALIAS (DbDataReader.GetOrdinal), and
// Postgres folds an unquoted output-column name to lower case. The aliases are therefore quoted, and
// this pins the exact ones the consumers ask for.
[Fact]
public void CatalogSql_projectsTheQuotedUpperCaseAliasesTheConsumersReadByName()
{
var d = new PostgresDialect();
d.ListSchemasSql.ShouldContain("\"TABLE_SCHEMA\"");
d.ListTablesSql.ShouldContain("\"TABLE_NAME\"");
d.ListTablesSql.ShouldContain("\"TABLE_TYPE\"");
d.ListColumnsSql.ShouldContain("\"COLUMN_NAME\"");
d.ListColumnsSql.ShouldContain("\"DATA_TYPE\"");
d.ListColumnsSql.ShouldContain("\"IS_NULLABLE\"");
}
[Fact]
public void SingleRowLimit_isLimit1_inTheSuffixPosition_withItsOwnLeadingSpace()
{
var d = new PostgresDialect();
// Postgres spells the limit at the END of the statement, the mirror image of T-SQL's TOP 1. The
// planner appends the suffix straight onto the finished statement, so the space belongs to it.
d.SingleRowLimitPrefix.ShouldBe("");
d.SingleRowLimitSuffix.ShouldBe(" LIMIT 1");
string.Concat("SELECT ", d.SingleRowLimitPrefix, "\"a\"", d.SingleRowLimitSuffix)
.ShouldBe("SELECT \"a\" LIMIT 1");
}
[Fact]
public void Dialect_identifiesItselfAsPostgres_andExposesTheAbstractFactory()
{
var d = new PostgresDialect();
d.Provider.ShouldBe(SqlProvider.Postgres);
d.Factory.ShouldBeSameAs(Npgsql.NpgsqlFactory.Instance);
// The seam must not leak Npgsql into its signature.
typeof(ISqlDialect).GetProperty(nameof(ISqlDialect.Factory))!
.PropertyType.ShouldBe(typeof(System.Data.Common.DbProviderFactory));
}
[Theory]
[InlineData("k0")]
[InlineData("w")]
[InlineData("schema")]
public void ParameterMarkerAndBindName_areBothAtPrefixed(string baseName)
{
var d = new PostgresDialect();
d.ParameterMarker(baseName).ShouldBe("@" + baseName);
d.ParameterBindName(baseName).ShouldBe("@" + baseName);
}
// Npgsql binds by name out of the box, so ConfigureCommand has nothing to do — and it must tolerate a
// command that is not an NpgsqlCommand at all (the driver can be pointed at a substituted factory).
[Fact]
public void ConfigureCommand_leavesAForeignCommandUntouched()
{
using var command = new SqliteCommand("SELECT 1");
command.Parameters.AddWithValue("@k0", 7);
Should.NotThrow(() => new PostgresDialect().ConfigureCommand(command));
command.CommandText.ShouldBe("SELECT 1");
command.Parameters.Count.ShouldBe(1);
command.Parameters[0].ParameterName.ShouldBe("@k0");
command.Parameters[0].Value.ShouldBe(7);
}
}
@@ -227,6 +227,50 @@ public sealed class SqlCatalogGateDriverTests
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>
/// The catalog path against a dialect whose markers are <c>:schema</c>/<c>:table</c> and whose bind
/// names are the bare <c>schema</c>/<c>table</c> — ODP.NET's shape. The gate still resolves a real
/// table and still refuses an unknown column, so the loader bound names the dialect's SQL actually
/// carries.
/// <para>Behaviourally discriminating, not decorative: a loader still binding the literal
/// <c>"@schema"</c> leaves <c>:schema</c> unbound, the catalog query matches nothing, and
/// <em>every</em> tag — including the honest one — falls to <c>BadNodeIdUnknown</c>.</para>
/// </summary>
[Fact]
public async Task The_catalog_load_binds_the_dialects_bind_names_rather_than_a_hardcoded_marker()
{
using var fixture = new SqlitePollFixture();
var dialect = new ColonMarkerDialect();
await using var driver = new SqlDriver(
new SqlDriverOptions
{
RawTags =
[
KvEntry("Speed", SqlitePollFixture.PresentKey),
KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"),
],
OperationTimeout = TimeSpan.FromSeconds(15),
CommandTimeout = TimeSpan.FromSeconds(10),
},
DriverInstanceId,
dialect,
fixture.ConnectionString,
factory: fixture.Factory,
logger: NullLogger<SqlDriver>.Instance);
await driver.InitializeAsync(ConfigJson, CancellationToken.None);
var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
// The catalog queries bound the bare names, and every catalog command went through the hook.
dialect.BoundParameterNames.ShouldContain("schema");
dialect.BoundParameterNames.ShouldContain("table");
dialect.BoundParameterNames.ShouldNotContain(n => n.StartsWith(':'));
dialect.ConfiguredCommands.ShouldBeGreaterThan(0);
}
// ---- helpers ----
/// <summary>
@@ -260,6 +304,12 @@ public sealed class SqlCatalogGateDriverTests
public string ListColumnsSql => Inner.ListColumnsSql;
public string ParameterMarker(string baseName) => Inner.ParameterMarker(baseName);
public string ParameterBindName(string baseName) => Inner.ParameterBindName(baseName);
public void ConfigureCommand(System.Data.Common.DbCommand command) => Inner.ConfigureCommand(command);
public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);
public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
@@ -122,6 +122,11 @@ public sealed class SqlDriverFactoryTests
// ---- provider gate ----
/// <summary>
/// The falsifiability control for the widened gate: <see cref="SqlProvider.MySql"/> is still a
/// reserved name and must fail loudly at construction, naming itself. Without this leg, a gate that
/// had quietly become "construct anything" would pass every test below.
/// </summary>
[Fact]
public void CreateInstance_aProviderThisBuildCannotConstruct_throwsSayingSo()
{
@@ -130,12 +135,73 @@ public sealed class SqlDriverFactoryTests
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""", null));
"d1", $$"""{"provider":"MySql","connectionStringRef":"{{name}}"}""", null));
ex.Message.ShouldContain("Postgres");
ex.Message.ShouldContain("MySql");
ex.Message.ShouldContain("not available in this build");
}
/// <summary>
/// The reserved names are named as reserved — an operator reading the rejection must be able to tell
/// "misspelled" from "this build does not carry it", so the message enumerates both halves.
/// </summary>
[Fact]
public void CreateInstance_aReservedProvider_namesTheSupportedAndTheReservedSets()
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
var ex = Should.Throw<InvalidOperationException>(() => SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":"Odbc","connectionStringRef":"{{name}}"}""", null));
ex.Message.ShouldContain(nameof(SqlProvider.SqlServer));
ex.Message.ShouldContain(nameof(SqlProvider.Postgres));
ex.Message.ShouldContain(nameof(SqlProvider.Oracle));
}
/// <summary>
/// The widened gate, asserted on the thing that actually varies: which dialect the config binds to.
/// Binding — not merely "no throw" — is what proves the provider reaches the poll path, since a gate
/// that fell through to <c>SqlServerDialect</c> would also construct without complaint and then speak
/// T-SQL at a Postgres server.
/// </summary>
[Theory]
[InlineData("SqlServer", typeof(SqlServerDialect))]
[InlineData("Postgres", typeof(PostgresDialect))]
[InlineData("Oracle", typeof(OracleDialect))]
public void ParseBinding_aSupportedProvider_bindsThatProvidersDialect(string provider, Type expected)
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
var binding = SqlDriverFactoryExtensions.ParseBinding(
"d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""");
binding.Dialect.ShouldBeOfType(expected);
binding.Dialect.Provider.ShouldBe(Enum.Parse<SqlProvider>(provider));
}
/// <summary>
/// End-to-end through the registered factory entry point, so the widening is proven on the path the
/// bootstrapper actually calls — not only on the internal parse.
/// </summary>
[Theory]
[InlineData("Postgres")]
[InlineData("Oracle")]
public async Task CreateInstance_aNewlySupportedProvider_constructsPastTheProviderGate(string provider)
{
var name = NewRef();
using var _ = new EnvironmentVariableScope(
SqlConnectionStringResolver.EnvironmentVariableFor(name), "Host=h;Database=db");
await using var driver = SqlDriverFactoryExtensions.CreateInstance(
"d1", $$"""{"provider":"{{provider}}","connectionStringRef":"{{name}}"}""", null);
driver.ShouldNotBeNull();
}
// ---- config validation (the reader and the shell deliberately do NOT do this) ----
[Fact]
@@ -3,6 +3,7 @@ using System.Data.Common;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
@@ -125,6 +126,54 @@ public sealed class SqlDriverProbeTests
result.Ok.ShouldBeFalse();
}
/// <summary>
/// The <b>production</b> dialect gate — the one seam every other test in this file bypasses.
/// <para>Each case above builds its probe with <see cref="SqlDriverProbe.ForTest"/>, which injects
/// <c>_ => dialect</c> and therefore never reaches <c>DefaultDialectFor</c>. That is exactly how the
/// probe kept a SqlServer-only gate while the factory and the browser were widened to Postgres +
/// Oracle: the whole suite stayed green, and AdminUI "Test Connect" failed for two of the three
/// shipped providers with <c>InvalidOperationException</c>. Caught by the live chrome/AdminUI gate,
/// not by any test — so this asserts the gate directly.</para>
/// </summary>
[Theory]
[InlineData(SqlProvider.SqlServer)]
[InlineData(SqlProvider.Postgres)]
[InlineData(SqlProvider.Oracle)]
public void ProductionDialectGate_constructsEveryShippedProvider(SqlProvider provider)
=> SqlDriverProbe.DefaultDialectForTest(provider).Provider.ShouldBe(provider);
/// <summary>
/// The falsification control for the Theory above: the two <b>reserved</b> members must still be
/// rejected. Without this, widening the gate to "return a dialect for anything" would pass.
/// </summary>
[Theory]
[InlineData(SqlProvider.MySql)]
[InlineData(SqlProvider.Odbc)]
public void ProductionDialectGate_stillRejectsReservedProviders(SqlProvider provider)
=> Should.Throw<InvalidOperationException>(() => SqlDriverProbe.DefaultDialectForTest(provider))
.Message.ShouldContain(provider.ToString());
/// <summary>
/// End-to-end through the <b>production constructor</b>: a Postgres config must get PAST dialect
/// selection and fail at the connection attempt instead. Both outcomes are a red result whose message
/// names only an exception type, so the type is the discriminator — with the SqlServer-only gate this
/// is <c>InvalidOperationException</c> thrown before any socket is opened.
/// </summary>
[Fact]
public async Task ProductionProbe_postgresConfig_reachesTheConnectionAttempt_notTheProviderGate()
{
// Port 1 on the loopback: nothing listens, so the connect fails fast and locally — no fixture, no
// network egress, and a failure that is the PROVIDER's, which is the point of the assertion.
var name = SetRef("Host=127.0.0.1;Port=1;Database=x;Username=u;Password=p;Timeout=2");
var result = await new SqlDriverProbe().ProbeAsync(
$$"""{"provider":"Postgres","connectionStringRef":"{{name}}"}""",
TimeSpan.FromSeconds(10), CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message!.ShouldNotContain(nameof(InvalidOperationException));
}
// ---- helpers ----
/// <summary>A config blob whose connectionStringRef resolves (for the life of the returned scope) to the
@@ -185,6 +185,84 @@ public class SqlGroupPlannerTests
plan.Parameters.ShouldBe(new object[] { "k0", "k1", "k2" });
}
// ---- dialect-owned parameter markers ----
/// <summary>
/// The falsifiability control for dialect-owned markers: against a <c>:</c>-marker dialect the emitted
/// statement must carry <c>:k0</c>, and no <c>@</c> at all. A planner holding the T-SQL prefix in a
/// const passes every SQL Server test in this class and fails only here.
/// </summary>
[Fact]
public void KeyValueMarkers_comeFromTheDialect_ratherThanAHardcodedAtSign()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "TagValues", "tag_name", "Line1.Speed", "num_value", null),
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
], new ColonMarkerDialect()).ToList();
plans[0].SqlText.ShouldEndWith("IN (:k0, :k1)");
plans[0].SqlText.ShouldNotContain("@");
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" });
}
[Fact]
public void TheWideRowSelectorMarker_comesFromTheDialect_ratherThanAHardcodedAtSign()
{
var plans = SqlGroupPlanner.Plan(
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
new ColonMarkerDialect()).ToList();
plans[0].SqlText.ShouldEndWith("= :w");
plans[0].SqlText.ShouldNotContain("@");
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
}
/// <summary>
/// The bind names ride the plan <em>separately</em> from the markers, because on ODP.NET they are a
/// different string. A plan that carried only markers would leave the reader deriving the bind name —
/// the convention it is not entitled to know.
/// </summary>
[Fact]
public void ParameterBindNames_areTheDialects_andNeedNotEqualTheMarkers()
{
var plans = SqlGroupPlanner.Plan(
[
KvTag("A", "TagValues", "tag_name", "Line1.Speed", "num_value", null),
KvTag("B", "TagValues", "tag_name", "Line1.Temp", "num_value", null),
], new ColonMarkerDialect()).ToList();
plans[0].ParameterNames.ShouldBe(new[] { ":k0", ":k1" }); // in the SQL text
plans[0].ParameterBindNames.ShouldBe(new[] { "k0", "k1" }); // on the DbParameter
plans[0].ParameterBindNames.Count.ShouldBe(plans[0].Parameters.Count);
}
[Fact]
public void TheWideRowSelectorBindName_isTheDialects_andNeedNotEqualTheMarker()
{
var plans = SqlGroupPlanner.Plan(
[WideTag("A", "LatestStatus", "oven_temp", selectorColumn: "station_id", selectorValue: "7")],
new ColonMarkerDialect()).ToList();
plans[0].ParameterNames.ShouldBe(new[] { ":w" });
plans[0].ParameterBindNames.ShouldBe(new[] { "w" });
}
/// <summary>
/// On a SqlClient-style dialect the two lists coincide — which is exactly why the colon control above
/// exists, and why this one cannot stand in for it.
/// </summary>
[Fact]
public void OnSqlServer_theBindNamesAreTheMarkers()
{
var plans = SqlGroupPlanner.Plan(
[KvTag("A", "dbo.TagValues", "tag_name", "Line1.Speed", "num_value", null)],
new SqlServerDialect()).ToList();
plans[0].ParameterBindNames.ShouldBe(plans[0].ParameterNames);
plans[0].ParameterBindNames.ShouldBe(new[] { "@k0" });
}
// ---- wide row ----
[Fact]
@@ -413,6 +413,47 @@ public sealed class SqlPollReaderTests
async () => await reader.ReadAsync(["Speed"], CancellationToken.None));
}
// ---- dialect-owned parameter naming ----
/// <summary>
/// Runs a real poll through a dialect whose SQL markers (<c>:k0</c>) and bind names (bare <c>k0</c>)
/// <b>differ</b> — ODP.NET's shape — against the SQLite fixture, and asserts <em>both</em> that the
/// values arrive and that the names actually put on the parameters are the dialect's bind names.
/// <para><b>The name assertion is the load-bearing half.</b> Microsoft.Data.Sqlite happily resolves
/// <c>:k0</c> and bare <c>k0</c> alike, so a reader that bound its markers would still return the
/// right values here — and pass a values-only test — while failing outright on ODP.NET.</para>
/// </summary>
[Fact]
public async Task ReadAsync_bindsTheDialectsBindNames_whichNeedNotBeTheMarkersInTheSql()
{
using var fixture = new SqlitePollFixture();
var dialect = new ColonMarkerDialect();
var reader = new SqlPollReader(
fixture.Factory, fixture.ConnectionString, dialect,
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false,
resolve: Resolver(
KvTag("Speed", SqlitePollFixture.PresentKey),
KvTag("Pressure", SqlitePollFixture.SecondPresentKey),
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation)));
var snapshots = await reader.ReadAsync(
["Speed", "Pressure", "Oven"], CancellationToken.None);
// The IN list bound both of its distinct values, and the wide-row selector bound its one.
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
snapshots[2].Value.ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
snapshots.All(s => SqlStatusCodes.IsGood(s.StatusCode)).ShouldBeTrue();
// The bind names are the dialect's bare ones, NOT the ":"-prefixed markers in the SQL text.
dialect.BoundParameterNames.ShouldBe(new[] { "k0", "k1", "w" }, ignoreOrder: true);
// Two groups, two commands — and the provider hook ran on each. On Oracle this is BindByName, whose
// absence mis-binds an IN list by position instead of failing.
dialect.ConfiguredCommands.ShouldBe(2);
}
// ---- connection lifecycle ----
[Theory]
@@ -12,6 +12,7 @@ public class SqlQueryPlanImmutabilityTests
public void Plan_doesNotAliasTheCallersLists()
{
var names = new List<string> { "@k0" };
var bindNames = new List<string> { "@k0" };
var values = new List<object?> { "v" };
var members = new List<SqlTagDefinition>
{
@@ -19,14 +20,17 @@ public class SqlQueryPlanImmutabilityTests
};
var selected = new List<string> { "k", "c" };
var plan = new SqlQueryPlan(SqlTagModel.KeyValue, "gk", "SELECT 1", names, values, members, selected);
var plan = new SqlQueryPlan(
SqlTagModel.KeyValue, "gk", "SELECT 1", names, bindNames, values, members, selected);
names.Add("@k1");
bindNames.Add("@k1");
values.Add("other");
members.Add(members[0]);
selected.Add("extra");
plan.ParameterNames.Count.ShouldBe(1);
plan.ParameterBindNames.Count.ShouldBe(1);
plan.Parameters.Count.ShouldBe(1);
plan.Members.Count.ShouldBe(1);
plan.SelectedColumns.Count.ShouldBe(2);
@@ -42,6 +42,28 @@ public sealed class SqliteDialect : ISqlDialect
/// <inheritdoc/>
public DbProviderFactory Factory => SqliteFactory.Instance;
/// <summary>
/// <c>@</c>-prefixed, matching SQL Server — SQLite accepts <c>@</c>, <c>:</c> and <c>$</c> equally, so
/// this dialect deliberately keeps the <em>same</em> spelling as the production one. Divergent
/// parameter naming is the job of <c>ColonMarkerDialect</c>, which is the control for it; if this
/// dialect diverged too, a SQL-Server-shaped assumption would have nothing left holding it in place.
/// </summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed marker.</returns>
public string ParameterMarker(string baseName) => "@" + baseName;
/// <summary>Identical to the marker — <c>Microsoft.Data.Sqlite</c> matches on the prefixed name.</summary>
/// <param name="baseName">The bare base name.</param>
/// <returns>The <c>@</c>-prefixed bind name.</returns>
public string ParameterBindName(string baseName) => "@" + baseName;
/// <summary>No-op: <c>SqliteCommand</c> binds by name and needs no per-command setup.</summary>
/// <param name="command">The command to configure; untouched here.</param>
public void ConfigureCommand(DbCommand command)
{
// Intentionally empty: see the summary.
}
/// <inheritdoc/>
public string LivenessSql => "SELECT 1";
@@ -1,16 +1,66 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
/// <summary>
/// Gitea #540. The previous edition of this file asserted
/// <c>Build("axis", 5) == "axis:5"</c> for four "parameter groups" that are not FOCAS
/// addressing spaces — it pinned the exact string the driver could not parse, which is why
/// a fully-green suite coexisted with a picker whose default value was unusable. Every case
/// here therefore asserts the address ROUND-TRIPS through the driver's parser, not merely
/// that it has the expected shape.
/// </summary>
public sealed class FocasAddressBuilderTests
{
[Theory]
[InlineData("axis", 5, "axis:5")]
[InlineData("spindle", 0, "spindle:0")]
[InlineData("program", 100, "program:100")]
[InlineData("status", 1, "status:1")]
public void Build_Canonical(string group, int parameterId, string expected)
=> FocasAddressBuilder.Build(group, parameterId).ShouldBe(expected);
[InlineData(FocasManualAddressKind.Pmc, "R", 100, null, "R100")]
[InlineData(FocasManualAddressKind.Pmc, "X", 0, 0, "X0.0")]
[InlineData(FocasManualAddressKind.Pmc, "F", 20, 3, "F20.3")]
[InlineData(FocasManualAddressKind.Pmc, "r", 100, null, "R100")] // letter is normalised
[InlineData(FocasManualAddressKind.Parameter, null, 1020, null, "PARAM:1020")]
[InlineData(FocasManualAddressKind.Parameter, null, 1815, 0, "PARAM:1815/0")]
[InlineData(FocasManualAddressKind.Macro, null, 500, null, "MACRO:500")]
[InlineData(FocasManualAddressKind.Macro, null, 500, 3, "MACRO:500")] // macros have no bit form
public void Build_produces_an_address_the_driver_can_parse(
FocasManualAddressKind kind, string? letter, int number, int? bit, string expected)
{
var built = FocasAddressBuilder.Build(kind, letter, number, bit);
built.ShouldBe(expected);
FocasAddress.TryParse(built).ShouldNotBeNull();
}
[Theory]
[InlineData(FocasManualAddressKind.Pmc, "Z", 10, null)] // not a PMC area letter
[InlineData(FocasManualAddressKind.Pmc, "", 10, null)] // no area chosen
[InlineData(FocasManualAddressKind.Pmc, "R", 10, 8)] // PMC bits are 0-7
[InlineData(FocasManualAddressKind.Pmc, "R", -1, null)] // negative byte
[InlineData(FocasManualAddressKind.Parameter, null, 1815, 32)] // parameter bits are 0-31
[InlineData(FocasManualAddressKind.Parameter, null, -5, null)]
public void Build_returns_empty_rather_than_an_unusable_address(
FocasManualAddressKind kind, string? letter, int number, int? bit)
=> FocasAddressBuilder.Build(kind, letter, number, bit).ShouldBeEmpty();
/// <summary>
/// The contract that matters, stated over the whole offered surface rather than a sample:
/// nothing the picker can compose is unreadable. Every PMC area the picker lists, at a
/// whole-word address and at each legal bit.
/// </summary>
[Fact]
public void Every_offered_pmc_area_composes_a_parseable_address()
{
foreach (var letter in FocasAddressBuilder.PmcLetters)
{
foreach (int? bit in new int?[] { null, 0, 7 })
{
var built = FocasAddressBuilder.Build(FocasManualAddressKind.Pmc, letter, 12, bit);
built.ShouldNotBeEmpty($"PMC area '{letter}' bit '{bit}' composed nothing");
FocasAddress.TryParse(built).ShouldNotBeNull($"'{built}' does not parse");
}
}
}
}
@@ -74,10 +74,36 @@ public sealed class FocasTagConfigModelTests
new FocasTagConfigModel { Address = "" }.Validate().ShouldNotBeNull();
}
[Fact]
public void Validate_returns_null_when_address_filled()
[Theory]
[InlineData("R100")]
[InlineData("X0.0")]
[InlineData("PARAM:1815/0")]
[InlineData("MACRO:500")]
[InlineData("FIXED:Identity/SeriesNumber")]
public void Validate_returns_null_for_every_address_form_the_driver_accepts(string address)
=> new FocasTagConfigModel { Address = address }.Validate().ShouldBeNull();
/// <summary>
/// Gitea #540. <c>Validate</c> used to accept any non-blank string, so a browse full
/// reference sailed through authoring, through the deploy gate, and was then skipped at
/// driver init — where the only trace was a Debug-level log line nobody reads. The first
/// case here is verbatim what the AdminUI showed for one of the 20 dead tags on the Makino.
/// </summary>
[Theory]
[InlineData("10.201.31.5:8193/Identity/SeriesNumber")]
[InlineData("focas://10.201.31.5:8193/Axes/X/AbsolutePosition")]
[InlineData("axis:5")]
[InlineData("Z99")] // Z is not a PMC area letter
[InlineData("PARAM:notanumber")]
[InlineData("FIXED:")]
public void Validate_rejects_an_address_the_driver_would_skip(string address)
{
new FocasTagConfigModel { Address = "R100" }.Validate().ShouldBeNull();
var error = new FocasTagConfigModel { Address = address }.Validate();
error.ShouldNotBeNull();
// The operator has to be able to act on it, so the message names the legal forms.
error.ShouldContain("PARAM:");
error.ShouldContain("FIXED:");
}
[Fact]
@@ -3,6 +3,7 @@ using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
@@ -107,6 +108,33 @@ public sealed class RawBrowseCommitMapperTests
private static Dictionary<string, string> NativeTypeFields(string nativeType) =>
new() { ["dataType"] = nativeType };
/// <summary>
/// Gitea #540 — the commit path, judged by whether what it produces is authorable rather
/// than by which key it lands under.
/// <para>
/// The mapper is not where that defect lived: it faithfully wrote the reference the FOCAS
/// browser handed it, which was <c>{deviceHost}/{path}</c> and parses as nothing. The fix
/// was in what the driver emits, so the check that keeps it fixed is that a leaf reference
/// survives the commit AND passes the same validation the modal applies — a mapper test
/// asserting only <c>o["address"] == reference</c> passed happily throughout.
/// </para>
/// </summary>
/// <param name="reference">A leaf reference of the kind the FOCAS browser emits.</param>
[Theory]
[InlineData("FIXED:Identity/SeriesNumber")]
[InlineData("FIXED:Axes/X/AbsolutePosition")]
[InlineData("FIXED:Spindle/S1/Load")]
[InlineData("FIXED:Timers/PowerOnSeconds")]
[InlineData("MACRO:3901")]
public void A_committed_FOCAS_leaf_is_an_address_the_authoring_gate_accepts(string reference)
{
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.FOCAS, reference);
var model = FocasTagConfigModel.FromJson(json);
model.Address.ShouldBe(reference);
model.Validate().ShouldBeNull($"'{reference}' committed to a tag the driver would skip");
}
/// <summary>
/// The committed <c>dataType</c> is the type the CONTROLLER reported, not the model default.
/// AB CIP / TwinCAT / FOCAS decode the wire bytes by this field, so a browsed REAL left at the
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
using ZB.MOM.WW.OtOpcUa.Driver.Sql;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
@@ -86,4 +87,47 @@ public sealed class SqlDriverFormContractTests
json.ShouldContain("\"SqlServer\"");
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!.Provider.ShouldBe(SqlProvider.SqlServer);
}
// ---- the multi-provider dropdown: every offered option survives the form model's own round-trip ----
/// <summary>
/// The dropdown offers SqlServer + Postgres + Oracle, so each must survive
/// <c>FormModel.ToDto()</c> → JSON → <c>FormModel.FromDto()</c> as a NAME string. The razor markup
/// itself is not reachable (no bUnit), so this pins the model half of the change: a provider the
/// dropdown can select but the form model cannot carry back would silently reset to SqlServer on
/// the next modal open.
/// </summary>
[Theory]
[InlineData(SqlProvider.SqlServer, "SqlServer")]
[InlineData(SqlProvider.Postgres, "Postgres")]
[InlineData(SqlProvider.Oracle, "Oracle")]
public void Every_dropdown_provider_round_trips_through_the_form_model(SqlProvider provider, string name)
{
var form = new SqlDriverForm.FormModel { Provider = provider, ConnectionStringRef = "MesStaging" };
var json = JsonSerializer.Serialize(form.ToDto(), FormOptions);
json.ShouldContain($"\"provider\":\"{name}\"");
json.ShouldNotContain($"\"provider\":{(int)provider}"); // never an ordinal — that faults the factory read
var reloaded = SqlDriverForm.FormModel.FromDto(
JsonSerializer.Deserialize<SqlDriverConfigDto>(json, FormOptions)!);
reloaded.Provider.ShouldBe(provider);
reloaded.ConnectionStringRef.ShouldBe("MesStaging");
}
/// <summary>
/// MySql and Odbc stay reserved-and-rejected (design §2), so the dropdown deliberately does not offer
/// them. Nothing in the form model blocks the value — this records the intent so a later "the enum has
/// five members, the dropdown should too" edit has to argue with a test.
/// </summary>
[Theory]
[InlineData(SqlProvider.MySql)]
[InlineData(SqlProvider.Odbc)]
public void Reserved_providers_are_not_offered_but_still_serialize_by_name(SqlProvider reserved)
{
var json = JsonSerializer.Serialize(
new SqlDriverForm.FormModel { Provider = reserved, ConnectionStringRef = "R" }.ToDto(), FormOptions);
json.ShouldContain($"\"provider\":\"{reserved}\"");
}
}