fix(code-review): resolve Batch 2 open findings (AbCip, AbLegacy, Galaxy, FOCAS)

- Driver.AbCip.Contracts-001: parse 'writable' from TagConfig JSON (default true) instead of hardcoding
- Driver.AbCip.Contracts-002/-003: Dt type comment; drop dead [Display]/[Range] annotations
- Driver.AbCip.Contracts-004: dedicated AbCipEquipmentTagParser test class (+15)
- Driver.AbCip-017: document Tick severity Low-fallback on Bad severity read
- Driver.AbLegacy.Contracts-002/-003/-004: isArray-scalar remarks (+tests), MaxTagBytes/ForFamily docs
- Driver.Galaxy.Browser-003 + Driver.Galaxy.Contracts-003: extract ResolveApiKey -> GalaxySecretRef (dedup)
- Driver.Galaxy-019: cache buffered-interval only on Ok + ILogger warnings + ClassifyIntervalReply (+tests)
- Driver.FOCAS.Contracts-002: thread WriteIdempotent through DiscoverAsync (+test)
This commit is contained in:
Joseph Doherty
2026-06-20 22:43:36 -04:00
parent 3cc6a5f30d
commit ab57e53b92
26 changed files with 577 additions and 220 deletions
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `a19b0f86` |
| Status | Reviewed |
| Open findings | 4 |
| Open findings | 0 |
## Checklist coverage
@@ -44,7 +44,7 @@ a category produced nothing rather than leaving it blank.
| Severity | Medium |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs:42` |
| Status | Open |
| Status | Resolved |
**Description:** `AbCipEquipmentTagParser.TryParse` hard-codes `Writable: true` on every
equipment-tag definition it produces, regardless of any `writable` field in the TagConfig JSON.
@@ -70,7 +70,14 @@ record's `Writable` parameter. (2) Either return `false` from `TryParse` when `d
to `AbCipDataType.Structure` (equipment-tag flow cannot declare members), or add an explicit
comment documenting the black-box dotted-path behaviour so the next reader understands the intent.
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20 — (1) `TryParse` now reads the optional `"writable"` boolean
field from the TagConfig JSON and threads it into `AbCipTagDefinition.Writable`, defaulting to
`true` when the field is absent. The `<remarks>` on `TryParse` was updated to document this
behaviour. (2) For the Structure concern, zero-behaviour-change option taken: an inline comment
was added at the `dataType` parse site in `TryParse` documenting that a `Structure` dataType on an
equipment tag is treated as a black-box dotted-path read (libplctag resolves the full path; the
equipment-tag flow does not enumerate UDT members). New tests in `AbCipEquipmentTagParserTests`
cover `writable:false`, `writable` absent, and the `Structure` path. Suite green (322 tests).
---
@@ -81,7 +88,7 @@ comment documenting the black-box dotted-path behaviour so the next reader under
| Severity | Low |
| Category | Correctness & logic bugs |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDataType.cs:28` |
| Status | Open |
| Status | Resolved |
**Description:** The inline comment on `AbCipDataType.Dt` reads:
@@ -114,7 +121,9 @@ Dt, // Logix DATE (0xCD — 4-byte unsigned days since 1984-01-01) or DATE_A
No behaviour change; documentation only.
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20 — replaced the inaccurate single-line comment on `Dt` with a
3-line comment describing both mapped CIP types (`0xCD` DATE / `0xCF` DT), the 4-byte read stride,
and the truncation note for DATE_AND_TIME. Build green (0 errors, 0 warnings).
---
@@ -125,7 +134,7 @@ No behaviour change; documentation only.
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs:84-85` |
| Status | Open |
| Status | Resolved |
**Description:** `AbCipDriverOptions.ProbeTimeoutSeconds` carries `[Display]` and `[Range(1, 60)]`
attributes from `System.ComponentModel.DataAnnotations`. No other driver contracts project
@@ -144,7 +153,11 @@ If the intent is to document the valid range, an `<remarks>` tag (e.g. "Valid ra
seconds; the AdminUI clamps to 60s server-side.") achieves the same goal without the attribute
dependency.
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20 — removed `[Display]` and `[Range(1, 60)]` from
`ProbeTimeoutSeconds` and removed the `using System.ComponentModel.DataAnnotations;` directive
(confirmed it was used only by those two attributes). Added a `<remarks>` element reading
"Valid range: 160 seconds; the AdminUI clamps to 60s server-side." to preserve the intent
in documentation. Build green (0 errors, 0 warnings).
---
@@ -155,7 +168,7 @@ dependency.
| Severity | Low |
| Category | Testing coverage |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipEquipmentTagParser.cs` (entire file) |
| Status | Open |
| Status | Resolved |
**Description:** The contracts module has no dedicated test project, and `AbCipEquipmentTagParser.TryParse`
is the module's only non-trivial logic. Its `ReadArrayShape` helper has four distinct outcome
@@ -175,7 +188,14 @@ is needed. Cover: valid scalar round-trip, 1-element array, N-element array, eac
array-shape combination, non-JSON and non-object input, missing/blank `tagPath`, the Structure
DataType path, and the `Writable` default.
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20 — added `AbCipEquipmentTagParserTests.cs` to
`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/` (15 tests). Covers: valid scalar
round-trip; 1-element array (`isArray:true, arrayLength:1`); N-element array; `isArray:true +
arrayLength:0` → scalar; `isArray:true + arrayLength absent` → scalar; non-JSON input → false;
non-object JSON array → false; non-object JSON string → false; missing `tagPath` → false; blank
`tagPath` → false; `tagPath` as number → false; `writable:false` honoured; `writable` absent
defaults to `true`; `writable:true` explicit; `dataType:"Structure"` accepted with `Members:null`
(documents current black-box behaviour). Suite green (322 tests).
---
+8 -2
View File
@@ -7,7 +7,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 1 |
| Open findings | 0 |
## Checklist coverage
@@ -337,7 +337,7 @@ flag and the legacy `ElementCount > 1` paths opt out). Full suite green (303 tes
| Severity | Low |
| Category | Documentation & comments |
| Location | `AbCipAlarmProjection.cs:173-185` (`Tick`) |
| Status | Open |
| Status | Resolved |
**Description:** `AbCipAlarmProjection.Tick` gates each node on the `InFaulted` snapshot's
`StatusCode` (`if (inFaultedDv.StatusCode != Good) continue;`) but reads the `Severity`
@@ -357,6 +357,12 @@ severity read is Bad, or add an XML/inline comment on `Tick` stating that severi
decision (what severity to surface when it is genuinely unknown) rather than a mechanical fix,
and the impact is negligible given the single-batch read shape.
**Resolution:** Resolved 2026-06-20 — added an inline comment on the `severity` read line in
`Tick` documenting that `severityDv.StatusCode` is not checked, that a Bad Severity read yields
`ToInt(null)=0``MapSeverity` buckets as Low, and that this is acceptable because InFaulted
and Severity are members of the same ALMD UDT read in one batch (a Good InFaulted almost always
implies a Good Severity). No behaviour change.
### Driver.AbCip-018
| Field | Value |
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `a19b0f86` |
| Status | Reviewed |
| Open findings | 3 |
| Open findings | 0 |
## Checklist coverage
@@ -69,7 +69,7 @@ verified by build (no test project in this module).
| Severity | Low |
| Category | Documentation & comments |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyEquipmentTagParser.cs:15` |
| Status | Open |
| Status | Resolved |
**Description:** `AbLegacyEquipmentTagParser.TryParse` has an undocumented edge case: when
`isArray` is the JSON literal `true` but `arrayLength` is absent, zero, or negative,
@@ -85,7 +85,13 @@ without a valid positive `arrayLength` silently produces a scalar (null `ArrayLe
Optionally add a unit test to `AbLegacyEquipmentTagTests` covering
`isArray:true, arrayLength:0/absent -> null` for regression protection.
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20. Added a `<remarks>` block to `TryParse` in
`AbLegacyEquipmentTagParser.cs` documenting that `isArray:true` without a valid positive
`arrayLength` silently produces a scalar (`ArrayLength` is `null`), referencing the
in-source review C-2 rationale. Added three regression unit tests to
`AbLegacyEquipmentTagTests`: `IsArray_true_with_arrayLength_zero_produces_scalar`,
`IsArray_true_with_no_arrayLength_produces_scalar`, and
`IsArray_true_with_negative_arrayLength_produces_scalar`. Suite: 199 passed, 0 failed.
---
@@ -96,7 +102,7 @@ Optionally add a unit test to `AbLegacyEquipmentTagTests` covering
| Severity | Low |
| Category | OtOpcUa conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs:10` |
| Status | Open |
| Status | Resolved |
**Description:** `AbLegacyPlcFamilyProfile.MaxTagBytes` is a record constructor parameter
populated with distinct values per family (240/232/240/240), but a global search finds zero
@@ -111,7 +117,11 @@ produce off-by-one sizing. If reserved for future clamping, the intent is undocu
clamping and is not currently enforced, or (b) remove the field and its four initialisers
in a dedicated cleanup PR (signature change is out of scope for this review).
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20. Applied option (a): added XML doc on the `MaxTagBytes`
constructor parameter in `AbLegacyPlcFamilyProfile.cs` noting it is reserved for future
array-length clamping, is NOT currently enforced anywhere in the driver, and that the values
are approximate PCCC packet payload caps (not libplctag fragment limits). Field and
initialisers unchanged.
---
@@ -122,7 +132,7 @@ in a dedicated cleanup PR (signature change is out of scope for this review).
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyPlcFamilyProfile.cs:22` |
| Status | Open |
| Status | Resolved |
**Description:** `AbLegacyPlcFamilyProfile.ForFamily` has a catch-all arm `_ => Slc500`
that silently returns the SLC 500 profile for any unrecognised `AbLegacyPlcFamily` value
@@ -139,4 +149,8 @@ added), or (b) replace `_ => Slc500` with
`_ => throw new ArgumentOutOfRangeException(nameof(family), family, null)` and apply it
in a cleanup PR. Semantic change -- defer.
**Resolution:** _(empty until closed)_
**Resolution:** Resolved 2026-06-20. Applied option (a): added a `<remarks>` block to the
`ForFamily` XML doc in `AbLegacyPlcFamilyProfile.cs` documenting that any unrecognised
`family` value silently returns the `Slc500` profile, and noting this is intentional for
forward-compatibility with configs authored before a new family enum member is added. The
switch body is unchanged.
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 1 |
| Open findings | 0 |
## Checklist coverage
@@ -61,13 +61,13 @@ a category produced nothing rather than leaving it blank.
| Severity | Low |
| Category | Design-document adherence |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasDriverOptions.cs:139-145` |
| Status | Open |
| Status | Resolved |
**Description:** `FocasTagDefinition` carries a `WriteIdempotent` field (default `false`) that the FOCAS driver never reads anywhere — neither in `DiscoverAsync`, `ReadAsync`, nor `WriteAsync`. `DiscoverAsync` always passes `WriteIdempotent: false` to `DriverAttributeInfo` (hardcoded), so the field has no runtime effect. `docs/drivers/FOCAS.md` does not mention `WriteIdempotent` in its configuration tables, making its purpose undiscoverable to operators.
**Recommendation:** Either (a) thread `FocasTagDefinition.WriteIdempotent` through to `DriverAttributeInfo` in `DiscoverAsync` so the field has runtime effect and matches the pattern of other drivers, or (b) remove it from the record and the `FocasDriverConfigDto` wire DTO. Option (a) is the safer fix and matches the design of Modbus and other drivers. This is deferred because the fix touches `FocasDriver.DiscoverAsync` outside this module and requires a driver-level test change.
**Resolution:** _(empty until closed)_
**Resolution:** Fixed (option a). In `FocasDriver.DiscoverAsync` the hardcoded `WriteIdempotent: false` was replaced with `tag.WriteIdempotent` so each user-authored tag's per-tag value is now threaded through to `DriverAttributeInfo`. The `<param name="WriteIdempotent">` doc on `FocasTagDefinition` was updated to state it is now threaded through `DiscoverAsync` to `DriverAttributeInfo`. A new test `DiscoverAsync_surfaces_WriteIdempotent_from_tag_definition` was added to `FocasDriverMediumFindingsTests` asserting both the `true` and `false` cases surface correctly. The full `Driver.FOCAS.Tests` suite (including `FocasPmcBitRmwTests` and `FocasReadWriteTests` write tests) passes.
---
+14 -5
View File
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 1 |
| Open findings | 0 |
## Checklist coverage
@@ -27,7 +27,7 @@ a category produced nothing rather than leaving it blank.
| 5 | Security | No issues found |
| 6 | Performance & resource management | No issues found |
| 7 | Design-document adherence | No issues found |
| 8 | Code organization & conventions | Driver.Galaxy.Browser-003 (Low): ResolveApiKey duplicated from GalaxyDriver with no sync mechanism |
| 8 | Code organization & conventions | Driver.Galaxy.Browser-003 (Low, Resolved): ResolveApiKey duplicated from GalaxyDriver; extracted to `GalaxySecretRef` in Driver.Galaxy.Contracts |
| 9 | Testing coverage | Driver.Galaxy.Browser-004 (Low): MapSecurityClass not unit-tested; pure static method with no gateway dependency |
| 10 | Documentation & comments | No issues found |
@@ -119,7 +119,7 @@ mirroring the existing pattern for `_client.DisposeAsync()`. Regression test
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:149` |
| Status | Open |
| Status | Resolved |
**Description:** `GalaxyDriverBrowser.ResolveApiKey` is a verbatim copy of
`GalaxyDriver.ResolveApiKey`. The comment acknowledges this and explains why the Browser
@@ -133,8 +133,17 @@ and emit a spurious warning.
which both `Driver.Galaxy` and `Driver.Galaxy.Browser` already reference. This is a
one-line addition to Contracts — no migration, no public-contract break.
**Resolution:** _(deferred — requires a change in the Galaxy.Contracts project, outside
this module's boundary; tracked for a future consolidation pass)_
**Resolution:** Resolved 2026-06-20 — closed by the same fix as the cross-referenced
Driver.Galaxy.Contracts-003. The resolver was extracted into a new
`public static class GalaxySecretRef` in `Driver.Galaxy.Contracts`
(`ResolveApiKey(string secretRef, ILogger? logger = null)`), which both Driver.Galaxy
and Driver.Galaxy.Browser already reference. `GalaxyDriverBrowser.BuildClientOptions`
now calls `GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger)` and its private
`ResolveApiKey` copy is deleted — eliminating the drift risk (a future `vault:` prefix
is now added in one place). Behaviour is preserved exactly: the Browser's `_logger` is
never null (defaults to `NullLogger`), so the literal-arm cleartext warning still fires.
The Browser project builds clean against the shared resolver. See
Driver.Galaxy.Contracts-003 for the full extraction details.
---
@@ -11,7 +11,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 1 |
| Open findings | 0 |
## Checklist coverage
@@ -27,7 +27,7 @@ a category produced nothing rather than leaving it blank.
| 5 | Security | No issues found (ApiKeySecretRef is documented as an indirection reference; no defaults store a cleartext secret; no ToString override leaks the ref) |
| 6 | Performance & resource management | No issues found (pure records; no allocations, disposables, or resource lifetimes) |
| 7 | Design-document adherence | No issues found (records match CLAUDE.md Gateway/MxAccess/Repository/Reconnect section layout) |
| 8 | Code organization & conventions | Driver.Galaxy.Contracts-003 (Low, Open): ResolveApiKey helper duplicated between GalaxyDriver and GalaxyBrowseSession; Contracts is the natural home |
| 8 | Code organization & conventions | Driver.Galaxy.Contracts-003 (Low, Resolved): ResolveApiKey helper duplicated between GalaxyDriver and GalaxyBrowseSession; extracted to `GalaxySecretRef` in Contracts |
| 9 | Testing coverage | No issues found (no logic to test; pure data records with default values) |
| 10 | Documentation & comments | Driver.Galaxy.Contracts-001 (Low, Resolved): Internal code-review finding ID `(Driver.Galaxy-010)` in shipped XML doc |
@@ -108,7 +108,7 @@ note the minimum and that `EventPump` enforces it at construction. Verified by b
| Severity | Low |
| Category | Code organization & conventions |
| Location | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/GalaxyDriverBrowser.cs:149` (duplicate) and `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs:472` (original) |
| Status | Open |
| Status | Resolved |
**Description:** `GalaxyDriver.ResolveApiKey` (the four-form `env:`/`file:`/`dev:`/literal
resolver, ~45 LOC) is duplicated verbatim as `GalaxyDriverBrowser.ResolveApiKey`. The
@@ -135,6 +135,20 @@ which ships in-box with .NET 10's BCL and adds no new NuGet dependency.
overload) into a `GalaxySecretRef` static class in this project; update both call sites
to delegate to it.
**Resolution:** _(deferred — cross-module coordination change; Driver.Galaxy and
Driver.Galaxy.Browser must both be updated in the same commit. Tracked for a future
consolidation pass. See also Driver.Galaxy.Browser-003.)_
**Resolution:** Resolved 2026-06-20 — extracted the four-form resolver into a new
`public static class GalaxySecretRef` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/GalaxySecretRef.cs`,
namespace `ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config`) as a single
`ResolveApiKey(string secretRef, ILogger? logger = null)` method (the two former
`GalaxyDriver` overloads collapsed into one optional-logger signature). The exact
resolution semantics are preserved byte-for-byte: `env:NAME` (throws when unset),
`file:PATH` (throws when missing/empty, trims), `dev:KEY` (literal, no warning), and
the back-compat literal arm (returns the literal and emits the same `Warning` when a
logger is supplied). The Contracts `.csproj` gained a single
`Microsoft.Extensions.Logging.Abstractions` PackageReference for the `ILogger`
parameter. Both call sites now delegate: `GalaxyDriver.BuildClientOptions` calls
`GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger)` and the two private
`GalaxyDriver.ResolveApiKey` overloads are deleted; `GalaxyDriverBrowser.BuildClientOptions`
likewise delegates (passing its non-null `_logger`) and its private copy is deleted. No
migration, no public wire-contract change. Regression coverage: `GalaxyDriverApiKeyResolverTests`
was repointed at `GalaxySecretRef.ResolveApiKey` (all 10 facts green). This finding and
the sibling Driver.Galaxy.Browser-003 are closed by this one extraction.
+23 -19
View File
@@ -7,7 +7,7 @@
| Review date | 2026-06-19 |
| Commit reviewed | `7286d320` |
| Status | Reviewed |
| Open findings | 1 |
| Open findings | 0 |
## Checklist coverage
@@ -83,7 +83,7 @@ observation + single `_ownedRepositoryClient`, the `ResolveApiKey`
estimate, the O(1) `SubscriptionRegistry` indices, and the `ReinitializeAsync`
equivalent-config gate all survive intact.
One new finding (Driver.Galaxy-019, Low, Open-deferred). No Critical / High /
One new finding (Driver.Galaxy-019, Low, Resolved 2026-06-20). No Critical / High /
Medium new findings. Category results:
| # | Category | Result |
@@ -91,12 +91,12 @@ Medium new findings. Category results:
| 1 | Correctness & logic bugs | No new issues found |
| 2 | OtOpcUa conventions | No new issues found |
| 3 | Concurrency & thread safety | No new issues found |
| 4 | Error handling & resilience | Driver.Galaxy-019 |
| 4 | Error handling & resilience | Driver.Galaxy-019 (Resolved) |
| 5 | Security | No new issues found (vendoring retired; no secret logging; `ResolveApiKey` warns on cleartext) |
| 6 | Performance & resource management | No new issues found |
| 7 | Design-document adherence | No new issues found |
| 8 | Code organization & conventions | No new issues found |
| 9 | Testing coverage | No new issues found (31 suites; `GatewayGalaxySubscriber` untestable per Driver.Galaxy-019) |
| 9 | Testing coverage | No new issues found (31 suites; `GatewayGalaxySubscriber` now has its first unit test — `GatewayGalaxySubscriberClassifyTests` for the Driver.Galaxy-019 classifier) |
| 10 | Documentation & comments | No new issues found |
## Findings
@@ -441,7 +441,7 @@ the existing csproj works correctly.
| Severity | Low |
| Category | Error handling & resilience |
| Location | `Runtime/GatewayGalaxySubscriber.cs:89-100` |
| Status | Open |
| Status | Resolved |
**Description:** `GatewayGalaxySubscriber.EnsureSessionIntervalAsync` applies
the session-level `SetBufferedUpdateInterval` command and then caches the
@@ -479,17 +479,21 @@ extract the reply→outcome classification into a pure internal helper
the sealed, internal-ctor `MxGatewaySession``GatewayGalaxySubscriber`
currently has zero unit tests for exactly this reason.
**Resolution:** Deferred 2026-06-19the fix is small but cannot be landed
with a real TDD red→green cycle in this module: the behaviour lives entirely
behind the sealed `MxGatewaySession` (no public/internal ctor; the same
constraint that forced every other gw call onto an injectable `IGalaxy*` seam),
so there is no way to drive a synthetic `MxCommandReply` through
`EnsureSessionIntervalAsync` without first refactoring out the pure-classifier
helper the recommendation calls for. That refactor plus giving the subscriber a
logger is a low-risk but non-trivial change to the production gw session path,
and the underlying intent (is caching on `MxaccessFailure` deliberate "the
gateway processed it, don't retry" behaviour, or an oversight?) is a design
question better confirmed against the gateway contract than guessed at in a
review sweep. Tracked for a follow-up that lands the classifier extraction +
its unit tests together. Impact is bounded to a sub-optimal (not broken)
publish cadence that self-heals on reconnect.
**Resolution:** Resolved 2026-06-20extracted the pure classifier
`internal static bool ClassifyIntervalReply(ProtocolStatusCode? code) => code == ProtocolStatusCode.Ok`
and routed `EnsureSessionIntervalAsync` through it, so `_lastAppliedIntervalMs`
is now set **only** on `Ok`. `MxaccessFailure` (COM-side set did not apply), any
other unexpected code, and a missing status all leave the cache untouched, so the
requested cadence is re-attempted on the next subscribe rather than permanently
pinned at the gateway default after a transient hiccup. The subscriber gained an
optional `ILogger? logger = null` ctor parameter (defaulting to `NullLogger.Instance`),
threaded through from `BuildProductionRuntimeAsync`'s `_logger` at the one
`new GatewayGalaxySubscriber(_ownedMxSession, _logger)` call site; it now emits a
`Warning` on both the `MxaccessFailure` soft-failure path and the unexpected-code
early-return path (no secret/key is logged), so the comment's promised signal exists.
Regression coverage: new `GatewayGalaxySubscriberClassifyTests`
(`tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/`) — the subscriber's first
unit test — pins `Ok → true`, `MxaccessFailure → false`, `Unspecified → false`,
`null → false`. The sealed `MxGatewaySession` ctor that previously blocked a TDD
cycle is now sidestepped because the classifier is a pure static helper testable in
isolation.
@@ -25,7 +25,9 @@ public enum AbCipDataType
Real, // 32-bit IEEE-754
LReal, // 64-bit IEEE-754
String, // Logix STRING (DINT Length + SINT[82] DATA — flattened to .NET string by libplctag)
Dt, // Date/Time — Logix DT == DINT representing seconds-since-epoch per Rockwell conventions
Dt, // Logix DATE (0xCD — 4-byte unsigned days since 1984-01-01) or DATE_AND_TIME / DT
// (0xCF — 8-byte unsigned microseconds since 1970-01-01). The driver reads 4 bytes
// via GetInt32; DATE decodes correctly, DATE_AND_TIME is truncated to the low 4 bytes.
/// <summary>
/// UDT / Predefined Structure (Timer / Counter / Control / Message / Axis). Shape is
/// resolved at discovery time; reads + writes fan out to member Variables unless the
@@ -1,5 +1,3 @@
using System.ComponentModel.DataAnnotations;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <summary>
@@ -81,8 +79,7 @@ public sealed class AbCipDriverOptions
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
/// </summary>
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")]
[Range(1, 60)]
/// <remarks>Valid range: 160 seconds; the AdminUI clamps to 60s server-side.</remarks>
public int ProbeTimeoutSeconds { get; init; } = 5;
}
@@ -13,10 +13,11 @@ public static class AbCipEquipmentTagParser
/// <param name="def">The transient definition when parsing succeeds.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is an AbCip TagConfig object.</returns>
/// <remarks>
/// The produced <see cref="AbCipTagDefinition.Writable"/> is always <c>true</c>: the
/// TagConfig JSON format for equipment tags does not carry a writability field, so the
/// PLC's ExternalAccess attribute is the effective write gate. Operators who need a
/// read-only OPC UA surface must rely on the PLC's ExternalAccess rejecting the write.
/// <see cref="AbCipTagDefinition.Writable"/> is read from the optional <c>"writable"</c>
/// boolean field in the TagConfig JSON; it defaults to <c>true</c> when the field is absent,
/// matching the record's documented default and the behaviour of pre-declared tags. Operators
/// who need a read-only OPC UA surface can author <c>"writable":false</c> in the TagConfig;
/// the PLC's ExternalAccess attribute remains the effective write gate at the wire level.
/// </remarks>
public static bool TryParse(string reference, out AbCipTagDefinition def)
{
@@ -36,6 +37,11 @@ public static class AbCipEquipmentTagParser
if (string.IsNullOrWhiteSpace(tagPath)) return false;
var deviceHostAddress = ReadString(root, "deviceHostAddress");
// A "dataType":"Structure" input is accepted and produces a Structure-typed definition
// with Members:null. The driver treats this as a black-box dotted-path read: libplctag
// resolves the full tag path (e.g. "Motor.Speed") without enumerating UDT members.
// The address space emits a placeholder String variable; UDT member declarations are
// not supported in the equipment-tag flow.
var dataType = ReadEnum(root, "dataType", AbCipDataType.DInt);
// Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A
// 1-element array (isArray:true, arrayLength:1) is a VALID 1-element array — the
@@ -43,9 +49,12 @@ public static class AbCipEquipmentTagParser
// scalar. ElementCount can't carry the signal (a scalar and a 1-element array both
// have a count of 1), so the explicit IsArray flag does.
var (isArray, elementCount) = ReadArrayShape(root);
// "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default.
var writable = !root.TryGetProperty("writable", out var writableEl)
|| writableEl.ValueKind != JsonValueKind.False;
def = new AbCipTagDefinition(
Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath,
DataType: dataType, Writable: true, ElementCount: elementCount, IsArray: isArray);
DataType: dataType, Writable: writable, ElementCount: elementCount, IsArray: isArray);
return true;
}
catch (JsonException) { return false; }
@@ -181,6 +181,11 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;
var nowFaulted = ToBool(inFaultedDv.Value);
// severityDv.StatusCode is not checked here. When the Severity read is Bad (value null),
// ToInt(null) returns 0 and MapSeverity buckets it as Low. This is acceptable because
// InFaulted and Severity are members of the same ALMD UDT read in one batch, so a Good
// InFaulted almost always implies a Good Severity. The "unknown severity → Low" fallback
// is intentional and matches the behaviour documented on Driver.AbCip-017.
var severity = ToInt(severityDv.Value);
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
@@ -12,6 +12,17 @@ public static class AbLegacyEquipmentTagParser
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
/// <param name="def">The transient definition when parsing succeeds.</param>
/// <returns><see langword="true"/> when <paramref name="reference"/> is an AbLegacy address object.</returns>
/// <remarks>
/// <para>
/// When <c>isArray</c> is the JSON literal <see langword="true"/> but <c>arrayLength</c> is
/// absent, zero, or negative, the result is silently a <b>scalar</b>
/// (<see cref="AbLegacyTagDefinition.ArrayLength"/> is <see langword="null"/>).
/// A valid positive <c>arrayLength</c> is required to produce an array tag; <c>isArray:true</c>
/// alone is not sufficient. This is intentional: a stale length behind a cleared or absent
/// <c>isArray</c> flag must never produce an orphan array tag that mismatches its scalar OPC UA
/// node (see in-source comment, review C-2).
/// </para>
/// </remarks>
public static bool TryParse(string reference, out AbLegacyTagDefinition def)
{
def = null!;
@@ -7,12 +7,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
public sealed record AbLegacyPlcFamilyProfile(
string LibplctagPlcAttribute,
string DefaultCipPath,
/// <summary>
/// Reserved for future array-length clamping. <b>Not currently enforced anywhere in the
/// driver.</b> The values are approximate upper bounds derived from PCCC packet payload
/// limits (e.g. SLC 5/05 240 bytes is the PCCC-over-EIP data cap, not a libplctag fragment
/// limit). Do not rely on this field for sizing decisions until an enforcement point is added.
/// </summary>
int MaxTagBytes,
bool SupportsStringFile,
bool SupportsLongFile)
{
/// <summary>Gets the profile for the specified PLC family.</summary>
/// <param name="family">The PLC family.</param>
/// <remarks>
/// Any unrecognised <paramref name="family"/> value (e.g. an integer cast to the enum, or a
/// value added to <see cref="AbLegacyPlcFamily"/> before this switch is updated) silently
/// returns the <see cref="Slc500"/> profile. This is intentional: it preserves
/// forward-compatibility for device configs authored against a build that predates a new
/// family enum member, preferring a safe default over a startup exception.
/// </remarks>
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
{
AbLegacyPlcFamily.Slc500 => Slc500,
@@ -157,9 +157,9 @@ public sealed record FocasDeviceOptions(
/// <c>FocasReadWriteTests</c>). Defaults to <c>true</c>.
/// </param>
/// <param name="WriteIdempotent">
/// Whether repeated writes of the same value are safe. Carried for parity; not yet
/// threaded through to <c>DriverAttributeInfo</c> in <c>DiscoverAsync</c> (see
/// Driver.FOCAS.Contracts-002). Defaults to <c>false</c>.
/// Whether repeated writes of the same value are safe. Threaded through to
/// <c>DriverAttributeInfo.WriteIdempotent</c> by <c>DiscoverAsync</c> so OPC UA
/// clients can optimise write coalescing for idempotent tags. Defaults to <c>false</c>.
/// </param>
public sealed record FocasTagDefinition(
string Name,
@@ -487,7 +487,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false,
WriteIdempotent: false));
WriteIdempotent: tag.WriteIdempotent));
}
}
return Task.CompletedTask;
@@ -122,14 +122,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
/// <summary>
/// Build the gateway client options from the form's Gateway section. Mirrors the
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
/// gateway sees an identical option shape. The API-key reference is resolved
/// inline (a slim version of <c>GalaxyDriver.ResolveApiKey</c>) because the
/// Browser project doesn't reference Driver.Galaxy.
/// gateway sees an identical option shape. The API-key reference is resolved via
/// the shared <see cref="GalaxySecretRef.ResolveApiKey"/> in Driver.Galaxy.Contracts
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
/// </summary>
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
{
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
ApiKey = ResolveApiKey(gw.ApiKeySecretRef),
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
UseTls = gw.UseTls,
CaCertificatePath = gw.CaCertificatePath,
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
@@ -138,57 +138,4 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
: null,
};
/// <summary>
/// Resolves <c>env:NAME</c>, <c>file:PATH</c>, and <c>dev:KEY</c> prefixes;
/// anything else is treated as a literal cleartext key with a startup warning.
/// Slim mirror of <c>GalaxyDriver.ResolveApiKey</c> — the runtime version lives
/// in a sibling project the Browser intentionally doesn't reference.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
private string ResolveApiKey(string secretRef)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var name = secretRef[4..];
var value = Environment.GetEnvironmentVariable(name);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
}
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = secretRef[5..];
if (!File.Exists(path))
{
throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
}
var contents = File.ReadAllText(path).Trim();
return !string.IsNullOrEmpty(contents)
? contents
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
}
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
// Explicit dev opt-in — no warning, the operator deliberately chose a
// cleartext literal (dev box, parity rig).
return secretRef[4..];
}
// Back-compat literal arm. An unprefixed string is treated as the literal
// API key — but emit a warning so an operator who accidentally committed a
// cleartext key into DriverConfig sees it when they open the address picker.
_logger.LogWarning(
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
return secretRef;
}
}
@@ -30,7 +30,7 @@ public sealed record GalaxyDriverOptions(
/// <summary>
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
/// resolved by <c>GalaxyDriver.ResolveApiKey</c> at InitializeAsync time. Four forms
/// resolved by <see cref="GalaxySecretRef.ResolveApiKey"/> at InitializeAsync time. Four forms
/// supported, in priority order:
/// <list type="bullet">
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
@@ -0,0 +1,88 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
/// <summary>
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Four
/// forms supported, evaluated in order:
/// <list type="number">
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
/// Throws when the variable is unset, so a misconfigured deployment fails
/// fast rather than silently sending an empty key.</item>
/// <item><c>file:PATH</c> — reads UTF-8 text from <c>PATH</c>, trimming
/// whitespace. Lets operators stash the key in an ACL'd file outside the
/// repo (the same pattern as the legacy <c>.local/galaxy-host-secret.txt</c>).</item>
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
/// doesn't emit a warning; production should never use this arm.</item>
/// <item>Anything else — used as the literal API key for back-compat with
/// configs that pre-date this resolver. When a logger is supplied the
/// resolver emits a startup warning so an operator who accidentally
/// committed a cleartext key sees it (Driver.Galaxy-010).</item>
/// </list>
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
/// changing the call site.
/// </summary>
/// <remarks>
/// Lives in the Contracts project so both the runtime <c>GalaxyDriver</c> and the
/// AdminUI <c>GalaxyDriverBrowser</c> (which intentionally don't reference each
/// other) share a single resolver rather than each maintaining a copy.
/// </remarks>
public static class GalaxySecretRef
{
/// <summary>
/// Resolves the supplied secret reference. When the ref falls through to the
/// back-compat literal arm (an unprefixed cleartext API key in
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits
/// a <see cref="LogLevel.Warning"/>. The <c>dev:</c> prefix is the explicit
/// opt-in path that doesn't warn.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
public static string ResolveApiKey(string secretRef, ILogger? logger = null)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var name = secretRef[4..];
var value = Environment.GetEnvironmentVariable(name);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
}
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = secretRef[5..];
if (!File.Exists(path))
{
throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
}
var contents = File.ReadAllText(path).Trim();
return !string.IsNullOrEmpty(contents)
? contents
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
}
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
// Explicit dev opt-in — no warning, the operator deliberately chose a
// cleartext literal (dev box, parity rig).
return secretRef[4..];
}
// Back-compat literal arm. An unprefixed string is treated as the literal
// API key — but emit a warning so an operator who accidentally committed a
// cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
// this warning when the literal is intentional.
logger?.LogWarning(
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
return secretRef;
}
}
@@ -5,5 +5,9 @@
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<!-- NO PackageReference. NO ProjectReference. -->
<!-- NO ProjectReference. The only PackageReference is the logging abstraction -->
<!-- needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
@@ -252,7 +252,7 @@ public sealed class GalaxyDriver
// listener (OTLP exporter, dotnet-trace, etc.) consumes these without the driver
// taking a dependency on the OpenTelemetry packages.
_subscriber = new TracedGalaxySubscriber(
new GatewayGalaxySubscriber(_ownedMxSession), _options.MxAccess.ClientName);
new GatewayGalaxySubscriber(_ownedMxSession, _logger), _options.MxAccess.ClientName);
_dataWriter = new TracedGalaxyDataWriter(
// Let the writer borrow live MXAccess item handles the subscription registry already
// holds, so the first write to an already-subscribed tag skips a redundant AddItem.
@@ -437,91 +437,14 @@ public sealed class GalaxyDriver
}
}
/// <summary>
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key bytes. Four
/// forms supported, evaluated in order:
/// <list type="number">
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
/// Throws when the variable is unset, so a misconfigured deployment fails
/// fast at InitializeAsync rather than silently sending an empty key.</item>
/// <item><c>file:PATH</c> — reads UTF-8 text from <c>PATH</c>, trimming
/// whitespace. Lets operators stash the key in an ACL'd file outside the
/// repo (the same pattern as the legacy <c>.local/galaxy-host-secret.txt</c>).</item>
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
/// doesn't emit a warning; production should never use this arm.</item>
/// <item>Anything else — used as the literal API key for back-compat with
/// configs that pre-date this resolver. When a logger is supplied the
/// resolver emits a startup warning so an operator who accidentally
/// committed a cleartext key sees it (Driver.Galaxy-010).</item>
/// </list>
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
/// changing the call site.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
internal static string ResolveApiKey(string secretRef) => ResolveApiKey(secretRef, logger: null);
/// <summary>
/// Logger-aware overload. Emits a <see cref="LogLevel.Warning"/> if the secret
/// ref falls through to the back-compat literal arm (an unprefixed cleartext
/// API key in <c>DriverConfig</c> JSON). The <c>dev:</c> prefix is the explicit
/// opt-in path that doesn't warn.
/// </summary>
/// <param name="secretRef">The secret reference string to resolve.</param>
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
internal static string ResolveApiKey(string secretRef, ILogger? logger)
{
ArgumentException.ThrowIfNullOrEmpty(secretRef);
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
{
var name = secretRef[4..];
var value = Environment.GetEnvironmentVariable(name);
return !string.IsNullOrEmpty(value)
? value
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
}
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var path = secretRef[5..];
if (!File.Exists(path))
{
throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
}
var contents = File.ReadAllText(path).Trim();
return !string.IsNullOrEmpty(contents)
? contents
: throw new InvalidOperationException(
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
}
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
{
// Explicit dev opt-in — no warning, the operator deliberately chose a
// cleartext literal (dev box, parity rig).
return secretRef[4..];
}
// Back-compat literal arm. An unprefixed string is treated as the literal
// API key — but emit a warning so an operator who accidentally committed a
// cleartext key into DriverConfig sees it at startup. Use the dev: prefix
// to suppress this warning when the literal is intentional.
logger?.LogWarning(
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
return secretRef;
}
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
{
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
// Driver.Galaxy-010: pass the logger so the literal-arm cleartext fallback
// surfaces a startup warning rather than silently shipping the key.
ApiKey = ResolveApiKey(gw.ApiKeySecretRef, _logger),
// surfaces a startup warning rather than silently shipping the key. The
// resolver lives in Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime
// driver and the AdminUI browser share one implementation.
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
UseTls = gw.UseTls,
CaCertificatePath = gw.CaCertificatePath,
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
@@ -1,3 +1,5 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.MxGateway.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
// Use the generated nested status enum for the SetBufferedUpdateInterval reply check.
@@ -18,14 +20,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
{
private readonly GalaxyMxSession _session;
private readonly ILogger _logger;
private readonly Lock _intervalLock = new();
private int _lastAppliedIntervalMs = -1; // -1 = never applied; 0 = explicit "use gw default"
/// <summary>Initializes a new instance of GatewayGalaxySubscriber.</summary>
/// <param name="session">The Galaxy MX session to use for subscription operations.</param>
public GatewayGalaxySubscriber(GalaxyMxSession session)
/// <param name="logger">
/// Optional logger; surfaces a warning when <c>SetBufferedUpdateInterval</c>
/// soft-fails so the cadence-not-applied condition isn't silent. Null is allowed
/// for unit-test construction and falls back to <see cref="NullLogger.Instance"/>.
/// </param>
public GatewayGalaxySubscriber(GalaxyMxSession session, ILogger? logger = null)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_logger = logger ?? NullLogger.Instance;
}
/// <summary>Subscribes to a bulk list of Galaxy references with optional buffered update interval.</summary>
@@ -86,11 +95,33 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
},
cancellationToken).ConfigureAwait(false);
if (reply.ProtocolStatus?.Code is not (ProtocolStatusCode.Ok or ProtocolStatusCode.MxaccessFailure))
var code = reply.ProtocolStatus?.Code;
// MxaccessFailure means the COM-side SetBufferedUpdateInterval did NOT apply, so
// we must NOT cache the requested value — caching it would suppress the retry on
// the next subscribe at this interval. Only Ok records the value as applied.
if (!ClassifyIntervalReply(code))
{
// Don't throw on a soft failure — the SubscribeBulk will still succeed at the
// gw's default cadence, which is functional just not the requested cadence.
// The trace span (PR 6.1) plus the warning here gives ops the signal.
// The trace span (PR 6.1) plus this warning gives ops the signal, and leaving
// _lastAppliedIntervalMs unchanged lets the next subscribe re-attempt the set.
if (code == ProtocolStatusCode.MxaccessFailure)
{
_logger.LogWarning(
"Galaxy SetBufferedUpdateInterval({IntervalMs}ms) soft-failed (MxaccessFailure); " +
"buffered subscriptions on server handle {ServerHandle} will publish at the gateway's " +
"default cadence. The requested interval was not cached, so a later subscribe will retry it.",
intervalMs, serverHandle);
}
else
{
_logger.LogWarning(
"Galaxy SetBufferedUpdateInterval({IntervalMs}ms) returned an unexpected protocol status " +
"{Code} on server handle {ServerHandle}; treating it as not-applied and leaving the " +
"requested interval uncached so a later subscribe retries it.",
intervalMs, code, serverHandle);
}
return;
}
@@ -100,6 +131,17 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
}
}
/// <summary>
/// Classifies a <c>SetBufferedUpdateInterval</c> reply: returns <c>true</c> only when
/// the requested interval was actually applied and may be cached as last-applied.
/// This is <see cref="ProtocolStatusCode.Ok"/> alone — <see cref="ProtocolStatusCode.MxaccessFailure"/>
/// means the COM-side set did NOT take effect (so caching it would prevent a retry),
/// and a <c>null</c> or any other unexpected code is treated as not-applied.
/// </summary>
/// <param name="code">The protocol status code from the gateway reply, or <c>null</c> when absent.</param>
/// <returns><c>true</c> if the interval should be recorded as applied; otherwise <c>false</c>.</returns>
internal static bool ClassifyIntervalReply(ProtocolStatusCode? code) => code == ProtocolStatusCode.Ok;
/// <summary>Unsubscribes from a bulk list of item handles.</summary>
/// <param name="itemHandles">The item handles to unsubscribe from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
@@ -0,0 +1,142 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Dedicated unit tests for <see cref="AbCipEquipmentTagParser.TryParse"/>.
/// Covers all distinct outcome branches: valid scalar, 1-element array, N-element array,
/// degenerate array shapes, non-JSON input, non-object JSON, blank/missing tagPath,
/// the <c>writable</c> field, and the <c>Structure</c> dataType path (Driver.AbCip.Contracts-004).
/// </summary>
[Trait("Category", "Unit")]
public class AbCipEquipmentTagParserTests
{
// ── Happy-path scalar ────────────────────────────────────────────────────────────────
[Fact]
public void Valid_scalar_round_trip_parses_all_fields()
{
var json = """{"deviceHostAddress":"ab://10.0.0.1/1,0","tagPath":"Motor.Speed","dataType":"Real"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Name.ShouldBe(json);
def.TagPath.ShouldBe("Motor.Speed");
def.DeviceHostAddress.ShouldBe("ab://10.0.0.1/1,0");
def.DataType.ShouldBe(AbCipDataType.Real);
def.Writable.ShouldBeTrue();
def.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Array shape ──────────────────────────────────────────────────────────────────────
[Fact]
public void One_element_array_isArray_true_arrayLength_1_is_an_array_not_a_scalar()
{
var json = """{"tagPath":"Tags[0]","dataType":"DInt","isArray":true,"arrayLength":1}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void N_element_array_isArray_true_arrayLength_N_parses_correctly()
{
var json = """{"tagPath":"Buf","dataType":"SInt","isArray":true,"arrayLength":8}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeTrue();
def.ElementCount.ShouldBe(8);
}
[Fact]
public void IsArray_true_arrayLength_0_is_canonical_scalar()
{
// Canonical rule: isArray:true AND arrayLength < 1 → scalar.
var json = """{"tagPath":"PT_101","isArray":true,"arrayLength":0}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
[Fact]
public void IsArray_true_arrayLength_absent_is_canonical_scalar()
{
// Canonical rule: isArray:true but arrayLength absent → scalar.
var json = """{"tagPath":"PT_101","isArray":true}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.IsArray.ShouldBeFalse();
def.ElementCount.ShouldBe(1);
}
// ── Rejection paths ──────────────────────────────────────────────────────────────────
[Fact]
public void Non_JSON_input_returns_false()
=> AbCipEquipmentTagParser.TryParse("not json at all", out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_array_returns_false()
=> AbCipEquipmentTagParser.TryParse("""["tagPath","foo"]""", out _).ShouldBeFalse();
[Fact]
public void Non_object_JSON_string_returns_false()
=> AbCipEquipmentTagParser.TryParse("\"Motor.Speed\"", out _).ShouldBeFalse();
[Fact]
public void Missing_tagPath_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"dataType":"DInt"}""", out _).ShouldBeFalse();
[Fact]
public void Blank_tagPath_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":" "}""", out _).ShouldBeFalse();
[Fact]
public void TagPath_as_number_returns_false()
=> AbCipEquipmentTagParser.TryParse("""{"tagPath":42}""", out _).ShouldBeFalse();
// ── Writable field (Driver.AbCip.Contracts-001) ───────────────────────────────────────
[Fact]
public void Writable_false_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":false}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeFalse();
}
[Fact]
public void Writable_absent_defaults_to_true()
{
var json = """{"tagPath":"Sensor.Val"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
[Fact]
public void Writable_true_explicit_is_honoured()
{
var json = """{"tagPath":"Sensor.Val","writable":true}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.Writable.ShouldBeTrue();
}
// ── Structure dataType (Driver.AbCip.Contracts-001 Structure concern) ─────────────────
/// <summary>
/// A "dataType":"Structure" equipment-tag input is accepted and produces a Structure-typed
/// definition with Members:null. The driver treats the tag path as a black-box dotted-path
/// read (libplctag resolves the full path); UDT member declarations are not supported in the
/// equipment-tag flow. This test documents current behaviour so a future change to reject
/// Structure is a conscious choice.
/// </summary>
[Fact]
public void Structure_dataType_is_accepted_with_null_Members_and_returns_true()
{
var json = """{"tagPath":"Motor","dataType":"Structure"}""";
AbCipEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.DataType.ShouldBe(AbCipDataType.Structure);
def.Members.ShouldBeNull();
def.TagPath.ShouldBe("Motor");
}
}
@@ -37,6 +37,32 @@ public sealed class AbLegacyEquipmentTagTests
=> AbLegacyEquipmentTagParser.TryParse(
"""{"address":"","dataType":"Int"}""", out _).ShouldBeFalse();
// -002 regression: isArray:true without a valid positive arrayLength → scalar (null ArrayLength).
[Fact]
public void IsArray_true_with_arrayLength_zero_produces_scalar()
{
var json = """{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":0}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
[Fact]
public void IsArray_true_with_no_arrayLength_produces_scalar()
{
var json = """{"address":"N7:0","dataType":"Int","isArray":true}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
[Fact]
public void IsArray_true_with_negative_arrayLength_produces_scalar()
{
var json = """{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":-5}""";
AbLegacyEquipmentTagParser.TryParse(json, out var def).ShouldBeTrue();
def!.ArrayLength.ShouldBeNull();
}
/// <summary>
/// End-to-end driver-level proof: an AbLegacy driver with NO authored tags can still read an
/// equipment-tag ref (the raw TagConfig JSON) — the resolver parses it into a transient
@@ -116,6 +116,39 @@ public sealed class FocasDriverMediumFindingsTests
.ShouldBe(SecurityClassification.ViewOnly);
}
// ---- Driver.FOCAS.Contracts-002: WriteIdempotent threaded through DiscoverAsync ----
/// <summary>
/// Verifies that a tag authored with <c>WriteIdempotent: true</c> surfaces
/// <c>WriteIdempotent == true</c> on its discovered <see cref="DriverAttributeInfo"/>,
/// and that a tag with the default <c>WriteIdempotent: false</c> surfaces <c>false</c>.
/// Resolves Driver.FOCAS.Contracts-002 — the field was previously hardcoded to
/// <c>false</c> in <c>DiscoverAsync</c> and had no runtime effect.
/// </summary>
[Fact]
public async Task DiscoverAsync_surfaces_WriteIdempotent_from_tag_definition()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags =
[
new FocasTagDefinition("Idempotent", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16, WriteIdempotent: true),
new FocasTagDefinition("NonIdempotent", "focas://10.0.0.5:8193", "R200", FocasDataType.Int16, WriteIdempotent: false),
],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single(v => v.BrowseName == "Idempotent").Info.WriteIdempotent
.ShouldBeTrue("a tag declared WriteIdempotent:true must surface true on DriverAttributeInfo");
builder.Variables.Single(v => v.BrowseName == "NonIdempotent").Info.WriteIdempotent
.ShouldBeFalse("a tag declared WriteIdempotent:false (the default) must surface false");
}
// ---- Driver.FOCAS-005: Volatile-guarded _health survives concurrent reads ----
/// <summary>Verifies that GetHealth reflects state updated from concurrent reads.</summary>
@@ -1,14 +1,17 @@
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// Follow-up #2 — pins the three resolution forms supported by
/// <see cref="GalaxyDriver.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
/// <see cref="GalaxySecretRef.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
/// and the literal-string fallback. A future DPAPI arm slots in here without
/// touching the call site.
/// touching the call site. (The resolver was extracted from <c>GalaxyDriver</c> to
/// the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the runtime
/// driver and the AdminUI browser share one copy.)
/// </summary>
public sealed class GalaxyDriverApiKeyResolverTests
{
@@ -16,7 +19,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
[Fact]
public void Literal_string_is_returned_unchanged()
{
GalaxyDriver.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
GalaxySecretRef.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
}
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
@@ -27,7 +30,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
Environment.SetEnvironmentVariable(name, "key-from-env");
try
{
GalaxyDriver.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
}
finally
{
@@ -43,7 +46,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
Environment.SetEnvironmentVariable(name, null);
var ex = Should.Throw<InvalidOperationException>(() =>
GalaxyDriver.ResolveApiKey($"env:{name}"));
GalaxySecretRef.ResolveApiKey($"env:{name}"));
ex.Message.ShouldContain(name);
ex.Message.ShouldContain("unset");
}
@@ -56,7 +59,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
File.WriteAllText(path, " key-from-file \n");
try
{
GalaxyDriver.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
}
finally
{
@@ -70,7 +73,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
{
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
var ex = Should.Throw<InvalidOperationException>(() =>
GalaxyDriver.ResolveApiKey($"file:{path}"));
GalaxySecretRef.ResolveApiKey($"file:{path}"));
ex.Message.ShouldContain(path);
ex.Message.ShouldContain("doesn't exist");
}
@@ -85,7 +88,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
// in the DriverConfig JSON. The resolver must surface a warning so an
// operator who committed one by accident sees it at startup.
var logger = new CaptureLogger();
var key = GalaxyDriver.ResolveApiKey("plain-text-key", logger);
var key = GalaxySecretRef.ResolveApiKey("plain-text-key", logger);
key.ShouldBe("plain-text-key");
logger.Entries.ShouldContain(e =>
@@ -100,7 +103,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
// key (dev / parity rig). The resolver must accept it AND suppress the
// warning so production logs aren't polluted on a deliberate dev choice.
var logger = new CaptureLogger();
var key = GalaxyDriver.ResolveApiKey("dev:plain-text-key", logger);
var key = GalaxySecretRef.ResolveApiKey("dev:plain-text-key", logger);
key.ShouldBe("plain-text-key");
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
@@ -115,7 +118,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
try
{
var logger = new CaptureLogger();
GalaxyDriver.ResolveApiKey($"env:{name}", logger);
GalaxySecretRef.ResolveApiKey($"env:{name}", logger);
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
}
finally
@@ -150,7 +153,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
try
{
var ex = Should.Throw<InvalidOperationException>(() =>
GalaxyDriver.ResolveApiKey($"file:{path}"));
GalaxySecretRef.ResolveApiKey($"file:{path}"));
ex.Message.ShouldContain("empty");
}
finally
@@ -0,0 +1,45 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// Driver.Galaxy-019 — pins <see cref="GatewayGalaxySubscriber.ClassifyIntervalReply"/>:
/// a <c>SetBufferedUpdateInterval</c> reply is only "applied" (and therefore cacheable
/// as the last-applied interval) when the gateway returns <see cref="ProtocolStatusCode.Ok"/>.
/// <see cref="ProtocolStatusCode.MxaccessFailure"/> means the COM-side set did NOT take
/// effect, so caching it would suppress the retry on the next subscribe — it must classify
/// as not-applied, as must any other unexpected code or a missing status.
/// </summary>
public sealed class GatewayGalaxySubscriberClassifyTests
{
/// <summary>Ok is the only code that records the interval as applied.</summary>
[Fact]
public void Ok_classifies_as_applied()
{
GatewayGalaxySubscriber.ClassifyIntervalReply(ProtocolStatusCode.Ok).ShouldBeTrue();
}
/// <summary>MxaccessFailure must NOT cache — the COM-side set did not apply.</summary>
[Fact]
public void MxaccessFailure_classifies_as_not_applied()
{
GatewayGalaxySubscriber.ClassifyIntervalReply(ProtocolStatusCode.MxaccessFailure).ShouldBeFalse();
}
/// <summary>Any other unexpected code is treated as not-applied.</summary>
[Fact]
public void Unexpected_code_classifies_as_not_applied()
{
GatewayGalaxySubscriber.ClassifyIntervalReply(ProtocolStatusCode.Unspecified).ShouldBeFalse();
}
/// <summary>A missing protocol status (null) is treated as not-applied.</summary>
[Fact]
public void Null_classifies_as_not_applied()
{
GatewayGalaxySubscriber.ClassifyIntervalReply(null).ShouldBeFalse();
}
}