Compare commits

...

77 Commits

Author SHA1 Message Date
dohertj2 ec6598ceae Merge pull request 'v3 Batch 4 — dual-namespace address space + raw-only runtime binding (v3.0)' (#472) from v3/batch4-address-space into master
v2-ci / build (push) Successful in 3m59s
v2-ci / unit-tests (push) Failing after 9m25s
2026-07-16 14:38:29 -04:00
Joseph Doherty b4b378c5cd docs(v3): Batch 4 PR description — dual-namespace address space (v3.0)
v2-ci / build (pull_request) Successful in 4m0s
v2-ci / unit-tests (pull_request) Failing after 11m55s
7-leg live-gate evidence + per-wave reviewer verdicts + the live-gate-caught VT
reassert fix + documented follow-ups (confirmed-but-deferred scripted-alarm
redeploy recovery, raw-rename hot-rebind, cross-repo ScadaBridge cutover).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 14:36:30 -04:00
Joseph Doherty e41ffe7655 merge reassert review fixes (M1 skip historian re-record, LOW-3 comment) into v3/batch4-address-space 2026-07-16 14:34:08 -04:00
Joseph Doherty 51022c3952 fix(v3-batch4): reassert skips historian re-record + scripted-alarm redeploy recovery (reassert review M1/M2/LOW-3)
M1 (MEDIUM): the VirtualTag re-assert re-published a stale last value with a
fresh deploy-time timestamp, and VirtualTagHostActor.OnResult recorded it to
the IHistoryWriter for Historize=true plans — every deploy would append an
artificial historian sample (BadInternalError if the last state was Bad) that
never corresponded to a real evaluation. Inert today (NullHistoryWriter) but a
data-quality bug once a VT history sink binds. Fix: EvaluationResult carries an
IsReassert flag (default false), set true in VirtualTagActor.OnReassertValue;
OnResult still PUBLISHES the AttributeValueUpdate (to repair the reset node) but
SKIPS _history.Record when IsReassert. Regression test
VirtualTagHostActorTests.Reassert_of_a_historized_vtag_publishes_but_does_not_re_record_to_the_historian
(fails before — count=2 — passes after: count stays 1).

LOW-3: corrected the ordering comments in VirtualTagActor.OnReassertValue and
VirtualTagHostActor.OnApply. ApplyVirtualTags goes to the VirtualTag HOST, not
the publish actor; the ordering holds because the re-assert reaches the publish
actor via a multi-hop chain (host -> child ReassertValue -> child -> parent
EvaluationResult -> OnResult -> publish actor) and thus lands AFTER
RebuildAddressSpace in the shared publish actor's FIFO mailbox.

M2 (CONFIRMED REAL — reported as scoped follow-up, not fixed here): the same
redeploy-reset race is latent for Part 9 scripted-alarm condition nodes. A
full-rebuild deploy clears + re-materialises them fresh
(OtOpcUaNodeManager.RebuildAddressSpace clears _alarmConditions @2160;
MaterialiseAlarmCondition recreates normal state), but the engine reload does
NOT re-emit an unchanged-active condition: ScriptedAlarmEngine.LoadAsync ->
EvaluatePredicateToStateAsync (@546-552) computes ApplyPredicate(seed, true)
where seed is the persisted state from the DB-backed EfAlarmConditionStateStore,
yielding EmissionKind.None, which ScriptedAlarmHostActor.OnEngineEmission (@292)
filters. Net: an active alarm with static dependencies under-reports until its
next real transition on a full-rebuild deploy. The fix is larger/riskier than
the VT case (touches the Core engine emission contract and must publish ONLY the
OPC UA node state, NOT the alerts topic, to avoid duplicate AVEVA history rows —
the alarm analogue of M1), so per review guidance it is deferred as a scoped
follow-up rather than forced into this commit.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 14:33:05 -04:00
Joseph Doherty 21eb81c915 merge VirtualTag reassert-on-redeploy fix (live-gate: dedup+node-reset race) into v3/batch4-address-space 2026-07-16 14:10:43 -04:00
Joseph Doherty b95efb0b28 fix(v3-batch4): Equipment VirtualTag runtime dependency/publish resolution (live-gate)
Equipment VirtualTags published Bad at runtime whenever their dependency
value was static. Root cause: a deploy re-materialises each VirtualTag's
UNS node to BadWaitingForInitialData (OpcUaPublishActor.HandleRebuild), but
a surviving unchanged-plan VirtualTagActor keeps its value-dedup state, so
its unchanged recompute is suppressed (VirtualTagActor.OnDependencyChanged
value dedup) and the freshly-reset node stays Bad forever. Only masked when
the dependency value changes every poll.

Fix: VirtualTagHostActor tells each surviving (not just-spawned) child to
ReassertValue on every apply; the child re-emits its last value/quality,
bypassing dedup. Ordering is safe — DriverHostActor enqueues the
RebuildAddressSpace (materialise) to the single-threaded publish actor
before the ApplyVirtualTags that triggers the re-assert, so the re-publish
lands on the freshly-materialised node.

Regression test: VirtualTagHostActorTests
.Unchanged_redeploy_reasserts_last_value_so_a_reset_uns_node_recovers
(fails before, passes after). Live-verified on docker-dev: GateVt reads
Good via both the absolute and the {{equip}}/MainPressure script forms.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 14:09:43 -04:00
Joseph Doherty 1badc10445 test(v3-batch4): add Calculation to the driver-probe idempotency key set
Pre-existing stale test surfaced by Batch 4's full gate (Batch 2/3 gates were
live-/run and never ran Host.IntegrationTests): Batch 2 registered CalculationProbe
in DriverFactoryBootstrap (9 probes) but AddOtOpcUaDriverProbes_is_idempotent's
AdminUiDriverTypeKeys still listed 8, so distinctTypes(9) != Length(8). Calculation
is a real DriverTypeNames.Calculation pseudo-driver with a probe; add it and refresh
the now-stale *DriverPage.razor comment (that routed flow retired in Batch 2). Not a
Batch-4 code change — a test-correctness fix.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 13:22:27 -04:00
Joseph Doherty e6607ad5ab merge WP5 (dual-namespace harness/integration tests + address-space docs) into v3/batch4-address-space 2026-07-16 13:13:01 -04:00
Joseph Doherty b8208b3312 test+docs(v3-batch4-wp5): 2-node dual-namespace harness tests + address-space docs
Tests:
- OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs (NEW, over-the-wire,
  offline-safe): both namespace URIs registered + distinct; Raw + UNS subtrees browse
  and read; UNS variable Organizes-references its raw node; single-source fan-out parity
  (identical value/quality/timestamp on both NodeIds); HistoryRead via either NodeId ->
  GoodNoData under the shared tagname; WriteOperate gate symmetric across both NodeIds.
- Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs (extended): full deploy
  -> persisted-artifact -> ParseComposition round-trip carrying both realms, sealing across
  the redundant 2-node cluster (redundancy non-interference). In-memory harness, offline.

Docs (dual-namespace reality):
- CLAUDE.md: new "v3 OPC UA Address Space (Batch 4)" section + Batch-4 testing paragraph.
- docs/Uns.md: address-space projection (two namespaces, Organizes edge, effective-name leaf).
- docs/Historian.md: dual-registration (both NodeIds -> one tagname); updated CLI examples.
- docs/ScriptedAlarms.md + docs/AlarmTracking.md: multi-notifier fan-out, ConditionId=RawPath.
- docs/ScriptEditor.md: dual-namespace clarification (script tag-path semantics unchanged).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 13:11:34 -04:00
Joseph Doherty e959423323 merge WP4 Wave C review hardening (M1 event-delivery test, M2/M3 reconcile+meter, L1/L3) into v3/batch4-address-space 2026-07-16 12:53:11 -04:00
Joseph Doherty 3cf3576c75 fix(v3-batch4-wp4): alarm fan-out hardening — event-delivery test, resolution-failure meter, teardown guards (Wave C review M1/M2/M3/L1/L3)
M1 — over-the-wire event-delivery proof (new NativeAlarmMultiNotifierEventDeliveryTests
in OpcUaServer.IntegrationTests): boots the real server, wires one condition to two
equipment folders, fires ONE transition, and asserts a Server-object subscriber gets
EXACTLY ONE event (the shared-InstanceStateSnapshot queue dedup), plus overlapping
Server + equipment-folder subscribers each get exactly one copy. Captures via the
subscription FastEventCallback keyed by ClientHandle (the per-item Notification/DequeueEvents
path delivers nothing for these conditions in the SDK client — the working capture is the
fast callback).

M2 — resolution-failure signal + path-agreement tripwire (AddressSpaceApplier): when a
native alarm HAS ReferencingEquipmentPaths but NONE resolve to an equipment folder, count
it as a failed node (degraded-apply meter) + LogWarning instead of a silent skip; documented
the DisplayName==Name coupling as a binding invariant. Tests:
Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier (real
composer output → applier inversion) + ..._unresolvable_referencing_equipment_counts_as_failed.

M3 — WireAlarmNotifiers is now a RECONCILE (unwires a folder dropped from the supplied set,
bidirectionally) so a future surgical ChangedRawTags path can't leave a de-referenced
equipment receiving the alarm; binding guard comment added on the classifier's
ChangedRawTags→Rebuild branch. Test: WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set.

L1 — MaterialiseAlarmCondition's kind-swap drop-and-recreate now calls
UnwireAlarmNotifiers(conditionKey) before discarding the old instance (teardown symmetry).

L3 — BuildEquipmentIdByFolderPath LogWarnings on a duplicate Area/Line/Name collision
(defense-in-depth; UNS uniqueness prevents it).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 12:52:34 -04:00
Joseph Doherty 90e52a4415 fix(v3-batch4): thread ReferencingEquipmentPaths into native AlarmTransitionEvent
Closes the WP3/WP4 boundary seam WP4 flagged: ForwardNativeAlarm (DriverHostActor,
a WP3-owned file WP4 could not edit) built the AlarmTransitionEvent without the
referencing-equipment paths, so the /alerts equipment-list chips WP4 added never
populated in production (would fail live-gate leg 5). The RawTagPlan already carries
ReferencingEquipmentPaths (the Area/Line/Equipment UNS folder paths); this rides them
onto the per-condition alarm meta and into every transition. Coordinator-owned
1-file change (WP3 merged, no parallel agent owns this file this wave).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 12:10:01 -04:00
Joseph Doherty 1e9454582e merge WP4 (multi-notifier native alarms + teardown symmetry) into v3/batch4-address-space 2026-07-16 12:08:33 -04:00
Joseph Doherty 8ebc712eff feat(v3-batch4-wp4): multi-notifier native alarms (single ReportEvent → raw + equipment notifiers) + teardown symmetry
Materialize each native alarm ONCE at the raw tag (ConditionId = RawPath, Raw
realm); wire the single condition as an SDK event notifier of each referencing
equipment's UNS folder so one ReportEvent fans to every root without
re-reporting per root (which would break Server-object dedup + Part 9 ack
correlation).

- New sink method WireAlarmNotifiers(alarmNodeId, alarmRealm,
  notifierFolderNodeIds, notifierFolderRealm) on IOpcUaAddressSpaceSink,
  forwarded through DeferredAddressSpaceSink + SdkAddressSpaceSink +
  NullOpcUaAddressSpaceSink (the forwarding-trap guard); auto-covered by the
  DeferredSinkForwardingReflectionTests realm + forwarding guards + a
  hand-written forward test.
- OtOpcUaNodeManager: the normative AddNotifier(isInverse) pair + idempotent
  EnsureFolderIsEventNotifier per equipment folder; tracked per condition in
  _alarmNotifierWiring. Teardown symmetry: RemoveNotifier(bidirectional:true) on
  RebuildAddressSpace, RemoveAlarmConditionNode, and RemoveEquipmentSubtree so no
  inverse-notifier entry leaks across redeploys.
- AddressSpaceApplier.MaterialiseRawSubtree wires notifiers for each native alarm
  tag, resolving its ReferencingEquipmentPaths (Area/Line/Equipment) to the
  EquipmentId folder NodeIds via BuildEquipmentIdByFolderPath.
- AlarmTransitionEvent gains ReferencingEquipmentPaths (empty default); /alerts
  renders the referencing-equipment list as display metadata.
- Un-skipped + rewrote the native-alarm dark tests
  (DriverHostActorNativeAlarmTests x6, DriverHostActorNativeAlarmAckRoutingTests
  x1) for the v3 raw-condition model; new NodeManagerMultiNotifierAlarmTests
  proves multi-notifier wiring + teardown symmetry (no leaked duplicates after a
  re-trip) + applier wiring test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 12:07:11 -04:00
Joseph Doherty 9d09523675 merge WP3 Wave B review fixes (H1 realm write-routing, M1/M2/L1/L3, byte-parity) into v3/batch4-address-space 2026-07-16 11:30:57 -04:00
Joseph Doherty 2e0743ad25 fix(v3-batch4-wp3): realm-qualified write routing + dormant discovery guard + self-correction/byte-parity tests (Wave B review H1/M1/M2/L1/L3)
H1 (HIGH): write-routing key now (AddressSpaceRealm, bareId), not bare-only.
A raw s=<RawPath> and a UNS s=<Area/Line/Equip/Eff> can collide as bare
strings; the bare-only key let a colliding raw+UNS pair route to the WRONG
driver ref (last-writer-wins). The realm the node manager resolves (RealmOf)
is now threaded through IOpcUaNodeWriteGateway.WriteAsync -> RouteNodeWrite ->
_driverRefByNodeId keyed by (realm, bareId). New regression test:
Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm.

M1 (MEDIUM): discovered-node injection made coherently DORMANT. HandleDiscoveredNodes
hard-short-circuits (single enforcement point; _discoveredByDriver never
populates so the re-inject tail is inert too), with a clear log pointing at the
/raw browse-commit flow. New pin: Discovered_nodes_are_ignored_dormant_in_v3;
the 16+2 v2 injection scenarios re-pointed to an accurate skip reason
(DiscoveryInjectionDormantV3).

M2 (MEDIUM): realm-qualified dual-node self-correction tests —
Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched +
Raw_realm_revert_reverts_raw_node_only (the second fails if the realm is dropped).

L1: removed the = AddressSpaceRealm.Uns defaults from the consequential
node-manager mutation methods (WriteValue/WriteAlarmCondition/MaterialiseAlarmCondition/
EnsureFolder/EnsureVariable/UpdateFolderDisplayName/UpdateTagAttributes/
RaiseNodesAddedModelChange/Remove*/RevertOptimisticWriteIfNeeded) + the
AttributeValueUpdate/AlarmStateUpdate records, so the compiler forces explicit
realm; read-only accessors + internal builders retain their defaults.

L3: fixed the stale VirtualTagHostActor class comment (V3NodeIds.Uns, not the
retired EquipmentNodeIds.Variable).

Also: DeploymentArtifactRawUnsParityTests — Raw/UNS node-set byte-parity
round-trip between AddressSpaceComposer.Compose and DeploymentArtifact.ParseComposition.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 11:30:13 -04:00
Joseph Doherty 77c39bf02d merge WP3 (raw-only binding + UNS fan-out + write routing) into v3/batch4-address-space 2026-07-16 10:58:08 -04:00
Joseph Doherty 3efcf8014b feat(v3-batch4-wp3): raw-only binding + UNS fan-out + write routing
Wave B of Batch 4 — the runtime binding seam for the dual namespace.

Applier (both realms, explicit realm at every sink call site):
- MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables
  keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath),
  all in AddressSpaceRealm.Raw; historian tagname = override else RawPath.
- MaterialiseUnsReferences: each UNS reference Variable under its equipment
  folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/
  historian tagname from the backing raw tag (both NodeIds -> one tagname).
- FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref
  stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS
  refs in place (raw-container removal falls back to rebuild).

DriverHostActor (dual-NodeId, single-source fan-out):
- _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from
  RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans
  to the raw NodeId AND every referencing UNS NodeId with identical
  value/quality/timestamp. Write inverse map keyed by the bare id; the
  ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a
  write to either NodeId resolves the same driver ref (-> RawPath write).
- Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate
  + AlarmStateUpdate carry the realm through to the sink.

Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and
RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now).

DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity
with the composer (reconstruct entities -> AddressSpaceComposer.Compose).

Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from
IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink /
DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now
explicit (realm reordered before the trailing optionals on EnsureVariable +
MaterialiseAlarmCondition). Node-manager convenience methods keep their
defaults (they are not the interface impl; Sdk delegates explicitly).

Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) +
DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3
raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags
provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 10:56:31 -04:00
Joseph Doherty 8b0b627f81 merge WP2 M1 fix (AddReference Organizes seam) into v3/batch4-address-space 2026-07-16 09:52:58 -04:00
Joseph Doherty e95615cef3 fix(v3-batch4-wp2): AddReference sink seam for Organizes UNS→Raw (Wave A review M1)
Close the Wave-A M1 gap: the sink surface had no way to wire the mandated
cross-tree Organizes reference from each UNS reference Variable to its backing
Raw node, forcing WP3 to reopen the frozen surface. Add a dedicated
realm-qualified AddReference method instead.

- IOpcUaAddressSpaceSink.AddReference(sourceNodeId, sourceRealm, targetNodeId,
  targetRealm, referenceType="Organizes"): realm-qualified both ends; idempotent;
  a missing endpoint is a logged no-op (never throws) so a mid-rebuild race can't
  fault a deploy. Base-interface capability (no surgical sniff, no transitional
  default — WP3 wires it explicitly per UNS reference variable).
- SdkAddressSpaceSink forwards to the node manager; DeferredAddressSpaceSink
  forwards unconditionally (like EnsureFolder); NullOpcUaAddressSpaceSink no-ops.
- OtOpcUaNodeManager.AddReference: resolve both nodes by full ns-qualified key
  (variable/folder/condition via ResolveNodeState), guard both-exist, then wire
  the edge bidirectionally (forward Organizes on source, inverse on target) with a
  ReferenceExists idempotency guard + ClearChangeMasks on mutated sides. Reference
  type resolved by ResolveReferenceType (Organizes default). Added internal
  GetNodeReferences test/diagnostic accessor.
- Reflection guard: the exhaustive-forwarding test + the realm-discriminator guard
  auto-cover AddReference (it has two AddressSpaceRealm params) — no edit needed.
- New SdkAddressSpaceSinkTests: Organizes UNS→Raw edge created bidirectionally +
  idempotent; missing endpoint is a safe no-op.
- All 15 IOpcUaAddressSpaceSink test doubles gain the AddReference no-op so the
  solution still builds.

Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors.
Tests: Commons.Tests 310/310; OpcUaServer.Tests 337 passed / 4 pre-existing skips.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 09:52:30 -04:00
Joseph Doherty 0dbdee003b merge WP2 (node-manager dual-namespace + sink realm surface) into v3/batch4-address-space 2026-07-16 09:34:52 -04:00
Joseph Doherty 4807aa7edd merge WP1 (composer/planner dual-subtree) into v3/batch4-address-space 2026-07-16 09:34:52 -04:00
Joseph Doherty 5534698d70 feat(v3-batch4-wp2): node manager dual-namespace + realm-aware sink surface
Register both v3 namespaces (Raw first, UNS second) on OtOpcUaNodeManager and
thread an AddressSpaceRealm discriminator through every node-naming sink method
so a bare node id is resolved to the correct namespace by realm — never parsed
out of the id string.

- IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink: every node-naming method
  gains `AddressSpaceRealm realm = AddressSpaceRealm.Uns` (transitional default so
  un-migrated WP3 call sites still compile; WP3/Wave B makes them explicit and
  removes the default). Null + SdkAddressSpaceSink impls updated; DeferredAddress-
  SpaceSink forwards realm through every method (the forwarding trap).
- DeferredSinkForwardingReflectionTests: existing exhaustive-forwarding guard
  auto-covers the new signatures; added an explicit guard that every node-naming
  sink method carries an AddressSpaceRealm parameter (RebuildAddressSpace exempt).
- OtOpcUaNodeManager: register RawNamespaceUri + UnsNamespaceUri; realm->namespace
  index via NamespaceIndexForRealm; all node maps (_variables/_folders/
  _alarmConditions/_nativeAlarmNodeIds/_historizedTagnames/_eventNotifierSources)
  re-keyed by the full ns-qualified NodeId string so Raw and UNS nodes sharing a
  bare id stay distinct; _notifierFolders already NodeId-keyed. A historized UNS
  reference node registers the SAME historian tagname as its backing raw node
  (both NodeIds -> one tagname). Inbound-write hook routes the full ns-qualified
  NodeId to the write gateway (realm-aware) and reverts by bare id + realm.
  HistoryRead seams resolve via NodeId directly. DefaultNamespaceUri kept as a
  transitional alias to UnsNamespaceUri for the SubscriptionSurvivalTests.
- Test doubles across Commons.Tests / OpcUaServer.Tests / Runtime.Tests updated to
  the new interface signatures; SdkAddressSpaceSinkTests asserts the UNS namespace
  index for default-realm nodes.

Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors.
Tests: Commons.Tests 310/310; OpcUaServer.Tests 335 passed / 4 pre-existing skips.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 09:33:45 -04:00
Joseph Doherty f4f3e17e3e feat(v3-batch4-wp1): composer/planner emit both Raw + UNS subtrees
Un-darken the address-space composition for the v3 dual namespace.

Composer (AddressSpaceComposer):
- Raw subtree: RawContainerNode (Folder/Driver/Device/TagGroup as
  Object/Folder nodes, keyed s=<RawPath>, parent-before-child) + RawTagPlan
  (raw tag Variables keyed (realm=Raw, s=<RawPath>) carrying DataType /
  writable / historize+historian tagname / array shape). Native-alarm intent
  attaches at the RAW tag (ConditionId = RawPath) with the list of referencing
  equipment UNS paths (one alarm at the raw tag, not one per equipment).
- UNS subtree: UnsReferenceVariable per UnsTagReference, keyed
  (realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>), carrying its
  backing RawPath (Organizes UNS->Raw target + fan-out) and inheriting
  DataType/AccessLevel from the backing raw tag. Effective name =
  DisplayNameOverride else backing raw Tag.Name.
- All RawPaths flow through one shared RawTopology (RawPathResolver +
  memoised container paths), byte-parity with EquipmentReferenceMap.
- Every new node carries an AddressSpaceRealm (so WP3's applier picks the
  namespace at the call site). Additive/defaulted model changes only — the
  un-migrated AddressSpaceApplier still compiles.

Planner (AddressSpacePlanner.Compute) + AddressSpacePlan:
- Per-realm diff sets: RawContainers + RawTags keyed by RawPath (NodeId) so a
  rename = remove+add; UnsReferenceVariables keyed by the stable
  UnsTagReferenceId so a backing-tag rename re-points (BackingRawPath moves,
  NodeId stable) and a display-name-override edit is UNS-only.
- PINNED: raw-tag rename = remove+add in Raw AND re-point in UNS.

Classifier folds the new sets in (adds->PureAdd, removes->PureRemove,
changed->Rebuild safe default).

Tests: AddressSpaceComposerDualNamespaceTests (7),
AddressSpacePlannerDualNamespaceTests (8, incl. the rename pin),
AddressSpaceChangeClassifierDualNamespaceTests (8); reactivated
EquipmentNamespaceMaterializationTests' Batch-4-pending case, re-authored to
drive the composer over the seeded config (the artifact-decode seam is WP3).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 09:30:12 -04:00
Joseph Doherty a1a56e22bb feat(v3-batch4): contracts — dual-namespace URIs, V3NodeIds, AddressSpaceRealm
Batch 4 contracts commit (coordinator lands before Wave A fan-out). Purely
additive so wave agents branch from a green base:

- AddressSpaceRealm enum (Raw | Uns) — the explicit realm discriminator that
  travels alongside a node's s= identifier at every sink seam (WP2 adds it to
  IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink; WP3 threads it).
- V3NodeIds — the two namespace URI constants (https://zb.com/otopcua/raw,
  https://zb.com/otopcua/uns, replacing the single .../ns) + Raw() pass-through
  (s= id == RawPath) + Uns()/UnsChild() slash-joined Area/Line/Equipment[/Eff]
  builders, both sharing RawPaths.Separator + ordinal segment validation.

Placement note: URIs live in V3NodeIds (Commons) rather than beside the retired
OtOpcUaNodeManager.DefaultNamespaceUri so runtime + tests reference them without
depending on OpcUaServer (single-source-of-truth, §5). WP2 rewires the node
manager to register both via V3NodeIds.RawNamespaceUri/UnsNamespaceUri.

EquipmentNodeIds retirement is NOT in this commit: its callers are the applier +
runtime (DriverHostActor/VirtualTagHostActor/DiscoveredNodeMapper), all WP3-owned
(the composer does not use it) — deleting it here would red the base. WP3 sweeps
it in Wave B.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 09:03:29 -04:00
dohertj2 f8f3b82ed6 Merge pull request 'v3 Batch 3 — UNS reference-only Equipment + {{equip}}/<RefName> resolution' (#471) from v3/batch3-uns-rework into master
v2-ci / build (push) Successful in 3m28s
v2-ci / unit-tests (push) Failing after 8m23s
2026-07-16 08:59:01 -04:00
Joseph Doherty 0c65e4412e docs(v3): Batch 3 PR description
v2-ci / build (pull_request) Successful in 3m36s
v2-ci / unit-tests (pull_request) Failing after 9m8s
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 07:38:20 -04:00
Joseph Doherty bdcb84bd7d docs(v3-batch3): UNS reference-only Equipment + {{equip}}/RefName resolution
Uns.md Tags section rewritten to the reference-only model (UnsTagReference list,
cluster-scoped picker, effective-name uniqueness, {{equip}}/RefName); CLAUDE.md
gains a v3 Batch 3 paragraph. ScriptEditor.md was updated by WP4.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 07:37:36 -04:00
Joseph Doherty 96af69e3d2 review(wave-b): close SA {{equip}} authoring gap (M1) + harden parity tests (L3)
MEDIUM-1: the editor<->authoring<->deploy invariant now holds on the ScriptedAlarm
surface too. CreateScriptedAlarmAsync/UpdateScriptedAlarmAsync validate {{equip}}/<RefName>
in BOTH the predicate script and the message template (via ExtractEquipReferenceNamesFromText),
matching what DraftValidator already enforces at deploy. Refactored ValidateEquipTokenAsync
into shared LoadEquipReferenceNamesAsync + CheckEquipReferencesResolve helpers.

LOW-1: LoadEquipReferenceNamesAsync uses IsNullOrEmpty (defense-in-depth) to match
EquipmentReferenceMap.Build; empty override is already normalized->null at the write path.

LOW-3: parity test hardened with two edge cases — unresolved-ref-left-intact (both seams
leave {{equip}}/X identical) and folder+tag-group RawPath ancestry.

Tests: VirtualTagEquipTokenValidationTests 9/9 (+3 SA), parity 4/4 (+2). AdminUI 659/0.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 07:18:59 -04:00
Joseph Doherty 29bb4f176e merge-fix(wave-b): dedupe BuildReference test helper (WP2+WP4 both added it)
Semantic collision git auto-merged: WP2 and WP4 each added a BuildReference
helper to the DraftValidatorTests partial class (same param types -> CS0111).
Kept one with the refId param name + default null, satisfying both call styles.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 07:03:21 -04:00
Joseph Doherty 940303276c merge worktree-agent-ad3207d913b3b74e6 (B3-WP4) into v3/batch3-uns-rework 2026-07-16 06:59:22 -04:00
Joseph Doherty dc0d7653b9 feat(v3-batch3): B3-WP4 — {{equip}}/<RefName> reference-relative resolution
Replace the deleted equipment-base-prefix {{equip}} mechanism (impossible now
that equipment is reference-only) with per-reference resolution:
{{equip}}/<RefName> resolves through the owning equipment's UnsTagReference rows
(by effective name) to the backing raw tag's RawPath. Every unresolved <RefName>
is a clear deploy error + authoring rejection + Monaco diagnostic — preserving
"editor accepts <=> publish accepts".

Commons:
- EquipmentScriptPaths: delete DeriveEquipmentBase; SubstituteEquipmentToken now
  takes the equipment's reference map (effectiveName -> RawPath) and substitutes
  {{equip}}/<RefName> inside path literals (unresolved left intact, never throws);
  add ExtractEquipReferenceNames (path-literal scoped) +
  ExtractEquipReferenceNamesFromText (message templates). Slash syntax replaces
  the v2 dot joint.
- New EquipmentReferenceMap: shared, input-shape-agnostic builder (entity + JSON
  sides) over RawPathResolver — the single authority both compose seams + the
  validator use, so resolved RawPaths agree byte-for-byte.

Compose seams (byte-parity kept):
- AddressSpaceComposer.Compose + DeploymentArtifact.ParseComposition both build
  the per-equipment reference map from UnsTagReferences + Tags + raw topology and
  substitute VirtualTag AND ScriptedAlarm-predicate sources before dependency
  extraction (runtime dep refs = resolved RawPaths).

Deploy gate + authoring + editor:
- DraftValidator.ValidateEquipReferenceResolution: EquipReferenceUnresolved error
  for unresolved {{equip}}/<RefName> in VirtualTag/ScriptedAlarm scripts + alarm
  message-template tokens (naming script, equipment, missing ref).
- UnsTreeService.ValidateEquipTokenAsync (Wave-A M1): reference-relative authoring
  rejection, replacing the stale DeriveEquipmentBase(empty)->reject-all logic.
- ScriptAnalysisService: {{equip}}/ completion offers the equipment's reference
  effective names; DiagnoseAsync flags unresolved refs (OTSCRIPT_EQUIPREF) same as
  the deploy gate. Requests carry optional EquipmentId (threaded MonacoEditor ->
  monaco-init.js -> fetch bodies). IScriptTagCatalog.GetEquipmentRelativeLeavesAsync
  repurposed to GetEquipmentReferenceNamesAsync(equipmentId, filter).

Tests: EquipmentScriptPaths substitution/extraction (slash pinned); composer<->
artifact reference-resolution byte-parity; DraftValidator unresolved-ref;
ScriptAnalysis completion+diagnostic; M1 authoring gate. Build clean (0/0); all
five affected suites green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:58:17 -04:00
Joseph Doherty ac12eec924 review(wave-a L1): correct concurrency-catch comment (no DB uniqueness on effective name)
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:24:05 -04:00
Joseph Doherty ac8d4eef30 merge worktree-agent-aaae13cca624ecb2d into v3/batch3-uns-rework 2026-07-16 06:16:42 -04:00
Joseph Doherty 8c05a9fe0a merge worktree-agent-a2c703868462add4a into v3/batch3-uns-rework 2026-07-16 06:16:42 -04:00
Joseph Doherty b610a8dde5 merge worktree-agent-a10be14b6713011cd into v3/batch3-uns-rework 2026-07-16 06:16:42 -04:00
Joseph Doherty 77bc010ba9 v3 Batch 3 WP1: UNS Equipment Tags tab → reference list + raw-tag picker
Replace the Batch-1-stubbed authored-tag flow with a UnsTagReference list on the
equipment page's Tags tab (v3 reference-only equipment): columns are effective name,
computed raw path, inherited datatype/access (read-only), display-name override
(editable), and remove. The retired equipment-authored-tag editor (TagModal.razor)
is deleted; the VirtualTag and ScriptedAlarm flows are untouched.

- "+ Add reference" opens a new AddReferenceModal that reuses RawTree in a new opt-in
  PickerMode (Tag-leaf checkboxes + Device/TagGroup "select all tags below"), scoped
  structurally to the equipment's cluster via LoadReferencePickerRootAsync (a single
  cluster root — cross-cluster tags are unreachable, not merely hidden). Default /raw
  usage is byte-unchanged (PickerMode defaults false).
- UnsTreeService (+ IUnsTreeService) gain the reference mutations:
  LoadReferencesForEquipmentAsync (computes each RawPath via the shared RawPathResolver),
  AddReferencesAsync (cluster-checked, all-or-nothing), RemoveReferenceAsync,
  SetReferenceOverrideAsync, plus the picker helpers LoadReferencePickerRootAsync /
  LoadDescendantTagIdsAsync.
- Consume IEffectiveNameGuard (constructor-injected, no-op fallback when unregistered):
  AddReferences, SetReferenceOverride, and the VirtualTag + ScriptedAlarm create/update
  mutations now call CheckAsync before persisting and surface a collision as the failure.
  WP2 supplies the implementation + DI registration.
- Drop the retired equipment↔driver binding: remove DriverInstanceId from EquipmentInput,
  the ImportEquipmentModal CSV column, the EquipmentPage Details driver select, and the
  dead decision-#122 driver-cluster guard.

Tests: new UnsTreeServiceReferenceTests (add/remove/override, cross-cluster + duplicate
rejection, guard-consumed rejection via a fake guard, RawPath projection, picker helpers);
Equipment/Import test suites rewritten to drop the retired driver-guard cases. AdminUI
suite green (639 passed, 3 skipped); full solution builds 0 errors.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:15:51 -04:00
Joseph Doherty 09ff43910c feat(raw): B3-WP3 rename-warning script scan + historized-override refine
Extend RawTreeService.BuildRenameWarningsAsync so a tag/ancestor rename now
raises three warning kinds over the affected (prefix-scanned) tags:

(a) refined — historized WITHOUT a historianTagname override (default tagname
    is the moving RawPath, so history forks); a pinned (override) tag no longer
    warns. Count/message adjusted.
(b) unchanged — UNS-referenced (names the equipment).
(c) NEW — substring-scan every Script body for the affected tag's OLD RawPath
    (ordinal, false-positives tolerated by design; no AST). OLD RawPaths are
    recomputed from the pre-save in-DB topology via RawPathResolver.

CollectTagsBeneath* + TagsForDevices now carry (DeviceId, TagGroupId, Name) so
the OLD RawPath can be rebuilt. Contained entirely within RawTreeService.cs;
IRawTreeService + RawTree.razor untouched (warnings flow through the existing
RawRenameResult.Warnings). Note: a direct tag rename goes through UpdateTagAsync
(UnsMutationResult, no warning surface) so it is out of scope without widening
the interface.

Tests: new RawTreeServiceRenameWarningTests (4) — all-three, pinned-no-warn,
unrelated-empty, ancestor-rename script match. AdminUI.Tests 642 green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:04:06 -04:00
Joseph Doherty b68c313372 feat(uns-v3): implement EffectiveNameGuard authoring-time uniqueness guard (B3-WP2)
Implements IEffectiveNameGuard (the Wave-A contract): per-equipment effective-name
uniqueness across UnsTagReferences (DisplayNameOverride else backing raw tag Name),
VirtualTags (Name), and ScriptedAlarms (Name). Ordinal comparison, self-row exclusion
by (kind, id), one pooled context per CheckAsync call. Registered Scoped in
EndpointRouteBuilderExtensions so WP1's UnsTreeService consumer goes live.

Verified the deploy-time DraftValidator UnsEffectiveNameCollision rule already
computes reference effective names from the backing raw tag's current Name (catches
rename-induced collisions), spans all three sources, compares ordinal, and names both
colliding sources + the equipment — no hardening needed. DraftSnapshotFactory already
loads Tags/UnsTagReferences/VirtualTags/ScriptedAlarms.

Tests: 8 guard tests (in-memory EF) + 3 deploy-gate tests (rename-induced collision,
override-vs-VT, ordinal case-sensitivity). All green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 06:01:10 -04:00
Joseph Doherty 6dc5af7aa2 v3 Batch 3 contracts: IEffectiveNameGuard authoring-time uniqueness seam
Wave-A shared contract. WP2 implements + registers the guard; WP1 consumes it
in UnsTreeService reference/VirtualTag/ScriptedAlarm mutations. Ordinal, mirrors
the deploy-time DraftValidator UnsEffectiveNameCollision rule.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 05:53:52 -04:00
dohertj2 a88dc86173 Merge pull request 'v3 Batch 2 — /raw project-tree AdminUI + Calculation driver' (#470) from v3/batch2-raw-ui into master
v2-ci / build (push) Successful in 3m29s
v2-ci / unit-tests (push) Failing after 8m38s
2026-07-16 05:50:26 -04:00
Joseph Doherty 33915c9e4d docs(v3): Batch 2 — /raw authoring tree guide + CLAUDE.md + PR description
v2-ci / build (pull_request) Successful in 3m25s
v2-ci / unit-tests (pull_request) Failing after 8m37s
docs/Raw.md (the /raw project tree: lazy tree, endpoint→DeviceConfig split, manual/CSV/browse
authoring, Calculation driver, routed-flow retirement); CLAUDE.md AdminUI section notes /raw +
DriverTypeNames rewire + Calculation driver; PR body with the 7-item live-gate evidence.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 05:27:31 -04:00
Joseph Doherty c598978d77 merge wt/wpfix into v3/batch2-raw-ui (Wave C review HIGH: calc-driver redeploy staleness) 2026-07-16 05:02:35 -04:00
Joseph Doherty af5e062ba4 fix(driver-calc): rebuild calc tag state on ReinitializeAsync + re-register deps post-apply (review HIGH)
A calc-tag add/remove/edit or a script edit changes the merged DriverConfig,
which DriverSpawnPlanner routes to an in-place ApplyDelta (not a respawn), i.e.
CalculationDriver.ReinitializeAsync. The old ReinitializeAsync ignored the new
config: the tag table + dependency-ref set were built once at construction and
never rebuilt, so after a redeploy added tags never computed, removed tags kept
publishing, and edited scripts served stale results (deploy reported success).

Part 1 (HIGH): extract RebuildTagTable(config) — shared by the ctor and reinit
so they cannot drift — and have ReinitializeAsync re-bind the new config
(ParseOptions) and rebuild _tagsByRawPath/_changeDependents/_dependencyRefs/
_stateByRawPath under _lock, preserving last-known state for surviving tags,
dropping removed tags, and starting new tags fresh. _dependencyRefs now reflects
the new authored tags. Corrected the now-false ReinitializeAsync comment.

Part 2 (MEDIUM ordering hazard): the host's ReRegister was Tell-ed synchronously
alongside the ApplyDelta Tell, so the adapter re-read DependencyRefs before the
async ReinitializeAsync rebuilt them (stale set, no self-correction). Now
DriverInstanceActor sends DeltaApplied(driverInstanceId) to DriverHostActor
AFTER ReinitializeAsync completes, and HandleDeltaApplied re-registers only that
driver's adapter — sequenced after the rebuild. Removed the inline loop.

Test: added a redeploy test to CalculationDependencyFlowTests exercising the
real DependencyMuxActor + adapter + driver — a new tag reading a NEW dep flows +
computes, a removed tag stops publishing, an edited script serves the new
result, and DependencyRefs re-registers the new set. Fails on pre-fix code
(DependencyRefs stays stale ["src/a"]), passes after the fix.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 05:01:58 -04:00
Joseph Doherty 2e6e89f4a5 merge wt/wp8 into v3/batch2-raw-ui (Wave C — DriverTypeNames rewire + retire routed driver flow) 2026-07-16 04:40:23 -04:00
Joseph Doherty 96471d2345 refactor(adminui): B2-WP8 DriverTypeNames rewire + retire routed driver-authoring flow
Part 1 — rewire the three DriverType dispatch maps from hand-authored string
literals to the canonical DriverTypeNames constants (single source of truth):
- TagConfigEditorMap, TagConfigValidator (AdminUI)
- EquipmentTagConfigInspector (ControlPlane)
This FIXES the live drift ("TwinCat"->TwinCAT, "Focas"->FOCAS). The maps are
OrdinalIgnoreCase so behavior is identical; the value is no future drift.
Calculation is not added to the inspector (no CalculationTagDefinitionFactory.Inspect).
Added TagConfigDriverTypeNameGuardTests pinning the maps to the constants.

Part 2 — retire the routed /clusters/{id}/drivers driver-authoring flow (/raw
Waves A-C now cover driver/device/tag authoring end-to-end):
- Deleted DriverTypePicker, DriverEditRouter, the 8 *DriverPage shells, and the
  ClusterDrivers list page.
- Removed the "Drivers" tab from ClusterNav. Routes /clusters/{id}/drivers* now
  simply do not exist -> clean 404.
- The Forms/DeviceForms bodies, shared driver sections, and address pickers used
  by the /raw modals are untouched.

Part 3 — updated PageAuthorizationGuardTests census: removed the 10 deleted
ConfigEditor pages + ClusterDrivers, updated group-count comments (ConfigEditor
21->11, AuthenticatedRead 17->16). Removed the now-dead Clusters.Drivers usings
from the 4 *DriverPageFormSerializationTests + the guard test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 04:39:22 -04:00
Joseph Doherty dc80a3b4f6 merge wt/wp7 into v3/batch2-raw-ui (Wave C — Calculation driver) 2026-07-16 04:30:27 -04:00
Joseph Doherty 53d222e0f7 feat(driver-calc): B2-WP7 Calculation driver — IDependencyConsumer + triggers + deploy cycle gate
Adds the Calculation pseudo-driver (tags computed by C# scripts over other tags' live
values) plus the host seam that feeds it:

- IDependencyConsumer capability interface (Core.Abstractions) + DriverTypeNames.Calculation.
- CalculationDriver : IDriver, ISubscribable, IReadable, IDependencyConsumer — change-gate +
  dedupe (VirtualTagActor parity), timer-group scheduler, Bad-transition + script-log emission,
  Good recovery; reuses the T0-4 CalculationEvaluator.
- DriverHostActor spawns a DependencyConsumerMuxAdapter per dependency-consuming driver,
  registering its refs on the per-node DependencyMuxActor and forwarding DependencyValueChanged
  → OnDependencyValue; re-registers on every apply, torn down with the driver.
- Factory + probe registered in DriverFactoryBootstrap (keeps the T0-3 guard green); guard test
  csproj references the driver so the bin-scan discovers the factory.
- DeploymentArtifact injects the resolved scriptSource (scriptId → SourceCode) into a calc tag's
  delivered TagConfig so the host-blind driver can compile it.
- DraftValidator deploy gates: CalculationScriptMissing / CalculationScriptNotFound (scriptId
  existence) + CalculationDependencyCycle (DependencyGraph.DetectCycles over calc→calc edges).
- Tests: driver units (dep-ref extraction, change-gate, dedupe, Bad+script-log+recovery, timer,
  read), tag-config parsing, DraftValidator gates (self/2-cycle/cross-driver-terminal), and a
  Runtime integration test proving values FLOW mux → adapter → driver → calc-of-calc end-to-end.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 04:29:37 -04:00
Joseph Doherty c07bbf7fd3 merge wt/wp6 into v3/batch2-raw-ui (Wave C — browse re-target) 2026-07-16 04:15:30 -04:00
Joseph Doherty 1f43449942 feat(adminui): B2-WP6 browse re-target — /raw device browse commits raw tags
Browse device… on a /raw Device/TagGroup opens the discovery-browser modal
against the merged Driver+Device config and commits selected browse leaves as
raw Tag rows under the target, via IRawTreeService.ImportTagsAsync.

- RawBrowseModal.razor: two-tier browse gate (bespoke IDriverBrowser →
  universal DiscoveryDriverBrowser via SupportsOnlineDiscovery → disabled),
  merged config from LoadMergedProbeConfigAsync, multi-select over
  DriverBrowseTree, opt-in 'create matching tag-groups' folder mirror,
  commit via ImportTagsAsync.
- RawBrowseCommitMapper: pure leaf→RawTagImportRow mapper — DriverDataType→
  OPC UA type, per-driver address field (nodeId/tagPath/symbolPath/address/
  attributeRef), target-group + mirror path combine. Unit-tested (39 tests).
- DriverBrowseTree: additive multi-select mode (checkboxes + ancestor-path
  callback), default off preserves single-select pickers.
- RawTree: OnBrowseDevice wired to the modal (Device + TagGroup menus).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 04:14:27 -04:00
Joseph Doherty ae9f906f52 review(b2-waveB): strip endpoint keys from driver-form config (H1) so DeviceConfig is sole endpoint source
Reviewer H1 (v3 regression): the endpoint→DeviceConfig split ADDED the endpoint to DeviceConfig but
the driver forms still serialized it into DriverConfig at defaults. Single-device worked only by an
STJ case-insensitive last-key-wins accident; a 2nd device on a single-top-level-Host driver (Modbus/S7)
silently reverted to the driver-form default (host=127.0.0.1). Fix: Modbus/S7/OpcUaClient driver-form
GetConfigJson strips the endpoint keys (host/port/unitId; host/port/rack/slot; scalar endpointUrl) from
the serialized channel config, leaving DeviceConfig as the sole source (OpcUaClient keeps its endpointUrls
failover list, which is driver-level policy).

Enum-serialization verified CLEAN fleet-wide; ImportTagsAsync all-or-nothing verified; RawTree wiring sound.
Deferred (documented in PR): H2 pre-existing Modbus/S7 form TimeSpan vs factory *Ms-int DTO mismatch (authored
durations dropped — predates v3); M1 CSV export drops non-columned alarm sub-keys; M2 blank flag column
clobbers a base-JSON isHistorized/isArray; M3 OpcUaClient device/driver endpoint precedence; L1 unused
LoadMergedProbeConfigAsync; L5 refresh collapses sibling expansion.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 03:56:19 -04:00
Joseph Doherty d6d454347f merge wt/wpint into v3/batch2-raw-ui (Wave-B integration wiring) 2026-07-16 03:39:13 -04:00
Joseph Doherty aef9ef8452 feat(adminui): B2 Wave-B integration — wire real /raw modals + dialogs + refresh
Replace RawTree's 22 stub handlers with the real Wave-B modals and 3 new
small dialogs, plus post-mutation subtree refresh:

- New dialogs: RawNameDialog (create/rename, RawPaths.ValidateSegment inline),
  RawConfirmDialog (deletes), RawDriverTypeDialog (New driver type+name picker,
  incl. Calculation).
- Configure driver/device -> DriverConfigModal/DeviceModal (edit); New device ->
  DeviceModal (create); Edit tag -> RawTagModal; Manual entry / CSV import/export
  -> the Raw*Modal surfaces; New folder/tag-group/group + New driver + Rename +
  Delete + Toggle -> IRawTreeService directly via the dialogs.
- Refresh: reload the container after create-under, the parent after item-level
  ops (parent found by searching the loaded tree; robust, non-throwing).
- Rename warnings + blocked-delete errors surfaced via the repurposed RawStubModal
  message surface. Browse device stays a placeholder (Wave C). Driver-level
  'Test connect' menu item removed (test-connect now lives on the device modal).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 03:38:32 -04:00
Joseph Doherty f986519317 merge wt/wp5 into v3/batch2-raw-ui (Wave B UI) 2026-07-16 03:27:23 -04:00
Joseph Doherty d949bcffc1 merge wt/wp4 into v3/batch2-raw-ui (Wave B UI) 2026-07-16 03:27:23 -04:00
Joseph Doherty 038ffc161b merge wt/wp3 into v3/batch2-raw-ui (Wave B UI) 2026-07-16 03:27:23 -04:00
Joseph Doherty 844f93f64f feat(adminui): B2-WP3 driver/device config modals + page-to-form refactor
Extract the 8 typed driver pages into embeddable <Driver>DriverForm bodies
(channel/protocol config) and add per-driver <Driver>DeviceForm bodies
(connection endpoint -> Device.DeviceConfig, v3 endpoint split). New
DriverConfigModal + DeviceModal dispatch by DriverType (DriverTypeNames) for
the /raw tree; Test-connect inside DeviceModal builds the merged Driver+Device
config transiently via DriverDeviceConfigMerger. Routed pages now host the
form bodies and keep working. Round-trip + enum-serialization guard tests for
the Modbus driver form + Modbus device model; retargeted the existing
page-form serialization tests + the _jsonOpts converter guard to the forms.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 03:26:24 -04:00
Joseph Doherty 59ea02d971 feat(adminui): B2-WP5 CSV tag import/export + review grid
Staged CSV tag import (upload -> parse -> review grid with per-row verdicts
-> all-or-nothing commit) and export for the /raw tree Device/TagGroup.

- CsvColumnMap: per-driver typed column <-> TagConfig JSON maps (model-backed
  for the 7 typed drivers, raw-key for Galaxy/Calculation) + the common column
  dictionary; typed columns merge OVER the TagConfigJson fallback (typed wins).
- RawTagCsvMapper: pure parse (review rows + verdicts + typed/fallback
  provenance + intra-batch dup detection) and export (residual TagConfigJson)
  over the T0-2 CsvParser/CsvWriter.
- RawTagCsvExportReader: DbContext-backed read helper (no IRawTreeService
  extension) enumerating a device's tags + reconstructing group paths.
- RawCsvImportModal + RawCsvExportModal (data-URI download).
- Reflection guard test (typed column -> real model property + batch-plan
  dictionary parity) + export->import round-trip property test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 03:19:14 -04:00
Joseph Doherty ada552fbec feat(adminui): B2-WP4 manual tag entry + raw tag modal + Calculation editor
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 03:08:05 -04:00
Joseph Doherty 54ab413396 merge wt/wpb0 into v3/batch2-raw-ui (Wave-B service prelude) 2026-07-16 02:58:53 -04:00
Joseph Doherty 76cffe1f49 feat(adminui): B2 Wave-B service prelude — tag CRUD/import + driver/device config + merged probe
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:58:13 -04:00
Joseph Doherty 928e06dd01 review(b2-waveA): /raw auto-expand enterprise roots; friendly create-race + delete failures; auth-guard classifies /raw + dev demo
Wave-A reviewer findings (no Critical/High):
- M-2: GlobalRaw auto-expands Enterprise roots so the Cluster level is visible on load (mirrors GlobalUns.ExpandStructural); clusters stay lazily collapsed.
- L-1: create mutations route SaveChanges through SaveCreateAsync — a lost sibling-name race (filtered-unique-index clash) becomes a friendly failure instead of an uncaught DbUpdateException.
- L-2: SaveDeleteAsync no longer leaks raw EF/SQL text; operator-friendly guidance.
- L-5: RawNode.ChildCount doc clarified (direct children only).
- Regression fix: PageAuthorizationGuardTests classifies the two new routable pages (/raw → ConfigEditor, /dev/context-menu-demo → AuthenticatedRead).
M-1 (ordinal vs SQL-collation sibling uniqueness) assessed benign: name indexes use the DB default collation and the server-side pre-check runs under that same collation, so pre-check and index agree — no case-variant rows can coexist, so runtime ordinal RawPath keying never collides.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:45:06 -04:00
Joseph Doherty fa9d2af430 merge wt/wp2 into v3/batch2-raw-ui (Wave A) 2026-07-16 02:35:17 -04:00
Joseph Doherty 9ff224012a merge wt/wp1 into v3/batch2-raw-ui (Wave A) 2026-07-16 02:35:17 -04:00
Joseph Doherty 76b8325b84 feat(adminui): B2-WP1 RawTreeService + lazy tree data layer + mutations
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:34:36 -04:00
Joseph Doherty b80b27f44b feat(adminui): B2-WP2 /raw page + lazy RawTree + context menus (modals stubbed)
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:27:00 -04:00
Joseph Doherty fbf3c26c2a contracts(b2-waveA): RawNode view-model + IRawTreeService read surface + RawRenameResult
Wave A contract-first fan-out types for the /raw project tree:
- RawNode/RawNodeKind: shared tree view-model (Enterprise→Cluster→Folder→Driver→Device→TagGroup→Tag), lazy-load metadata, per-kind ids + DriverType propagation for menu gating.
- IRawTreeService: committed READ surface (LoadRootsAsync + LoadChildrenAsync); WP1 extends with the mutation surface, WP2 consumes read-only.
- RawRenameResult: rename outcome carrying non-blocking warnings (historized/UNS-referenced now; script-scan in Batch 3).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:20:05 -04:00
Joseph Doherty c3277b52c9 review(track0): async ContextMenu OnClick + keyboard-⋯ anchoring + focus-return; discovery-driven DriverTypeNames guard; CSV round-trip comment
Wave-0 reviewer findings (no Critical/High):
- T0-1 M: ContextMenuItem.OnClick Action→Func<Task> (kills async-void at menu call sites Wave A/B/C need); keyboard-activated ⋯ now anchors below the button instead of viewport (0,0); focus returns to trigger on close; backdrop closes on native right-click too.
- T0-3 M: guard test now discovers *DriverFactoryExtensions.Register by convention from the deployed driver assemblies instead of a hand-copied list, so a future driver lacking a constant is actually caught.
- T0-2 L: corrected the round-trip 'sole non-round-trippable shape' comment (any table whose final row is a lone empty field).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:17:37 -04:00
Joseph Doherty 9a896efecd merge wt/t0-4 into v3/batch2-raw-ui (Track 0) 2026-07-16 02:08:20 -04:00
Joseph Doherty a38af16f0f merge wt/t0-3 into v3/batch2-raw-ui (Track 0) 2026-07-16 02:08:20 -04:00
Joseph Doherty 3c34f58bd2 merge wt/t0-2 into v3/batch2-raw-ui (Track 0) 2026-07-16 02:08:20 -04:00
Joseph Doherty 1fb96afd8b merge wt/t0-1 into v3/batch2-raw-ui (Track 0) 2026-07-16 02:08:20 -04:00
Joseph Doherty 86ca1a76d1 feat(driver-calc): T0-4 Calculation evaluator core + project
New Driver.Calculation project + CalculationEvaluator, mirroring the
VirtualTag RoslynVirtualTagEvaluator: source-hash compile cache
(CompiledScriptCache), 2 s TimedScriptEvaluator, passthrough fast-path,
single-tag mode (ctx.SetVirtualTag dropped + logged), sandbox/compile/
timeout/throw all surfaced as VirtualTagEvalResult.Failure for WP7 to map
to Bad quality. IScriptCacheOwner.ClearCompiledScripts for the deploy
generation boundary. 11 parity unit tests. Both projects added to the
solution. Evaluator + tests only — driver shell / registration is WP7.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:07:38 -04:00
Joseph Doherty 17c7e97efb feat(commons): T0-2 RFC-4180 CSV parser + writer
Add a pure, no-I/O RFC 4180 CSV reader and writer to the Commons project
(greenfield — no CSV code existed).

CsvParser: string / TextReader -> rows of string[]; streaming IEnumerable
overload + eager Parse(string) + thin ParseWithHeader. Handles quoted
fields (embedded delimiter/CR/LF/CRLF, "" -> " escape), space
preservation, CRLF/LF/CR terminators, no phantom trailing row. Strict
malformed-input policy: FormatException with 1-based line/column on a
quote inside an unquoted field, a char after a closing quote, or an
unterminated quote. Faithful empty-line policy: a blank line is one empty
field (callers filter).

CsvWriter: rows -> string / TextWriter; quote-on-demand (delimiter, quote,
CR, LF only; internal quotes doubled), configurable delimiter/newline
(default CRLF), optional quote-all, no trailing terminator.

Tests (xUnit + Shouldly, 109): full RFC edge corpus for the parser, the
writer quote-on-demand rules, and the required Parse(Write(rows)) == rows
round-trip property over a table of every tricky character (x CRLF/LF/
quote-all/semicolon).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:06:43 -04:00
Joseph Doherty 12b9978974 feat(core): T0-3 DriverTypeNames constants + reflection guard
Add DriverTypeNames as the single source of truth for driver-type
dispatch strings, one const per currently-registered driver factory
(value = the exact factory DriverTypeName). Fixes the latent drift
between hand-authored dispatch-map literals and the real factory names
(e.g. TwinCAT/FOCAS/GalaxyMxGateway) at the source.

A reflection guard test in Core.Abstractions.Tests builds the real
registered-factory set by driving each *DriverFactoryExtensions.Register
into a live DriverFactoryRegistry (mirroring the Host's
DriverFactoryBootstrap) and asserts bidirectional parity with the
constants, so any future rename on either side breaks the test gate.

Consumers are rewired in Batch 2 WP8; Calculation's const lands with its
factory in WP7.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:05:09 -04:00
Joseph Doherty 1d7afbb1eb feat(adminui): T0-1 reusable ContextMenu component + demo page
Adds a reusable right-click / context menu Blazor component for the AdminUI:
- ContextMenu.razor: right-click surface (browser menu suppressed) + optional
  ellipsis fallback trigger, both anchoring the menu at the pointer; keyboard
  nav (arrows/Enter/Esc), transparent backdrop outside-click close (no JS).
- ContextMenu.razor.css: scoped styles on the shared theme tokens.
- ContextMenuItem.cs: Label/OnClick/Icon/Disabled/IsSeparator model + Separator().
- Dev demo page at /dev/context-menu-demo.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 02:05:01 -04:00
dohertj2 f121f8ca16 Merge pull request 'v3 Batch 1 — greenfield schema + RawPath identity rewiring' (#469) from v3/batch1-schema-identity into master
v2-ci / build (push) Successful in 3m43s
v2-ci / unit-tests (push) Failing after 9m31s
2026-07-16 01:52:49 -04:00
243 changed files with 24523 additions and 7083 deletions
+45 -3
View File
@@ -68,9 +68,45 @@ shared-lib consumption, or per-project commands — update the **OtOpcUa entry i
gRPC session. The gateway owns the COM apartment + STA pump gRPC session. The gateway owns the COM apartment + STA pump
server-side; the driver speaks `MxCommand` / `MxEvent` protos server-side; the driver speaks `MxCommand` / `MxEvent` protos
exclusively. exclusively.
3. **OPC UA Server** — Exposes authored equipment tags as variable nodes. 3. **OPC UA Server** — Exposes the deployed tree under **two OPC UA namespaces**
Galaxy tags are bound by `TagConfig.FullName` (`tag_name.AttributeName`); (see below). Galaxy tags are bound by `TagConfig.FullName`
reads/writes/subscriptions are translated to that reference for MXAccess. (`tag_name.AttributeName`); reads/writes/subscriptions are translated to that
reference for MXAccess.
### v3 OPC UA Address Space (Batch 4): dual namespace
The server exposes **two OPC UA namespaces** (replacing the single
`https://zb.com/otopcua/ns`), built by `AddressSpaceComposer` /
`AddressSpaceApplier` and served by `OtOpcUaNodeManager` (which registers both
URIs; `V3NodeIds` + `AddressSpaceRealm` are the identity authority):
| Namespace URI | Realm | Subtree | NodeId `s=` scheme |
|---|---|---|---|
| `https://zb.com/otopcua/raw` | `AddressSpaceRealm.Raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
| `https://zb.com/otopcua/uns` | `AddressSpaceRealm.Uns` | the UNS tree (Area → Line → Equipment → signal) | slash-joined **`Area/Line/Equipment/EffectiveName`** |
- **Single source, fanned to both.** Every device value has exactly one source —
the raw tag's node. A `UnsTagReference` projects that raw tag into an equipment
as a UNS-namespace variable that carries an **`Organizes` reference to its raw
node** and mirrors it. One driver publish for a RawPath fans (in `DriverHostActor`)
to the raw NodeId AND every referencing UNS NodeId with identical
value/quality/timestamp — the mux key stays single (RawPath).
- **Writes via either NodeId** route to the same driver ref under the same
`WriteOperate` gating (the node-manager write gate is realm-qualified and fails
closed); a failed device write reverts both NodeIds via the shared fan-out
(write-outcome self-correction).
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
fan via SDK notifiers to the raw device folder AND every referencing equipment
folder — one `ReportEvent`, one Server-object copy; ack/confirm/shelve route on
`ConditionId = RawPath`.
- **Historian dual-registration.** Both NodeIds register the **same** historian
tagname; the mux `HistorizedTagRef` stays single (RawPath). HistoryRead works
through either NodeId.
The retired `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme and
the single-namespace `EquipmentTags` materialization path are gone. See
`docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` +
`docs/plans/2026-07-15-v3-batch4-address-space-plan.md`, and `docs/Uns.md`.
### Key Concept: Tag Name and FullName ### Key Concept: Tag Name and FullName
@@ -215,6 +251,12 @@ Address pickers in AdminUI support live browse for OpcUaClient and Galaxy driver
The AdminUI's global **UNS** page (`/uns`) is the single surface for managing the unified namespace fleet-wide (Area → Line → Equipment → Tag/VirtualTag), replacing the old per-cluster UNS/Equipment/Tags tabs. See `docs/Uns.md`. The AdminUI's global **UNS** page (`/uns`) is the single surface for managing the unified namespace fleet-wide (Area → Line → Equipment → Tag/VirtualTag), replacing the old per-cluster UNS/Equipment/Tags tabs. See `docs/Uns.md`.
**v3 (Batch 2): device I/O is authored in the `/raw` project tree** (`GlobalRaw.razor``RawTree.razor`, `IRawTreeService`) — a cluster-rooted `Folder → Driver → Device → TagGroup → Tag` hierarchy with per-node context menus (the reusable `ContextMenu`), lazy loading, driver/device config modals (endpoint moved to `Device.DeviceConfig`; Test-connect probes the `DriverDeviceConfigMerger`-merged config), manual-entry + CSV import/export (RFC-4180, staged review grid, all-or-nothing), and browse-commit of discovered leaves into raw tags. The routed `/clusters/{id}/drivers` flow (`DriverTypePicker`, `DriverEditRouter`, the 8 `*DriverPage` shells, the Drivers list) is **retired** — its form bodies live on inside the `/raw` modals. The `DriverType` dispatch maps are keyed off the `DriverTypeNames` constants (fixing the `TwinCat`/`Focas` drift). A `Calculation` pseudo-driver (`DriverTypeNames.Calculation`, `IDependencyConsumer`, deploy-time `scriptId`-existence + Tarjan cycle gates) computes signal-level tags from other tags' RawPaths. See `docs/Raw.md`.
**v3 (Batch 3): UNS Equipment is reference-only.** The equipment **Tags** tab no longer authors or binds tags — it holds `UnsTagReference` rows pointing at raw tags authored in `/raw`. "+ Add reference" opens the `RawTree` in picker mode, **cluster-scoped** (structurally + server-enforced `tag.cluster == equipment.cluster`); rows show effective name / RawPath / inherited DataType+AccessLevel / display-name override. **Effective-name uniqueness** (references + VirtualTags + ScriptedAlarms share the `{EquipmentId}/{EffectiveName}` NodeId space) is enforced at authoring (`IEffectiveNameGuard`) and at the deploy gate (`DraftValidator.UnsEffectiveNameCollision` — catches rename-induced collisions). Scripts use **`ctx.GetTag("{{equip}}/<RefName>")`** — resolved per-equipment through `UnsTagReference` effective names to the backing RawPath at both compose seams (`AddressSpaceComposer` + `DeploymentArtifact`, via the shared `EquipmentReferenceMap`, byte-parity); an unresolved `<RefName>` is a deploy error (`EquipReferenceUnresolved`) AND a live Monaco diagnostic (`OTSCRIPT_EQUIPREF`) — editor accepts ⇔ publish accepts. `EquipmentScriptPaths.DeriveEquipmentBase` is deleted (the dot-joint `{{equip}}.X` became the slash-joint `{{equip}}/<RefName>`). Raw rename warns when a beneath-it tag is historized-without-override / UNS-referenced / named by a script literal (`RawTreeService` substring scan). `ImportEquipmentModal` dropped the `DriverInstanceId` column. See `docs/Uns.md` + `docs/ScriptEditor.md`.
**v3 (Batch 4 = v3.0): dual-namespace address space.** The OPC UA server exposes **two namespaces**`https://zb.com/otopcua/raw` (Raw device tree, `s=<RawPath>`) + `https://zb.com/otopcua/uns` (UNS tree, `s=<Area>/<Line>/<Equipment>/<EffectiveName>`) — replacing the single `.../ns` and the retired `EquipmentNodeIds` scheme. Every value has ONE source (the raw node's publish); a driver publish for a RawPath fans in `DriverHostActor` to the raw NodeId AND every referencing UNS NodeId with identical value/quality/timestamp, and each UNS variable `Organizes`-references its raw node. Writes route via **either** NodeId to the same driver ref under the same realm-qualified `WriteOperate` gate (failed-write revert works through both); native alarms materialize ONCE at the raw tag (`ConditionId = RawPath`) and fan via SDK `AddNotifier` to the raw device folder + every referencing equipment folder (one `ReportEvent`, one Server-object copy); both NodeIds register the SAME historian tagname (mux `HistorizedTagRef` stays single, keyed by RawPath). Identity authority: `V3NodeIds` + `AddressSpaceRealm` (carried explicitly at every sink seam — never parsed out of the id string). See the "v3 OPC UA Address Space (Batch 4)" section above, `docs/Uns.md`, and `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`.
The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `<Driver>TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`. The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `<Driver>TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`.
## Scripting / Script Editor ## Scripting / Script Editor
+2
View File
@@ -21,6 +21,7 @@
<Project Path="src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj" /> <Project Path="src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj" />
</Folder> </Folder>
<Folder Name="/src/Drivers/"> <Folder Name="/src/Drivers/">
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
@@ -81,6 +82,7 @@
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj" /> <Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/tests/Drivers/"> <Folder Name="/tests/Drivers/">
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj" />
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj" /> <Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj" />
+27
View File
@@ -24,6 +24,33 @@ condition — the dedup logic prefers the richer driver-native record
because it carries the full operator + raise-time + category metadata because it carries the full operator + raise-time + category metadata
that the value-driven path collapses. that the value-driven path collapses.
## v3 Batch 4 — multi-notifier delivery (raw + equipment folders)
Under the v3 dual-namespace address space, a native driver alarm is authored on a
**raw tag** (`/raw`) and its Part 9 `AlarmConditionState` materializes **once** at the
raw tag — `ConditionId` = the tag's **RawPath**, parent notifier = the raw device/group
folder (`ns=Raw`). Because equipment references raw tags (v3 reference-only UNS), the
same condition is wired as an **event notifier of every referencing equipment folder**
(`ns=UNS`) via the SDK `AddNotifier` pattern (`OtOpcUaNodeManager.WireAlarmNotifiers`):
`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
`EnsureFolderIsEventNotifier(equipFolder)`.
- A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder,
every referencing equipment folder, and up to the Server object. A subscriber at any one
root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot`
dedup (`MonitoredItem.QueueEvent``IsEventContainedInQueue`), **never** one copy per root.
Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break
Server-object dedup + Part 9 ack correlation).
- **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls
`RemoveNotifier(..., bidirectional:true)` on rebuild / subtree-removal / reference-removal, so
inverse-notifier entries never leak across redeploys.
- **Ack/confirm/shelve route on `ConditionId = RawPath`** (never `SourceNodeId`) regardless of
which notifier root the operator subscribed at — an ack issued from an equipment-folder
subscription resolves to the same raw condition.
- The `alerts`-topic `AlarmTransitionEvent` carries the (possibly empty) **list of referencing
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
identity RawPath + condition NodeId) with the equipment list as display metadata.
## Galaxy driver path (driver-native) ## Galaxy driver path (driver-native)
Restored in PR B.2 of the epic. `GalaxyDriver` implements Restored in PR B.2 of the epic. `GalaxyDriver` implements
+23 -6
View File
@@ -199,6 +199,15 @@ The server supports all four OPC UA HistoryRead variants:
`Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant `Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant
OPC UA client can discover historized capability from the node's attributes. OPC UA client can discover historized capability from the node's attributes.
> **v3 Batch 4 — dual-namespace registration.** A historized raw tag surfaces as **two**
> variable nodes: the Raw-namespace node (`ns=Raw, s=<RawPath>`) and, for each referencing
> equipment, a UNS-namespace node (`ns=UNS, s=<Area>/<Line>/<Equipment>/<EffectiveName>`).
> **Both NodeIds register the SAME historian tagname** (the raw tag's `FullName` /
> `historianTagname`), so HistoryRead against either NodeId resolves to the same tagname and
> returns the same series. The historization intent is single-sourced — the mux's
> `HistorizedTagRef` set stays keyed by RawPath (no doubles), and continuous historization /
> `EnsureTags` provisioning run once per raw tag regardless of how many equipment reference it.
**Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that **Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that
owns at least one alarm condition is already an event notifier; the server registers a owns at least one alarm condition is already an event notifier; the server registers a
`sourceName` (the equipment id) for each such folder and maps event history reads to the `sourceName` (the equipment id) for each such folder and maps event history reads to the
@@ -355,31 +364,39 @@ for the full alarm-historian routing.
The `historyread` command reads historical data from any node. Supply start and end times in The `historyread` command reads historical data from any node. Supply start and end times in
ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference. ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference.
> **v3 Batch 4 NodeIds.** A historized tag is readable through **either** of its two NodeIds —
> the Raw-namespace node (`s=<RawPath>`, e.g. `Plant/Modbus/dev1/Speed`) or a referencing
> equipment's UNS-namespace node (`s=<Area>/<Line>/<Equipment>/<EffectiveName>`). Both register
> the same historian tagname, so the returned series is identical. The `ns=N` index is assigned
> at connect time per the namespace URI (`https://zb.com/otopcua/raw` /
> `https://zb.com/otopcua/uns`) — resolve it from the server's `NamespaceArray` rather than
> hard-coding it. The examples below show a Raw-namespace read (`ns=2` illustrative).
```bash ```bash
# Raw history for a historized Galaxy tag (last 24 hours by default) # Raw history for a historized tag via its Raw-namespace NodeId (last 24 hours by default)
otopcua-cli historyread \ otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \ -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ -n "ns=2;s=Plant/Modbus/dev1/Speed" \
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z"
# Limit to 100 values # Same series via a referencing equipment's UNS-namespace NodeId, limited to 100 values
otopcua-cli historyread \ otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \ -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ -n "ns=3;s=filling/line1/station1/Speed" \
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
--max 100 --max 100
# 1-hour average aggregate # 1-hour average aggregate
otopcua-cli historyread \ otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \ -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ -n "ns=2;s=Plant/Modbus/dev1/Speed" \
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
--aggregate Average --interval 3600000 --aggregate Average --interval 3600000
# Authenticated read (ReadOnly role or higher required) # Authenticated read (ReadOnly role or higher required)
otopcua-cli historyread \ otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \ -u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \ -n "ns=2;s=Plant/Modbus/dev1/Speed" \
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \ --start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
-U reader -P password -U reader -P password
``` ```
+91
View File
@@ -0,0 +1,91 @@
# The Raw project tree (`/raw`)
v3 authors device I/O in a **Raw project tree** — a cluster-rooted, Kepware-style hierarchy
that is the single surface for driver/device/tag authoring. It is the peer of the `/uns`
unified-namespace page; the two OPC UA namespaces (`ns=Raw` / `ns=UNS`) light up in Batch 4.
```
Enterprise → Cluster → Folder(s) → Driver → Device → TagGroup(s) → Tag
```
Every node's identity is its **RawPath** (`<Folder/…>/<Driver>/<Device>/<TagGroup/…>/<Tag>`,
cluster-scoped, `/`-separated, ordinal). Names are validated as RawPath segments (no `/`, no
leading/trailing whitespace) at authoring and at the deploy gate.
## The tree
`/raw` (`GlobalRaw.razor``RawTree.razor`, backed by `IRawTreeService`) lazily loads each
container level on expand (unlike `/uns`, which eager-loads). Each node has a right-click
context menu **and** a `⋯` fallback (the reusable `ContextMenu` component):
| Node | Actions |
|---|---|
| Cluster | New folder · New driver |
| Folder | New folder · New driver · Rename · Delete |
| Driver | Configure · New device · Enable/Disable · Rename · Delete |
| Device | Configure (endpoint + **Test connect**) · New tag-group · Add tags ▸ · Delete |
| TagGroup | New group · Add tags ▸ · Rename · Delete |
| Tag | Edit · Delete |
Deletes are blocked while a node has children (or, for a raw tag, while a `UnsTagReference`
points at it — the error names the referencing equipment). Renames return non-blocking
**warnings**: renaming a node changes the RawPath of every historized / UNS-referenced tag
beneath it (its historian tagname / UNS projection path moves).
## Endpoint → DeviceConfig (the channel/device split)
v3 moves the **connection endpoint** off the driver and onto the **Device** (`DeviceConfig`),
mirroring Kepware's channel/device split. The driver's `DriverConfig` holds protocol/channel
settings; the device's `DeviceConfig` holds host/port/endpoint. At deploy/probe time
`DriverDeviceConfigMerger` merges the device's endpoint up into the driver config, and Test
connect (inside the **Device** modal) probes that merged config. Driver forms no longer
serialize the endpoint keys, so `DeviceConfig` is the single source of truth.
## Authoring tags
**Add tags ▸** on a Device or TagGroup offers:
- **Manual entry** — a grid of Name / DataType / AccessLevel / WriteIdempotent / PollGroup +
the driver-typed `TagConfig` editor per row.
- **Import/Export CSV** — a staged flow (upload → parse → **review grid** with per-row
verdicts → commit) over an RFC-4180 parser. Columns: the common set
(`Name, TagGroupPath, DataType, AccessLevel, WriteIdempotent, PollGroup, IsHistorized,
HistorianTagname, IsArray, ArrayLength, Alarm.*, TagConfigJson`) plus the per-driver typed
columns (e.g. Modbus `Region, Address, ModbusDataType, ByteOrder, …`). `TagGroupPath`
auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in
the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit.
Export round-trips the same shape.
- **Browse device…** — enabled per the two-tier resolution (bespoke `IDriverBrowser`
universal `DiscoveryDriverBrowser` when the driver's `ITagDiscovery.SupportsOnlineDiscovery`
is true → grayed out otherwise). Multi-selected leaves become raw `Tag` rows under the
target, with the browsed reference written into the driver-typed `TagConfig` **address
field** (`nodeId` / `tagPath` / …), never an identity key. An opt-in "create matching
tag-groups" toggle mirrors the browse folder nesting. The browser dials a real ephemeral
driver against the **merged** Driver+Device config.
## The `Calculation` driver
Signal-level calculated tags are ordinary raw tags bound to a `Calculation` driver
(`DriverTypeNames.Calculation`), computed by C# scripts that read other tags' live values via
`ctx.GetTag("<RawPath>")`. See `docs/plans/2026-07-15-calculation-driver-mini-design.md` for
the full design. Highlights:
- One auto-created default `Engine` device; per-tag `TagConfig` is
`{ "scriptId": "…", "changeTriggered": true, "timerIntervalMs": 5000 }`.
- The host feeds dependency values via the new `IDependencyConsumer` capability + a
`DependencyConsumerMuxAdapter` on the per-node dependency mux; calc-of-calc chains work
because a calc output re-enters the mux. The driver rebuilds its tag/dependency table on
every redeploy (`ReinitializeAsync`) and re-registers its refs after the delta applies.
- **Deploy gates** (`DraftValidator`): `scriptId` existence, and a hard **cycle gate**
(`DependencyGraph.DetectCycles` over calc→calc edges; cross-driver refs are terminal) that
rejects an A→B→A oscillation loop naming the members. Compile is not hard-gated (VirtualTag
parity); a runtime compile/throw/timeout lands as Bad quality + a `script-logs` entry.
## Retirement note
The routed `/clusters/{id}/drivers` driver-authoring flow (`DriverTypePicker`,
`DriverEditRouter`, the 8 per-type `*DriverPage` shells, and the per-cluster Drivers list) is
**retired** — authoring lives in `/raw`. The extracted driver/device **form bodies** live on
inside the `/raw` modals. The `DriverType` dispatch maps (`TagConfigEditorMap`,
`TagConfigValidator`, `EquipmentTagConfigInspector`) are keyed off the single-source-of-truth
`DriverTypeNames` constants (fixing the historical `TwinCat`/`Focas` drift).
+59 -40
View File
@@ -212,6 +212,13 @@ The catalog is scoped per Blazor circuit; each call creates and disposes its own
`DbContext` via the pooled factory (same pattern as `UnsTreeService`). Results `DbContext` via the pooled factory (same pattern as `UnsTreeService`). Results
are bounded to 200 entries to keep the completion list responsive on large fleets. are bounded to 200 entries to keep the completion list responsive on large fleets.
> **v3 Batch 4 (dual namespace) — unchanged for scripts.** A `ctx.GetTag(...)` literal
> still resolves by the tag's `FullName` / **RawPath** — which is precisely the identity
> of the Raw-namespace node (`ns=Raw, s=<RawPath>`). A `{{equip}}/<RefName>` token resolves
> through the equipment's `UnsTagReference` to that same backing RawPath (see below), so
> scripts key off the single value source regardless of the UNS projection. The dual
> namespace changes only the OPC UA address-space surface, not script tag-path semantics.
### Literal gate ### Literal gate
`TryGetTagPathLiteral` identifies the tag-path context by climbing from the `TryGetTagPathLiteral` identifies the tag-path context by climbing from the
@@ -307,76 +314,88 @@ Register the replacement in `EndpointRouteBuilderExtensions.AddAdminUI`
### Why ### Why
Today each VirtualTag bound to a script typically needs its own near-duplicate Each VirtualTag bound to a script would otherwise need its own near-duplicate
script because tag paths are hard-coded absolutes (e.g. `TestMachine_001.Speed`). script because tag paths are hard-coded absolute RawPaths (e.g.
The `{{equip}}` token breaks this coupling: point many VirtualTags' `ScriptId` at `Cell1/Modbus/Dev1/Speed`). The `{{equip}}/<RefName>` token breaks this coupling:
a single template script, and each resolves the token to its own equipment's tag point many VirtualTags' `ScriptId` at a single template script, and each resolves
base prefix at deploy time. No schema change is required — sharing a `Script` the token through **its own equipment's tag references** at deploy time. No schema
record across VirtualTags already works; `{{equip}}` is what makes the shared change is required — sharing a `Script` record across VirtualTags already works;
script resolve per-equipment. `{{equip}}/<RefName>` is what makes the shared script resolve per-equipment.
### Before / after ### Before / after
**Before — one script per machine:** **Before — one script per machine (hard-coded RawPath):**
```csharp ```csharp
// Script "Calc_TestMachine_001" — hard-coded, cannot reuse // Script "Calc_TestMachine_001" — hard-coded, cannot reuse
return ctx.GetTag("TestMachine_001.Speed").Value; return ctx.GetTag("Cell1/Modbus/Dev1/Speed").Value;
``` ```
**After — one shared template:** **After — one shared template:**
```csharp ```csharp
// Script "Calc_Speed" — works for any machine // Script "Calc_Speed" — works for any machine
return ctx.GetTag("{{equip}}.Speed").Value; return ctx.GetTag("{{equip}}/Speed").Value;
``` ```
`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`. `TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`, and
At deploy, each VirtualTag receives its own expanded copy: each has a **tag reference** whose effective name is `Speed`. At deploy, each
`TestMachine_001.Speed` and `TestMachine_002.Speed` respectively. VirtualTag receives its own expanded copy — `{{equip}}/Speed` resolves to that
equipment's referenced raw tag's RawPath.
### Token rules ### Token rules (v3)
- The token is `{{equip}}` (double braces, lowercase). - The token joint is a **slash**: `{{equip}}/<RefName>` (double-brace stem,
lowercase). `<RefName>` is a **reference effective name** — the reference's
`DisplayNameOverride`, else the backing raw tag's `Name`.
- It is substituted **only inside `ctx.GetTag(…)` / `ctx.SetVirtualTag(…)` first-argument - It is substituted **only inside `ctx.GetTag(…)` / `ctx.SetVirtualTag(…)` first-argument
string literals** — comments, logger strings, and other code are untouched. string literals** — comments, logger strings, and other code are untouched.
- The operator writes the separator and tail: `ctx.GetTag("{{equip}}.Speed")`, - The whole post-prefix literal content is the reference name, so effective names
`ctx.GetTag("{{equip}}.Sub.Field")`, etc. with internal spaces are supported: `ctx.GetTag("{{equip}}/Motor Speed")`.
- The token expands to the equipment's **tag base prefix** — the common - `{{equip}}/<RefName>` **resolves through the owning equipment's `UnsTagReference`
substring-before-the-first-dot of that equipment's configured driver-tag rows** (by effective name) to the backing raw tag's `RawPath`. Example: a
`FullName` values. Example: tags `TestMachine_001.Speed` and reference named `Speed` backing `Cell1/Modbus/Dev1/Speed` → the token expands to
`TestMachine_001.Temp` → base `TestMachine_001`. `Cell1/Modbus/Dev1/Speed`.
- Scripted-alarm predicate scripts resolve `{{equip}}/<RefName>` the same way; alarm
**message-template** tokens follow the same resolution rule.
### Validation requirement ### Validation requirement
Saving a VirtualTag that is bound to a `{{equip}}`-using script is rejected in Saving a VirtualTag (or ScriptedAlarm) whose script uses `{{equip}}/<RefName>` is
the AdminUI if the equipment does not have at least one configured driver tag, or rejected in the AdminUI when `<RefName>` is not one of the owning equipment's
if its tags span multiple object prefixes (i.e. the common prefix is ambiguous or reference effective names. The same rule runs at the deploy gate
absent). The rejection message is surfaced as a clear validation error on the save (`DraftValidator.ValidateEquipReferenceResolution`, error code
form. This check is enforced eagerly so that an unresolved `{{equip}}` token — `EquipReferenceUnresolved`), which also catches a **rename-induced** breakage the
which would leave a path that resolves to nothing at runtime (Bad quality) — can authoring check never saw. The rejection names the script/alarm, the equipment, and
never reach the deployed artifact. the missing ref — so an unresolved token can never reach the deployed artifact.
### Editor support ### Editor support
In the Monaco script editor (ScriptEdit page and the `/uns` virtual-tag modal's In the Monaco script editor (ScriptEdit page and the `/uns` virtual-tag modal's
inline script panel): inline script panel):
- **Hover** — hovering a `{{equip}}` path literal shows an - **Hover** — hovering a `{{equip}}/<RefName>` path literal shows an
*"Equipment-relative path — resolved at deploy"* note. *"Equipment-relative path — resolved through the equipment's tag reference at
- **Completions** — typing `{{equip}}.` inside a `GetTag`/`SetVirtualTag` literal deploy"* note.
offers completion of attribute leaf names (the part after the first dot of known - **Completions** — typing `{{equip}}/` inside a `GetTag`/`SetVirtualTag` literal
tag references in the catalog). offers the owning equipment's **reference effective names** (needs an equipment
context; inert on the shared ScriptEdit page).
- **Diagnostics** — an unresolved `{{equip}}/<RefName>` is flagged (`OTSCRIPT_EQUIPREF`)
identically to the deploy gate, preserving *editor accepts ⇔ publish accepts*.
### Maintainer note ### Maintainer note
Substitution runs at the two compose seams — Substitution runs at the two compose seams — `AddressSpaceComposer.Compose` and
`Phase7Composer.Compose` and `DeploymentArtifact.BuildEquipmentVirtualTagPlans` `DeploymentArtifact.ParseComposition` — via the shared
— via the shared `ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths` helper, `ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths.SubstituteEquipmentToken`
**before** dependency extraction. The runtime, the static change-trigger helper, fed the equipment's reference map (effective name → RawPath) built by
dependency graph, and the literal-only path rule are therefore all unchanged: `EquipmentReferenceMap` over the shared `RawPathResolver`. Substitution runs
by the time they see the script, `{{equip}}` has been replaced with a concrete **before** dependency extraction, so the runtime, the static change-trigger
tag-base prefix and the path is a normal string literal. dependency graph, and the literal-only path rule are unchanged: by the time they
see the script, `{{equip}}/<RefName>` has been replaced with a concrete RawPath and
the path is a normal string literal. An unresolved ref is left un-substituted (the
validator rejects it first — substitution never throws). The two seams build the
reference map identically, so their plans stay byte-parity.
--- ---
+21 -4
View File
@@ -127,9 +127,25 @@ object alongside the usual `"FullName"`:
(unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9 (unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9
`AlarmConditionState` under its equipment folder **instead of** a value variable. `AlarmConditionState` under its equipment folder **instead of** a value variable.
No EF/schema change is required; the intent rides in the schemaless `TagConfig` No EF/schema change is required; the intent rides in the schemaless `TagConfig`
blob and is parsed byte-parity in both the compose (`Phase7Composer`) and deploy blob and is parsed byte-parity in both the compose (`AddressSpaceComposer`) and deploy
(`DeploymentArtifact`) paths. (`DeploymentArtifact`) paths.
> **v3 Batch 4 — multi-notifier fan-out.** The `"alarm"` object now rides on a **raw
> tag** authored in `/raw` (referenced into equipment), and the Part 9 condition
> materializes **once at the raw tag** — its `ConditionId` is the tag's **RawPath**, its
> parent notifier is the raw device/group folder. For **each referencing equipment**, the
> node manager (`WireAlarmNotifiers`) wires the SDK `AddNotifier` pattern
> (`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
> `EnsureFolderIsEventNotifier(equipFolder)`) so the single condition fans to the raw device
> folder AND every equipment folder. A **single** `ReportEvent` fans to all notifier roots —
> a Server-object subscriber sees **exactly one** copy (the Part 9 shared-snapshot dedup),
> never one-per-root. Teardown is symmetric (`RemoveNotifier(..., bidirectional:true)` on
> rebuild / reference removal) so inverse-notifier entries never leak across redeploys.
> Ack/confirm/shelve route on **`ConditionId = RawPath`** (never `SourceNodeId`), and the
> `/alerts` row lists the referencing equipment paths. See
> [AlarmTracking.md](AlarmTracking.md) and the WP4 section of
> `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`.
### TagConfig alarm fields ### TagConfig alarm fields
| Field | Values | Default | | Field | Values | Default |
@@ -208,9 +224,10 @@ native alarm transitions identically. Publication to the `alerts` topic is
the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm
for failover. for failover.
The alarm is authored on the `Tags` tab of the equipment page (`/uns/equipment/{id}`) The alarm is authored on the **raw tag** in `/raw` by including the `"alarm"` object in the
by editing the tag's raw `TagConfig` JSON to include the `"alarm"` object. No tag's `TagConfig` JSON (v3 Batch 4 — equipment no longer authors tags; it references raw
other configuration is required. tags). No other configuration is required: any equipment that references the raw tag
automatically receives the alarm at its equipment folder via the multi-notifier fan-out above.
### Native-alarm OPC UA operator operations ### Native-alarm OPC UA operator operations
+74 -12
View File
@@ -74,26 +74,88 @@ changed by editing the area's cluster in the Area modal, which moves the
whole branch. There is no separate "served-by" concept and no migration — whole branch. There is no separate "served-by" concept and no migration —
it is simply `UnsArea.ClusterId`. it is simply `UnsArea.ClusterId`.
### Tags ### Tags — reference-only (v3)
Tags created on the equipment page are **equipment-bound** and require a driver > **v3 (Batch 3):** UNS equipment no longer authors or binds tags. Device I/O is
instance. The driver list on the Tags tab is scoped to the equipment's cluster > authored once in the **Raw project tree** (`/raw` — see [`Raw.md`](Raw.md)); an
and to drivers on an **Equipment-kind** namespace, so a driver-less equipment > equipment's **Tags** tab holds **references** to those raw tags. The old
shows no eligible drivers until you bind one (edit the equipment on the Details > driver-bound Tag modal on this tab is retired.
tab and pick a driver).
**Galaxy / AVEVA System Platform points are ordinary equipment tags** bound to The **Tags** tab is a list of `UnsTagReference` rows. Each row shows the
a `GalaxyMxGateway` driver instance. Author them on the Tags tab using the **effective name**, the backing tag's **RawPath**, its inherited **DataType** and
standard Tag modal; the Galaxy address picker browses the live Galaxy hierarchy **AccessLevel** (read-only — they come from the raw tag), and an editable
so you can select the attribute and set `TagConfig.FullName`. There is no **display-name override**. The effective name is the override else the raw tag's
separate alias concept or `SystemPlatform`-kind namespace. `Name`.
**"+ Add reference"** opens a raw-tree picker (the `/raw` tree in picker mode)
**scoped to the equipment's cluster** — cross-cluster tags are structurally
unreachable, and the service also enforces `tag.cluster == equipment.cluster` on
commit. Tick individual tag leaves, or use a device / tag-group's *"Select all
tags below"* menu to pull many at once.
**Effective-name uniqueness:** within an equipment the effective name must be
unique across references, VirtualTags, and ScriptedAlarms (they share the
equipment's UNS NodeId space, `{EquipmentId}/{EffectiveName}`). This is enforced
both at authoring (a readable rejection naming the colliding source) and at the
deploy gate (`DraftValidator`, `UnsEffectiveNameCollision`) — the deploy gate is
what catches **rename-induced** collisions a raw rename produced after the
reference was authored, naming both colliding sources and the equipment.
Removing a raw tag in `/raw` is blocked while a `UnsTagReference` points at it
(the error names the referencing equipment); renaming a raw tag or any ancestor
warns when a beneath-it tag is historized (no `historianTagname` override),
UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md).
**Galaxy / AVEVA System Platform points** are now ordinary raw tags on a
`GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw
tag. There is no separate alias concept or `SystemPlatform`-kind namespace.
### OPC UA address-space projection (v3 Batch 4 — dual namespace)
> **v3 (Batch 4):** the server now exposes the address space under **two OPC UA
> namespaces** instead of the old single `https://zb.com/otopcua/ns`:
>
> | Namespace URI | Subtree | NodeId `s=` scheme |
> |---|---|---|
> | `https://zb.com/otopcua/raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
> | `https://zb.com/otopcua/uns` | the UNS tree (Area → Line → Equipment → signal) | the slash-joined **`Area/Line/Equipment/EffectiveName`** |
Every device value has **exactly one source** — the raw tag's node in the Raw
namespace. A `UnsTagReference` projects that raw tag into an equipment as a
**UNS-namespace variable** whose NodeId leaf is the reference's **effective name**
(the display-name override else the raw tag's `Name`). The UNS variable does not
bind a driver of its own: it carries an **`Organizes` reference to its backing raw
node** and mirrors it. A single driver publish for a RawPath **fans out** to the
raw NodeId AND every referencing UNS NodeId with identical value / quality /
source-timestamp — the two NodeIds never drift.
- **Reads / subscriptions** work through either NodeId and return the same data.
- **Writes** route through either NodeId to the same backing driver ref under the
**same `WriteOperate` gating** (a UNS write is neither more nor less privileged
than the raw write it fans from); a failed device write reverts both NodeIds via
the shared fan-out.
- **HistoryRead** works through either NodeId and returns the same series — both
NodeIds register the **same historian tagname** (see [`Historian.md`](Historian.md)).
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
fan via SDK notifiers to the raw device folder AND every referencing equipment
folder — see [`ScriptedAlarms.md`](ScriptedAlarms.md) / [`AlarmTracking.md`](AlarmTracking.md).
Note the two distinct identity strings: the wire **NodeId** is the path
`Area/Line/Equipment/EffectiveName`, while the **effective-name uniqueness key**
above is `{EquipmentId}/{EffectiveName}` (the logical per-equipment collision
space the guards enforce). They are related but not the same string.
### Virtual tags ### Virtual tags
A virtual tag is bound to an equipment and driven by a **script** (no driver). A virtual tag is bound to an equipment and driven by a **script** (no driver).
Add and edit virtual tags on the equipment page's **Virtual Tags** tab; the Add and edit virtual tags on the equipment page's **Virtual Tags** tab; the
data type is chosen from the standard OPC UA type list and the Monaco script data type is chosen from the standard OPC UA type list and the Monaco script
editor is available inline. editor is available inline. Scripts may read the equipment's references
relative-to-equipment with **`ctx.GetTag("{{equip}}/<RefName>")`** — the token
resolves through the equipment's `UnsTagReference` rows (by effective name) to
the backing raw tag's RawPath at deploy; an unresolved `<RefName>` is a deploy
error and a live Monaco diagnostic (editor accepts ⇔ publish accepts). See
[`ScriptEditor.md`](ScriptEditor.md).
### Galaxy tags ### Galaxy tags
+74
View File
@@ -0,0 +1,74 @@
# v3 Batch 2 — `/raw` project-tree AdminUI + `Calculation` driver
Implements `docs/plans/2026-07-15-v3-batch2-raw-ui-calculation-plan.md` (Track 0 + WP1WP8),
executed via the `/v3-batch 2` coordinator: contracts-first fan-out, worktree-isolated Opus
agents per wave, a code-reviewer pass + green build/test after each wave, then the
non-negotiable 7-item live gate on docker-dev.
## What landed
- **Track 0** — reusable `ContextMenu` (right-click + `⋯`, keyboard/focus); RFC-4180
`CsvParser`/`CsvWriter`; `DriverTypeNames` constants + a reflection-discovery guard;
`CalculationEvaluator` core (VirtualTag-evaluator parity).
- **Wave A**`IRawTreeService`/`RawTreeService` (lazy tree + mutations, integrity guards,
rename-warnings); `/raw` page + lazy `RawTree` with per-node context menus.
- **Wave B** — driver→embeddable-form refactor + `DriverConfigModal`/`DeviceModal`
(endpoint→`DeviceConfig`, Test-connect via merged config); manual tag entry + tag edit
modal + `Calculation` tag editor; CSV import (staged review grid) / export; all wired into
the tree (name/confirm/driver-type dialogs + post-mutation refresh).
- **Wave C** — browse re-target (two-tier gate, merged-config feed, commit → raw tags with
address-field mapping + folder mirroring); `Calculation` driver (`IDependencyConsumer` +
mux adapter, timer + change triggers, `DraftValidator` scriptId + cycle gates);
`DriverTypeNames` rewire + retirement of the routed driver flow.
**Address space stays dark** (values light up in Batch 4). Full solution builds 0 errors;
`AdminUI.Tests` 638 passed / 3 skipped, `Runtime.Tests` 357 passed, driver + guard suites green.
## Reviewer findings fixed in-branch
- **Wave B H1** — driver forms still serialized the endpoint into `DriverConfig`; stripped
the endpoint keys (Modbus/S7/OpcUaClient) so `DeviceConfig` is the sole source (a 2nd
device on a single-Host driver no longer reverts to `127.0.0.1`).
- **Wave C HIGH**`CalculationDriver.ReinitializeAsync` ignored the new config, so calc
tag/script edits were silently inert after a redeploy and the re-register loop was
decorative. Now rebuilds the tag/dependency table on reinit and re-registers deps after
the delta applies (`DeltaApplied` notification, race-free). New redeploy test proves it.
- Wave-A/B/0 mediums (auto-expand, friendly create-race/delete failures, discovery-driven
guard, CSV comment). Deferred (documented): pre-existing Modbus/S7 form `TimeSpan``*Ms`
DTO mismatch; CSV alarm-subfield/flag-precedence edge cases; OpcUaClient endpoint
precedence; unused `LoadMergedProbeConfigAsync`; refresh collapses sibling expansion.
## Live `/run` gate (docker-dev `:9200`, both central nodes rebuilt)
1. **Authoring + Test-connect**`/raw` → New Modbus driver `gate-modbus` (form body renders,
`PLC family`/`MELSEC` enum dropdowns correct) → Device1 endpoint `10.100.0.35:5020` in
`DeviceConfig`**Test connect OK · 94 ms** against the live Modbus fixture; SQL confirms
endpoint in `DeviceConfig` (`{"Host":"10.100.0.35","Port":5020,"UnitId":1}`).
2. **Historian-tagname deploy block** — a historized tag with a 260-char effective tagname →
`POST /api/deployments` **422** `[HistorianTagnameTooLong] tag 'TAG-gatelong' … is 260 chars
(max 255)`; shortened override → **202 Accepted**.
3. **CSV round-trip + bad-enum** — export produces the exact column dictionary
(`…,Region,Address,ModbusDataType,ByteOrder,…`); importing 1 valid + 1 bad-enum row →
review grid "2 rows, 1 valid, 1 invalid", row-2 error *"Column 'ModbusDataType':
'NOTATYPE' is not a valid ModbusDataType (expected one of: Bool, Int16, UInt16, …)"*,
**Commit disabled** (all-or-nothing; SQL confirms nothing committed); a valid-only import →
Commit → `ImportedTag` lands with typed columns assembled into `TagConfig`.
4. **Browse-commit** — OpcUaClient device → opc-plc (`opc.tcp://10.100.0.35:50000`): two-tier
gate enabled browse, merged config connected, real tree walked (NetworkSet/…/OpcPlc);
multi-selected 2 leaves with "create matching tag-groups" → raw tags land under the
mirrored `Basic` group with DataType + the browse ref in the `nodeId` address field
(`nsu=…;s=AlternatingBoolean`), not an identity key.
5. **Calculation deploy + cycle gate** — a calc tag reading a Modbus RawPath deploys **202**;
a 2-cycle (`calc1/Engine/A``calc1/Engine/B`) → **422** `[CalculationDependencyCycle] …
(members: calc1/Engine/A, calc1/Engine/B)`; clean redeploy 202, no crash loops (both
central nodes stable; only a deploy-reject WRN in the log).
6. **Rename warning** — renaming `gate-modbus``gate-modbus-renamed` fired the warning modal
*"1 historized tag beneath this node will change its RawPath (historian tagname)…"*.
7. **Retired routes**`/clusters/{id}/drivers`, `…/new`, `…/new/{slug}`, `…/{id}` all
return **404**; `/raw` + `/uns` still 200.
## Docs
`docs/Raw.md` (new — the `/raw` authoring tree + endpoint split + CSV + Calculation +
retirement note); `CLAUDE.md` AdminUI section updated; the Calculation mini-design remains the
driver's authority.
+89
View File
@@ -0,0 +1,89 @@
# v3 Batch 3 — UNS reference-only Equipment + `{{equip}}/<RefName>` reference-relative resolution
Implements `docs/plans/2026-07-15-v3-batch3-uns-rework-plan.md` (WP1WP4), executed via the
`/v3-batch 3` coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, a
code-reviewer pass + green build/test after each wave, then the non-negotiable 5-item live gate on
docker-dev.
## What landed
- **Contracts**`IEffectiveNameGuard` (authoring-time effective-name uniqueness seam; WP2 implements,
WP1 consumes).
- **Wave A** (3 parallel agents):
- **WP1** — equipment **Tags** tab → `UnsTagReference` list (effective name / RawPath / inherited
DataType+AccessLevel / display-name override); new `AddReferenceModal` reusing `RawTree` in opt-in
`PickerMode` (multi-select tag leaves + device/tag-group "Select all tags below"), **cluster-scoped**
(structural + server-enforced `tag.cluster == equipment.cluster`); `UnsTreeService` reference CRUD;
consumes `IEffectiveNameGuard` in all colliding mutations; `ImportEquipmentModal`/`EquipmentInput`
dropped `DriverInstanceId`; deleted the retired equipment `TagModal`.
- **WP2**`EffectiveNameGuard` (injectable, ordinal) + DI registration; verified the deploy-gate
`UnsEffectiveNameCollision` rule (already computes ref effective name as override-else-current-raw-name,
so it catches rename-induced collisions; ordinal; names both sources + equipment).
- **WP3**`RawTreeService.BuildRenameWarningsAsync`: refined historized warning (only tags **without**
a `historianTagname` override) + UNS-referenced + **new** substring scan of every `Script.SourceCode`
for affected tags' OLD RawPaths (recomputed pre-save via `RawPathResolver`, ordinal).
- **Wave B** (1 integration agent):
- **WP4**`{{equip}}/<RefName>` reference-relative resolution. `EquipmentScriptPaths.DeriveEquipmentBase`
deleted; slash joint replaces the dot joint; new shared `EquipmentReferenceMap` (`equipmentId →
effectiveName → RawPath`) built identically at both compose seams (`AddressSpaceComposer` +
`DeploymentArtifact`, byte-parity). Unresolved-ref deploy error (`EquipReferenceUnresolved`) covering VT
scripts, SA predicates, and SA message-template tokens; authoring guard (`ValidateEquipTokenAsync`,
the Wave-A M1 fix); Monaco `{{equip}}/` completion of reference effective names + unresolved diagnostic
(`OTSCRIPT_EQUIPREF`), `EquipmentId` threaded request→model→JS.
**Address space stays dark** (values light up in Batch 4). Full solution builds 0 errors; Commons **309**,
Configuration **121**, AdminUI **659**, OpcUaServer **335/4-skip**, Runtime **361/42-skip** (the skips are
the Batch-1 dark-address-space tests Batch 4 un-skips).
## Reviewer findings fixed in-branch
- **Wave A** — no HIGH. Verified: the register-AND-consume guard seam (prod DI injects the real guard; the
NoOp fallback only wins under `new` in tests; guard invoked in all 6 colliding mutations); cluster scoping
structural + server-enforced; Razor default `RawTree` byte-unchanged; WP3 pre-save RawPath recomputation.
L1 (misleading concurrency comment) fixed.
- **Wave B** — no HIGH; byte-parity + production wiring confirmed. **M1** fixed: the editor⇔authoring⇔deploy
invariant now holds on the **ScriptedAlarm** surface too (SA predicate + message-template `{{equip}}` refs
validated at authoring, matching the deploy gate). **L3** fixed: parity test hardened (unresolved-ref-intact
+ folder/tag-group RawPath ancestry). L1 already closed at the write path (empty override normalized→null).
- **Deferred (documented follow-ups):** absolute-path Monaco completion still projects the raw leaf name, not
the RawPath (out of `{{equip}}` scope; `{{equip}}/` completion works) — rework `ScriptTagCatalog` to RawPaths
in Batch 4; a broken raw-topology-chain reference is deploy-accepted but compose-dropped (low reachability);
message-template `{{equip}}/X` is gate-validated but rendered/substituted only in Batch 4.
## Live `/run` gate (docker-dev `:9200`, both central nodes rebuilt on Batch-3 code)
Substrate: an Area→Line→Equipment seeded in MAIN, plus a SITE-A raw tag so cross-cluster exclusion is
demonstrable. (Hand-seeded equipment surfaced the Batch-1 `EquipmentIdNotDerived` invariant — the canonical
`EQ-<hash>` id was applied before the gate.)
1. **Reference picker + list** — "+ Add reference" header *"The tree is scoped to the equipment's cluster"*;
only the **Main cluster** root shows (SITE-A/B absent — the seeded `SiteAOnly` tag unreachable); lazy
expansion; a device **"Select all tags below"** pulled tags across a collapsed group → **4 references in
one commit**. Rows show effective name / RawPath (incl. deep `opcua1/plc/OpcPlc/Telemetry/Basic/…`
ancestry) / DataType / Access / override. Set override "MainPressure" → effective name updated
`HR200 → MainPressure`, RawPath unchanged.
2. **Collision***authoring:* setting a reference override to an existing VirtualTag's name → red banner
**"effective name 'GateVt' already used by VirtualTag 'VT-gatevt' in equipment '…'"** (not persisted).
*rename-induced:* renamed a raw tag so two references collide (allowed at rename) → deploy **422**
`[UnsEffectiveNameCollision] 2 UNS signals collide on effective name 'ImportedTag' … reference '…',
reference '…'`.
3. **`{{equip}}` (editor ⇔ deploy)** — resolving `{{equip}}/MainPressure` deploys **202**; misspelled
`{{equip}}/MainPresure` deploys **422** `[EquipReferenceUnresolved] … has no reference named 'MainPresure'`.
Monaco: red squiggle + hover *"'{{equip}}/MainPresure' does not resolve — … no reference named
'MainPresure' … (OTSCRIPT_EQUIPREF)"*; typing `{{equip}}/` completes the four reference **effective**
names (AlternatingBoolean, ImportedTag, **MainPressure**, RandomSignedInt32).
4. **Rename warning** — renaming a driver whose beneath-it tag is historized-without-override + UNS-referenced
+ named in a script literal → **"Renamed — with warnings"** listing all three (historized fork,
UNS-referenced by 'gateequip', "2 scripts ('cval','gatevt') reference tags … by their raw paths"); renaming
an unrelated driver → **no warning dialog**. (Note: direct *tag* rename flows through `UpdateTagAsync`
which has no warnings surface — warnings fire on container renames affecting descendants; documented
follow-up if per-tag-rename warnings are wanted.)
5. **Import without driver column** — the Import equipment CSV modal columns are `Name, MachineCode, UnsLineId`
(+ optional `ZTag, SAPID, Manufacturer, Model`); **no `DriverInstanceId`**.
## Docs
`docs/Uns.md` Tags section rewritten to the reference-only model; `docs/ScriptEditor.md` updated (WP4) to the
slash/reference `{{equip}}` semantics; `CLAUDE.md` gains a v3 Batch 3 paragraph.
https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
+115
View File
@@ -0,0 +1,115 @@
# v3 Batch 4 — dual-namespace address space + raw-only runtime binding (= v3.0)
Implements `docs/plans/2026-07-15-v3-batch4-address-space-plan.md` (B4-WP1WP5), executed via the
`/v3-batch 4` coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, a
code-reviewer pass + green build/test after each wave, then the non-negotiable **7-leg v3.0 live gate**
on docker-dev + Client.CLI. **This batch lights up the address space** — v3.0.
## What landed
- **Contracts** (`a1a56e22`) — two namespace URIs (`…/otopcua/raw`, `…/otopcua/uns`, replacing the single
`…/ns`), `V3NodeIds` (raw = `s=<RawPath>`, UNS = `s=<Area>/<Line>/<Equipment>/<EffectiveName>`), and the
`AddressSpaceRealm` (`Raw | Uns`) discriminator carried at every sink seam.
- **Wave A** (2 agents ∥):
- **WP1** — composer/planner un-darkened: emit the **Raw** subtree (Folder→Driver→Device→TagGroup→Tag,
tags as `(realm=Raw, s=RawPath)`) + the **UNS** subtree (each `UnsTagReference` a Variable
`(realm=Uns, s=Area/Line/Equipment/EffectiveName)` carrying its backing RawPath + an `Organizes`
UNS→Raw ref); native-alarm plans at the **raw** tag (ConditionId=RawPath) carrying referencing-equipment
paths; per-realm planner diff (raw rename = remove+add Raw + re-point UNS); reactivated the Batch-1-skipped
`EquipmentNamespaceMaterializationTests`.
- **WP2** — node manager registers both namespaces; `AddressSpaceRealm` on every node-naming sink method
(maps keyed by ns-qualified NodeId so a Raw + UNS node sharing a bare `s=` id stay distinct; historized
UNS node registers the **same** tagname, mux single by RawPath); everything **forwarded through
`DeferredAddressSpaceSink`** + reflection guard extended (the forwarding trap). **M1 fix**: new
`AddReference` sink seam wiring the `Organizes` UNS→Raw edge bidirectionally (idempotent, missing-endpoint
no-op, forwarded + reflection-covered).
- **Wave B** (1 agent — the delicate one):
- **WP3** — applier materializes both realms + wires the Organizes edge; `DriverHostActor` dual-NodeId
registration + single-source fan-out (drift-free — one publish → raw NodeId AND every referencing UNS
NodeId, identical value/quality/timestamp); write routing + failed-write revert through both NodeIds;
`EquipmentNodeIds` retired; transitional realm defaults removed from the sink surface. **Review fixes**:
**H1** realm-qualified `(realm, bareId)` write-routing key (a raw RawPath that coincidentally equals a UNS
path can no longer misroute a write); **M1** coherent-dormant discovery-injection guard; **M2** dual-node
self-correction tests; **L1** node-manager routing defaults removed (~180 call sites); byte-parity
round-trip test.
- **Wave C** (2 agents ∥):
- **WP4** — multi-notifier native alarms: one condition at the raw tag, `AddNotifier`-fanned to the raw
device folder + every referencing equipment folder, **one `ReportEvent`** (exactly one Server-object copy —
proven by an over-the-wire event-delivery test), teardown symmetry (`RemoveNotifier(bidirectional:true)` on
rebuild/condition-removal/subtree-removal + reconcile), ack/confirm/shelve still on ConditionId=RawPath;
`AlarmTransitionEvent` + `/alerts` gain the referencing-equipment list. **Review fixes**: M1 event-delivery
test, M2 resolution-failure meter + composer↔applier path-agreement test, M3 reconcile, L1/L3.
- **WP5** — over-the-wire dual-namespace integration tests (`DualNamespaceAddressSpaceTests`) + 2-node
harness materialization round-trip; docs (`CLAUDE.md`, `docs/Uns.md`, `docs/ScriptEditor.md`,
`docs/Historian.md`, `docs/ScriptedAlarms.md`, `docs/AlarmTracking.md`).
- **Coordinator seam-fix** — threaded `ReferencingEquipmentPaths` into `DriverHostActor.ForwardNativeAlarm`'s
`AlarmTransitionEvent` so `/alerts` chips populate in production (WP3-owned file WP4 couldn't edit).
- **Live-gate bug fix** — the gate caught a genuine prod-inert defect: **Equipment VirtualTags never resolved
live** (a redeploy resets the VT's UNS node to `BadWaitingForInitialData`, but a surviving unchanged-plan
`VirtualTagActor` keeps its value-dedup state and suppresses the re-publish → the reset node stays Bad
forever with a static dependency). Fix: `ReassertValue` on apply re-emits the last value after the node
reset. Regression test fail-before/pass-after; live-verified Good. (Reviewer follow-ups: reassert skips
historian re-record (M1); scripted-alarm redeploy recovery (M2); comment (LOW-3).)
## Reviewer verdict per wave
No HIGH survived any wave. Wave A: forwarding trap verified solid (DispatchProxy guard catches unforwarded
methods), collision-free ns-qualified keying, planner raw-rename-repoints-UNS holds; M1 (Organizes seam) fixed.
Wave B: **H1** write-route realm collision fixed with a regression test; M1/M2/L1 fixed. Wave C: single-ReportEvent
+ teardown symmetry + forwarding trap all confirmed; M1/M2/M3/L1/L3 fixed. VT-fix review: ordering confirmed real,
dedup/02-S13 semantics intact; M1/M2/LOW-3 closed.
## v3.0 live gate (docker-dev `:9200` / `opc.tcp://:4840`+`:4841`, both centrals rebuilt on Batch-4 code)
1. **Browse both namespaces**`ns=2` Raw tree (`pymodbus/plc/HR200`, `s=<RawPath>`) + `ns=3` UNS tree
(`gatearea/gateline/gateequip/MainPressure`, effective names); both materialize `failed=0`.
2. **Single-source fan-out**`MainPressure` (UNS) = `HR200` (Raw) = **1234**, identical value AND timestamp;
Calculation tag `Cval` computes (=1235); the `{{equip}}/MainPressure` VirtualTag reads Good (post-fix).
3. **Writes** — write **4242** via the UNS NodeId → read-back **4242** via the raw NodeId; write **777** via the
raw NodeId; `opc-readonly` rejected **0x801F0000** on the UNS NodeId (role gating symmetric). Failed-write
dual-node revert is unit-covered (`exception_injector` fixture not provisioned on this rig).
4. **History** — HistoryRead via the raw NodeId and the UNS NodeId both return **GoodNoData** under the same
historian tagname (Null source — no gateway on docker-dev); provisioning tally `dispatched=1 … failed=0`.
5. **Alarms (multi-notifier)** — the native alarm materialized as a Part 9 **AlarmCondition** at the raw tag
(ConditionId = RawPath); the **equipment folder** accepts an alarm subscription + ConditionRefresh, proving
it is a live event-notifier wired to the raw condition; the Server object likewise. The transition delivery
(exactly-one Server copy + delivery at both notifiers + ack) is covered by the over-the-wire
`NativeAlarmMultiNotifierEventDeliveryTests` — docker-dev has no `IAlarmSource` driver (only Galaxy is one)
so a live transition can't be tripped on the rig.
6. **Rename cascade** — renamed `HR200``HR200X` + deployed: old raw NodeId gone, new present; the UNS
reference `MainPressure` follows automatically; the `{{equip}}` VirtualTag keeps working and tracks the
renamed tag live (3610→3611 Good); the **absolute** RawPath script literal (`Cval`) now fails **Bad
0x80000000**. (Caveat: a raw-tag rename needs a node recreate/restart to hot-rebind the renamed tag's live
driver value — the pre-existing deploy-THEN-recreate pattern for structural edits.)
7. **Redundancy** — ServiceLevel split **central-1 = 250 / central-2 = 240** (both Good) — the Primary/Secondary
differentiation is intact with the second namespace present; single-emit-per-condition is unit-covered (the
alarm-emit gate is Primary-only).
## Tests
Full solution builds **0 errors**. Commons 306, Configuration 121, OpcUaServer 375/4-skip (+ OpcUaServer.IntegrationTests
dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminUI 659, Host.IntegrationTests green
(the pre-existing Batch-2 `Calculation`-probe stale test fixed here). The remaining Runtime skips are legacy
`EquipmentTags`-model dark tests + the dormant discovery-injection scenarios (accurate skip reasons).
## Documented follow-ups (non-blocking)
- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix).**
The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed:
`RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition
fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields
`EmissionKind.None` for a still-active condition (dropped by `OnEngineEmission`), so an **active scripted
alarm with static dependencies under-reports as normal after a full-rebuild deploy until its next real
transition** (the known "snapshot-is-one-shot / restart-to-re-deliver" gotcha — NOT a Batch-4 regression).
Deferred because the fix is a Core-engine emission-contract change carrying **double-alarm-history risk**
(a naive re-emit would append a duplicate AVEVA/historian row per deploy — the alarm analogue of the VT M1
historian issue). Proposed shape: a host-driven, node-only `AlarmStateUpdate` re-assert in `OnAlarmsLoaded`
(same safe post-materialise ordering as the VT fix), reusing `_engine.GetState(alarmId)` + `LoadedAlarmIds`,
**never touching the `alerts` topic**.
- Raw-tag rename hot-rebind of the renamed tag's live value without a recreate (today: deploy-THEN-recreate).
- Absolute-path Monaco completion → RawPath (from Batch 3; the `{{equip}}/` completion works).
- Cross-repo (post-merge): ScadaBridge Data-Connection-Layer NodeId/namespace cutover (raw `s=<RawPath>` /
UNS `s=<Area>/<Line>/<Equipment>/<EffectiveName>`, retired `EquipmentNodeIds`) + the umbrella index
`../scadaproj/CLAUDE.md` OtOpcUa entry.
https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
@@ -0,0 +1,293 @@
using System.Globalization;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Commons.Csv;
/// <summary>
/// A pure, allocation-lean RFC 4180 CSV reader with no file/stream I/O of its own — it consumes a
/// <see cref="string"/> or a <see cref="TextReader"/> and yields rows of fields. This is the single
/// CSV-reading authority in the tree; keep the RFC contract pinned here (and mirrored in
/// <see cref="CsvWriter"/>) rather than re-deriving it at each call site.
/// </summary>
/// <remarks>
/// <para><b>Faithful to RFC 4180.</b> The grammar handled:</para>
/// <list type="bullet">
/// <item>Fields are separated by the delimiter (default <c>','</c>); records are separated by
/// newlines.</item>
/// <item>A field may be quoted with double-quotes (<c>"</c>). A quoted field may contain the
/// delimiter, CR, LF, and CRLF literally, and represents a literal double-quote by doubling it
/// (<c>""</c> → <c>"</c>).</item>
/// <item>An unquoted field runs literally up to the next delimiter or newline; leading and
/// trailing spaces are preserved (RFC 4180 §2.4 — spaces are part of a field).</item>
/// <item>CRLF, bare LF, and bare CR are all accepted as record terminators.</item>
/// </list>
/// <para><b>Empty-line policy.</b> Faithful to the RFC: a line with no characters yields a single row
/// containing one empty field (<c>[""]</c>); it is NOT silently dropped. Callers that want blank lines
/// skipped should filter the result (e.g. <c>rows.Where(r =&gt; r.Length &gt; 1 || r[0].Length &gt; 0)</c>).
/// A trailing record terminator at end-of-input does NOT produce a phantom empty final row, and its
/// absence does not lose the last row.</para>
/// <para><b>Malformed-input policy.</b> This parser is <b>strict</b>. It throws
/// <see cref="System.FormatException"/> (carrying a 1-based line/column position) for the malformed
/// shapes RFC 4180 forbids: (1) a bare double-quote inside an otherwise-unquoted field (e.g.
/// <c>ab"c</c>), (2) a stray character after a closing quote other than the delimiter or a newline
/// (e.g. <c>"ab"c</c>), and (3) an unterminated quoted field at end-of-input. Strictness is deliberate
/// — silent lenient recovery hides data-shape bugs in imported files.</para>
/// </remarks>
public static class CsvParser
{
private const char Quote = '"';
private const char Cr = '\r';
private const char Lf = '\n';
/// <summary>
/// Parses <paramref name="text"/> fully into rows of fields. Convenience wrapper over
/// <see cref="Parse(TextReader,char)"/>; materialises the whole document.
/// </summary>
/// <param name="text">The CSV document. <c>null</c> is treated as empty.</param>
/// <param name="delimiter">The field separator (default comma).</param>
/// <returns>The rows, each an array of field values. Empty input yields zero rows.</returns>
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar.</exception>
public static IReadOnlyList<string[]> Parse(string? text, char delimiter = ',')
{
using var reader = new StringReader(text ?? string.Empty);
var rows = new List<string[]>();
foreach (var row in Parse(reader, delimiter))
{
rows.Add(row);
}
return rows;
}
/// <summary>
/// Streams rows from <paramref name="reader"/> lazily — a single forward pass, one row
/// materialised at a time. The reader is not disposed by this method.
/// </summary>
/// <param name="reader">The source. Read to end-of-input.</param>
/// <param name="delimiter">The field separator (default comma).</param>
/// <returns>A lazily-evaluated sequence of rows; each row is a freshly allocated field array.</returns>
/// <exception cref="System.ArgumentNullException"><paramref name="reader"/> is <c>null</c>.</exception>
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar.</exception>
public static IEnumerable<string[]> Parse(TextReader reader, char delimiter = ',')
{
ArgumentNullException.ThrowIfNull(reader);
if (delimiter is Quote or Cr or Lf)
{
throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter));
}
return Iterate(reader, delimiter);
}
private static IEnumerable<string[]> Iterate(TextReader reader, char delimiter)
{
var field = new StringBuilder();
var row = new List<string>();
// True once the current row has produced any field boundary or content — i.e. once we've seen a
// char that commits us to emitting at least one field. Reset to false right after a record
// terminator closes a row, so a trailing newline at EOF does NOT synthesise a phantom row.
var rowOpen = false;
// 1-based cursor, maintained for FormatException messages.
var line = 1;
var col = 0;
int read;
while ((read = reader.Read()) != -1)
{
var c = (char)read;
col++;
if (c == Quote)
{
if (field.Length != 0)
{
throw new FormatException(
$"Unexpected double-quote inside an unquoted field at line {line}, column {col}. " +
"A field is quoted only when the quote is its first character.");
}
rowOpen = true;
// Consume the quoted body; the opening quote has been read.
ReadQuotedField(reader, field, ref line, ref col);
// A closing quote must be followed by the delimiter, a newline, or EOF.
var next = reader.Peek();
if (next == -1)
{
row.Add(field.ToString());
field.Clear();
yield return row.ToArray();
row.Clear();
rowOpen = false;
yield break;
}
var nc = (char)next;
if (nc == delimiter)
{
reader.Read();
col++;
row.Add(field.ToString());
field.Clear();
continue;
}
if (nc is Cr or Lf)
{
row.Add(field.ToString());
field.Clear();
ConsumeNewline(reader, ref line, ref col);
yield return row.ToArray();
row.Clear();
rowOpen = false;
continue;
}
throw new FormatException(
$"Unexpected character '{nc}' after closing quote at line {line}, column {col + 1}. " +
"A quoted field must be followed by a delimiter, a newline, or end-of-input.");
}
if (c == delimiter)
{
row.Add(field.ToString());
field.Clear();
rowOpen = true;
continue;
}
if (c is Cr or Lf)
{
row.Add(field.ToString());
field.Clear();
if (c == Cr && reader.Peek() == Lf)
{
reader.Read();
}
line++;
col = 0;
yield return row.ToArray();
row.Clear();
rowOpen = false;
continue;
}
field.Append(c);
rowOpen = true;
}
// EOF: emit a trailing row only if content is pending. A clean terminator already cleared rowOpen.
if (rowOpen || field.Length != 0 || row.Count != 0)
{
row.Add(field.ToString());
yield return row.ToArray();
}
}
/// <summary>
/// Reads the body of a quoted field into <paramref name="field"/>. The opening quote has already
/// been consumed. On return the reader sits immediately after the closing quote.
/// </summary>
private static void ReadQuotedField(TextReader reader, StringBuilder field, ref int line, ref int col)
{
var openLine = line;
var openCol = col;
int read;
while ((read = reader.Read()) != -1)
{
var c = (char)read;
col++;
if (c == Quote)
{
if (reader.Peek() == Quote)
{
reader.Read();
col++;
field.Append(Quote);
continue;
}
return; // closing quote
}
if (c == Lf)
{
line++;
col = 0;
}
field.Append(c);
}
throw new FormatException(
$"Unterminated quoted field opened at line {openLine}, column {openCol}: reached end-of-input " +
"before the closing double-quote.");
}
/// <summary>Consumes a newline (CRLF, CR, or LF) whose first character has been peeked but not read.</summary>
private static void ConsumeNewline(TextReader reader, ref int line, ref int col)
{
var first = reader.Read();
if (first == Cr && reader.Peek() == Lf)
{
reader.Read();
}
line++;
col = 0;
}
/// <summary>
/// Header-aware convenience: parses <paramref name="text"/> and maps every subsequent row onto the
/// first (header) row's field names. Thin wrapper over <see cref="Parse(string,char)"/>.
/// </summary>
/// <param name="text">The CSV document; the first row is treated as the header.</param>
/// <param name="delimiter">The field separator (default comma).</param>
/// <returns>
/// One dictionary per data row, keyed by header name (ordinal, case-sensitive). A row shorter than
/// the header maps only the columns present; a column beyond the header's width is keyed by its
/// 0-based index rendered as a string. An empty document yields zero rows.
/// </returns>
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar, or the header contains a duplicate column name.</exception>
public static IReadOnlyList<IReadOnlyDictionary<string, string>> ParseWithHeader(string? text, char delimiter = ',')
{
var rows = Parse(text, delimiter);
if (rows.Count == 0)
{
return Array.Empty<IReadOnlyDictionary<string, string>>();
}
var header = rows[0];
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var name in header)
{
if (!seen.Add(name))
{
throw new FormatException($"Duplicate header column name '{name}'.");
}
}
var result = new List<IReadOnlyDictionary<string, string>>(rows.Count - 1);
for (var i = 1; i < rows.Count; i++)
{
var cells = rows[i];
var map = new Dictionary<string, string>(cells.Length, StringComparer.Ordinal);
for (var c = 0; c < cells.Length; c++)
{
var key = c < header.Length ? header[c] : c.ToString(CultureInfo.InvariantCulture);
map[key] = cells[c];
}
result.Add(map);
}
return result;
}
}
@@ -0,0 +1,142 @@
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Commons.Csv;
/// <summary>
/// A pure RFC 4180 CSV writer with no file/stream I/O of its own — it renders rows of fields to a
/// <see cref="string"/> or a <see cref="TextWriter"/>. The inverse of <see cref="CsvParser"/>:
/// <c>CsvParser.Parse(CsvWriter.WriteToString(rows))</c> reproduces <c>rows</c> exactly for any field
/// content.
/// </summary>
/// <remarks>
/// <para><b>Quote-on-demand.</b> A field is wrapped in double-quotes only when it must be — i.e. when
/// it contains the delimiter, a double-quote, CR, or LF — and internal double-quotes are doubled
/// (<c>"</c> → <c>""</c>). Fields that need no quoting are emitted verbatim, so ordinary values stay
/// human-readable. Pass <c>quoteAllFields: true</c> to force every field quoted.</para>
/// <para><b>Newline.</b> The record terminator defaults to CRLF (<c>\r\n</c>) per RFC 4180 and is
/// configurable. No terminator is written after the final row (matching the parser's
/// no-phantom-trailing-row contract, so a round-trip is exact).</para>
/// </remarks>
public static class CsvWriter
{
private const char Quote = '"';
private const char Cr = '\r';
private const char Lf = '\n';
/// <summary>The RFC 4180 record terminator, <c>"\r\n"</c>. The default <c>newline</c> for every write.</summary>
public const string Crlf = "\r\n";
/// <summary>
/// Renders <paramref name="rows"/> to a CSV string.
/// </summary>
/// <param name="rows">The rows to write; each inner sequence is one record's fields. A <c>null</c> field is written as empty.</param>
/// <param name="delimiter">The field separator (default comma).</param>
/// <param name="newline">The record terminator between rows (default CRLF). Not appended after the last row.</param>
/// <param name="quoteAllFields">When <c>true</c>, every field is quoted regardless of content.</param>
/// <returns>The CSV text. An empty <paramref name="rows"/> yields the empty string.</returns>
/// <exception cref="System.ArgumentNullException"><paramref name="rows"/> is <c>null</c>.</exception>
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
public static string WriteToString(
IEnumerable<IEnumerable<string?>> rows,
char delimiter = ',',
string newline = Crlf,
bool quoteAllFields = false)
{
var sb = new StringBuilder();
using var writer = new StringWriter(sb);
Write(writer, rows, delimiter, newline, quoteAllFields);
return sb.ToString();
}
/// <summary>
/// Writes <paramref name="rows"/> to <paramref name="writer"/>. The writer is not disposed or
/// flushed by this method.
/// </summary>
/// <param name="writer">The destination.</param>
/// <param name="rows">The rows to write; each inner sequence is one record's fields. A <c>null</c> field is written as empty.</param>
/// <param name="delimiter">The field separator (default comma).</param>
/// <param name="newline">The record terminator between rows (default CRLF). Not appended after the last row.</param>
/// <param name="quoteAllFields">When <c>true</c>, every field is quoted regardless of content.</param>
/// <exception cref="System.ArgumentNullException"><paramref name="writer"/> or <paramref name="rows"/> is <c>null</c>.</exception>
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
public static void Write(
TextWriter writer,
IEnumerable<IEnumerable<string?>> rows,
char delimiter = ',',
string newline = Crlf,
bool quoteAllFields = false)
{
ArgumentNullException.ThrowIfNull(writer);
ArgumentNullException.ThrowIfNull(rows);
if (delimiter is Quote or Cr or Lf)
{
throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter));
}
var firstRow = true;
foreach (var row in rows)
{
if (!firstRow)
{
writer.Write(newline);
}
firstRow = false;
WriteRow(writer, row, delimiter, quoteAllFields);
}
}
private static void WriteRow(TextWriter writer, IEnumerable<string?> row, char delimiter, bool quoteAllFields)
{
ArgumentNullException.ThrowIfNull(row);
var firstField = true;
foreach (var field in row)
{
if (!firstField)
{
writer.Write(delimiter);
}
firstField = false;
WriteField(writer, field ?? string.Empty, delimiter, quoteAllFields);
}
}
private static void WriteField(TextWriter writer, string field, char delimiter, bool quoteAllFields)
{
if (!quoteAllFields && !NeedsQuoting(field, delimiter))
{
writer.Write(field);
return;
}
writer.Write(Quote);
foreach (var ch in field)
{
if (ch == Quote)
{
writer.Write(Quote); // double an internal quote
}
writer.Write(ch);
}
writer.Write(Quote);
}
private static bool NeedsQuoting(string field, char delimiter)
{
foreach (var ch in field)
{
if (ch == delimiter || ch == Quote || ch == Cr || ch == Lf)
{
return true;
}
}
return false;
}
}
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
/// <param name="AlarmTypeName">OPC UA Part 9 condition subtype name — one of <c>LimitAlarm</c> / <c>DiscreteAlarm</c> / <c>OffNormalAlarm</c> / <c>AlarmCondition</c> (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.</param> /// <param name="AlarmTypeName">OPC UA Part 9 condition subtype name — one of <c>LimitAlarm</c> / <c>DiscreteAlarm</c> / <c>OffNormalAlarm</c> / <c>AlarmCondition</c> (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.</param>
/// <param name="Comment">Operator-supplied comment on ack / confirm / comment transitions; <c>null</c> for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.</param> /// <param name="Comment">Operator-supplied comment on ack / confirm / comment transitions; <c>null</c> for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.</param>
/// <param name="HistorizeToAveva">When <c>false</c>, the durable historian sink suppresses this transition (the live <c>alerts</c> fan-out is unaffected); <c>null</c> or <c>true</c> historize. <c>null</c> is the cross-version/rolling-restart case: an old-format message missing the field deserializes to <c>null</c> (CLR default for <c>bool?</c>) and is historized (safe default-on), matching the <c>AlarmTypeName</c> null-coalesce in <c>HistorianAdapterActor.Translate</c>. The producer (<c>ScriptedAlarmHostActor</c>) always sets a concrete <c>true</c>/<c>false</c>.</param> /// <param name="HistorizeToAveva">When <c>false</c>, the durable historian sink suppresses this transition (the live <c>alerts</c> fan-out is unaffected); <c>null</c> or <c>true</c> historize. <c>null</c> is the cross-version/rolling-restart case: an old-format message missing the field deserializes to <c>null</c> (CLR default for <c>bool?</c>) and is historized (safe default-on), matching the <c>AlarmTypeName</c> null-coalesce in <c>HistorianAdapterActor.Translate</c>. The producer (<c>ScriptedAlarmHostActor</c>) always sets a concrete <c>true</c>/<c>false</c>.</param>
/// <param name="ReferencingEquipmentPaths">v3 Batch 4 (multi-notifier native alarms) — the (possibly empty) list of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference the alarm's backing raw tag. A native alarm is a SINGLE Part 9 condition materialised once at the raw tag (its <see cref="AlarmId"/> is the RawPath); the same condition fans events to every referencing equipment's UNS folder via SDK notifiers, and this list is carried so <c>/alerts</c> shows the one condition row with all its referencing equipment as display metadata. Empty for scripted alarms (they are per-equipment) and for a native alarm whose raw tag no equipment references. Defaults empty so every existing producer + rolling-restart deserialization keeps compiling / working.</param>
public sealed record AlarmTransitionEvent( public sealed record AlarmTransitionEvent(
string AlarmId, string AlarmId,
string EquipmentPath, string EquipmentPath,
@@ -28,4 +29,5 @@ public sealed record AlarmTransitionEvent(
DateTime TimestampUtc, DateTime TimestampUtc,
string AlarmTypeName = "AlarmCondition", string AlarmTypeName = "AlarmCondition",
string? Comment = null, string? Comment = null,
bool? HistorizeToAveva = null); bool? HistorizeToAveva = null,
IReadOnlyList<string>? ReferencingEquipmentPaths = null);
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// Which of the two v3 OPC UA namespaces a node lives in. Carried explicitly alongside a
/// node's <c>s=</c> identifier at every address-space sink seam so the namespace is chosen at
/// the call site, never parsed back out of the id string (explicit beats inferred).
/// <see cref="V3NodeIds.NamespaceUri"/> maps each realm to its namespace URI.
/// </summary>
public enum AddressSpaceRealm
{
/// <summary>The device-oriented Raw tree (Folder→Driver→Device→TagGroup→Tag); a node's
/// <c>s=</c> id is its <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.RawPaths">RawPath</see>.</summary>
Raw,
/// <summary>The equipment-oriented UNS tree (Area→Line→Equipment→signal); a node's <c>s=</c>
/// id is the slash-joined <c>Area/Line/Equipment[/EffectiveName]</c>.</summary>
Uns,
}
@@ -23,30 +23,41 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance; _inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
/// <inheritdoc /> /// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc); => _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
/// <inheritdoc /> /// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc); => _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
/// <inheritdoc /> /// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); => _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) // Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName); // referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
=> _inner.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); => _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace(); public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
/// <inheritdoc /> /// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) => _inner.RaiseNodesAddedModelChange(affectedNodeId); public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm);
/// <inheritdoc />
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
=> _inner.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType);
/// <inheritdoc /> /// <inheritdoc />
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise — // Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
@@ -55,9 +66,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
// Without this forward the surgical optimization is inert on every driver-role host, because // Without this forward the surgical optimization is inert on every driver-role host, because
// actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape // actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape
// ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host. // ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host.
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength) public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical => _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength); && surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
/// <inheritdoc /> /// <inheritdoc />
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise — // Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
@@ -65,9 +76,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild. // sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
// Without this forward the rename-refresh optimization is inert on every driver-role host, because // Without this forward the rename-refresh optimization is inert on every driver-role host, because
// actors inject THIS wrapper, not the inner sink. // actors inject THIS wrapper, not the inner sink.
public bool UpdateFolderDisplayName(string folderNodeId, string displayName) public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical => _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName); && surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm);
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes / // R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false // UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
@@ -75,14 +86,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is // (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
// inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch. // inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch.
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveVariableNode(string variableNodeId) public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId); => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveAlarmConditionNode(string alarmNodeId) public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId); => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm);
/// <inheritdoc /> /// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId) public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId); => _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId, realm);
} }
@@ -1,31 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// Single source of truth for equipment-namespace OPC UA NodeId strings. The variable NodeId is
/// FOLDER-SCOPED (<c>{parent}/{Name}</c>), NOT the driver-side FullName — a driver wire ref is not
/// unique across identical machines, so FullName-as-NodeId would collide in the sink. Used by the
/// materialiser (AddressSpaceApplier), the VirtualTag publish map, and the driver live-value router so all
/// three agree on the exact NodeId a variable was placed at.
/// </summary>
public static class EquipmentNodeIds
{
/// <summary>The sub-folder NodeId under an equipment for a non-empty FolderPath: <c>{equipmentId}/{folderPath}</c>.</summary>
/// <param name="equipmentId">The owning equipment's NodeId.</param>
/// <param name="folderPath">The tag/vtag FolderPath (must be non-empty for this to be meaningful).</param>
/// <returns>The sub-folder NodeId string.</returns>
public static string SubFolder(string equipmentId, string folderPath) => $"{equipmentId}/{folderPath}";
/// <summary>
/// The folder-scoped variable NodeId: <c>{parent}/{name}</c> where <c>parent = equipmentId</c> when
/// <paramref name="folderPath"/> is null/empty, else <see cref="SubFolder"/>.
/// </summary>
/// <param name="equipmentId">The owning equipment's NodeId.</param>
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
/// <param name="name">The tag/vtag Name (the leaf browse segment).</param>
/// <returns>The folder-scoped variable NodeId string.</returns>
public static string Variable(string equipmentId, string? folderPath, string name)
{
var parent = string.IsNullOrWhiteSpace(folderPath) ? equipmentId : SubFolder(equipmentId, folderPath);
return $"{parent}/{name}";
}
}
@@ -13,7 +13,11 @@ public interface IOpcUaAddressSpaceSink
/// <param name="value">The value to write.</param> /// <param name="value">The value to write.</param>
/// <param name="quality">The quality status of the value.</param> /// <param name="quality">The quality status of the value.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param> /// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc); /// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) the <paramref name="nodeId"/> lives in — the sink
/// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param>
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been /// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>, /// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
@@ -25,7 +29,9 @@ public interface IOpcUaAddressSpaceSink
/// <param name="alarmNodeId">The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).</param> /// <param name="alarmNodeId">The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).</param>
/// <param name="state">The full condition state to project onto the node.</param> /// <param name="state">The full condition state to project onto the node.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param> /// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc); /// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in (must match the realm
/// the condition was materialised under).</param>
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// <summary> /// <summary>
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients /// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
@@ -41,7 +47,31 @@ public interface IOpcUaAddressSpaceSink
/// <param name="isNative">When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when /// <param name="isNative">When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when
/// false (default) it is a scripted alarm. The node manager tracks native condition node ids so a later /// false (default) it is a scripted alarm. The node manager tracks native condition node ids so a later
/// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine.</param> /// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine.</param>
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false); /// <param name="realm">The namespace realm the condition (and its parent folder <paramref name="equipmentNodeId"/>)
/// live in.</param>
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false);
/// <summary>
/// v3 Batch 4 (multi-notifier native alarms) — wire an already-materialised native alarm condition
/// (<paramref name="alarmNodeId"/>, materialised at the raw tag via <see cref="MaterialiseAlarmCondition"/>
/// with ConditionId = the RawPath) as an event notifier of EACH referencing equipment's UNS folder, so
/// the condition's <b>single</b> <c>ReportEvent</c> fans one event to every referencing equipment root
/// WITHOUT re-reporting per root (distinct EventIds would break Server-object dedup + Part 9 ack
/// correlation). Per folder the sink wires the SDK's bidirectional notifier pattern
/// (<c>alarm.AddNotifier(isInverse:true, folder)</c> + <c>folder.AddNotifier(isInverse:false, alarm)</c>)
/// and promotes the folder to an event notifier. <b>Idempotent</b> (a re-wire of the same pair updates,
/// never duplicates); a <b>missing endpoint is a no-op</b> (logged, never thrown) so a mid-rebuild race
/// can't fault a deploy. The sink tracks each wired pair so a later rebuild / condition-removal /
/// equipment-subtree-removal tears the notifier down bidirectionally (no inverse-notifier entry leaks
/// across redeploys).
/// </summary>
/// <param name="alarmNodeId">The native alarm condition's node id (== the backing tag's RawPath).</param>
/// <param name="alarmRealm">The namespace realm the condition lives in (Raw for native alarms).</param>
/// <param name="notifierFolderNodeIds">The equipment folder node ids (their <c>s=</c> ids) to wire as
/// extra event-notifier roots for this condition. An unknown / not-yet-materialised folder id is skipped.</param>
/// <param name="notifierFolderRealm">The namespace realm the notifier folders live in (Uns for equipment folders).</param>
void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm);
/// <summary> /// <summary>
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to /// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to
@@ -52,7 +82,8 @@ public interface IOpcUaAddressSpaceSink
/// <param name="folderNodeId">The OPC UA node ID for the folder.</param> /// <param name="folderNodeId">The OPC UA node ID for the folder.</param>
/// <param name="parentNodeId">The parent folder node ID, or null for namespace root.</param> /// <param name="parentNodeId">The parent folder node ID, or null for namespace root.</param>
/// <param name="displayName">The display name for the folder.</param> /// <param name="displayName">The display name for the folder.</param>
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName); /// <param name="realm">The namespace realm the folder (and its <paramref name="parentNodeId"/>) live in.</param>
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm);
/// <summary> /// <summary>
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under /// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
@@ -77,7 +108,10 @@ public interface IOpcUaAddressSpaceSink
/// rank + dimensions carry the array-ness.</param> /// rank + dimensions carry the array-ness.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is /// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is
/// true; ignored for scalars. Null ⇒ length 0 (unbounded).</param> /// true; ignored for scalars. Null ⇒ length 0 (unbounded).</param>
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null); /// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
/// <summary> /// <summary>
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a /// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
@@ -91,7 +125,28 @@ public interface IOpcUaAddressSpaceSink
/// (Part 3 GeneralModelChangeEvent, verb NodeAdded). /// (Part 3 GeneralModelChangeEvent, verb NodeAdded).
/// </summary> /// </summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param> /// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
void RaiseNodesAddedModelChange(string affectedNodeId); /// <param name="realm">The namespace realm <paramref name="affectedNodeId"/> lives in.</param>
void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm);
/// <summary>
/// Add an OPC UA reference from an already-materialised source node to an already-materialised
/// target node, both realm-qualified. Used by <c>AddressSpaceApplier</c> to link each UNS reference
/// Variable (source, <see cref="AddressSpaceRealm.Uns"/>) to its backing Raw node (target,
/// <see cref="AddressSpaceRealm.Raw"/>) with an <c>Organizes</c> edge, so the UNS variable
/// browse-resolves to its raw source (and the raw node back via the inverse). The edge is wired
/// bidirectionally (forward on the source, inverse on the target) so browse resolves both ways.
/// <b>Idempotent</b> (an existing edge is not duplicated); a <b>missing endpoint is a no-op</b>
/// (logged, never thrown) so a mid-rebuild race can't fault a deploy.
/// </summary>
/// <param name="sourceNodeId">The source node's <c>s=</c> id (the referencing node — e.g. the UNS variable).</param>
/// <param name="sourceRealm">The namespace realm <paramref name="sourceNodeId"/> lives in.</param>
/// <param name="targetNodeId">The target node's <c>s=</c> id (the referenced node — e.g. the backing Raw node).</param>
/// <param name="targetRealm">The namespace realm <paramref name="targetNodeId"/> lives in.</param>
/// <param name="referenceType">The hierarchical reference type name — <c>Organizes</c> (default),
/// <c>HasComponent</c>, or <c>HasProperty</c>; unknown names fall back to <c>Organizes</c>.</param>
void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm,
string targetNodeId, AddressSpaceRealm targetRealm,
string referenceType = "Organizes");
} }
/// <summary>OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained /// <summary>OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained
@@ -106,23 +161,29 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
private NullOpcUaAddressSpaceSink() { } private NullOpcUaAddressSpaceSink() { }
/// <inheritdoc /> /// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { } public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
/// <inheritdoc /> /// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { } public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
/// <inheritdoc /> /// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { } public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <inheritdoc /> /// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
/// <inheritdoc /> /// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { } public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <inheritdoc /> /// <inheritdoc />
public void RebuildAddressSpace() { } public void RebuildAddressSpace() { }
/// <inheritdoc /> /// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) { } public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
/// <inheritdoc />
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
} }
@@ -11,11 +11,17 @@ public interface IOpcUaNodeWriteGateway
{ {
/// <summary>Route a write of <paramref name="value"/> to the driver backing node /// <summary>Route a write of <paramref name="value"/> to the driver backing node
/// <paramref name="nodeId"/>; the returned task resolves with the device-write outcome.</summary> /// <paramref name="nodeId"/>; the returned task resolves with the device-write outcome.</summary>
/// <param name="nodeId">The folder-scoped equipment-variable node id being written.</param> /// <param name="nodeId">The full ns-qualified node id being written (<c>node.NodeId.ToString()</c>).</param>
/// <param name="value">The value the client wrote.</param> /// <param name="value">The value the client wrote.</param>
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) <paramref name="nodeId"/> lives in — the node
/// manager resolves it from the node's namespace index (<c>RealmOf</c>). It is REQUIRED for correct
/// routing: a raw <c>s=&lt;RawPath&gt;</c> and a UNS <c>s=&lt;Area/Line/Equip/Eff&gt;</c> can collide as bare
/// strings, so the routing map is keyed by <c>(realm, bareId)</c> — dropping the realm would let an
/// operator write route to the WRONG driver ref.</param>
/// <param name="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
/// <returns>A task resolving to the device-write outcome.</returns> /// <returns>A task resolving to the device-write outcome.</returns>
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct); Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct);
} }
/// <summary>Outcome of routing an inbound node write to the backing driver.</summary> /// <summary>Outcome of routing an inbound node write to the backing driver.</summary>
@@ -31,6 +37,6 @@ public sealed class NullOpcUaNodeWriteGateway : IOpcUaNodeWriteGateway
public static readonly NullOpcUaNodeWriteGateway Instance = new(); public static readonly NullOpcUaNodeWriteGateway Instance = new();
private NullOpcUaNodeWriteGateway() { } private NullOpcUaNodeWriteGateway() { }
/// <inheritdoc /> /// <inheritdoc />
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct) => public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
Task.FromResult(new NodeWriteOutcome(false, "writes unavailable")); Task.FromResult(new NodeWriteOutcome(false, "writes unavailable"));
} }
@@ -21,8 +21,10 @@ public interface ISurgicalAddressSpaceSink
/// <param name="dataType">The OPC UA built-in data type name (e.g. "Boolean", "Int32", "Float").</param> /// <param name="dataType">The OPC UA built-in data type name (e.g. "Boolean", "Int32", "Float").</param>
/// <param name="isArray">When true the node is a 1-D array (ValueRank=OneDimension); when false scalar.</param> /// <param name="isArray">When true the node is a 1-D array (ValueRank=OneDimension); when false scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param> /// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
/// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in — the sink resolves the
/// namespace index from the realm rather than parsing it out of the id string.</param>
/// <returns>True when the in-place update was applied; false when the node is missing (caller rebuilds).</returns> /// <returns>True when the in-place update was applied; false when the node is missing (caller rebuilds).</returns>
bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength); bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm);
/// <summary>Update an existing folder node's <c>DisplayName</c> IN PLACE, notifying subscribers /// <summary>Update an existing folder node's <c>DisplayName</c> IN PLACE, notifying subscribers
/// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line /// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line
@@ -32,8 +34,9 @@ public interface ISurgicalAddressSpaceSink
/// should rebuild instead).</summary> /// should rebuild instead).</summary>
/// <param name="folderNodeId">The node id of the folder whose display name to update in place.</param> /// <param name="folderNodeId">The node id of the folder whose display name to update in place.</param>
/// <param name="displayName">The new display name to apply.</param> /// <param name="displayName">The new display name to apply.</param>
/// <param name="realm">The namespace realm <paramref name="folderNodeId"/> lives in.</param>
/// <returns>True when the in-place update was applied; false when the folder is missing (caller rebuilds).</returns> /// <returns>True when the in-place update was applied; false when the folder is missing (caller rebuilds).</returns>
bool UpdateFolderDisplayName(string folderNodeId, string displayName); bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm);
/// <summary>Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the /// <summary>Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the
/// predefined-node set, drop any historized-tagname registration), then raise a Part 3 /// predefined-node set, drop any historized-tagname registration), then raise a Part 3
@@ -43,8 +46,9 @@ public interface ISurgicalAddressSpaceSink
/// re-subscription attempts get BadNodeIdUnknown from the SDK. Returns false when the node id is unknown /// re-subscription attempts get BadNodeIdUnknown from the SDK. Returns false when the node id is unknown
/// (the node-manager maps drifted from the plan — caller falls back to a full rebuild).</summary> /// (the node-manager maps drifted from the plan — caller falls back to a full rebuild).</summary>
/// <param name="variableNodeId">The folder-scoped node id of the variable to remove in place.</param> /// <param name="variableNodeId">The folder-scoped node id of the variable to remove in place.</param>
/// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in.</param>
/// <returns>True when the node was removed; false when the id is unknown (caller rebuilds).</returns> /// <returns>True when the node was removed; false when the id is unknown (caller rebuilds).</returns>
bool RemoveVariableNode(string variableNodeId); bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm);
/// <summary>Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the /// <summary>Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the
/// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The /// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The
@@ -52,8 +56,9 @@ public interface ISurgicalAddressSpaceSink
/// stops replaying on ConditionRefresh. Returns false when the condition id is unknown (caller /// stops replaying on ConditionRefresh. Returns false when the condition id is unknown (caller
/// rebuilds).</summary> /// rebuilds).</summary>
/// <param name="alarmNodeId">The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id).</param> /// <param name="alarmNodeId">The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id).</param>
/// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in.</param>
/// <returns>True when the condition was removed; false when the id is unknown (caller rebuilds).</returns> /// <returns>True when the condition was removed; false when the id is unknown (caller rebuilds).</returns>
bool RemoveAlarmConditionNode(string alarmNodeId); bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm);
/// <summary>Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables, /// <summary>Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables,
/// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames, /// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames,
@@ -62,6 +67,7 @@ public interface ISurgicalAddressSpaceSink
/// the equipment folder outside the lock. Returns false when the folder id is unknown (caller /// the equipment folder outside the lock. Returns false when the folder id is unknown (caller
/// rebuilds).</summary> /// rebuilds).</summary>
/// <param name="equipmentNodeId">The equipment folder node id whose entire subtree to remove.</param> /// <param name="equipmentNodeId">The equipment folder node id whose entire subtree to remove.</param>
/// <param name="realm">The namespace realm <paramref name="equipmentNodeId"/> lives in.</param>
/// <returns>True when the subtree was removed; false when the id is unknown (caller rebuilds).</returns> /// <returns>True when the subtree was removed; false when the id is unknown (caller rebuilds).</returns>
bool RemoveEquipmentSubtree(string equipmentNodeId); bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm);
} }
@@ -0,0 +1,110 @@
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// v3 dual-namespace NodeId + namespace-URI authority. Two OPC UA namespaces expose the same
/// underlying values under two identity schemes:
/// <list type="bullet">
/// <item><b>Raw</b> (<see cref="RawNamespaceUri"/>): the device-oriented tree
/// Folder→Driver→Device→TagGroup→Tag. A node's <c>s=</c> identifier IS its
/// <see cref="RawPaths">RawPath</see> — folders/drivers/devices/groups included (each keys
/// on its own RawPath prefix).</item>
/// <item><b>Uns</b> (<see cref="UnsNamespaceUri"/>): the equipment-oriented tree
/// Area→Line→Equipment→signal. A node's <c>s=</c> identifier is the slash-joined
/// <c>Area/Line/Equipment[/EffectiveName]</c>.</item>
/// </list>
/// These replace the single <c>https://zb.com/otopcua/ns</c> namespace and the retired
/// <c>EquipmentNodeIds</c> ({equipmentId}/{folderPath}/{name}) scheme. Realm travels alongside
/// the identifier as an <see cref="AddressSpaceRealm"/> at every sink seam — the namespace is
/// never parsed back out of the id string. Both schemes share <see cref="RawPaths.Separator"/>
/// and its ordinal, case-sensitive segment charset so identity is enforced in one place.
/// </summary>
public static class V3NodeIds
{
/// <summary>The Raw namespace URI (device-oriented subtree).</summary>
public const string RawNamespaceUri = "https://zb.com/otopcua/raw";
/// <summary>The UNS namespace URI (equipment-oriented subtree).</summary>
public const string UnsNamespaceUri = "https://zb.com/otopcua/uns";
/// <summary>The namespace URI for a realm.</summary>
/// <param name="realm">The address-space realm.</param>
/// <returns>The realm's namespace URI.</returns>
/// <exception cref="ArgumentOutOfRangeException">Unknown realm.</exception>
public static string NamespaceUri(AddressSpaceRealm realm) => realm switch
{
AddressSpaceRealm.Raw => RawNamespaceUri,
AddressSpaceRealm.Uns => UnsNamespaceUri,
_ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."),
};
// ----- Raw realm -----
/// <summary>
/// The Raw-namespace <c>s=</c> identifier for a raw node — identical to its
/// <see cref="RawPaths">RawPath</see> (folders, drivers, devices, groups, and tags all key
/// on their own RawPath). A pass-through seam so call sites read intent rather than a bare
/// string; the argument is expected to already be a RawPaths-built path.
/// </summary>
/// <param name="rawPath">The (already-built) RawPath.</param>
/// <returns>The Raw-namespace <c>s=</c> identifier (== <paramref name="rawPath"/>).</returns>
public static string Raw(string rawPath)
{
ArgumentException.ThrowIfNullOrEmpty(rawPath);
return rawPath;
}
// ----- Uns realm -----
/// <summary>
/// The UNS-namespace <c>s=</c> identifier for a folder or variable, slash-joined from
/// ordered segments (Area, then Line, then Equipment, then an optional EffectiveName).
/// Every segment is validated via <see cref="RawPaths.ValidateSegment"/> (no embedded
/// separator, no leading/trailing whitespace) so a UNS id round-trips like a RawPath.
/// </summary>
/// <param name="segments">The ordered, non-empty segments (Area → leaf).</param>
/// <returns>The slash-joined UNS <c>s=</c> identifier.</returns>
/// <exception cref="ArgumentException">A segment is invalid, or the sequence is empty.</exception>
public static string Uns(params string[] segments) => UnsFromSegments(segments);
/// <summary>Build a UNS <c>s=</c> identifier from ordered segments. See <see cref="Uns(string[])"/>.</summary>
/// <param name="segments">The ordered, non-empty segments (Area → leaf).</param>
/// <returns>The slash-joined UNS <c>s=</c> identifier.</returns>
public static string Uns(IEnumerable<string> segments) => UnsFromSegments(segments);
/// <summary>
/// Append a child segment (a Line under an Area, an Equipment under a Line, an
/// EffectiveName under an Equipment) to an already-built UNS path.
/// </summary>
/// <param name="parentUnsPath">The parent UNS path (assumed already valid).</param>
/// <param name="childSegment">The child segment to append.</param>
/// <returns>The combined UNS <c>s=</c> identifier.</returns>
/// <exception cref="ArgumentException">The child segment is invalid, or the parent is blank.</exception>
public static string UnsChild(string parentUnsPath, string childSegment)
{
ArgumentException.ThrowIfNullOrEmpty(parentUnsPath);
var error = RawPaths.ValidateSegment(childSegment);
if (error is not null)
throw new ArgumentException($"Invalid UNS NodeId segment ('{childSegment}'): {error}", nameof(childSegment));
return parentUnsPath + RawPaths.Separator + childSegment;
}
private static string UnsFromSegments(IEnumerable<string> segments)
{
ArgumentNullException.ThrowIfNull(segments);
var list = segments as IReadOnlyList<string> ?? segments.ToList();
if (list.Count == 0)
throw new ArgumentException("A UNS NodeId must have at least one segment.", nameof(segments));
for (var i = 0; i < list.Count; i++)
{
var error = RawPaths.ValidateSegment(list[i]);
if (error is not null)
throw new ArgumentException($"Invalid UNS NodeId segment at index {i} ('{list[i]}'): {error}", nameof(segments));
}
return string.Join(RawPaths.Separator, list);
}
}
@@ -0,0 +1,94 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// Builds the per-equipment reference map — <c>equipmentId → (effectiveName → backing-tag RawPath)</c> —
/// the single authority both compose seams (<c>AddressSpaceComposer</c> + <c>DeploymentArtifact</c>)
/// and the draft validator use to resolve <c>{{equip}}/&lt;RefName&gt;</c> script paths. A reference's
/// <b>effective name</b> is its <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>; the
/// RawPath is computed through the shared <see cref="RawPathResolver"/> (the same identity authority the
/// rest of v3 uses), so the entity side (authoring/validation) and the artifact-decode side agree
/// byte-for-byte.
/// <para>Input-shape agnostic + pure: callers flatten their references (EF entities on one side,
/// artifact JSON on the other) into the same primitive tuple + tag lookup, so the join, the
/// effective-name rule, and the collision policy live ONLY here. References are consumed in
/// <c>UnsTagReferenceId</c>-ordinal order and effective-name collisions keep the FIRST — a valid draft
/// has none (the uniqueness validator rejects them), and the deterministic first-wins keeps the two
/// seams parity-stable on any input.</para>
/// </summary>
public static class EquipmentReferenceMap
{
/// <summary>A flattened UNS tag reference: which equipment references which raw tag under an optional override.</summary>
/// <param name="UnsTagReferenceId">Stable logical id — the ordering key for deterministic first-wins.</param>
/// <param name="EquipmentId">The referencing equipment.</param>
/// <param name="TagId">The backing raw tag.</param>
/// <param name="DisplayNameOverride">Optional effective-name override; <see langword="null"/> = use the raw tag's Name.</param>
public readonly record struct ReferenceRow(string UnsTagReferenceId, string EquipmentId, string TagId, string? DisplayNameOverride);
/// <summary>The backing raw tag's identity inputs for its RawPath + effective name.</summary>
/// <param name="Name">The raw tag's leaf name (also the default effective name).</param>
/// <param name="DeviceId">The tag's owning device id.</param>
/// <param name="TagGroupId">The tag's owning tag-group id, or <see langword="null"/> when directly under the device.</param>
public readonly record struct TagRow(string Name, string DeviceId, string? TagGroupId);
/// <summary>
/// Build the reference map. For each reference (ordered by <c>UnsTagReferenceId</c>), resolve the
/// backing tag's RawPath via <paramref name="resolver"/> and key it under the effective name within
/// the owning equipment. References whose backing tag is unknown or whose RawPath cannot be built
/// (broken chain) are skipped. Effective-name collisions within an equipment keep the first.
/// </summary>
/// <param name="references">The flattened UNS tag references (any order; sorted internally).</param>
/// <param name="tagsById">Backing raw tags keyed by <c>TagId</c>.</param>
/// <param name="resolver">The shared RawPath resolver over the raw topology.</param>
/// <returns><c>equipmentId → (effectiveName → RawPath)</c>. Equipments with no resolvable reference are absent.</returns>
public static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> Build(
IEnumerable<ReferenceRow> references,
IReadOnlyDictionary<string, TagRow> tagsById,
RawPathResolver resolver)
{
ArgumentNullException.ThrowIfNull(references);
ArgumentNullException.ThrowIfNull(tagsById);
ArgumentNullException.ThrowIfNull(resolver);
var byEquip = new Dictionary<string, Dictionary<string, string>>(StringComparer.Ordinal);
foreach (var r in references.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal))
{
if (string.IsNullOrEmpty(r.EquipmentId) || string.IsNullOrEmpty(r.TagId)) continue;
if (!tagsById.TryGetValue(r.TagId, out var tag)) continue;
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!;
if (string.IsNullOrEmpty(effectiveName)) continue;
var rawPath = resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names)
if (!byEquip.TryGetValue(r.EquipmentId, out var map))
byEquip[r.EquipmentId] = map = new Dictionary<string, string>(StringComparer.Ordinal);
map.TryAdd(effectiveName, rawPath); // first-wins on collision (validator rejects real collisions)
}
return byEquip.ToDictionary(
kv => kv.Key,
kv => (IReadOnlyDictionary<string, string>)kv.Value,
StringComparer.Ordinal);
}
/// <summary>The set of effective names a single equipment's references contribute (for the authoring
/// gate + Monaco completion, which need only the names — not the RawPaths).</summary>
/// <param name="references">The flattened UNS tag references for (typically) one equipment.</param>
/// <param name="tagNameById">Backing raw tag Name keyed by <c>TagId</c>.</param>
/// <returns>The distinct effective names, ordinal.</returns>
public static IReadOnlySet<string> EffectiveNames(
IEnumerable<ReferenceRow> references,
IReadOnlyDictionary<string, string> tagNameById)
{
ArgumentNullException.ThrowIfNull(references);
ArgumentNullException.ThrowIfNull(tagNameById);
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in references)
{
var effectiveName = r.DisplayNameOverride
?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (!string.IsNullOrEmpty(effectiveName)) names.Add(effectiveName);
}
return names;
}
}
@@ -3,19 +3,29 @@ using System.Text.RegularExpressions;
namespace ZB.MOM.WW.OtOpcUa.Commons.Types; namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary> /// <summary>
/// Helpers for equipment-relative virtual-tag script paths. The reserved token /// Helpers for equipment-relative virtual-tag / scripted-alarm script paths. The reserved token
/// <c>{{equip}}</c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is /// <c>{{equip}}/&lt;RefName&gt;</c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is
/// replaced at the compose seams with the owning equipment's tag base prefix (derived /// replaced at the compose seams (<c>AddressSpaceComposer</c> + <c>DeploymentArtifact</c>) with the
/// from its child-tag <c>FullName</c>s). Pure + regex-based (no Roslyn) so the OpcUaServer /// backing raw tag's <c>RawPath</c>, resolved through the owning equipment's
/// composer and the Runtime artifact-decode path can both share it. Also the single home /// <c>UnsTagReference</c> rows by effective name (<c>&lt;RefName&gt;</c>).
/// for the <c>ctx.GetTag("…")</c> dependency-ref extraction those two seams used to /// <para><b>v3 syntax:</b> the token joint is a <b>slash</b> — <c>{{equip}}/&lt;RefName&gt;</c> — replacing
/// duplicate. /// the v2 dot-prefix derivation (<c>{{equip}}.X</c>). <c>&lt;RefName&gt;</c> is a reference's effective name
/// (its <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>). An unresolved
/// <c>&lt;RefName&gt;</c> is left un-substituted (substitution never throws); the deploy-time validator +
/// the authoring gate + the Monaco diagnostic reject it first, preserving the invariant
/// <b>the editor accepts ⇔ publish accepts</b>.</para>
/// Pure + regex-based (no Roslyn) so the OpcUaServer composer and the Runtime artifact-decode path can
/// both share it. Also the single home for the <c>ctx.GetTag("…")</c> dependency-ref extraction those
/// two seams used to duplicate.
/// </summary> /// </summary>
public static class EquipmentScriptPaths public static class EquipmentScriptPaths
{ {
/// <summary>The reserved equipment-base token.</summary> /// <summary>The reserved equipment token stem.</summary>
public const string EquipToken = "{{equip}}"; public const string EquipToken = "{{equip}}";
/// <summary>The reserved equipment reference-relative prefix — the token stem plus the slash joint.</summary>
public const string EquipTokenPrefix = "{{equip}}/";
// ctx.GetTag("ref") — reads only; the dependency graph subscribes to exactly these. // ctx.GetTag("ref") — reads only; the dependency graph subscribes to exactly these.
private static readonly Regex GetTagRefRegex = private static readonly Regex GetTagRefRegex =
new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled); new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled);
@@ -32,6 +42,14 @@ public static class EquipmentScriptPaths
private static readonly Regex PathLiteralRegex = private static readonly Regex PathLiteralRegex =
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled); new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled);
// {{equip}}/<RefName> occurrence in FREE TEXT (message templates). <RefName> runs until the next
// delimiter that cannot appear in an intra-literal RawPath segment used inline in a template —
// whitespace, quote, brace, paren, semicolon, or the '/' separator. (Reference effective names may
// contain internal spaces; that space-bearing form is unambiguous only inside a path literal, so the
// free-text scan is best-effort — see ExtractEquipReferenceNamesFromText.)
private static readonly Regex EquipRefTextRegex =
new(@"\{\{equip\}\}/([^\s""{}();/]+)", RegexOptions.Compiled);
// A pure pass-through virtual-tag body: exactly `return ctx.GetTag("<ref>").Value;` // A pure pass-through virtual-tag body: exactly `return ctx.GetTag("<ref>").Value;`
// (whitespace-insensitive). Captures <ref> for relay→alias conversion. Anything with extra // (whitespace-insensitive). Captures <ref> for relay→alias conversion. Anything with extra
// statements, arithmetic, a different member than .Value, or multiple GetTag calls is NOT a relay. // statements, arithmetic, a different member than .Value, or multiple GetTag calls is NOT a relay.
@@ -46,44 +64,89 @@ public static class EquipmentScriptPaths
!string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal); !string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal);
/// <summary> /// <summary>
/// Equipment tag base = the single shared substring-before-first-dot across the /// Replace each <c>{{equip}}/&lt;RefName&gt;</c> reference-relative path with the backing raw tag's
/// equipment's child-tag <c>FullName</c>s. Returns <c>null</c> when there are no usable /// <c>RawPath</c> from <paramref name="referenceMap"/> (effective name → RawPath), inside
/// FullNames or they don't agree on one prefix (equipment spanning multiple objects). /// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. A path literal whose content is
/// <c>{{equip}}/&lt;RefName&gt;</c> is treated as a single reference: <c>&lt;RefName&gt;</c> is the
/// entire remainder after the prefix (so reference effective names may contain internal spaces).
/// Identity when <paramref name="source"/> is null/empty, <paramref name="referenceMap"/> is empty,
/// or the token is absent (so every existing script — none of which use the token — is byte-unchanged).
/// An <c>&lt;RefName&gt;</c> absent from the map is left un-substituted (never throws) — the validator
/// rejects it first at deploy.
/// </summary> /// </summary>
/// <param name="childFullNames">The equipment's child-tag driver FullNames.</param> /// <param name="source">The script source.</param>
/// <returns>The shared base prefix, or null when none/ambiguous.</returns> /// <param name="referenceMap">The equipment's reference map: effective name → backing-tag RawPath.</param>
public static string? DeriveEquipmentBase(IEnumerable<string?> childFullNames) /// <returns>The source with each resolvable <c>{{equip}}/&lt;RefName&gt;</c> substituted inside path literals.</returns>
public static string SubstituteEquipmentToken(string source, IReadOnlyDictionary<string, string> referenceMap)
{ {
string? found = null; if (string.IsNullOrEmpty(source) || referenceMap is null || referenceMap.Count == 0) return source;
foreach (var fn in childFullNames) if (!source.Contains(EquipTokenPrefix, StringComparison.Ordinal)) return source;
{ return PathLiteralRegex.Replace(source, m =>
if (string.IsNullOrWhiteSpace(fn)) continue; m.Groups[1].Value
var dot = fn.IndexOf('.'); + SubstituteContent(m.Groups[2].Value, referenceMap)
var prefix = dot < 0 ? fn : fn.Substring(0, dot); + m.Groups[3].Value);
if (prefix.Length == 0) continue; }
if (found is null) found = prefix;
else if (!string.Equals(found, prefix, StringComparison.Ordinal)) return null; // Substitute the reference-relative token inside a single path-literal's content. The content is one
} // whole tag path; when it starts with the {{equip}}/ prefix the remainder is the reference name.
return found; private static string SubstituteContent(string content, IReadOnlyDictionary<string, string> referenceMap)
{
if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) return content;
var refName = content.Substring(EquipTokenPrefix.Length);
if (refName.Length == 0) return content;
return referenceMap.TryGetValue(refName, out var rawPath) ? rawPath : content;
} }
/// <summary> /// <summary>
/// Replace <c>{{equip}}</c> with <paramref name="equipBase"/> inside /// Distinct <c>&lt;RefName&gt;</c> values used via <c>{{equip}}/&lt;RefName&gt;</c> inside
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. Identity when /// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals, in first-seen order. Scoped to the SAME
/// <paramref name="equipBase"/> is null/empty or the token is absent (so every existing /// path literals <see cref="SubstituteEquipmentToken"/> operates on (so a token in a comment / logger
/// script — none of which use the token — is byte-unchanged). /// string is not reported), and the entire post-prefix remainder is the reference name (matching
/// substitution) — this keeps the validator / authoring gate / Monaco diagnostic in lockstep with what
/// resolves. Feeds the deploy-gate + authoring rejection + editor completion.
/// </summary> /// </summary>
/// <param name="source">The script source.</param> /// <param name="scriptSource">The virtual-tag / scripted-alarm predicate script source.</param>
/// <param name="equipBase">The equipment base prefix, or null/empty for no substitution.</param> /// <returns>Distinct reference names, first-seen order; empty when none / no token.</returns>
/// <returns>The source with the token substituted inside path literals.</returns> public static IReadOnlyList<string> ExtractEquipReferenceNames(string? scriptSource)
public static string SubstituteEquipmentToken(string source, string? equipBase)
{ {
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(equipBase)) return source; if (string.IsNullOrEmpty(scriptSource)
if (!source.Contains(EquipToken, StringComparison.Ordinal)) return source; || !scriptSource.Contains(EquipTokenPrefix, StringComparison.Ordinal))
return PathLiteralRegex.Replace(source, m => return Array.Empty<string>();
m.Groups[1].Value var seen = new HashSet<string>(StringComparer.Ordinal);
+ m.Groups[2].Value.Replace(EquipToken, equipBase, StringComparison.Ordinal) var result = new List<string>();
+ m.Groups[3].Value); foreach (Match m in PathLiteralRegex.Matches(scriptSource))
{
var content = m.Groups[2].Value;
if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) continue;
var refName = content.Substring(EquipTokenPrefix.Length);
if (refName.Length > 0 && seen.Add(refName)) result.Add(refName);
}
return result;
}
/// <summary>
/// Distinct <c>&lt;RefName&gt;</c> values used via <c>{{equip}}/&lt;RefName&gt;</c> in FREE TEXT — the
/// scripted-alarm <c>MessageTemplate</c>, which is not a path literal — in first-seen order. Best-effort:
/// a reference name is captured up to the next whitespace / quote / brace / paren / semicolon / slash, so
/// a space-bearing effective name used inline in a template resolves only up to its first space (a
/// documented, benign limitation — template rendering is dark until Batch 4). Feeds the deploy-gate rule
/// for alarm message tokens.
/// </summary>
/// <param name="text">The free text (e.g. a scripted-alarm message template).</param>
/// <returns>Distinct reference names, first-seen order; empty when none / no token.</returns>
public static IReadOnlyList<string> ExtractEquipReferenceNamesFromText(string? text)
{
if (string.IsNullOrEmpty(text)
|| !text.Contains(EquipTokenPrefix, StringComparison.Ordinal))
return Array.Empty<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>();
foreach (Match m in EquipRefTextRegex.Matches(text))
{
var refName = m.Groups[1].Value;
if (refName.Length > 0 && seen.Add(refName)) result.Add(refName);
}
return result;
} }
/// <summary> /// <summary>
@@ -115,10 +178,11 @@ public static class EquipmentScriptPaths
/// <c>{{equip}}</c> double-brace form is excluded by the token regex. Deterministic so the live /// <c>{{equip}}</c> double-brace form is excluded by the token regex. Deterministic so the live
/// composer (<c>AddressSpaceComposer</c>) and the artifact-decode mirror (<c>DeploymentArtifact</c>) /// composer (<c>AddressSpaceComposer</c>) and the artifact-decode mirror (<c>DeploymentArtifact</c>)
/// produce the exact same ordered list — the byte-parity contract <c>EquipmentScriptedAlarmPlan</c> /// produce the exact same ordered list — the byte-parity contract <c>EquipmentScriptedAlarmPlan</c>
/// equality depends on. Scripted alarms do NOT use <c>{{equip}}</c> substitution (only virtual /// equality depends on. The predicate source passed here is the ALREADY-substituted source (the two
/// tags do) — pass the predicate source as-is. /// compose seams substitute <c>{{equip}}/&lt;RefName&gt;</c> before extraction, identically), so the
/// merged refs are resolved RawPaths.
/// </summary> /// </summary>
/// <param name="predicateSource">The resolved predicate script source.</param> /// <param name="predicateSource">The resolved (substituted) predicate script source.</param>
/// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param> /// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param>
/// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns> /// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns>
public static IReadOnlyList<string> ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate) public static IReadOnlyList<string> ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate)
@@ -154,7 +218,7 @@ public static class EquipmentScriptPaths
/// <param name="source">The virtual-tag script source to inspect.</param> /// <param name="source">The virtual-tag script source to inspect.</param>
/// <param name="tagReference"> /// <param name="tagReference">
/// When the method returns <see langword="true"/>, the captured <c>GetTag</c> path literal /// When the method returns <see langword="true"/>, the captured <c>GetTag</c> path literal
/// (e.g. <c>TestMachine_020.TestChangingInt</c> or <c>{{equip}}.Speed</c>); /// (e.g. <c>TestMachine_020.TestChangingInt</c> or <c>{{equip}}/Speed</c>);
/// otherwise <see langword="null"/>. /// otherwise <see langword="null"/>.
/// </param> /// </param>
/// <returns> /// <returns>
@@ -55,6 +55,10 @@ public sealed class DraftSnapshot
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = []; public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
/// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary> /// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary>
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = []; public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
/// <summary>User-authored scripts (shared by VirtualTags, ScriptedAlarms, and Calculation tags). Drives
/// the WP7 Calculation gates: <c>scriptId</c> existence + the calc→calc dependency-cycle check.</summary>
public IReadOnlyList<Script> Scripts { get; init; } = [];
/// <summary>Gets the list of poll groups.</summary> /// <summary>Gets the list of poll groups.</summary>
public IReadOnlyList<PollGroup> PollGroups { get; init; } = []; public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
@@ -50,6 +50,7 @@ public static class DraftSnapshotFactory
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct), UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct), VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct), ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct), PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
ActiveReservations = await db.ExternalIdReservations ActiveReservations = await db.ExternalIdReservations
@@ -2,6 +2,7 @@ using System.Text.RegularExpressions;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation; namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
@@ -38,9 +39,108 @@ public static class DraftValidator
ValidateRawNameCharset(draft, errors); ValidateRawNameCharset(draft, errors);
ValidateHistorizedTagnameLength(draft, errors); ValidateHistorizedTagnameLength(draft, errors);
ValidateUnsEffectiveLeafUniqueness(draft, errors); ValidateUnsEffectiveLeafUniqueness(draft, errors);
ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors);
return errors; return errors;
} }
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
/// <list type="number">
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
/// a <see cref="Script"/> row in the draft.</item>
/// <item><b>Cycle gate (hard error)</b> — build the calc→calc edge set (a calc tag → its
/// <c>ctx.GetTag</c> deps that are <b>themselves</b> Calculation tags; cross-driver refs are terminal,
/// not edges) and run <see cref="DependencyGraph.DetectCycles"/> (Tarjan SCC). Any SCC / self-loop is a
/// deploy error naming the cycle members — an undetected calc cycle is a live oscillation loop.</item>
/// </list>
/// Compile is deliberately NOT hard-gated (VirtualTag parity — the editor's live diagnostics mirror it and
/// a runtime compile failure lands as Bad quality + a script-log).</summary>
private static void ValidateCalculationTags(DraftSnapshot draft, List<ValidationError> errors)
{
var driverTypeByInstance = draft.DriverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
var driverTypeByDevice = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => driverTypeByInstance.TryGetValue(g.First().DriverInstanceId, out var dt) ? dt : null,
StringComparer.Ordinal);
var resolver = BuildRawPathResolver(draft);
var scriptExists = new HashSet<string>(draft.Scripts.Select(s => s.ScriptId), StringComparer.Ordinal);
var scriptSourceById = draft.Scripts
.GroupBy(s => s.ScriptId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
// Identify calc tags (tags on a Calculation-typed device) + their RawPath + scriptId in one pass.
var calcTags = new List<(Tag Tag, string? RawPath, string? ScriptId)>();
var calcRawPaths = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in draft.Tags)
{
if (!driverTypeByDevice.TryGetValue(t.DeviceId, out var dt)
|| !string.Equals(dt, Core.Abstractions.DriverTypeNames.Calculation, StringComparison.Ordinal))
continue;
var rawPath = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
calcTags.Add((t, rawPath, ParseScriptId(t.TagConfig)));
if (rawPath is not null) calcRawPaths.Add(rawPath);
}
if (calcTags.Count == 0) return;
// Rule 1 — scriptId existence.
foreach (var (t, _, scriptId) in calcTags)
{
if (string.IsNullOrWhiteSpace(scriptId))
errors.Add(new("CalculationScriptMissing",
$"Calculation tag '{t.TagId}' has no 'scriptId' in its TagConfig", t.TagId));
else if (!scriptExists.Contains(scriptId!))
errors.Add(new("CalculationScriptNotFound",
$"Calculation tag '{t.TagId}' references script '{scriptId}', which does not exist in the draft",
t.TagId));
}
// Rule 2 — cycle gate. Edges run from a calc tag to each dep that is ITSELF a calc tag.
var graph = new DependencyGraph();
foreach (var (_, rawPath, scriptId) in calcTags)
{
if (rawPath is null) continue; // broken chain — flagged by the charset rule; not a graph node
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src) ? src : null;
var calcDeps = EquipmentScriptPaths.ExtractDependencyRefs(source)
.Where(calcRawPaths.Contains)
.ToHashSet(StringComparer.Ordinal);
graph.Add(rawPath, calcDeps);
}
foreach (var cycle in graph.DetectCycles())
errors.Add(new("CalculationDependencyCycle",
"Calculation tags form a dependency cycle (members: " + string.Join(", ", cycle) +
"); a calc→calc cycle is a live oscillation loop and must be broken before deploy",
cycle.Count > 0 ? cycle[0] : null));
}
/// <summary>Extract the <c>scriptId</c> string from a calc tag's schemaless <c>TagConfig</c> JSON. Never
/// throws — malformed/blank/non-object JSON or an absent/non-string <c>scriptId</c> yields null.</summary>
private static string? ParseScriptId(string? tagConfig)
{
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
if (root.TryGetProperty("scriptId", out var el)
&& el.ValueKind == System.Text.Json.JsonValueKind.String)
{
var v = el.GetString();
return string.IsNullOrWhiteSpace(v) ? null : v;
}
return null;
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
/// <summary>Builds the shared <see cref="RawPathResolver"/> from the draft's raw topology so the /// <summary>Builds the shared <see cref="RawPathResolver"/> from the draft's raw topology so the
/// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary> /// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary>
private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft) private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
@@ -139,6 +239,64 @@ public static class DraftValidator
} }
} }
/// <summary>v3: every <c>{{equip}}/&lt;RefName&gt;</c> used in an equipment's VirtualTag script,
/// ScriptedAlarm predicate script, or ScriptedAlarm message template must resolve to one of the owning
/// equipment's <c>UnsTagReference</c> effective names (<c>DisplayNameOverride</c> else the backing raw
/// tag's <c>Name</c>) — the same set the compose seams substitute against. An unresolved <c>&lt;RefName&gt;</c>
/// is a deploy error (replacing the v2 silent null-base no-substitution), naming the script/alarm, the
/// equipment, and the missing ref. This is the deploy-gate half of the invariant the Monaco diagnostic +
/// the authoring gate enforce (editor accepts ⇔ publish accepts).</summary>
private static void ValidateEquipReferenceResolution(DraftSnapshot draft, List<ValidationError> errors)
{
var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
// equipmentId → set of reference effective names ({{equip}}/<RefName> resolves through REFERENCES
// only — VirtualTags/ScriptedAlarms are computed signals with no backing RawPath).
var refNamesByEquip = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (var r in draft.UnsTagReferences)
{
var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (string.IsNullOrEmpty(effective)) continue;
if (!refNamesByEquip.TryGetValue(r.EquipmentId, out var set))
refNamesByEquip[r.EquipmentId] = set = new HashSet<string>(StringComparer.Ordinal);
set.Add(effective!);
}
var scriptSourceById = draft.Scripts.GroupBy(s => s.ScriptId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
void CheckRefs(string equipmentId, IEnumerable<string> refNames, string source)
{
var have = refNamesByEquip.TryGetValue(equipmentId, out var set) ? set : null;
foreach (var name in refNames)
{
if (have is not null && have.Contains(name)) continue;
errors.Add(new("EquipReferenceUnresolved",
$"{source} uses '{EquipmentScriptPaths.EquipTokenPrefix}{name}' but equipment '{equipmentId}' has " +
$"no reference named '{name}'; add a reference with that effective name or correct the script.",
equipmentId));
}
}
foreach (var v in draft.VirtualTags)
{
var src = scriptSourceById.TryGetValue(v.ScriptId, out var s) ? s : null;
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
if (used.Count > 0) CheckRefs(v.EquipmentId, used, $"VirtualTag '{v.VirtualTagId}'");
}
foreach (var a in draft.ScriptedAlarms)
{
var src = scriptSourceById.TryGetValue(a.PredicateScriptId, out var s) ? s : null;
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src)
.Concat(EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(a.MessageTemplate))
.Distinct(StringComparer.Ordinal)
.ToList();
if (used.Count > 0) CheckRefs(a.EquipmentId, used, $"ScriptedAlarm '{a.ScriptedAlarmId}'");
}
}
private static bool IsValidSegment(string? s) => private static bool IsValidSegment(string? s) =>
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment); s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);
@@ -7,7 +7,12 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn> <!-- WP7: the calc deploy-gate reuses the pure DependencyGraph (Tarjan SCC) from Core.VirtualTags,
whose closure includes Microsoft.CodeAnalysis.CSharp.Scripting 4.12.0 (== CodeAnalysis.Common
4.12.0), while EF's transitive CodeAnalysis.Common 5.0.0 wins resolution. Suppress NU1608 — the
only surface Configuration touches (DependencyGraph.DetectCycles) has ZERO Roslyn dependency, so
the version skew never runs. Mirrors the identical suppression + rationale in the Host csproj. -->
<NoWarn>$(NoWarn);CS1591;NU1608</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration</RootNamespace> <RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration</RootNamespace>
</PropertyGroup> </PropertyGroup>
@@ -30,6 +35,9 @@
<!-- R2-11 (01/C-1): consume the shared TagConfigIntent.Parse byte-parity authority in Commons. <!-- R2-11 (01/C-1): consume the shared TagConfigIntent.Parse byte-parity authority in Commons.
Cycle-safe — Commons has zero in-repo ProjectReferences. --> Cycle-safe — Commons has zero in-repo ProjectReferences. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/> <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<!-- WP7: the deploy-gate cycle check reuses DependencyGraph (Tarjan SCC) for the calc→calc
dependency-cycle rule. Cycle-safe — Core.VirtualTags does not reference Configuration. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,72 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Single source of truth for the driver-type identifier strings the runtime
/// dispatches on. Each constant's <b>value</b> is the exact <c>DriverTypeName</c>
/// that the corresponding driver factory registers under in the process
/// <c>DriverFactoryRegistry</c> — the string the deploy pipeline stores in
/// <c>DriverInstance.DriverType</c> and every dispatch map keys by.
/// </summary>
/// <remarks>
/// <para>
/// Historically several dispatch maps hand-authored these strings and drifted from
/// the authoritative factory names (e.g. <c>"TwinCat"</c> vs the real
/// <c>"TwinCAT"</c>, <c>"Focas"</c> vs <c>"FOCAS"</c>). Consumers should reference
/// these constants instead of literals so the drift can never recur.
/// </para>
/// <para>
/// The reflection guard test
/// (<c>DriverTypeNamesGuardTests</c>) asserts bidirectional parity between these
/// constants and the set the driver factories actually register, so a rename on
/// either side breaks the build's test gate. Only constants for
/// <b>currently-registered</b> factories belong here — a not-yet-registered driver
/// (e.g. the Calculation driver landing in a later Batch 2 package) adds its own
/// constant when its factory is wired in.
/// </para>
/// </remarks>
public static class DriverTypeNames
{
/// <summary>Modbus TCP / RTU-over-TCP driver.</summary>
public const string Modbus = "Modbus";
/// <summary>Siemens S7 driver.</summary>
public const string S7 = "S7";
/// <summary>Allen-Bradley CIP (ControlLogix / CompactLogix) driver.</summary>
public const string AbCip = "AbCip";
/// <summary>Allen-Bradley legacy (PCCC / DF1-over-TCP) driver.</summary>
public const string AbLegacy = "AbLegacy";
/// <summary>Beckhoff TwinCAT ADS driver.</summary>
public const string TwinCAT = "TwinCAT";
/// <summary>FANUC FOCAS CNC driver.</summary>
public const string FOCAS = "FOCAS";
/// <summary>OPC UA client (upstream-server) driver.</summary>
public const string OpcUaClient = "OpcUaClient";
/// <summary>AVEVA System Platform (Wonderware) Galaxy driver, via the mxaccessgw gateway.</summary>
public const string Galaxy = "GalaxyMxGateway";
/// <summary>Calculation pseudo-driver — tags computed by C# scripts over other tags' live values.</summary>
public const string Calculation = "Calculation";
/// <summary>
/// Every driver-type string declared above, for callers that need to enumerate
/// the full set (e.g. validation of an authored <c>DriverType</c>).
/// </summary>
public static IReadOnlyCollection<string> All { get; } =
[
Modbus,
S7,
AbCip,
AbLegacy,
TwinCAT,
FOCAS,
OpcUaClient,
Galaxy,
Calculation,
];
}
@@ -0,0 +1,44 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// <summary>
/// Optional composable driver capability: the driver consumes <b>other</b> tags' live values.
/// The host registers the declared <see cref="DependencyRefs"/> with its per-node dependency mux
/// and forwards every matching value change into <see cref="OnDependencyValue"/> — the seam that
/// lets a host-blind driver (e.g. the <c>Calculation</c> pseudo-driver) read the address space's
/// live values without any cross-driver plumbing of its own.
/// </summary>
/// <remarks>
/// <para>
/// The mechanism mirrors how a <c>VirtualTagActor</c> registers interest on the
/// <c>DependencyMuxActor</c>: the mux already receives every driver's
/// <c>AttributeValuePublished</c> keyed by wire-ref (under v3, RawPath), so a driver that
/// implements this interface simply piggy-backs on that fan-out. A calc tag's computed output
/// re-enters the mux via the ordinary driver publish path, so calc-of-calc chains work with no
/// extra machinery — which is exactly why the deploy-time cycle gate is mandatory.
/// </para>
/// <para>
/// Implementations of <see cref="OnDependencyValue"/> are invoked from an actor context and
/// <b>must be non-blocking</b> — do the work inline and cheaply (the compile cache makes
/// steady-state calc evaluation a bounded method invocation), never block on I/O.
/// </para>
/// </remarks>
public interface IDependencyConsumer
{
/// <summary>
/// Wire-refs (RawPaths) this driver needs fed to it, derived from its authored tags. The host
/// re-reads this after <see cref="IDriver.InitializeAsync"/> / <see cref="IDriver.ReinitializeAsync"/>
/// and (re)registers the set with its dependency mux on every apply, so a tag/script edit that
/// changes the set converges without a bespoke notification.
/// </summary>
IReadOnlyCollection<string> DependencyRefs { get; }
/// <summary>
/// Host push of a single dependency value change. Called from an actor context, so the
/// implementation must be non-blocking.
/// </summary>
/// <param name="rawPath">The changed dependency's wire-ref (RawPath).</param>
/// <param name="value">The new value (may be null).</param>
/// <param name="statusCode">The OPC UA status code carried with the value (0 = Good).</param>
/// <param name="timestampUtc">The source timestamp of the change.</param>
void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc);
}
@@ -0,0 +1,473 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// The <c>Calculation</c> pseudo-driver. Its tags are ordinary raw tags whose value is <b>computed</b>
/// by a C# script over other tags' live values — there is no backend, no endpoint, no discovery, no
/// writes. It implements:
/// <list type="bullet">
/// <item><see cref="IDriver"/> — lifecycle + always-Connected health.</item>
/// <item><see cref="IDependencyConsumer"/> — the host feeds it the other tags' live values.</item>
/// <item><see cref="ISubscribable"/> — computed values flow out via <see cref="OnDataChange"/>
/// exactly like any driver, so the ordinary node fan-out + the mux re-entry (calc-of-calc) apply.</item>
/// <item><see cref="IReadable"/> — on-demand read returns the last computed snapshot
/// (<c>BadWaitingForInitialData</c> before the first evaluation).</item>
/// </list>
///
/// <para>
/// <b>Triggers.</b> A tag re-evaluates on any of its declared dependencies changing (change-trigger,
/// gated exactly like <c>VirtualTagActor</c>: nothing publishes until every declared dep has arrived
/// at least once, and equal results are deduped) and/or on a timer (one timer per interval group).
/// </para>
/// <para>
/// <b>Error semantics (mini-design §6).</b> An evaluator <c>Failure</c> publishes <b>Bad</b> quality
/// carrying the last-known value, once per Good→Bad transition and only after all deps have arrived,
/// and emits a <c>ScriptLogEntry</c> on the <c>script-logs</c> topic (via the injected
/// <see cref="ScriptRootLogger"/>'s <c>ScriptLogTopicSink</c>). Recovery to Good is force-published.
/// A historized Bad records <c>BadInternalError</c>.
/// </para>
/// </summary>
public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscribable, IReadable, IDisposable
{
// OPC UA status codes surfaced by the calc driver.
private const uint StatusGood = 0u;
private const uint StatusBadWaitingForInitialData = 0x80320000u;
private const uint StatusBadInternalError = 0x80020000u;
private readonly string _driverInstanceId;
private readonly CalculationDriverOptions _options;
private readonly ILogger<CalculationDriver> _logger;
private readonly ScriptRootLogger _scriptRoot;
private readonly CalculationEvaluator _evaluator;
// Authored calc tags, keyed by RawPath (identity + evaluation id). Built at construction so
// DependencyRefs is available BEFORE InitializeAsync (the host reads it when spawning the mux-adapter),
// and REBUILT on every ReinitializeAsync (an in-place ApplyDelta re-binds an edited calc-tag set).
private readonly Dictionary<string, CalculationTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
// rawPath (a dependency) → the calc tags that read it AND are change-triggered. Drives OnDependencyValue.
private readonly Dictionary<string, List<string>> _changeDependents = new(StringComparer.Ordinal);
// Union of every tag's declared dependency refs — the interest set the host registers on the mux.
// Reassigned by RebuildTagTable so a redeploy that adds/removes dependencies re-registers on the mux.
private IReadOnlyCollection<string> _dependencyRefs = Array.Empty<string>();
// Live evaluation state. Guarded by _lock: OnDependencyValue (actor thread) and timer ticks
// (pool threads) can both drive an evaluation, so all state mutation is serialized.
private readonly object _lock = new();
private readonly Dictionary<string, object?> _depValues = new(StringComparer.Ordinal);
private readonly HashSet<string> _arrivedDeps = new(StringComparer.Ordinal);
private readonly Dictionary<string, TagState> _stateByRawPath = new(StringComparer.Ordinal);
private CalculationTimerScheduler? _timers;
private volatile bool _running;
private DateTime? _lastComputeUtc;
private bool _disposed;
/// <summary>Occurs when a calc tag's computed value changes (or transitions Good↔Bad).</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Initializes a new <see cref="CalculationDriver"/>.</summary>
/// <param name="options">Bound options carrying the authored raw calc tags.</param>
/// <param name="driverInstanceId">Stable logical id of this driver instance.</param>
/// <param name="scriptRoot">Root script logger — user <c>ctx.Logger</c> output + failure entries fan out
/// to the <c>script-logs</c> topic through its <c>ScriptLogTopicSink</c>. When null a no-op logger is used.</param>
/// <param name="logger">Host-side diagnostics logger; defaults to the null logger.</param>
public CalculationDriver(
CalculationDriverOptions options,
string driverInstanceId,
ScriptRootLogger? scriptRoot = null,
ILogger<CalculationDriver>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
_options = options;
_driverInstanceId = driverInstanceId;
_logger = logger ?? NullLogger<CalculationDriver>.Instance;
_scriptRoot = scriptRoot ?? new ScriptRootLogger(new LoggerConfiguration().CreateLogger());
_evaluator = new CalculationEvaluator(
_logger is ILogger<CalculationEvaluator> el ? el : NullLogger<CalculationEvaluator>.Instance,
_scriptRoot,
_options.RunTimeout);
RebuildTagTable(_options);
}
/// <summary>
/// (Re)build the authored tag table (<see cref="_tagsByRawPath"/>), the change-trigger fan-out
/// (<see cref="_changeDependents"/>), the dependency-ref interest set (<see cref="_dependencyRefs"/>),
/// and the per-tag state map (<see cref="_stateByRawPath"/>) from <paramref name="config"/>. Called
/// once at construction AND on every <see cref="ReinitializeAsync"/> so an in-place ApplyDelta — a
/// calc-tag add/remove/edit, or a script-source edit — actually takes effect. Extracted so the ctor
/// and reinit paths share ONE construction routine and cannot drift.
/// <para>Per-tag <see cref="TagState"/> is preserved for a tag that survives the edit (cheap last-known
/// value carry-over); a brand-new tag starts fresh; a removed tag's state is dropped. MUST be called
/// under <see cref="_lock"/> when invoked post-construction (the ctor runs single-threaded).</para>
/// </summary>
/// <param name="config">The (new) bound options carrying the authored raw calc tags.</param>
private void RebuildTagTable(CalculationDriverOptions config)
{
_tagsByRawPath.Clear();
_changeDependents.Clear();
// Retain surviving tags' state; anything not re-authored is dropped when we swap this in below.
var retainedState = new Dictionary<string, TagState>(StringComparer.Ordinal);
foreach (var entry in config.RawTags)
{
if (!CalculationTagDefinition.TryFromTagConfig(entry.TagConfig, entry.RawPath, out var def))
{
_logger.LogWarning(
"Calculation {Driver}: raw tag {RawPath} has no scriptId; skipping (not a calc tag)",
_driverInstanceId, entry.RawPath);
continue;
}
if (string.IsNullOrWhiteSpace(def.ScriptSource))
{
_logger.LogWarning(
"Calculation {Driver}: calc tag {RawPath} (script {Script}) has no resolved scriptSource; " +
"it will not compute until the deploy artifact injects the script source",
_driverInstanceId, entry.RawPath, def.ScriptId);
}
_tagsByRawPath[entry.RawPath] = def;
// Carry the last-known state forward for a surviving tag; a brand-new tag starts fresh.
retainedState[entry.RawPath] =
_stateByRawPath.TryGetValue(entry.RawPath, out var prior) ? prior : new TagState();
if (def.ChangeTriggered)
{
foreach (var dep in def.DependencyRefs)
{
if (!_changeDependents.TryGetValue(dep, out var list))
_changeDependents[dep] = list = new List<string>();
if (!list.Contains(entry.RawPath, StringComparer.Ordinal)) list.Add(entry.RawPath);
}
}
}
// Swap in the retained/new state map — a removed tag's state is dropped so it stops publishing.
_stateByRawPath.Clear();
foreach (var (rawPath, state) in retainedState) _stateByRawPath[rawPath] = state;
_dependencyRefs = _tagsByRawPath.Values
.SelectMany(t => t.DependencyRefs)
.Distinct(StringComparer.Ordinal)
.ToArray();
}
// Reinit config-bind options — mirrors the factory's bind (case-insensitive, enum-string, trailing-comma
// tolerant) so an ApplyDelta payload re-binds identically to the spawn payload.
private static readonly JsonSerializerOptions ReinitJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Re-bind the authored raw calc tags from a new merged DriverConfig JSON (the ApplyDelta
/// payload). The ctor-fixed <see cref="CalculationEvaluator"/> timeout is kept — <c>RunTimeout</c> is bound
/// at spawn (a change to it is out of this in-place path's scope). A parse failure logs and keeps the
/// current tags so a malformed delta can never blank the driver.</summary>
private CalculationDriverOptions ParseOptions(string driverConfigJson)
{
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
try
{
var dto = JsonSerializer.Deserialize<ReinitConfigDto>(driverConfigJson, ReinitJsonOptions);
if (dto is not null)
{
return new CalculationDriverOptions
{
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
RunTimeout = _options.RunTimeout,
};
}
}
catch (JsonException ex)
{
_logger.LogWarning(ex,
"Calculation {Driver}: reinit config parse failed; keeping the current tag table",
_driverInstanceId);
}
}
return new CalculationDriverOptions { RawTags = _options.RawTags, RunTimeout = _options.RunTimeout };
}
/// <summary>Minimal reinit-bind DTO — the calc driver has no backend config, only the injected raw tags
/// (+ an optional eval-timeout, ignored on reinit since the evaluator is ctor-fixed). Mirrors the factory's
/// <c>CalculationDriverConfigDto</c>.</summary>
private sealed class ReinitConfigDto
{
public List<RawTagEntry>? RawTags { get; init; }
public int? RunTimeoutMs { get; init; }
}
// ---- IDriver ----
/// <inheritdoc />
public string DriverInstanceId => _driverInstanceId;
/// <inheritdoc />
public string DriverType => DriverTypeNames.Calculation;
/// <inheritdoc />
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_running = true;
StartTimers();
_logger.LogInformation(
"Calculation {Driver}: initialized with {TagCount} calc tag(s), {DepCount} distinct dependency ref(s)",
_driverInstanceId, _tagsByRawPath.Count, _dependencyRefs.Count);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// A calc-tag add/remove/edit — or a script edit that changes the injected scriptSource — changes the
// merged DriverConfig, which DriverSpawnPlanner routes to an in-place ApplyDelta → THIS call (a
// respawn fires only on a DriverType/ResilienceConfig change). So the authored tag table is NOT
// fixed at construction: we re-bind the new config and REBUILD the tag table + dependency-ref set
// here. Without this, added tags never compute, removed tags keep publishing, and edited scripts
// serve stale results (deploy reports success but nothing changed). After the rebuild _dependencyRefs
// reflects the new authored tags, and the host re-registers the mux interest post-reinit (the
// DriverInstanceActor.DeltaApplied → DriverHostActor path, sequenced AFTER this completes).
StopTimers();
var newOptions = ParseOptions(driverConfigJson);
lock (_lock)
{
RebuildTagTable(newOptions);
}
// Drop compiled scripts (IScriptCacheOwner contract — stops collectible ALCs accreting AND forces a
// recompile from each tag's NEW ScriptSource) + re-arm timers off the rebuilt tag set.
_evaluator.ClearCompiledScripts();
_running = true;
StartTimers();
return Task.CompletedTask;
}
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken)
{
_running = false;
StopTimers();
return Task.CompletedTask;
}
/// <inheritdoc />
public DriverHealth GetHealth() => new(DriverState.Healthy, _lastComputeUtc, null);
/// <inheritdoc />
public long GetMemoryFootprint() => 0;
/// <inheritdoc />
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
{
// The compile cache is the only optional cache; dropping it is safe (recompiled on next eval).
_evaluator.ClearCompiledScripts();
return Task.CompletedTask;
}
// ---- IDependencyConsumer ----
/// <inheritdoc />
public IReadOnlyCollection<string> DependencyRefs => _dependencyRefs;
/// <inheritdoc />
public void OnDependencyValue(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
{
if (!_running) return;
List<string>? dependents;
lock (_lock)
{
_depValues[rawPath] = value;
_arrivedDeps.Add(rawPath);
if (!_changeDependents.TryGetValue(rawPath, out dependents) || dependents.Count == 0) return;
// Evaluate each change-triggered dependent inline under the lock (the compile cache makes this
// a bounded method invocation — the same "fast enough to run inline" contract as the VT evaluator).
foreach (var calcRawPath in dependents)
EvaluateTag(calcRawPath, timestampUtc);
}
}
// ---- ISubscribable ----
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var handle = new CalculationSubscriptionHandle($"calc-{Guid.NewGuid():N}");
// Initial-data callback (OPC UA convention): surface the current snapshot for each subscribed calc
// tag so a freshly-materialised node shows BadWaitingForInitialData until its first evaluation lands.
foreach (var reference in fullReferences)
{
var snapshot = ReadOne(reference);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, snapshot));
}
return Task.FromResult<ISubscriptionHandle>(handle);
}
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
=> Task.CompletedTask;
// ---- IReadable ----
/// <inheritdoc />
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
results[i] = ReadOne(fullReferences[i]);
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
}
private DataValueSnapshot ReadOne(string reference)
{
var now = DateTime.UtcNow;
lock (_lock)
{
if (!_stateByRawPath.TryGetValue(reference, out var st) || !st.HasValue)
return new DataValueSnapshot(null, StatusBadWaitingForInitialData, null, now);
var status = st.LastPublishedBad ? StatusBadInternalError : StatusGood;
return new DataValueSnapshot(st.LastValue, status, st.LastTimestampUtc, st.LastTimestampUtc);
}
}
// ---- evaluation ----
/// <summary>Timer-trigger entry point — evaluates a single calc tag. Guards the lock itself (called
/// from a pool thread, unlike the change path which is already under the lock).</summary>
private void EvaluateTagLocked(string rawPath)
{
if (!_running) return;
lock (_lock) EvaluateTag(rawPath, DateTime.UtcNow);
}
/// <summary>Evaluate one calc tag and publish the outcome. MUST be called under <see cref="_lock"/>.</summary>
private void EvaluateTag(string rawPath, DateTime timestampUtc)
{
if (!_tagsByRawPath.TryGetValue(rawPath, out var def)) return;
if (string.IsNullOrWhiteSpace(def.ScriptSource)) return; // unresolved script — never computes (logged at init)
var st = _stateByRawPath[rawPath];
// Change-gate (mini-design §4): publish nothing until every declared dep has arrived at least once.
var allArrived = def.DependencyRefs.All(_arrivedDeps.Contains);
// Build the per-tag dependency snapshot (only its declared reads, current values).
var deps = new Dictionary<string, object?>(StringComparer.Ordinal);
foreach (var dep in def.DependencyRefs)
if (_depValues.TryGetValue(dep, out var v)) deps[dep] = v;
var result = _evaluator.Evaluate(rawPath, def.ScriptSource, deps);
if (!result.Success)
{
OnEvaluationFailed(def, st, result.Reason ?? "evaluator failure", allArrived, timestampUtc);
return;
}
if (!allArrived) return; // change-trigger warm-up: hold until inputs are ready
// Value dedup — but bypass it while recovering from Bad so a recovery whose value equals the
// pre-failure value still republishes Good (otherwise the node would stay Bad forever).
if (!st.LastPublishedBad && st.HasValue && Equals(st.LastValue, result.Value))
return;
st.HasValue = true;
st.LastValue = result.Value;
st.LastPublishedBad = false;
st.LastTimestampUtc = timestampUtc;
_lastComputeUtc = DateTime.UtcNow;
Publish(rawPath, result.Value, StatusGood, timestampUtc);
}
/// <summary>Failure tail (mirrors <c>VirtualTagActor</c> 02/S13): always emits the per-failure
/// script-log; additionally publishes a Bad snapshot — carrying the last-known value — once per
/// Good→Bad transition and only after every declared dependency has arrived.</summary>
private void OnEvaluationFailed(
CalculationTagDefinition def, TagState st, string reason, bool allArrived, DateTime timestampUtc)
{
PublishScriptLog(def, reason);
if (st.LastPublishedBad || !allArrived) return;
st.LastPublishedBad = true;
st.LastTimestampUtc = timestampUtc;
// Carry the last-known value (null if none) with Bad quality.
Publish(def.RawPath, st.HasValue ? st.LastValue : null, StatusBadInternalError, timestampUtc);
}
private void Publish(string rawPath, object? value, uint statusCode, DateTime timestampUtc)
{
var snapshot = new DataValueSnapshot(value, statusCode, timestampUtc, timestampUtc);
// Fired under _lock — the subscriber (DriverInstanceActor) marshals the event to its own thread,
// so there is no re-entrant call back into this driver.
OnDataChange?.Invoke(this, new DataChangeEventArgs(SharedHandle, rawPath, snapshot));
}
private void PublishScriptLog(CalculationTagDefinition def, string reason)
{
// Route through the script logger bound with ScriptId + the calc tag's RawPath (in the VirtualTagId
// slot the Script-log page attributes on). The ScriptRootLogger's ScriptLogTopicSink converts this
// Warning event into a ScriptLogEntry on the script-logs DPS topic — the driver needs no Akka access.
_scriptRoot.Logger
.ForContext(ScriptLoggerFactory.ScriptIdProperty, def.ScriptId)
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, def.RawPath)
.Warning("{Reason}", reason);
}
private void StartTimers()
{
var timed = _tagsByRawPath.Values.Where(t => t.TimerInterval is not null).ToArray();
if (timed.Length == 0) return;
_timers = new CalculationTimerScheduler(EvaluateTagLocked, _logger);
_timers.Start(timed);
}
private void StopTimers()
{
_timers?.Dispose();
_timers = null;
}
/// <summary>Test/diagnostic accessor: number of timer ticks skipped for in-flight groups.</summary>
internal long TimerSkippedTickCount => _timers?.SkippedTickCount ?? 0;
// A single shared handle for host-pushed publishes (change + timer). SubscribeAsync mints its own
// per-call handle for the initial-data callback; steady-state publishes reuse this so OnDataChange
// always carries a non-null handle.
private static readonly CalculationSubscriptionHandle SharedHandle = new("calc-shared");
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_running = false;
StopTimers();
_evaluator.Dispose();
}
/// <summary>Per-tag published state (guarded by <see cref="_lock"/>).</summary>
private sealed class TagState
{
public bool HasValue;
public object? LastValue;
public bool LastPublishedBad;
public DateTime LastTimestampUtc;
}
private sealed record CalculationSubscriptionHandle(string DiagnosticId) : ISubscriptionHandle;
}
@@ -0,0 +1,96 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// Static factory registration helper for <see cref="CalculationDriver"/>. The Server's
/// <c>DriverFactoryBootstrap</c> calls <see cref="Register"/> once at startup; the bootstrapper then
/// materialises <c>Calculation</c> DriverInstance rows into live driver instances. Mirrors
/// <c>ModbusDriverFactoryExtensions</c>, with the addition of a <see cref="ScriptRootLogger"/> so a
/// script failure fans out onto the <c>script-logs</c> topic (via the root logger's
/// <c>ScriptLogTopicSink</c>) — the calc driver has no Akka access of its own.
/// </summary>
public static class CalculationDriverFactoryExtensions
{
/// <summary>The <c>DriverInstance.DriverType</c> string this factory registers under.</summary>
public const string DriverTypeName = "Calculation";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>
/// Register the Calculation factory with the driver registry. The optional
/// <paramref name="loggerFactory"/> + <paramref name="scriptRoot"/> are captured at registration
/// time; both may be null (the guard test invokes <c>Register(registry)</c> reflectively with the
/// trailing parameters defaulted), in which case the driver runs with the null logger + a no-op
/// script-logger.
/// </summary>
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
/// <param name="scriptRoot">Optional root script logger (fans failure entries onto the <c>script-logs</c> topic).</param>
public static void Register(
DriverFactoryRegistry registry,
ILoggerFactory? loggerFactory = null,
ScriptRootLogger? scriptRoot = null)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot));
}
/// <summary>Public overload for the Server-side bootstrapper + test consumers.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
public static CalculationDriver CreateInstance(string driverInstanceId, string driverConfigJson)
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, scriptRoot: null);
/// <summary>Logger/script-logger-aware overload — used by <see cref="Register"/>'s closure via DI.</summary>
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
/// <param name="driverConfigJson">The merged driver config JSON (carries the <c>RawTags</c> array).</param>
/// <param name="loggerFactory">Optional logger factory for per-instance diagnostics loggers.</param>
/// <param name="scriptRoot">Optional root script logger for failure fan-out.</param>
/// <returns>The constructed <see cref="CalculationDriver"/>.</returns>
public static CalculationDriver CreateInstance(
string driverInstanceId, string driverConfigJson,
ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
var dto = JsonSerializer.Deserialize<CalculationDriverConfigDto>(driverConfigJson, JsonOptions)
?? throw new InvalidOperationException(
$"Calculation driver config for '{driverInstanceId}' deserialised to null");
var options = new CalculationDriverOptions
{
RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
RunTimeout = dto.RunTimeoutMs is { } ms && ms > 0
? TimeSpan.FromMilliseconds(ms) : TimeSpan.FromSeconds(2),
};
return new CalculationDriver(
options, driverInstanceId, scriptRoot,
logger: loggerFactory?.CreateLogger<CalculationDriver>());
}
/// <summary>The merged-config DTO the calc factory binds from. The Calculation driver has no
/// backend/endpoint config — only the injected raw tags (+ an optional evaluation-timeout override).</summary>
internal sealed class CalculationDriverConfigDto
{
/// <summary>The driver's authored raw calc tags (RawPath + TagConfig blob + WriteIdempotent + DeviceName).</summary>
public List<RawTagEntry>? RawTags { get; init; }
/// <summary>Optional per-script evaluation wall-clock budget override, in milliseconds (default 2000).</summary>
public int? RunTimeoutMs { get; init; }
}
}
@@ -0,0 +1,20 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// Bound options for a <see cref="CalculationDriver"/> instance. The Calculation pseudo-driver has
/// no backend/endpoint config — its only input is the authored raw calc tags the deploy artifact
/// injects as the <see cref="RawTags"/> list (each carrying the tag's <c>scriptId</c> + resolved
/// <c>scriptSource</c> + trigger config inside its <see cref="RawTagEntry.TagConfig"/> blob).
/// </summary>
public sealed class CalculationDriverOptions
{
/// <summary>The driver's authored raw calc tags (RawPath + TagConfig blob + WriteIdempotent), from
/// the deploy artifact. Each is mapped to a <see cref="CalculationTagDefinition"/> at construction.</summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = Array.Empty<RawTagEntry>();
/// <summary>Per-script wall-clock evaluation budget handed to the <see cref="CalculationEvaluator"/>.
/// Defaults to 2 seconds (parity with the VirtualTag evaluator).</summary>
public TimeSpan RunTimeout { get; init; } = TimeSpan.FromSeconds(2);
}
@@ -0,0 +1,45 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// Test-connect probe for the <c>Calculation</c> pseudo-driver. There is no backend to connect to —
/// the driver computes tags from other tags' values — so the probe just confirms the driver config
/// parses (mini-design §7). Per-script compile verification belongs to the editor diagnostics + the
/// tag editor's inline panel, not the probe (which receives only the driver config). Never throws;
/// returns Ok with negligible latency.
/// </summary>
public sealed class CalculationDriverProbe : IDriverProbe
{
private static readonly JsonSerializerOptions Opts = new()
{
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
Converters = { new JsonStringEnumConverter() },
};
/// <inheritdoc />
public string DriverType => CalculationDriverFactoryExtensions.DriverTypeName;
/// <inheritdoc />
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(configJson))
return Task.FromResult(new DriverProbeResult(false, "Config JSON is empty.", null));
try
{
// Parse-only: a Calculation driver's config is well-formed as long as it deserialises to an
// object (the RawTags array is optional — a driver with no calc tags is still valid).
_ = JsonSerializer.Deserialize<CalculationDriverFactoryExtensions.CalculationDriverConfigDto>(configJson, Opts);
}
catch (Exception ex)
{
return Task.FromResult(new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null));
}
return Task.FromResult(new DriverProbeResult(true, null, TimeSpan.Zero));
}
}
@@ -0,0 +1,193 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
using SerilogLogger = Serilog.ILogger;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// T0-4 — the Calculation driver's script evaluator. A calc tag is a raw tag whose value is
/// produced by a C# script over other tags' live values. This evaluator is a deliberate mirror of
/// the VirtualTag <c>RoslynVirtualTagEvaluator</c>: it compiles each unique source once via
/// <see cref="ScriptEvaluator{TContext, TResult}"/> (the Roslyn-backed sandbox), caches the
/// resulting evaluator keyed by source in a <see cref="CompiledScriptCache{TContext, TResult}"/>,
/// and runs steady-state evaluations as in-process method invocations against the dependency
/// dictionary — fast enough to run inline on the driver's dispatch. It reuses
/// <see cref="VirtualTagContext"/> verbatim so the Monaco editor, sandbox, and diagnostics pipeline
/// apply to calc scripts with no divergence.
///
/// <para>
/// <b>Single-tag mode.</b> A calc tag produces exactly one output value — its own — so cross-tag
/// <c>ctx.SetVirtualTag</c> writes are dropped (and logged at Debug). Fan-out between tags is owned
/// by the host's <c>DependencyMuxActor</c> (calc-of-calc chains re-enter the mux), never by the
/// eval engine, exactly as in the VirtualTag adapter.
/// </para>
/// <para>
/// <b>Error surfacing (for WP7).</b> This evaluator never throws for script faults: compile error,
/// sandbox violation, runtime throw, and timeout all return <see cref="VirtualTagEvalResult.Failure"/>
/// with a descriptive <c>Reason</c>. A successful run returns <see cref="VirtualTagEvalResult.Ok"/>
/// carrying the computed value (which may be null). WP7's <c>CalculationDriver</c> maps a
/// <c>Failure</c> to Bad quality + a <c>ScriptLogEntry</c> and an <c>Ok</c> to the published value.
/// </para>
/// <para>
/// <b>Cache management.</b> Implements <see cref="IScriptCacheOwner"/> so WP7 can drop compiled
/// scripts at each deploy generation whose source set changed — mirroring the VirtualTag adapter's
/// apply-boundary clear, which is what stops collectible <c>AssemblyLoadContext</c>s accreting across
/// script edits.
/// </para>
/// </summary>
public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
{
// Source-hash-keyed compile cache: Lazy single-compile (no double-compile race) and Clear()/Dispose()
// that dispose each evaluator's collectible AssemblyLoadContext. ClearCompiledScripts() — driven by the
// WP7 driver per deploy generation — is what stops ALCs accreting across script edits.
private readonly CompiledScriptCache<VirtualTagContext, object?> _cache = new();
private readonly ILogger<CalculationEvaluator> _logger;
private readonly SerilogLogger _scriptRoot;
private readonly TimeSpan _runTimeout;
private bool _disposed;
/// <summary>Initializes a new <see cref="CalculationEvaluator"/>.</summary>
/// <param name="logger">Logger for host-side compilation/execution diagnostics.</param>
/// <param name="scriptRoot">Root script logger; user <c>ctx.Logger.*</c> output flows through this to the Script-log page.</param>
/// <param name="runTimeout">Wall-clock budget per script run; defaults to 2 seconds (parity with the VT evaluator).</param>
public CalculationEvaluator(
ILogger<CalculationEvaluator> logger,
ScriptRootLogger scriptRoot,
TimeSpan? runTimeout = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
}
/// <summary>
/// Evaluate <paramref name="scriptSource"/> for the calc tag <paramref name="calcTagId"/> against
/// the snapshot in <paramref name="dependencies"/> (RawPath → last value). Never throws — script
/// faults are reported via <see cref="VirtualTagEvalResult.Failure"/>; a successful run returns
/// <see cref="VirtualTagEvalResult.Ok"/> carrying the single computed value.
/// </summary>
/// <param name="calcTagId">Identity of the calc tag (bound to the script log; in the live path equals the script id).</param>
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
public VirtualTagEvalResult Evaluate(string calcTagId, string scriptSource, IReadOnlyDictionary<string, object?> dependencies)
{
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
if (string.IsNullOrWhiteSpace(scriptSource)) return VirtualTagEvalResult.Failure("empty expression");
// Passthrough fast-path: the mirror shape `return ctx.GetTag("X").Value;` needs no Roslyn.
// Semantics are byte-identical to the compiled mirror: a present dependency returns its value
// (Ok), an absent one makes GetTag yield a Bad snapshot whose .Value is null, so the script
// returns null — Ok(null) here too. Near-misses fall through to the compiled path.
if (PassthroughScript.TryMatch(scriptSource, out var passthroughRef))
{
return dependencies.TryGetValue(passthroughRef, out var ptValue)
? VirtualTagEvalResult.Ok(ptValue)
: VirtualTagEvalResult.Ok(null);
}
var readCache = BuildReadCache(dependencies);
// Per-evaluation script logger: bind both ScriptId and CalcTagId (via the VirtualTagId slot, which
// the Script-log page attributes on) from the calc-tag id so each line is attributable.
var scriptLog = _scriptRoot
.ForContext(ScriptLoggerFactory.ScriptIdProperty, calcTagId)
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, calcTagId);
var context = new VirtualTagContext(
readCache,
// Single-tag mode: a calc tag has exactly one output (its own), so cross-tag writes are dropped.
setVirtualTag: (path, _) =>
_logger.LogDebug("Calculation {Id}: cross-tag write to {Path} dropped (single-tag evaluator)",
calcTagId, path),
logger: scriptLog);
// Fetch + run inside a bounded loop so an ObjectDisposedException caused by a concurrent
// ClearCompiledScripts (which disposed the evaluator between GetOrCompile and the run's
// disposed guard) re-fetches and retries exactly once. The loop runs at most twice.
for (var attempt = 0; ; attempt++)
{
ScriptEvaluator<VirtualTagContext, object?> evaluator;
try
{
evaluator = _cache.GetOrCompile(scriptSource);
}
catch (CompilationErrorException ex)
{
_logger.LogWarning(ex, "Calculation {Id}: Roslyn compile failed", calcTagId);
return VirtualTagEvalResult.Failure($"compile error: {ex.Message}");
}
catch (ScriptSandboxViolationException ex)
{
_logger.LogWarning(ex, "Calculation {Id}: sandbox violation", calcTagId);
return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}");
}
catch (ObjectDisposedException)
{
// Fetch-time disposal is a shutdown signal (the cache is tearing down), not the
// apply-boundary race — never retry.
return VirtualTagEvalResult.Failure("evaluator disposed");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Calculation {Id}: compile threw", calcTagId);
return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}");
}
try
{
// Route through TimedScriptEvaluator (Task.Run + WaitAsync): a raw CancellationToken can't
// interrupt a CPU-bound/infinite-loop Roslyn script, so the timed wrapper is what bounds a
// runaway script to the wall-clock budget instead of wedging the driver's dispatch.
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _runTimeout);
var raw = timed.RunAsync(context).GetAwaiter().GetResult();
return VirtualTagEvalResult.Ok(raw);
}
catch (ScriptTimeoutException)
{
return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s");
}
catch (ObjectDisposedException) when (!_disposed && attempt == 0)
{
// A concurrent ClearCompiledScripts disposed the evaluator between our GetOrCompile and the
// run's disposed-guard. Re-fetch (recompiling the same source into the CURRENT generation)
// and retry once. A second disposal mid-retry falls through to the failure path below.
continue;
}
catch (ObjectDisposedException)
{
return VirtualTagEvalResult.Failure("evaluator disposed during evaluation");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Calculation {Id}: script execution threw", calcTagId);
return VirtualTagEvalResult.Failure($"script threw: {ex.Message}");
}
}
}
/// <inheritdoc />
public void ClearCompiledScripts() => _cache.Clear();
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
IReadOnlyDictionary<string, object?> deps)
{
// VirtualTagContext.GetTag returns a DataValueSnapshot — wrap each raw dep value as Good-quality
// so the script's `(int)ctx.GetTag("a").Value` pattern works. Null values stay null.
var nowUtc = DateTime.UtcNow;
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var kv in deps)
{
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
}
return cache;
}
/// <summary>Disposes the evaluator, draining + disposing every cached compile's collectible ALC.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cache.Dispose();
}
}
@@ -0,0 +1,85 @@
using System.Text.Json;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// A single authored calc tag as the driver runs it: its <see cref="RawPath"/> identity, the script
/// that computes it (<see cref="ScriptId"/> for attribution, <see cref="ScriptSource"/> for
/// evaluation), its trigger config (<see cref="ChangeTriggered"/> / <see cref="TimerInterval"/>), and
/// the static <see cref="DependencyRefs"/> extracted from the script's literal
/// <c>ctx.GetTag("…")</c> reads.
/// </summary>
/// <param name="RawPath">The tag's cluster-scoped RawPath — its identity + the calc-tag id passed to the evaluator.</param>
/// <param name="ScriptId">Logical FK to the shared <c>Script</c> entity — bound to the failure script-log for attribution.</param>
/// <param name="ScriptSource">The resolved C# script source that produces the tag's value; empty when unresolved.</param>
/// <param name="ChangeTriggered">Re-evaluate whenever a declared dependency changes.</param>
/// <param name="TimerInterval">Optional periodic re-evaluation cadence; null ⇒ change-trigger only.</param>
/// <param name="DependencyRefs">Distinct literal <c>ctx.GetTag</c> RawPaths this tag reads (first-seen order).</param>
public sealed record CalculationTagDefinition(
string RawPath,
string ScriptId,
string ScriptSource,
bool ChangeTriggered,
TimeSpan? TimerInterval,
IReadOnlyList<string> DependencyRefs)
{
/// <summary>
/// Map a calc tag's <c>TagConfig</c> blob (<c>{ "scriptId", "scriptSource", "changeTriggered",
/// "timerIntervalMs" }</c>) + its RawPath into a <see cref="CalculationTagDefinition"/>. Returns
/// <see langword="false"/> (and leaves <paramref name="def"/> null) only when the blob has no
/// <c>scriptId</c> — a tag with no identifiable script cannot be a calc tag. A present
/// <c>scriptId</c> with an empty/absent <c>scriptSource</c> still maps (the driver logs it and the
/// tag never computes). Never throws — malformed JSON yields a miss.
/// </summary>
/// <param name="tagConfig">The calc tag's schemaless <c>TagConfig</c> JSON.</param>
/// <param name="rawPath">The tag's RawPath identity.</param>
/// <param name="def">The mapped definition when this returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> when a definition (with a scriptId) was mapped.</returns>
public static bool TryFromTagConfig(string tagConfig, string rawPath, out CalculationTagDefinition def)
{
def = null!;
if (string.IsNullOrWhiteSpace(tagConfig)) return false;
string? scriptId;
string scriptSource;
bool changeTriggered;
int? timerMs;
try
{
using var doc = JsonDocument.Parse(tagConfig);
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
scriptId = root.TryGetProperty("scriptId", out var sidEl) && sidEl.ValueKind == JsonValueKind.String
? sidEl.GetString() : null;
if (string.IsNullOrWhiteSpace(scriptId)) return false;
scriptSource = root.TryGetProperty("scriptSource", out var srcEl) && srcEl.ValueKind == JsonValueKind.String
? srcEl.GetString() ?? string.Empty : string.Empty;
// changeTriggered defaults to true (mini-design §2) — only an explicit false disables it.
changeTriggered = !(root.TryGetProperty("changeTriggered", out var ctEl)
&& ctEl.ValueKind == JsonValueKind.False);
timerMs = root.TryGetProperty("timerIntervalMs", out var tEl)
&& tEl.ValueKind == JsonValueKind.Number && tEl.TryGetInt32(out var ms) && ms > 0
? ms : null;
}
catch (JsonException)
{
return false;
}
var deps = EquipmentScriptPaths.ExtractDependencyRefs(scriptSource);
def = new CalculationTagDefinition(
RawPath: rawPath,
ScriptId: scriptId!,
ScriptSource: scriptSource,
ChangeTriggered: changeTriggered,
TimerInterval: timerMs is { } m ? TimeSpan.FromMilliseconds(m) : null,
DependencyRefs: deps);
return true;
}
}
@@ -0,0 +1,110 @@
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// Periodic re-evaluation scheduler for calc tags carrying a <c>timerIntervalMs</c>. A deliberate
/// mirror of the VirtualTag <c>TimerTriggerScheduler</c> (<c>Core.VirtualTags</c>): one
/// <see cref="System.Threading.Timer"/> per distinct interval group (so the wire count stays low
/// regardless of tag count) with a per-group in-flight flag that skips a tick when the prior tick for
/// the same group is still running. Revived <b>inside the driver</b> because the VT scheduler is welded
/// to <c>VirtualTagEngine.EvaluateOneAsync</c>; here each tick invokes the driver's own evaluate
/// callback per tag in the group.
/// </summary>
internal sealed class CalculationTimerScheduler : IDisposable
{
private readonly Action<string> _evaluate;
private readonly ILogger _logger;
private readonly List<Timer> _timers = [];
private readonly List<TickGroup> _groups = [];
private readonly CancellationTokenSource _cts = new();
private long _skippedTickCount;
private bool _disposed;
/// <summary>Initializes a new instance of the <see cref="CalculationTimerScheduler"/> class.</summary>
/// <param name="evaluate">Callback invoked with a calc tag's RawPath when its timer group ticks.</param>
/// <param name="logger">Logger for diagnostics.</param>
public CalculationTimerScheduler(Action<string> evaluate, ILogger logger)
{
_evaluate = evaluate ?? throw new ArgumentNullException(nameof(evaluate));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>Number of timer callbacks that skipped their work because the prior tick for the same
/// group was still running. Monotonic; exposed for tests + operational metrics.</summary>
public long SkippedTickCount => Interlocked.Read(ref _skippedTickCount);
/// <summary>Stand up one <see cref="Timer"/> per distinct interval. All tags sharing an interval share
/// a timer; each tick re-evaluates every tag in the group.</summary>
/// <param name="tags">The calc tags to schedule (only those with a positive <see cref="CalculationTagDefinition.TimerInterval"/> are scheduled).</param>
public void Start(IEnumerable<CalculationTagDefinition> tags)
{
ArgumentNullException.ThrowIfNull(tags);
if (_disposed) throw new ObjectDisposedException(nameof(CalculationTimerScheduler));
var byInterval = tags
.Where(t => t.TimerInterval is { } iv && iv > TimeSpan.Zero)
.GroupBy(t => t.TimerInterval!.Value);
foreach (var group in byInterval)
{
var paths = group.Select(t => t.RawPath).ToArray();
var interval = group.Key;
var ctx = new TickGroup(paths);
_groups.Add(ctx);
var timer = new Timer(_ => OnTimer(ctx), null, interval, interval);
_timers.Add(timer);
_logger.LogInformation("CalculationTimerScheduler: {TagCount} tag(s) on {Interval} cadence",
paths.Length, interval);
}
}
private void OnTimer(TickGroup ctx)
{
if (_cts.IsCancellationRequested) return;
// Skip the tick when the prior one for this group is still running — bounds the outstanding
// work to one tick per group regardless of how long an evaluation takes.
if (Interlocked.CompareExchange(ref ctx.InFlight, 1, 0) != 0)
{
Interlocked.Increment(ref _skippedTickCount);
return;
}
try
{
foreach (var path in ctx.Paths)
{
if (_cts.IsCancellationRequested) return;
try { _evaluate(path); }
catch (Exception ex) { _logger.LogError(ex, "CalculationTimerScheduler evaluate failed for {Path}", path); }
}
}
finally
{
Interlocked.Exchange(ref ctx.InFlight, 0);
}
}
/// <summary>Releases all timers and disposes the scheduler's resources.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cts.Cancel();
foreach (var t in _timers)
{
try { t.Dispose(); } catch { /* best-effort teardown */ }
}
_timers.Clear();
_groups.Clear();
_cts.Dispose();
}
private sealed class TickGroup(IReadOnlyList<string> paths)
{
// 0 = idle, 1 = a tick is currently running for this group.
public int InFlight;
public IReadOnlyList<string> Paths { get; } = paths;
}
}
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Calculation</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Calculation</AssemblyName>
</PropertyGroup>
<ItemGroup>
<!-- Scripting infra shared with the VirtualTag Roslyn evaluator. Core.Scripting brings
CompiledScriptCache / TimedScriptEvaluator / ScriptEvaluator / the sandbox exceptions /
ScriptRootLogger + ScriptLoggerFactory (and, transitively, Microsoft.CodeAnalysis
for CompilationErrorException). Core.Scripting.Abstractions owns VirtualTagContext +
PassthroughScript; Commons owns VirtualTagEvalResult + IScriptCacheOwner; Core.Abstractions
owns DataValueSnapshot. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests"/>
</ItemGroup>
</Project>
@@ -0,0 +1,13 @@
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// <summary>
/// Payload for a leaf toggle in <c>DriverBrowseTree</c>'s multi-select mode (WP6 "Browse device…"
/// re-target). Carries the toggled <see cref="Leaf"/> plus the captured browse-folder nesting above it
/// (<see cref="FolderPath"/>, root→parent display names) so the browse modal can optionally mirror the
/// nesting onto TagGroups on commit. Fired only for <see cref="BrowseNodeKind.Leaf"/> nodes.
/// </summary>
/// <param name="Leaf">The toggled leaf browse node (its <c>NodeId</c> is the driver full reference).</param>
/// <param name="FolderPath">The ancestor folder display names, root→parent (empty for a top-level leaf).</param>
public sealed record BrowseLeafSelection(BrowseNode Leaf, IReadOnlyList<string> FolderPath);
@@ -16,6 +16,7 @@
<NavRailItem Href="/hosts" Text="Host status" /> <NavRailItem Href="/hosts" Text="Host status" />
<NavRailItem Href="/clusters" Text="Clusters" /> <NavRailItem Href="/clusters" Text="Clusters" />
<NavRailItem Href="/uns" Text="UNS" /> <NavRailItem Href="/uns" Text="UNS" />
<NavRailItem Href="/raw" Text="Raw" />
<NavRailItem Href="/reservations" Text="Reservations" /> <NavRailItem Href="/reservations" Text="Reservations" />
<NavRailItem Href="/certificates" Text="Certificates" /> <NavRailItem Href="/certificates" Text="Certificates" />
<NavRailItem Href="/role-grants" Text="Role grants" /> <NavRailItem Href="/role-grants" Text="Role grants" />
@@ -64,7 +64,22 @@ else
<tr> <tr>
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td> <td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
<td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td> <td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
<td><span class="mono small">@e.EquipmentPath</span></td> <td>
<span class="mono small">@e.EquipmentPath</span>
@* v3 Batch 4 (multi-notifier native alarms): one condition row carries the list
of referencing equipment (Area/Line/Equipment) as display metadata. Shown only
when the producer populated it (native alarms whose raw tag ≥1 equipment
references); scripted alarms + unreferenced native alarms leave it empty. *@
@if (e.ReferencingEquipmentPaths is { Count: > 0 } refs)
{
<div class="text-muted small" title="Referencing equipment">
@foreach (var p in refs)
{
<span class="chip chip-idle" style="font-size:0.72rem; margin:1px">@p</span>
}
</div>
}
</td>
<td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td> <td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
<td class="num">@e.Severity</td> <td class="num">@e.Severity</td>
<td>@e.User</td> <td>@e.User</td>
@@ -1,83 +0,0 @@
@page "/clusters/{ClusterId}/drivers"
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Drivers &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers/new" class="btn btn-primary btn-sm">New driver</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (_rows is null)
{
<p>Loading…</p>
}
else
{
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">@_rows.Count driver instance@(_rows.Count == 1 ? "" : "s")</div>
@if (_rows.Count == 0)
{
<div style="padding:1rem" class="text-muted">No driver instances for this cluster.</div>
}
else
{
@foreach (var d in _rows)
{
<details style="border-top:1px solid var(--rule)">
<summary style="padding:.75rem 1rem;cursor:pointer">
<span class="mono">@d.DriverInstanceId</span>
&middot; <span>@d.Name</span>
&middot; <span class="chip chip-idle ms-1">@d.DriverType</span>
@if (!d.Enabled) { <span class="chip chip-idle ms-1">Disabled</span> }
</summary>
<div style="padding:0 1rem 1rem">
<div class="d-flex mb-2">
<a href="/clusters/@ClusterId/drivers/@d.DriverInstanceId" class="btn btn-sm btn-outline-primary">Edit</a>
</div>
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@FormatJson(d.DriverConfig)</pre>
@if (!string.IsNullOrWhiteSpace(d.ResilienceConfig))
{
<div class="text-muted small mt-2">Resilience overrides:</div>
<pre class="mono small" style="background:var(--surface-2);padding:1rem;border-radius:4px;overflow:auto">@FormatJson(d.ResilienceConfig)</pre>
}
</div>
</details>
}
}
</section>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
private List<DriverInstance>? _rows;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_rows = await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == ClusterId)
.OrderBy(d => d.DriverInstanceId)
.ToListAsync();
}
private static string FormatJson(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "";
try
{
using var doc = System.Text.Json.JsonDocument.Parse(raw);
return System.Text.Json.JsonSerializer.Serialize(doc.RootElement,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
}
catch
{
return raw;
}
}
}
@@ -1,448 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/abcip"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New AB CIP driver" : "Edit AB CIP driver") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (!_loaded)
{
<p>Loading&hellip;</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="abcipDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="AB CIP address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<AbCipAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
</DriverTagPicker>
@* Operation timeout *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Timeout (seconds)</label>
<InputNumber @bind-Value="_form.TimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default libplctag call timeout. Default 2 s.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.EnableControllerBrowse" class="form-check-input" id="enableBrowse" />
<label class="form-check-label" for="enableBrowse">Enable controller browse</label>
</div>
<div class="form-text">Walk the Logix symbol table and surface globals under Discovered/.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.EnableDeclarationOnlyUdtGrouping" class="form-check-input" id="enableUdtGroup" />
<label class="form-check-label" for="enableUdtGroup">Enable declaration-only UDT grouping</label>
</div>
<div class="form-text">Only enable when UDT member declaration order matches controller compiled layout.</div>
</div>
</div>
</div>
</section>
@* Alarm projection *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Alarm projection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.EnableAlarmProjection" class="form-check-input" id="enableAlarm" />
<label class="form-check-label" for="enableAlarm">Enable ALMD alarm projection</label>
</div>
<div class="form-text">Surfaces ALMD tags as alarm conditions via IAlarmSource. Default off.</div>
</div>
<div class="col-md-3">
<label class="form-label">Alarm poll interval (seconds)</label>
<InputNumber @bind-Value="_form.AlarmPollIntervalSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 1 s.</div>
</div>
</div>
</div>
</section>
@* Connectivity probe *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.ProbeEnabled" class="form-check-input" id="probeEnabled" />
<label class="form-check-label" for="probeEnabled">Enabled</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Interval (seconds)</label>
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Timeout (seconds)</label>
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 2 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe tag path</label>
<InputText @bind-Value="_form.ProbeTagPath" class="form-control form-control-sm mono"
placeholder="e.g. Program:Main.SomeTag" />
<div class="form-text">Required when probe is enabled. Leave blank and an operator warning is logged.</div>
</div>
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
</div>
</div>
</div>
</section>
@* Devices *@
<CollectionEditor TRow="AbCipDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".12s"
NewRow="@(() => new AbCipDeviceRow())" Clone="@(r => r.Clone())"
Validate="AbCipDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td><td>@d.PlcFamily</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="ab://gateway/1,0" /></div>
<div class="col-md-3"><label class="form-label">PLC family</label>
<select class="form-select form-select-sm" @bind="d.PlcFamily">
@foreach (var e in Enum.GetValues<AbCipPlcFamily>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
@* Tags *@
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "AbCip";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices is a collection — edited via the CollectionEditor modal.
private List<AbCipDeviceRow> _devices = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_form = new FormModel();
}
else
{
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (_existing is not null)
{
_identityModel = new()
{
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new AbCipDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(AbCipDeviceRow.FromDefinition).ToList();
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
{
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists.";
return;
}
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
});
}
else
{
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
}
catch (Exception ex)
{
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static AbCipDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Mutable VM for the modal editor — AbCipDeviceOptions is an immutable record.
public sealed class AbCipDeviceRow
{
public string HostAddress { get; set; } = "";
public AbCipPlcFamily PlcFamily { get; set; } = AbCipPlcFamily.ControlLogix;
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// (AllowPacking, ConnectionSize) across a load→save.
private AbCipDeviceOptions? _source;
public AbCipDeviceRow Clone() => (AbCipDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbCipDeviceRow FromDefinition(AbCipDeviceOptions d) => new()
{
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
_source = d,
};
public AbCipDeviceOptions ToDefinition()
{
var baseDef = _source ?? new AbCipDeviceOptions(HostAddress.Trim(), PlcFamily);
return baseDef with
{
HostAddress = HostAddress.Trim(),
PlcFamily = PlcFamily,
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(AbCipDeviceRow row, IReadOnlyList<AbCipDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices) are kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Operation
public int TimeoutSeconds { get; set; } = 2;
public bool EnableControllerBrowse { get; set; } = false;
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
// Alarm projection
public bool EnableAlarmProjection { get; set; } = false;
public int AlarmPollIntervalSeconds { get; set; } = 1;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public string? ProbeTagPath { get; set; }
// Admin UI probe timeout
public int AdminProbeTimeoutSeconds { get; set; } = 5;
// Persistence
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(AbCipDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
EnableControllerBrowse = o.EnableControllerBrowse,
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
EnableAlarmProjection = o.EnableAlarmProjection,
AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
ProbeTagPath = o.Probe.ProbeTagPath,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public AbCipDriverOptions ToOptions(
IReadOnlyList<AbCipDeviceOptions> devices) => new()
{
Devices = devices,
Probe = new AbCipProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
EnableControllerBrowse = EnableControllerBrowse,
EnableAlarmProjection = EnableAlarmProjection,
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
}
@@ -1,399 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/ablegacy"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New AB Legacy driver" : "Edit AB Legacy driver") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (!_loaded)
{
<p>Loading&hellip;</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="ablegacyDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="AB Legacy address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<AbLegacyAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Operation settings *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Timeout (seconds)</label>
<InputNumber @bind-Value="_form.TimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default read/write timeout. Default 2 s.</div>
</div>
</div>
</div>
</section>
@* Connectivity probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.ProbeEnabled" class="form-check-input" id="probeEnabled" />
<label class="form-check-label" for="probeEnabled">Enabled</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Interval (seconds)</label>
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Timeout (seconds)</label>
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 2 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe address</label>
<InputText @bind-Value="_form.ProbeAddress" class="form-control form-control-sm mono"
placeholder="S:0" />
<div class="form-text">PCCC file address to read for probe. Default S:0 (status file word 0).</div>
</div>
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
</div>
</div>
</div>
</section>
@* Devices *@
<CollectionEditor TRow="AbLegacyDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".10s"
NewRow="@(() => new AbLegacyDeviceRow())" Clone="@(r => r.Clone())"
Validate="AbLegacyDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>PLC family</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td><td>@d.PlcFamily</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="10.0.0.10" /></div>
<div class="col-md-3"><label class="form-label">PLC family</label>
<select class="form-select form-select-sm" @bind="d.PlcFamily">
@foreach (var e in Enum.GetValues<AbLegacyPlcFamily>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
@* Tags *@
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "AbLegacy";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<AbLegacyDeviceRow> _devices = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_form = new FormModel();
}
else
{
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (_existing is not null)
{
_identityModel = new()
{
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new AbLegacyDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(AbLegacyDeviceRow.FromDefinition).ToList();
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
{
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists.";
return;
}
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
});
}
else
{
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
}
catch (Exception ex)
{
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static AbLegacyDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Mutable VM for the modal editor — AbLegacyDeviceOptions is an immutable record.
public sealed class AbLegacyDeviceRow
{
public string HostAddress { get; set; } = "";
public AbLegacyPlcFamily PlcFamily { get; set; } = AbLegacyPlcFamily.Slc500;
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves fields the editor doesn't expose
// across a load→save.
private AbLegacyDeviceOptions? _source;
public AbLegacyDeviceRow Clone() => (AbLegacyDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static AbLegacyDeviceRow FromDefinition(AbLegacyDeviceOptions d) => new()
{
HostAddress = d.HostAddress, PlcFamily = d.PlcFamily, DeviceName = d.DeviceName,
_source = d,
};
public AbLegacyDeviceOptions ToDefinition()
{
var baseDef = _source ?? new AbLegacyDeviceOptions(HostAddress.Trim(), PlcFamily);
return baseDef with
{
HostAddress = HostAddress.Trim(),
PlcFamily = PlcFamily,
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(AbLegacyDeviceRow row, IReadOnlyList<AbLegacyDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Operation
public int TimeoutSeconds { get; set; } = 2;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public string? ProbeAddress { get; set; } = "S:0";
// Admin UI probe timeout
public int AdminProbeTimeoutSeconds { get; set; } = 5;
// Persistence
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(AbLegacyDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
ProbeAddress = o.Probe.ProbeAddress,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public AbLegacyDriverOptions ToOptions(
IReadOnlyList<AbLegacyDeviceOptions> devices) => new()
{
Devices = devices,
Probe = new AbLegacyProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
}
@@ -1,82 +0,0 @@
@* Dispatch page: reads DriverInstance.DriverType and dispatches to the matching typed editor
via <DynamicComponent> using _componentMap. Shows an error panel when the driver type has
no registered typed page. *@
@page "/clusters/{ClusterId}/drivers/{DriverInstanceId}"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@if (!_loaded)
{
<p>Loading…</p>
}
else if (_existing is null)
{
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Edit driver instance &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else if (ResolveComponentType() is { } pageType)
{
<DynamicComponent Type="pageType" Parameters="BuildParameters()" />
}
else
{
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Edit driver instance &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
<section class="panel notice rise" style="animation-delay:.02s; border-color:var(--alert)">
Driver instance <span class="mono">@DriverInstanceId</span> has an unknown <code>DriverType</code> value of
<strong><span class="mono">@_existing.DriverType</span></strong>. No editor is registered for this type.
Likely causes: the row was written by a newer build, or the type-string was corrupted in the database.
</section>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string DriverInstanceId { get; set; } = "";
private DriverInstance? _existing;
private bool _loaded;
private static readonly IReadOnlyDictionary<string, Type> _componentMap =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
["Modbus"] = typeof(ModbusDriverPage),
["AbCip"] = typeof(AbCipDriverPage),
["AbLegacy"] = typeof(AbLegacyDriverPage),
["S7"] = typeof(S7DriverPage),
["TwinCat"] = typeof(TwinCATDriverPage),
["Focas"] = typeof(FocasDriverPage),
["OpcUaClient"] = typeof(OpcUaClientDriverPage),
["GalaxyMxGateway"] = typeof(GalaxyDriverPage),
};
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
_loaded = true;
}
private Type? ResolveComponentType()
=> _componentMap.TryGetValue(_existing!.DriverType, out var t) ? t : null;
private IDictionary<string, object> BuildParameters()
=> new Dictionary<string, object>
{
["ClusterId"] = ClusterId,
["DriverInstanceId"] = DriverInstanceId,
};
}
@@ -1,49 +0,0 @@
@* Driver type picker — presents a card grid of registered driver types and links to the
per-type new-driver creation page (/clusters/{ClusterId}/drivers/new/{slug}). *@
@page "/clusters/{ClusterId}/drivers/new"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">New driver &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Pick a driver type</div>
<div style="padding:1rem">
<div class="row g-3">
@foreach (var t in _types)
{
<div class="col-md-4 col-lg-3">
<a href="/clusters/@ClusterId/drivers/new/@t.Slug" class="card h-100 text-decoration-none">
<div class="card-body">
<div class="text-muted small mono">@t.Icon</div>
<div class="card-title mt-1"><strong>@t.DisplayName</strong></div>
<div class="card-text small text-muted">@t.Description</div>
</div>
</a>
</div>
}
</div>
</div>
</section>
@code {
[Parameter] public string ClusterId { get; set; } = "";
private sealed record DriverTypeEntry(string DisplayName, string Slug, string Icon, string Description);
private static readonly IReadOnlyList<DriverTypeEntry> _types = new[]
{
new DriverTypeEntry("Modbus TCP", "modbustcp", "[M]", "Modbus/TCP — generic registers/coils via port 502."),
new DriverTypeEntry("AbCip", "abcip", "[CIP]", "Allen-Bradley CompactLogix/ControlLogix via CIP."),
new DriverTypeEntry("AbLegacy", "ablegacy", "[AB]", "Allen-Bradley PLC-5/SLC-500/MicroLogix via DF1."),
new DriverTypeEntry("S7", "s7", "[S7]", "Siemens S7-300/400/1200/1500 via ISO-on-TCP."),
new DriverTypeEntry("TwinCat", "twincat", "[TC]", "Beckhoff TwinCAT via ADS."),
new DriverTypeEntry("Focas", "focas", "[FOC]", "Fanuc CNC via FOCAS library."),
new DriverTypeEntry("OpcUaClient", "opcuaclient", "[OPC]", "Upstream OPC UA server pull."),
new DriverTypeEntry("Galaxy", "galaxy", "[Gx]", "AVEVA System Platform (Wonderware) via mxaccessgw."),
};
}
@@ -1,502 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/focas"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New Fanuc FOCAS driver" : "Edit Fanuc FOCAS driver") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="focasDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="FOCAS address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<FOCASAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
</DriverTagPicker>
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label" for="focasTimeoutSec">Timeout (seconds)</label>
<InputNumber id="focasTimeoutSec" @bind-Value="_form.TimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Per-operation timeout. Default 2 s.</div>
</div>
</div>
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="focasProbeEnabled" @bind-Value="_form.ProbeEnabled"
class="form-check-input" />
<label class="form-check-label" for="focasProbeEnabled">Probe enabled</label>
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasProbeInterval">Probe interval (s)</label>
<InputNumber id="focasProbeInterval" @bind-Value="_form.ProbeIntervalSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasProbeTimeout">Probe timeout (s)</label>
<InputNumber id="focasProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasAdminProbe">Admin probe timeout (s)</label>
<InputNumber id="focasAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
@* Alarm projection *@
<section class="panel rise mt-3" style="animation-delay:.11s">
<div class="panel-head">Alarm projection</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="focasAlarmEnabled" @bind-Value="_form.AlarmProjectionEnabled"
class="form-check-input" />
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
</div>
<div class="form-text">Surfaces FOCAS alarms via IAlarmSource.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds"
class="form-control form-control-sm" />
<div class="form-text">One cnc_rdalmmsg2 call per device per tick. Default 2 s.</div>
</div>
</div>
</div>
</section>
@* Handle recycle *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Handle recycle</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="focasRecycleEnabled" @bind-Value="_form.HandleRecycleEnabled"
class="form-check-input" />
<label class="form-check-label" for="focasRecycleEnabled">Handle recycle enabled</label>
</div>
<div class="form-text">Proactive FWLIB session recycle to prevent handle pool exhaustion. Default off.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasRecycleInterval">Recycle interval (minutes)</label>
<InputNumber id="focasRecycleInterval" @bind-Value="_form.HandleRecycleIntervalMinutes"
class="form-control form-control-sm" />
<div class="form-text">Typical: 30 min (shared pool) or 360 min (single client).</div>
</div>
</div>
</div>
</section>
@* Fixed tree *@
<section class="panel rise mt-3" style="animation-delay:.17s">
<div class="panel-head">Fixed-node tree</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="focasFixedTree" @bind-Value="_form.FixedTreeEnabled"
class="form-check-input" />
<label class="form-check-label" for="focasFixedTree">Fixed tree enabled</label>
</div>
<div class="form-text">Exposes Identity/, Axes/, etc. from cnc_sysinfo/cnc_rdaxisname/cnc_rddynamic2. Default off.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasAxisPoll">Axis poll interval (ms)</label>
<InputNumber id="focasAxisPoll" @bind-Value="_form.FixedTreePollIntervalMs"
class="form-control form-control-sm" />
<div class="form-text">cnc_rddynamic2 cadence per axis. Default 250 ms.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasProgramPoll">Program poll interval (s)</label>
<InputNumber id="focasProgramPoll" @bind-Value="_form.FixedTreeProgramPollIntervalSeconds"
class="form-control form-control-sm" />
<div class="form-text">Program/mode info cadence. 0 = disabled. Default 1 s.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasTimerPoll">Timer poll interval (s)</label>
<InputNumber id="focasTimerPoll" @bind-Value="_form.FixedTreeTimerPollIntervalSeconds"
class="form-control form-control-sm" />
<div class="form-text">Power-on/cutting/cycle timer cadence. 0 = disabled. Default 30 s.</div>
</div>
</div>
</div>
</section>
@* Devices *@
<CollectionEditor TRow="FocasDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".20s"
NewRow="@(() => new FocasDeviceRow())" Clone="@(r => r.Clone())"
Validate="FocasDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>CNC series</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td><td>@d.Series</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="192.168.0.10:8193" /></div>
<div class="col-md-3"><label class="form-label">CNC series</label>
<select class="form-select form-select-sm" @bind="d.Series">
@foreach (var e in Enum.GetValues<FocasCncSeries>()) { <option value="@e">@e</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
@* Tags *@
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "Focas";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private bool _loaded, _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices/Tags are collections — edited via the CollectionEditor modal.
private List<FocasDeviceRow> _devices = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
_form = FormModel.FromOptions(new FocasDriverOptions());
}
else
{
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (_existing is not null)
{
_identityModel = new()
{
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new FocasDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(FocasDeviceRow.FromDefinition).ToList();
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
var opts = _form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList());
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
{
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
}
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
});
}
else
{
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
}
catch (Exception ex)
{
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static FocasDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Mutable VM for the modal editor — FocasDeviceOptions is an immutable record.
public sealed class FocasDeviceRow
{
public string HostAddress { get; set; } = "";
public FocasCncSeries Series { get; set; } = FocasCncSeries.Unknown;
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
// expose across a load→save.
private FocasDeviceOptions? _source;
public FocasDeviceRow Clone() => (FocasDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static FocasDeviceRow FromDefinition(FocasDeviceOptions d) => new()
{
HostAddress = d.HostAddress, Series = d.Series, DeviceName = d.DeviceName,
_source = d,
};
public FocasDeviceOptions ToDefinition()
{
var baseDef = _source ?? new FocasDeviceOptions(HostAddress.Trim());
return baseDef with
{
HostAddress = HostAddress.Trim(),
Series = Series,
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(FocasDeviceRow row, IReadOnlyList<FocasDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Connection
public int TimeoutSeconds { get; set; } = 2;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 10;
// Alarm projection
public bool AlarmProjectionEnabled { get; set; } = false;
public int AlarmProjectionPollIntervalSeconds { get; set; } = 2;
// Handle recycle
public bool HandleRecycleEnabled { get; set; } = false;
public int HandleRecycleIntervalMinutes { get; set; } = 60;
// Fixed tree
public bool FixedTreeEnabled { get; set; } = false;
public int FixedTreePollIntervalMs { get; set; } = 250;
public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1;
public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30;
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(FocasDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
AlarmProjectionEnabled = o.AlarmProjection.Enabled,
AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds,
HandleRecycleEnabled = o.HandleRecycle.Enabled,
HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes,
FixedTreeEnabled = o.FixedTree.Enabled,
FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds,
FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds,
FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds,
};
public FocasDriverOptions ToOptions(
IReadOnlyList<FocasDeviceOptions> devices) => new()
{
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new FocasProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
AlarmProjection = new FocasAlarmProjectionOptions
{
Enabled = AlarmProjectionEnabled,
PollInterval = TimeSpan.FromSeconds(AlarmProjectionPollIntervalSeconds),
},
HandleRecycle = new FocasHandleRecycleOptions
{
Enabled = HandleRecycleEnabled,
Interval = TimeSpan.FromMinutes(HandleRecycleIntervalMinutes),
},
FixedTree = new FocasFixedTreeOptions
{
Enabled = FixedTreeEnabled,
PollInterval = TimeSpan.FromMilliseconds(FixedTreePollIntervalMs),
ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds),
TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds),
},
Devices = devices,
};
}
}
@@ -1,460 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/galaxy"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New AVEVA Galaxy driver" : "Edit AVEVA Galaxy driver") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (!_loaded)
{
<p>Loading&hellip;</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="galaxyDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.Galaxy.ProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Galaxy address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
</DriverTagPicker>
@* mxaccessgw connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">mxaccessgw connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Gateway endpoint</label>
<InputText @bind-Value="_form.Galaxy.GatewayEndpoint" class="form-control form-control-sm mono"
placeholder="https://localhost:5001" />
<div class="form-text">gRPC endpoint of the mxaccessgw process.</div>
</div>
<div class="col-md-6">
<label class="form-label">API key secret ref</label>
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" class="form-control form-control-sm mono"
placeholder="env:MX_API_KEY" />
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.Galaxy.GatewayUseTls" class="form-check-input" id="gwTls" />
<label class="form-check-label" for="gwTls">Use TLS</label>
</div>
</div>
<div class="col-md-5">
<label class="form-label">CA certificate path (optional)</label>
<InputText @bind-Value="_form.Galaxy.GatewayCaCertificatePath" class="form-control form-control-sm mono"
placeholder="C:\certs\ca.pem" />
</div>
<div class="col-md-2">
<label class="form-label">Connect timeout (s)</label>
<InputNumber @bind-Value="_form.Galaxy.GatewayConnectTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 10 s.</div>
</div>
<div class="col-md-2">
<label class="form-label">Call timeout (s)</label>
<InputNumber @bind-Value="_form.Galaxy.GatewayDefaultCallTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 30 s.</div>
</div>
<div class="col-md-2">
<label class="form-label">Stream timeout (s, 0 = unlimited)</label>
<InputNumber @bind-Value="_form.Galaxy.GatewayStreamTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 0 (lifetime of driver).</div>
</div>
</div>
</div>
</section>
@* MXAccess *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">MXAccess</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Client name</label>
<InputText @bind-Value="_form.Galaxy.MxClientName" class="form-control form-control-sm"
placeholder="OtOpcUa-Primary" />
<div class="form-text">Must be unique per OtOpcUa instance — redundancy pairs each get a distinct name.</div>
</div>
<div class="col-md-3">
<label class="form-label">Publishing interval (ms)</label>
<InputNumber @bind-Value="_form.Galaxy.MxPublishingIntervalMs" class="form-control form-control-sm" />
<div class="form-text">Default 1000 ms.</div>
</div>
<div class="col-md-2">
<label class="form-label">Write user ID</label>
<InputNumber @bind-Value="_form.Galaxy.MxWriteUserId" class="form-control form-control-sm" />
<div class="form-text">0 = anonymous.</div>
</div>
<div class="col-md-3">
<label class="form-label">Event pump channel capacity</label>
<InputNumber @bind-Value="_form.Galaxy.MxEventPumpChannelCapacity" class="form-control form-control-sm" />
<div class="form-text">Default 50000. Raise if events.dropped appears.</div>
</div>
</div>
</div>
</section>
@* Repository *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Galaxy repository</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Discover page size</label>
<InputNumber @bind-Value="_form.Galaxy.RepositoryDiscoverPageSize" class="form-control form-control-sm" />
<div class="form-text">Default 5000 objects per page.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.Galaxy.RepositoryWatchDeployEvents" class="form-check-input" id="watchDeploy" />
<label class="form-check-label" for="watchDeploy">Watch deploy events</label>
</div>
<div class="form-text mt-0">Triggers address-space rebuild on Galaxy re-deploy.</div>
</div>
</div>
</div>
</section>
@* Reconnect *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Reconnect backoff</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Initial backoff (ms)</label>
<InputNumber @bind-Value="_form.Galaxy.ReconnectInitialBackoffMs" class="form-control form-control-sm" />
<div class="form-text">Default 500 ms.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max backoff (ms)</label>
<InputNumber @bind-Value="_form.Galaxy.ReconnectMaxBackoffMs" class="form-control form-control-sm" />
<div class="form-text">Default 30000 ms.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.Galaxy.ReconnectReplayOnSessionLost" class="form-check-input" id="replayOnLost" />
<label class="form-check-label" for="replayOnLost">Replay subscriptions on session lost</label>
</div>
</div>
</div>
</div>
</section>
@* Diagnostics *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Diagnostics</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.Galaxy.ProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 30.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "GalaxyMxGateway";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_form = new FormModel();
}
else
{
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (_existing is not null)
{
_identityModel = new()
{
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? CreateDefaultOptions();
_form = new FormModel();
_form.Galaxy = GalaxyFormModel.FromRecord(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private static GalaxyDriverOptions CreateDefaultOptions() => new(
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
Repository: new GalaxyRepositoryOptions(),
Reconnect: new GalaxyReconnectOptions());
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
var opts = _form.Galaxy.ToRecord();
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
{
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
}
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
});
}
else
{
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
}
catch (Exception ex)
{
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts);
private static GalaxyDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
public sealed class FormModel
{
public GalaxyFormModel Galaxy { get; set; } = new();
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>
/// Mutable flat mirror of <see cref="GalaxyDriverOptions"/> and its nested records.
/// All positional-record fields are flattened with a section prefix to avoid name
/// collisions. <see cref="FromRecord"/> / <see cref="ToRecord"/> handle translation.
/// </summary>
public sealed class GalaxyFormModel
{
// GalaxyGatewayOptions
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
public bool GatewayUseTls { get; set; } = true;
public string? GatewayCaCertificatePath { get; set; }
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
public int GatewayDefaultCallTimeoutSeconds { get; set; } = 30;
public int GatewayStreamTimeoutSeconds { get; set; } = 0;
// GalaxyMxAccessOptions
public string MxClientName { get; set; } = "OtOpcUa";
public int MxPublishingIntervalMs { get; set; } = 1000;
public int MxWriteUserId { get; set; } = 0;
public int MxEventPumpChannelCapacity { get; set; } = 50_000;
// GalaxyRepositoryOptions
public int RepositoryDiscoverPageSize { get; set; } = 5000;
public bool RepositoryWatchDeployEvents { get; set; } = true;
// GalaxyReconnectOptions
public int ReconnectInitialBackoffMs { get; set; } = 500;
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
public bool ReconnectReplayOnSessionLost { get; set; } = true;
// GalaxyDriverOptions top-level
public int ProbeTimeoutSeconds { get; set; } = 30;
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
{
// Null-coalesce each nested record to its default so that persisted configs
// that pre-date a section (e.g. no Reconnect key, or PascalCase keys that
// don't match the camelCase deserializer) don't cause a NullReferenceException.
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
var repo = r.Repository ?? new GalaxyRepositoryOptions();
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
return new()
{
GatewayEndpoint = gw.Endpoint,
GatewayApiKeySecretRef = gw.ApiKeySecretRef,
GatewayUseTls = gw.UseTls,
GatewayCaCertificatePath = gw.CaCertificatePath,
GatewayConnectTimeoutSeconds = gw.ConnectTimeoutSeconds,
GatewayDefaultCallTimeoutSeconds = gw.DefaultCallTimeoutSeconds,
GatewayStreamTimeoutSeconds = gw.StreamTimeoutSeconds,
MxClientName = mx.ClientName,
MxPublishingIntervalMs = mx.PublishingIntervalMs,
MxWriteUserId = mx.WriteUserId,
MxEventPumpChannelCapacity = mx.EventPumpChannelCapacity,
RepositoryDiscoverPageSize = repo.DiscoverPageSize,
RepositoryWatchDeployEvents = repo.WatchDeployEvents,
ReconnectInitialBackoffMs = rc.InitialBackoffMs,
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
};
}
public GalaxyDriverOptions ToRecord() => new(
Gateway: new GalaxyGatewayOptions(
Endpoint: GatewayEndpoint,
ApiKeySecretRef: GatewayApiKeySecretRef,
UseTls: GatewayUseTls,
CaCertificatePath: string.IsNullOrWhiteSpace(GatewayCaCertificatePath) ? null : GatewayCaCertificatePath,
ConnectTimeoutSeconds: GatewayConnectTimeoutSeconds,
DefaultCallTimeoutSeconds: GatewayDefaultCallTimeoutSeconds,
StreamTimeoutSeconds: GatewayStreamTimeoutSeconds),
MxAccess: new GalaxyMxAccessOptions(
ClientName: MxClientName,
PublishingIntervalMs: MxPublishingIntervalMs,
WriteUserId: MxWriteUserId,
EventPumpChannelCapacity: MxEventPumpChannelCapacity),
Repository: new GalaxyRepositoryOptions(
DiscoverPageSize: RepositoryDiscoverPageSize,
WatchDeployEvents: RepositoryWatchDeployEvents),
Reconnect: new GalaxyReconnectOptions(
InitialBackoffMs: ReconnectInitialBackoffMs,
MaxBackoffMs: ReconnectMaxBackoffMs,
ReplayOnSessionLost: ReconnectReplayOnSessionLost))
{
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
};
}
}
@@ -1,572 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/modbustcp"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New Modbus/TCP driver" : "Edit Modbus/TCP driver") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (!_loaded)
{
<p>Loading&hellip;</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="modbustcpDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="Modbus address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<ModbusAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Transport *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Transport</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Host</label>
<InputText @bind-Value="_form.Host" class="form-control form-control-sm" placeholder="127.0.0.1" />
</div>
<div class="col-md-3">
<label class="form-label">Port</label>
<InputNumber @bind-Value="_form.Port" class="form-control form-control-sm" />
</div>
<div class="col-md-3">
<label class="form-label">Unit ID (slave ID)</label>
<InputNumber @bind-Value="_form.UnitId" class="form-control form-control-sm" />
</div>
<div class="col-md-3">
<label class="form-label">Timeout (seconds)</label>
<InputNumber @bind-Value="_form.TimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 2 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">PLC family</label>
<InputSelect @bind-Value="_form.Family" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<ModbusFamily>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
<div class="col-md-3">
<label class="form-label">MELSEC sub-family</label>
<InputSelect @bind-Value="_form.MelsecSubFamily" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<MelsecFamily>())
{
<option value="@e">@e</option>
}
</InputSelect>
<div class="form-text">Only used when Family = MELSEC.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.AutoReconnect" class="form-check-input" id="autoReconnect" />
<label class="form-check-label" for="autoReconnect">Auto-reconnect</label>
</div>
</div>
</div>
</div>
</section>
@* Batch sizes *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Batch sizes</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Max registers per read</label>
<InputNumber @bind-Value="_form.MaxRegistersPerRead" class="form-control form-control-sm" />
<div class="form-text">Spec max 125. Reduce for limited devices.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max registers per write</label>
<InputNumber @bind-Value="_form.MaxRegistersPerWrite" class="form-control form-control-sm" />
<div class="form-text">Spec max 123.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max coils per read</label>
<InputNumber @bind-Value="_form.MaxCoilsPerRead" class="form-control form-control-sm" />
<div class="form-text">Spec max 2000.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max read gap (coalescing)</label>
<InputNumber @bind-Value="_form.MaxReadGap" class="form-control form-control-sm" />
<div class="form-text">0 = no coalescing. Typical 532.</div>
</div>
</div>
</div>
</section>
@* Write options *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Write options</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.UseFC15ForSingleCoilWrites" class="form-check-input" id="useFC15" />
<label class="form-check-label" for="useFC15">Use FC15 for single coil writes</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.UseFC16ForSingleRegisterWrites" class="form-check-input" id="useFC16" />
<label class="form-check-label" for="useFC16">Use FC16 for single register writes</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.DisableFC23" class="form-check-input" id="disableFC23" />
<label class="form-check-label" for="disableFC23">Disable FC23 (reserved — no effect today)</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.WriteOnChangeOnly" class="form-check-input" id="writeOnChangeOnly" />
<label class="form-check-label" for="writeOnChangeOnly">Write on change only</label>
</div>
</div>
</div>
</div>
</section>
@* Keep-alive *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">TCP keep-alive</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.KeepAliveEnabled" class="form-check-input" id="kaEnabled" />
<label class="form-check-label" for="kaEnabled">Enabled</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Time (seconds)</label>
<InputNumber @bind-Value="_form.KeepAliveTimeSeconds" class="form-control form-control-sm" />
<div class="form-text">Idle time before first probe. Default 30 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Interval (seconds)</label>
<InputNumber @bind-Value="_form.KeepAliveIntervalSeconds" class="form-control form-control-sm" />
<div class="form-text">Between probes once started. Default 10 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Retry count</label>
<InputNumber @bind-Value="_form.KeepAliveRetryCount" class="form-control form-control-sm" />
<div class="form-text">Probes before declaring socket dead. Default 3.</div>
</div>
</div>
</div>
</section>
@* Reconnect backoff *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Reconnect backoff</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Initial delay (seconds)</label>
<InputNumber @bind-Value="_form.ReconnectInitialDelaySeconds" class="form-control form-control-sm" />
<div class="form-text">0 = immediate retry.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max delay (seconds)</label>
<InputNumber @bind-Value="_form.ReconnectMaxDelaySeconds" class="form-control form-control-sm" />
<div class="form-text">Default 30 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Backoff multiplier</label>
<InputNumber @bind-Value="_form.ReconnectBackoffMultiplier" class="form-control form-control-sm" />
<div class="form-text">Default 2.0 (doubles each step).</div>
</div>
<div class="col-md-3">
<label class="form-label">Idle disconnect (seconds, 0 = off)</label>
<InputNumber @bind-Value="_form.IdleDisconnectTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Proactive close after idle period. 0 = disabled.</div>
</div>
</div>
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.16s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.ProbeEnabled" class="form-check-input" id="probeEnabled" />
<label class="form-check-label" for="probeEnabled">Enabled</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe interval (seconds)</label>
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 2 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe register address</label>
<InputNumber @bind-Value="_form.ProbeAddress" class="form-control form-control-sm" />
<div class="form-text">Zero-based. Default 0 (FC03 at register 0).</div>
</div>
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
</div>
<div class="col-md-3">
<label class="form-label">Auto-prohibit re-probe interval (seconds, 0 = off)</label>
<InputNumber @bind-Value="_form.AutoProhibitReprobeIntervalSeconds" class="form-control form-control-sm" />
<div class="form-text">Interval to retry auto-prohibited coalesced ranges.</div>
</div>
</div>
</div>
</section>
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "Modbus";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_form = new FormModel();
}
else
{
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (_existing is not null)
{
_identityModel = new()
{
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new ModbusDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
{
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists.";
return;
}
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
});
}
else
{
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
}
catch (Exception ex)
{
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(_form.ToOptions(), _jsonOpts);
private static ModbusDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<ModbusDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Flat mutable model — all scalars exposed as settable properties so Blazor @bind-Value works.
// Collection (Tags) is kept on the component (_tags) and passed in when building the final Options.
public sealed class FormModel
{
// Transport
public string Host { get; set; } = "127.0.0.1";
public int Port { get; set; } = 502;
public int UnitId { get; set; } = 1;
public int TimeoutSeconds { get; set; } = 2;
// Family
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
// Transport flags
public bool AutoReconnect { get; set; } = true;
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
// Batch sizes (using int; clamped to ushort on ToOptions)
public int MaxRegistersPerRead { get; set; } = 125;
public int MaxRegistersPerWrite { get; set; } = 123;
public int MaxCoilsPerRead { get; set; } = 2000;
public int MaxReadGap { get; set; } = 0;
// Write options
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
public bool DisableFC23 { get; set; } = false;
public bool WriteOnChangeOnly { get; set; } = false;
// Keep-alive
public bool KeepAliveEnabled { get; set; } = true;
public int KeepAliveTimeSeconds { get; set; } = 30;
public int KeepAliveIntervalSeconds { get; set; } = 10;
public int KeepAliveRetryCount { get; set; } = 3;
// Reconnect backoff
public int ReconnectInitialDelaySeconds { get; set; } = 0;
public int ReconnectMaxDelaySeconds { get; set; } = 30;
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public int ProbeAddress { get; set; } = 0;
// Auto-prohibit re-probe (0 = disabled)
public int AutoProhibitReprobeIntervalSeconds { get; set; } = 0;
// Admin UI probe timeout
public int AdminProbeTimeoutSeconds { get; set; } = 5;
// Persistence
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(ModbusDriverOptions o) => new()
{
Host = o.Host,
Port = o.Port,
UnitId = o.UnitId,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Family = o.Family,
MelsecSubFamily = o.MelsecSubFamily,
AutoReconnect = o.AutoReconnect,
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
MaxRegistersPerRead = o.MaxRegistersPerRead,
MaxRegistersPerWrite = o.MaxRegistersPerWrite,
MaxCoilsPerRead = o.MaxCoilsPerRead,
MaxReadGap = o.MaxReadGap,
UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites,
UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites,
DisableFC23 = o.DisableFC23,
WriteOnChangeOnly = o.WriteOnChangeOnly,
KeepAliveEnabled = o.KeepAlive.Enabled,
KeepAliveTimeSeconds = (int)o.KeepAlive.Time.TotalSeconds,
KeepAliveIntervalSeconds = (int)o.KeepAlive.Interval.TotalSeconds,
KeepAliveRetryCount = o.KeepAlive.RetryCount,
ReconnectInitialDelaySeconds = (int)o.Reconnect.InitialDelay.TotalSeconds,
ReconnectMaxDelaySeconds = (int)o.Reconnect.MaxDelay.TotalSeconds,
ReconnectBackoffMultiplier = o.Reconnect.BackoffMultiplier,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
ProbeAddress = o.Probe.ProbeAddress,
AutoProhibitReprobeIntervalSeconds = o.AutoProhibitReprobeInterval.HasValue
? (int)o.AutoProhibitReprobeInterval.Value.TotalSeconds : 0,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public ModbusDriverOptions ToOptions() => new()
{
Host = Host,
Port = Port,
UnitId = (byte)Math.Clamp(UnitId, 0, 255),
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new ModbusProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
ProbeAddress = (ushort)Math.Clamp(ProbeAddress, 0, 65535),
},
MaxRegistersPerRead = (ushort)Math.Clamp(MaxRegistersPerRead, 0, 65535),
MaxRegistersPerWrite = (ushort)Math.Clamp(MaxRegistersPerWrite, 0, 65535),
MaxCoilsPerRead = (ushort)Math.Clamp(MaxCoilsPerRead, 0, 65535),
UseFC15ForSingleCoilWrites = UseFC15ForSingleCoilWrites,
UseFC16ForSingleRegisterWrites = UseFC16ForSingleRegisterWrites,
DisableFC23 = DisableFC23,
AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0
? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null,
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
Family = Family,
MelsecSubFamily = MelsecSubFamily,
WriteOnChangeOnly = WriteOnChangeOnly,
AutoReconnect = AutoReconnect,
KeepAlive = new ModbusKeepAliveOptions
{
Enabled = KeepAliveEnabled,
Time = TimeSpan.FromSeconds(KeepAliveTimeSeconds),
Interval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
RetryCount = KeepAliveRetryCount,
},
IdleDisconnectTimeout = IdleDisconnectTimeoutSeconds > 0
? TimeSpan.FromSeconds(IdleDisconnectTimeoutSeconds) : null,
Reconnect = new ModbusReconnectOptions
{
InitialDelay = TimeSpan.FromSeconds(ReconnectInitialDelaySeconds),
MaxDelay = TimeSpan.FromSeconds(ReconnectMaxDelaySeconds),
BackoffMultiplier = ReconnectBackoffMultiplier,
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
}
@@ -1,554 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/opcuaclient"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New OPC UA Client driver" : "Edit OPC UA Client driver") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (!_loaded)
{
<p>Loading&hellip;</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="opcuaclientDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.OpcUa.ProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="OPC UA address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<OpcUaClientAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
</DriverTagPicker>
@* Endpoint *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Endpoint</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">Endpoint URL</label>
<InputText @bind-Value="_form.OpcUa.EndpointUrl" class="form-control form-control-sm mono"
placeholder="opc.tcp://plc.internal:4840" />
<div class="form-text">Single-endpoint shortcut. When EndpointUrls list is non-empty, this field is ignored.</div>
</div>
<div class="col-md-4">
<label class="form-label">Browse root NodeId (blank = ObjectsFolder)</label>
<InputText @bind-Value="_form.OpcUa.BrowseRoot" class="form-control form-control-sm mono"
placeholder="i=85" />
<div class="form-text">Restrict mirroring to a sub-tree.</div>
</div>
<div class="col-md-4">
<label class="form-label">Application URI</label>
<InputText @bind-Value="_form.OpcUa.ApplicationUri" class="form-control form-control-sm mono" />
</div>
<div class="col-md-4">
<label class="form-label">Session name</label>
<InputText @bind-Value="_form.OpcUa.SessionName" class="form-control form-control-sm" />
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.OpcUa.AutoAcceptCertificates" class="form-check-input" id="autoAcceptCerts" />
<label class="form-check-label" for="autoAcceptCerts">Auto-accept certificates <span class="badge bg-warning text-dark">Dev only</span></label>
</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label">Per-endpoint connect timeout (s)</label>
<InputNumber @bind-Value="_form.OpcUa.PerEndpointConnectTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 3 s — failover sweep budget.</div>
</div>
<div class="col-md-3">
<label class="form-label">Operation timeout (s)</label>
<InputNumber @bind-Value="_form.OpcUa.TimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 10 s — steady-state reads/writes.</div>
</div>
<div class="col-md-3">
<label class="form-label">Session timeout (s)</label>
<InputNumber @bind-Value="_form.OpcUa.SessionTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 120 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Keep-alive interval (s)</label>
<InputNumber @bind-Value="_form.OpcUa.KeepAliveIntervalSeconds" class="form-control form-control-sm" />
<div class="form-text">Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Reconnect period (s)</label>
<InputNumber @bind-Value="_form.OpcUa.ReconnectPeriodSeconds" class="form-control form-control-sm" />
<div class="form-text">Initial delay after session drop. Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max discovered nodes</label>
<InputNumber @bind-Value="_form.OpcUa.MaxDiscoveredNodes" class="form-control form-control-sm" />
<div class="form-text">Default 10000.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max browse depth</label>
<InputNumber @bind-Value="_form.OpcUa.MaxBrowseDepth" class="form-control form-control-sm" />
<div class="form-text">Default 10.</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-12">
<CollectionEditor TRow="EndpointUrlRow" Items="_endpoints"
Title="Endpoint URLs" ItemNoun="endpoint" AnimationDelay=".07s"
NewRow="@(() => new EndpointUrlRow())" Clone="@(r => r.Clone())"
Validate="EndpointUrlRow.ValidateRow">
<HeaderTemplate>
<tr><th>Endpoint URL (failover list — first reachable wins)</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="e">
<td class="mono">@e.Url</td>
</RowTemplate>
<EditTemplate Context="e">
<label class="form-label">Endpoint URL</label>
<input class="form-control form-control-sm mono" @bind="e.Url"
placeholder="opc.tcp://plc.internal:4840" />
<div class="form-text">When this list is non-empty, the single Endpoint URL above is ignored.</div>
</EditTemplate>
</CollectionEditor>
</div>
</div>
</div>
</section>
@* Security *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Security</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Security mode</label>
<InputSelect @bind-Value="_form.OpcUa.SecurityMode" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaSecurityMode>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
<div class="col-md-4">
<label class="form-label">Security policy</label>
<InputSelect @bind-Value="_form.OpcUa.SecurityPolicy" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaSecurityPolicy>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
</div>
</div>
</section>
@* Authentication *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Authentication</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Auth type</label>
<InputSelect @bind-Value="_form.OpcUa.AuthType" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaAuthType>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
@if (_form.OpcUa.AuthType == OpcUaAuthType.Username)
{
<div class="col-md-4">
<label class="form-label">Username</label>
<InputText @bind-Value="_form.OpcUa.Username" class="form-control form-control-sm" />
</div>
<div class="col-md-4">
<label class="form-label">Password</label>
<InputText @bind-Value="_form.OpcUa.Password" type="password" class="form-control form-control-sm" autocomplete="new-password" />
</div>
}
@if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate)
{
<div class="col-md-6">
<label class="form-label">User certificate path (PFX/PEM)</label>
<InputText @bind-Value="_form.OpcUa.UserCertificatePath" class="form-control form-control-sm mono"
placeholder="C:\certs\user.pfx" />
</div>
<div class="col-md-4">
<label class="form-label">Certificate password (if PFX-locked)</label>
<InputText @bind-Value="_form.OpcUa.UserCertificatePassword" type="password" class="form-control form-control-sm" autocomplete="new-password" />
</div>
}
</div>
</div>
</section>
@* Namespace mapping *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Namespace mapping</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Target namespace kind</label>
<InputSelect @bind-Value="_form.OpcUa.TargetNamespaceKind" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaTargetNamespaceKind>())
{
<option value="@e">@e</option>
}
</InputSelect>
<div class="form-text">Equipment = raw data re-mapped to UNS. SystemPlatform = processed data; hierarchy preserved as-is.</div>
</div>
<div class="col-12">
<label class="form-label">UNS mapping table (read-only — edit via raw JSON import)</label>
<pre class="form-control form-control-sm mono" style="min-height:3rem;overflow:auto;white-space:pre-wrap;">@_unsMappingTableJson</pre>
<div class="form-text">Keys = remote browse-path prefixes; values = UNS Area/Line/Name paths. Required when TargetNamespaceKind = Equipment.</div>
</div>
</div>
</div>
</section>
@* Diagnostics *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Diagnostics</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.OpcUa.ProbeTimeoutSeconds" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "OpcUaClient";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private bool _loaded;
private bool _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because EndpointUrls is a collection — edited via the CollectionEditor modal.
private List<EndpointUrlRow> _endpoints = [];
// Read-only JSON snippet for the UnsMappingTable, which has no list editor yet.
private string _unsMappingTableJson = "{}";
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new()
{
DriverInstanceId = "",
Name = "",
DriverType = DriverTypeKey,
Enabled = true,
};
_form = new FormModel();
}
else
{
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (_existing is not null)
{
_identityModel = new()
{
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new OpcUaClientDriverOptions();
_form = new FormModel();
_form.OpcUa = OpcUaClientFormModel.FromRecord(opts);
_endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList();
_unsMappingTableJson = System.Text.Json.JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
var opts = _form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList());
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
{
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
}
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
});
}
else
{
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
}
catch (Exception ex)
{
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts);
private static OpcUaClientDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
public sealed class FormModel
{
public OpcUaClientFormModel OpcUa { get; set; } = new();
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>
/// Mutable VM for a single endpoint URL row. EndpointUrls is a plain
/// <c>List&lt;string&gt;</c> (a failover list) so the row is a thin wrapper the
/// <see cref="CollectionEditor{TRow}"/> modal can bind to.
/// </summary>
public sealed class EndpointUrlRow
{
public string Url { get; set; } = "";
public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone();
public static EndpointUrlRow FromUrl(string u) => new() { Url = u };
public string ToUrl() => Url.Trim();
public static string? ValidateRow(EndpointUrlRow row, IReadOnlyList<EndpointUrlRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Url)) return "URL is required.";
if (!row.Url.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
return "Endpoint URL must start with opc.tcp://";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Url.Trim(), row.Url.Trim(), StringComparison.OrdinalIgnoreCase))
return $"Duplicate endpoint '{row.Url}'.";
return null;
}
}
/// <summary>
/// Mutable mirror of <see cref="OpcUaClientDriverOptions"/> with int wrappers for
/// TimeSpan fields so Blazor InputNumber can bind them.
/// EndpointUrls is edited via the CollectionEditor (held on the page as a row list and
/// threaded into <see cref="ToRecord"/>); UnsMappingTable is shown as read-only JSON and
/// survives round-trip via the original deserialized record, re-serialized unchanged.
/// </summary>
public sealed class OpcUaClientFormModel
{
// Connection
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
public string? BrowseRoot { get; set; }
public string ApplicationUri { get; set; } = "urn:localhost:OtOpcUa:GatewayClient";
public string SessionName { get; set; } = "OtOpcUa-Gateway";
public bool AutoAcceptCertificates { get; set; } = false;
public int PerEndpointConnectTimeoutSeconds { get; set; } = 3;
public int TimeoutSeconds { get; set; } = 10;
public int SessionTimeoutSeconds { get; set; } = 120;
public int KeepAliveIntervalSeconds { get; set; } = 5;
public int ReconnectPeriodSeconds { get; set; } = 5;
public int MaxDiscoveredNodes { get; set; } = 10_000;
public int MaxBrowseDepth { get; set; } = 10;
// Security
public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None;
public OpcUaSecurityPolicy SecurityPolicy { get; set; } = OpcUaSecurityPolicy.None;
// Authentication
public OpcUaAuthType AuthType { get; set; } = OpcUaAuthType.Anonymous;
public string? Username { get; set; }
public string? Password { get; set; }
public string? UserCertificatePath { get; set; }
public string? UserCertificatePassword { get; set; }
// Namespace mapping
public OpcUaTargetNamespaceKind TargetNamespaceKind { get; set; } = OpcUaTargetNamespaceKind.Equipment;
// Diagnostics
public int ProbeTimeoutSeconds { get; set; } = 15;
// Preserved read-only collection (round-tripped unchanged from original record)
internal IReadOnlyDictionary<string, string> _unsMappingTable = new System.Collections.Generic.Dictionary<string, string>();
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
{
EndpointUrl = r.EndpointUrl,
BrowseRoot = r.BrowseRoot,
ApplicationUri = r.ApplicationUri,
SessionName = r.SessionName,
AutoAcceptCertificates = r.AutoAcceptCertificates,
PerEndpointConnectTimeoutSeconds = (int)r.PerEndpointConnectTimeout.TotalSeconds,
TimeoutSeconds = (int)r.Timeout.TotalSeconds,
SessionTimeoutSeconds = (int)r.SessionTimeout.TotalSeconds,
KeepAliveIntervalSeconds = (int)r.KeepAliveInterval.TotalSeconds,
ReconnectPeriodSeconds = (int)r.ReconnectPeriod.TotalSeconds,
MaxDiscoveredNodes = r.MaxDiscoveredNodes,
MaxBrowseDepth = r.MaxBrowseDepth,
SecurityMode = r.SecurityMode,
SecurityPolicy = r.SecurityPolicy,
AuthType = r.AuthType,
Username = r.Username,
Password = r.Password,
UserCertificatePath = r.UserCertificatePath,
UserCertificatePassword = r.UserCertificatePassword,
TargetNamespaceKind = r.TargetNamespaceKind,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
_unsMappingTable = r.UnsMappingTable,
};
public OpcUaClientDriverOptions ToRecord(IReadOnlyList<string> endpointUrls) => new()
{
EndpointUrl = EndpointUrl,
EndpointUrls = endpointUrls,
BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot,
ApplicationUri = ApplicationUri,
SessionName = SessionName,
AutoAcceptCertificates = AutoAcceptCertificates,
PerEndpointConnectTimeout = TimeSpan.FromSeconds(PerEndpointConnectTimeoutSeconds),
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
SessionTimeout = TimeSpan.FromSeconds(SessionTimeoutSeconds),
KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
ReconnectPeriod = TimeSpan.FromSeconds(ReconnectPeriodSeconds),
MaxDiscoveredNodes = MaxDiscoveredNodes,
MaxBrowseDepth = MaxBrowseDepth,
SecurityMode = SecurityMode,
SecurityPolicy = SecurityPolicy,
AuthType = AuthType,
Username = string.IsNullOrWhiteSpace(Username) ? null : Username,
Password = string.IsNullOrWhiteSpace(Password) ? null : Password,
UserCertificatePath = string.IsNullOrWhiteSpace(UserCertificatePath) ? null : UserCertificatePath,
UserCertificatePassword = string.IsNullOrWhiteSpace(UserCertificatePassword) ? null : UserCertificatePassword,
TargetNamespaceKind = TargetNamespaceKind,
UnsMappingTable = _unsMappingTable,
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
};
}
}
@@ -1,356 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/s7"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.S7
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New Siemens S7 driver" : "Edit Siemens S7 driver") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="s7DriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="S7 address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<S7AddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)" />
</DriverTagPicker>
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="s7Host">Host</label>
<InputText id="s7Host" @bind-Value="_form.Host"
class="form-control form-control-sm mono"
placeholder="192.168.0.1" />
<div class="form-text">PLC IP address or hostname.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7Port">Port</label>
<InputNumber id="s7Port" @bind-Value="_form.Port"
class="form-control form-control-sm" />
<div class="form-text">ISO-on-TCP; usually 102.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7TimeoutSec">Timeout (seconds)</label>
<InputNumber id="s7TimeoutSec" @bind-Value="_form.TimeoutSeconds"
class="form-control form-control-sm" />
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="s7CpuType">CPU type</label>
<InputSelect id="s7CpuType" @bind-Value="_form.CpuType"
class="form-select form-select-sm">
@foreach (var v in Enum.GetValues<S7CpuType>())
{
<option value="@v">@v</option>
}
</InputSelect>
<div class="form-text">Controls ISO-TSAP slot byte during handshake.</div>
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="s7Rack">Rack</label>
<InputNumber id="s7Rack" @bind-Value="_form.Rack"
class="form-control form-control-sm" />
<div class="form-text">Almost always 0.</div>
</div>
<div class="col-md-2 mb-3">
<label class="form-label" for="s7Slot">Slot</label>
<InputNumber id="s7Slot" @bind-Value="_form.Slot"
class="form-control form-control-sm" />
<div class="form-text">S7-300/400 = 2; S7-1200/1500 = 0.</div>
</div>
</div>
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="s7ProbeEnabled" @bind-Value="_form.ProbeEnabled"
class="form-check-input" />
<label class="form-check-label" for="s7ProbeEnabled">Probe enabled</label>
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7ProbeIntervalSec">Probe interval (s)</label>
<InputNumber id="s7ProbeIntervalSec" @bind-Value="_form.ProbeIntervalSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7ProbeTimeoutSec">Probe timeout (s)</label>
<InputNumber id="s7ProbeTimeoutSec" @bind-Value="_form.ProbeTimeoutSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7AdminProbeTimeout">Admin probe timeout (s)</label>
<InputNumber id="s7AdminProbeTimeout" @bind-Value="_form.AdminProbeTimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
@* Tags *@
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "S7";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private bool _loaded, _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
_form = FormModel.FromOptions(new S7DriverOptions());
}
else
{
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (_existing is not null)
{
_identityModel = new()
{
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new S7DriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
var opts = _form.ToOptions();
var configJson = System.Text.Json.JsonSerializer.Serialize(opts, _jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
{
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
}
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
});
}
else
{
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
}
catch (Exception ex)
{
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(), _jsonOpts);
private static S7DriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<S7DriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
public sealed class FormModel
{
// Connection
public string Host { get; set; } = "127.0.0.1";
public int Port { get; set; } = 102;
public S7CpuType CpuType { get; set; } = S7CpuType.S71500;
public short Rack { get; set; } = 0;
public short Slot { get; set; } = 0;
public int TimeoutSeconds { get; set; } = 5;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 5;
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(S7DriverOptions o) => new()
{
Host = o.Host,
Port = o.Port,
CpuType = o.CpuType,
Rack = o.Rack,
Slot = o.Slot,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public S7DriverOptions ToOptions() => new()
{
Host = Host,
Port = Port,
CpuType = CpuType,
Rack = Rack,
Slot = Slot,
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new S7ProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
}
@@ -1,410 +0,0 @@
@page "/clusters/{ClusterId}/drivers/new/twincat"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.AdminUI.Clients
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
@inject NavigationManager Nav
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">@(IsNew ? "New Beckhoff TwinCAT driver" : "Edit Beckhoff TwinCAT driver") &middot; <span class="mono">@ClusterId</span></h4>
<a href="/clusters/@ClusterId/drivers" class="btn btn-outline-secondary btn-sm">Cancel</a>
</div>
<ClusterNav ClusterId="@ClusterId" ActiveTab="drivers" />
@if (!_loaded)
{
<p>Loading…</p>
}
else if (!IsNew && _existing is null)
{
<section class="panel notice rise" style="animation-delay:.02s">
Driver instance <span class="mono">@DriverInstanceId</span> was not found in cluster <span class="mono">@ClusterId</span>.
</section>
}
else
{
<EditForm Model="_form" OnValidSubmit="SubmitAsync" FormName="twincatDriverEdit">
<DataAnnotationsValidator />
<DriverFormShell IsNew="IsNew" Busy="_busy" Error="@_error"
CancelHref="@($"/clusters/{ClusterId}/drivers")"
OnDelete="@(IsNew ? null : (EventCallback?)EventCallback.Factory.Create(this, DeleteAsync))">
<DriverIdentitySection Model="_identityModel" IsNew="IsNew" ShowDriverType="false" />
@if (!IsNew && !string.IsNullOrEmpty(DriverInstanceId))
{
<DriverStatusPanel DriverInstanceId="@DriverInstanceId" Enabled="@_identityModel.Enabled" />
}
<div class="mt-2 mb-3">
<DriverTestConnectButton DriverType="@DriverTypeKey"
GetConfigJson="@SerializeCurrentConfig"
TimeoutSeconds="@_form.AdminProbeTimeoutSeconds" />
<button type="button" class="btn btn-sm btn-outline-secondary mt-2"
@onclick="@(() => _showPicker = true)">
Pick address
</button>
</div>
<DriverTagPicker @bind-Visible="_showPicker"
Title="TwinCAT address"
CurrentAddress="@_pickedAddress"
OnPickAddress="@OnAddressPicked">
<TwinCATAddressPickerBody CurrentAddress="@_pickedAddress"
CurrentAddressChanged="@((s) => _pickedAddress = s)"
GetConfigJson="@SerializeCurrentConfig" />
</DriverTagPicker>
@* Options *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Options</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label" for="tcTimeoutSec">Timeout (seconds)</label>
<InputNumber id="tcTimeoutSec" @bind-Value="_form.TimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Default 2 s per operation.</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-4">
<InputCheckbox id="tcNativeNotif" @bind-Value="_form.UseNativeNotifications"
class="form-check-input" />
<label class="form-check-label" for="tcNativeNotif">Use native ADS notifications</label>
</div>
<div class="form-text">Recommended. Disable for AMS routers with notification limits.</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-4">
<InputCheckbox id="tcControllerBrowse" @bind-Value="_form.EnableControllerBrowse"
class="form-check-input" />
<label class="form-check-label" for="tcControllerBrowse">Enable controller browse</label>
</div>
<div class="form-text">Walks the symbol table via SymbolLoaderFactory at discovery. Default off.</div>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="tcNotifDelay">Notification max delay (ms)</label>
<InputNumber id="tcNotifDelay" @bind-Value="_form.NotificationMaxDelayMs"
class="form-control form-control-sm" />
<div class="form-text">0 = push immediately. Increase to coalesce high-churn signals.</div>
</div>
</div>
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="tcProbeEnabled" @bind-Value="_form.ProbeEnabled"
class="form-check-input" />
<label class="form-check-label" for="tcProbeEnabled">Probe enabled</label>
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcProbeInterval">Probe interval (s)</label>
<InputNumber id="tcProbeInterval" @bind-Value="_form.ProbeIntervalSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcProbeTimeout">Probe timeout (s)</label>
<InputNumber id="tcProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcAdminProbe">Admin probe timeout (s)</label>
<InputNumber id="tcAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
@* Devices *@
<CollectionEditor TRow="TwinCATDeviceRow" Items="_devices" Title="Devices" ItemNoun="device"
AnimationDelay=".11s"
NewRow="@(() => new TwinCATDeviceRow())" Clone="@(r => r.Clone())"
Validate="TwinCATDeviceRow.ValidateRow">
<HeaderTemplate>
<tr><th>Host address</th><th>Device name</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="d">
<td class="mono">@d.HostAddress</td>
<td>@(string.IsNullOrWhiteSpace(d.DeviceName) ? "—" : d.DeviceName)</td>
</RowTemplate>
<EditTemplate Context="d">
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Host address (AMS Net Id:port)</label>
<input class="form-control form-control-sm mono" @bind="d.HostAddress"
placeholder="192.168.0.1.1.1:851" /></div>
<div class="col-md-6"><label class="form-label">Device name</label>
<input class="form-control form-control-sm" @bind="d.DeviceName" /></div>
</div>
</EditTemplate>
</CollectionEditor>
@* Tags *@
<section class="panel notice rise mt-3" style="animation-delay:.18s">
<div class="panel-head">Tags</div>
<div style="padding:1rem" class="text-muted">
Tag authoring moves to the Raw tree (<strong>/raw</strong>) in v3 Batch 2.
Pre-declared driver tags have been retired.
</div>
</section>
<DriverResilienceSection @bind-ResilienceConfig="_form.ResilienceConfig" />
</DriverFormShell>
</EditForm>
}
@code {
[Parameter] public string ClusterId { get; set; } = "";
[Parameter] public string? DriverInstanceId { get; set; }
private const string DriverTypeKey = "TwinCat";
private bool IsNew => string.IsNullOrEmpty(DriverInstanceId);
private static readonly System.Text.Json.JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
private FormModel _form = new();
private DriverIdentitySection.DriverIdentityModel _identityModel = new() { DriverType = DriverTypeKey };
private DriverInstance? _existing;
private bool _loaded, _busy;
private string? _error;
// Address picker state
private bool _showPicker;
private string _pickedAddress = "";
private void OnAddressPicked(string address) => _pickedAddress = address;
// Held separately because Devices is a collection — edited via the CollectionEditor modal.
private List<TwinCATDeviceRow> _devices = [];
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
_identityModel = new() { DriverType = DriverTypeKey, Enabled = true };
_form = FormModel.FromOptions(new TwinCATDriverOptions());
}
else
{
_existing = await db.DriverInstances.AsNoTracking()
.FirstOrDefaultAsync(d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (_existing is not null)
{
_identityModel = new()
{
DriverInstanceId = _existing.DriverInstanceId,
Name = _existing.Name,
DriverType = _existing.DriverType,
Enabled = _existing.Enabled,
};
var opts = TryDeserialize(_existing.DriverConfig) ?? new TwinCATDriverOptions();
_form = FormModel.FromOptions(opts);
_form.ResilienceConfig = _existing.ResilienceConfig;
_form.RowVersion = _existing.RowVersion;
_devices = opts.Devices.Select(TwinCATDeviceRow.FromDefinition).ToList();
}
}
_loaded = true;
}
private async Task SubmitAsync()
{
_busy = true; _error = null;
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
await using var db = await DbFactory.CreateDbContextAsync();
if (IsNew)
{
if (await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == _identityModel.DriverInstanceId))
{
_error = $"Driver instance '{_identityModel.DriverInstanceId}' already exists."; return;
}
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = _identityModel.DriverInstanceId,
ClusterId = ClusterId,
Name = _identityModel.Name,
DriverType = DriverTypeKey,
Enabled = _identityModel.Enabled,
DriverConfig = configJson,
ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig,
});
}
else
{
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { _error = "Row no longer exists."; return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
entity.Name = _identityModel.Name;
entity.Enabled = _identityModel.Enabled;
entity.DriverConfig = configJson;
entity.ResilienceConfig = string.IsNullOrWhiteSpace(_form.ResilienceConfig) ? null : _form.ResilienceConfig;
}
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were editing. Reload to see the latest values, then re-apply your changes.";
}
catch (Exception ex) { _error = ex.Message; }
finally { _busy = false; }
}
private async Task DeleteAsync()
{
if (IsNew) return;
_busy = true; _error = null;
try
{
await using var db = await DbFactory.CreateDbContextAsync();
var entity = await db.DriverInstances.FirstOrDefaultAsync(
d => d.ClusterId == ClusterId && d.DriverInstanceId == DriverInstanceId);
if (entity is null) { Nav.NavigateTo($"/clusters/{ClusterId}/drivers"); return; }
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = _form.RowVersion;
db.DriverInstances.Remove(entity);
await db.SaveChangesAsync();
Nav.NavigateTo($"/clusters/{ClusterId}/drivers");
}
catch (DbUpdateConcurrencyException)
{
_error = "Another user changed this driver instance while you were viewing it. Reload before deleting.";
}
catch (Exception ex)
{
_error = $"Delete failed: {ex.Message}. (Likely because equipment/tags still reference this driver — remove them first.)";
}
finally { _busy = false; }
}
private string SerializeCurrentConfig()
=> System.Text.Json.JsonSerializer.Serialize(
_form.ToOptions(
_devices.Select(r => r.ToDefinition()).ToList()),
_jsonOpts);
private static TwinCATDriverOptions? TryDeserialize(string json)
{
try { return System.Text.Json.JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Mutable VM for the modal editor — TwinCATDeviceOptions is an immutable record.
public sealed class TwinCATDeviceRow
{
public string HostAddress { get; set; } = "";
public string? DeviceName { get; set; }
// Original record (null for newly-added rows). Preserves any fields the editor doesn't
// expose across a load→save.
private TwinCATDeviceOptions? _source;
public TwinCATDeviceRow Clone() => (TwinCATDeviceRow)MemberwiseClone(); // _source is an immutable record ref — safe to share
public static TwinCATDeviceRow FromDefinition(TwinCATDeviceOptions d) => new()
{
HostAddress = d.HostAddress, DeviceName = d.DeviceName,
_source = d,
};
public TwinCATDeviceOptions ToDefinition()
{
var baseDef = _source ?? new TwinCATDeviceOptions(HostAddress.Trim());
return baseDef with
{
HostAddress = HostAddress.Trim(),
DeviceName = string.IsNullOrWhiteSpace(DeviceName) ? null : DeviceName.Trim(),
};
}
public static string? ValidateRow(TwinCATDeviceRow row, IReadOnlyList<TwinCATDeviceRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.HostAddress)) return "Host address is required.";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].HostAddress, row.HostAddress, StringComparison.OrdinalIgnoreCase))
return $"Duplicate device host address '{row.HostAddress}'.";
return null;
}
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// The Devices collection is kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Options
public int TimeoutSeconds { get; set; } = 2;
public bool UseNativeNotifications { get; set; } = true;
public bool EnableControllerBrowse { get; set; } = false;
public int NotificationMaxDelayMs { get; set; } = 0;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 10;
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(TwinCATDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
UseNativeNotifications = o.UseNativeNotifications,
EnableControllerBrowse = o.EnableControllerBrowse,
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public TwinCATDriverOptions ToOptions(
IReadOnlyList<TwinCATDeviceOptions> devices) => new()
{
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
UseNativeNotifications = UseNativeNotifications,
EnableControllerBrowse = EnableControllerBrowse,
NotificationMaxDelayMs = NotificationMaxDelayMs,
Probe = new TwinCATProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
Devices = devices,
};
}
}
@@ -0,0 +1,39 @@
@page "/dev/context-menu-demo"
@* Dev-only host page to live-verify the reusable ContextMenu component. *@
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode InteractiveServer
<PageTitle>ContextMenu demo</PageTitle>
<h1>ContextMenu demo</h1>
<p>Right-click the card below, or use the <strong>⋯</strong> button.</p>
<div style="display:flex; align-items:center; gap:1rem; margin:1rem 0;">
<ContextMenu Items="_items" ShowEllipsis="false">
<div style="padding:2rem 3rem; border:1px dashed var(--bs-border-color); border-radius:.5rem; user-select:none;">
Right-click me
</div>
</ContextMenu>
<ContextMenu Items="_items" ShowEllipsis="true" />
</div>
<p>Last action: <strong>@_lastAction</strong></p>
@code {
private string _lastAction = "(none)";
private IReadOnlyList<ContextMenuItem> _items => new List<ContextMenuItem>
{
new() { Label = "Edit", Icon = "✏️", OnClick = () => Set("Edit") },
new() { Label = "Duplicate", Icon = "📄", OnClick = () => Set("Duplicate") },
ContextMenuItem.Separator(),
new() { Label = "Delete", Icon = "🗑️", Disabled = true, OnClick = () => Set("Delete") },
};
private Task Set(string action)
{
_lastAction = action;
return Task.CompletedTask;
}
}
@@ -0,0 +1,74 @@
@page "/raw"
@attribute [Authorize(Policy = AdminUiPolicies.ConfigEditor)]
@rendermode RenderMode.InteractiveServer
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@inject IRawTreeService Svc
@* Peer of GlobalUns (/uns) for the v3 Raw project tree — the cluster-rooted
Enterprise → Cluster → Folder → Driver → Device → TagGroup → Tag hierarchy.
Loads the eager roots on init, then hands off to RawTree, which lazily expands
each container via IRawTreeService.LoadChildrenAsync and surfaces per-node
context menus (real create/configure/tag modals arrive in later Batch-2 waves). *@
<PageTitle>Raw</PageTitle>
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Raw</h4>
<span class="text-muted small">Changes apply on the next deployment.</span>
</div>
<section class="panel rise" style="animation-delay:.02s">
<div class="panel-head d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>Project tree</span>
</div>
@if (_loading)
{
<div style="padding:1rem" class="text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>Loading…
</div>
}
else if (_loadError is not null)
{
<div style="padding:1rem" class="text-danger">@_loadError</div>
}
else if (_roots.Count == 0)
{
<div style="padding:1rem" class="text-muted">No clusters yet.</div>
}
else
{
<div style="padding:.5rem 1rem">
<RawTree Roots="_roots" />
</div>
}
</section>
@code {
private IReadOnlyList<RawNode> _roots = Array.Empty<RawNode>();
private bool _loading = true;
private string? _loadError;
protected override async Task OnInitializedAsync()
{
try
{
_roots = await Svc.LoadRootsAsync();
// Auto-expand the Enterprise roots so the Cluster level (which owns the lazy plumbing) is
// visible on load, mirroring GlobalUns.ExpandStructural. Enterprise children (clusters) are
// eagerly materialised by LoadRootsAsync, so this needs no lazy fetch; clusters stay
// collapsed and load their folders/drivers on first expand.
foreach (var enterprise in _roots)
enterprise.Expanded = true;
}
catch (Exception ex)
{
_loadError = $"Failed to load the Raw tree: {ex.Message}";
}
finally
{
_loading = false;
}
}
}
@@ -78,17 +78,9 @@ else
</InputSelect> </InputSelect>
<ValidationMessage For="@(() => _form.UnsLineId)" /> <ValidationMessage For="@(() => _form.UnsLineId)" />
</div> </div>
<div class="col-md-6 mb-3">
<label class="form-label" for="eq-driver">Driver instance</label>
<InputSelect id="eq-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
<option value="">(none / driver-less)</option>
@foreach (var (id, display) in _driverOptions)
{
<option value="@id">@display</option>
}
</InputSelect>
</div>
</div> </div>
@* v3: equipment no longer binds a driver — device I/O is authored in the /raw tree and surfaced
here by reference on the Tags tab. The old "Driver instance" select was removed. *@
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label" for="eq-ztag">ZTag (ERP)</label> <label class="form-label" for="eq-ztag">ZTag (ERP)</label>
@@ -138,38 +130,48 @@ else
} }
else if (_activeTab == "tags") else if (_activeTab == "tags")
{ {
@* v3 Batch 3: equipment is reference-only — the Tags tab lists UnsTagReference rows pointing at
raw tags. Datatype/access are inherited (read-only) from the backing raw tag; the display-name
override is the only per-reference editable field. *@
<div class="d-flex justify-content-end align-items-center gap-2 mb-2"> <div class="d-flex justify-content-end align-items-center gap-2 mb-2">
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddTag">Add tag</button> <button type="button" class="btn btn-outline-primary btn-sm" @onclick="OpenAddReference">+ Add reference</button>
</div> </div>
@if (!string.IsNullOrWhiteSpace(_tagError)) @if (!string.IsNullOrWhiteSpace(_refError))
{ {
<div class="text-danger small mb-2">@_tagError</div> <div class="text-danger small mb-2">@_refError</div>
} }
@if (_tags is null) @if (_refs is null)
{ {
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p> <p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
} }
else if (_tags.Count == 0) else if (_refs.Count == 0)
{ {
<p class="text-muted">No tags yet.</p> <p class="text-muted">No tag references yet.</p>
} }
else else
{ {
<table class="table table-sm"> <table class="table table-sm align-middle">
<thead> <thead>
<tr><th>Name</th><th>Driver</th><th>Data type</th><th>Access</th><th class="text-end">Actions</th></tr> <tr>
<th>Effective name</th><th>Raw path</th><th>Data type</th><th>Access</th>
<th>Display-name override</th><th class="text-end">Actions</th>
</tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var t in _tags) @foreach (var r in _refs)
{ {
<tr @key="t.TagId"> <tr @key="r.UnsTagReferenceId">
<td>@t.Name</td> <td>@r.EffectiveName</td>
<td class="mono">@t.DriverInstanceId</td> <td class="mono small">@r.RawPath</td>
<td>@t.DataType</td> <td>@r.DataType</td>
<td>@t.AccessLevel</td> <td>@r.AccessLevel</td>
<td class="text-end"> <td>
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => OpenEditTag(t.TagId)">Edit</button> <input class="form-control form-control-sm" @bind="_overrideEdits[r.UnsTagReferenceId]"
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => DeleteTag(t.TagId)">Delete</button> placeholder="(uses raw name)" />
</td>
<td class="text-end text-nowrap">
<button type="button" class="btn btn-outline-secondary btn-sm me-1" @onclick="() => SaveOverride(r)">Save name</button>
<button type="button" class="btn btn-outline-danger btn-sm" @onclick="() => RemoveReference(r)">Remove</button>
</td> </td>
</tr> </tr>
} }
@@ -177,9 +179,8 @@ else
</table> </table>
} }
<TagModal Visible="_tagModalVisible" IsNew="_tagModalIsNew" EquipmentId="@EquipmentId" <AddReferenceModal Visible="_refModalVisible" EquipmentId="@EquipmentId"
Existing="_tagModalExisting" Drivers="_tagDriverOptions" OnCommitted="OnReferencesCommittedAsync" OnCancel="@(() => { _refModalVisible = false; })" />
OnSaved="OnTagSavedAsync" OnCancel="@(() => { _tagModalVisible = false; })" />
} }
else if (_activeTab == "vtags") else if (_activeTab == "vtags")
{ {
@@ -290,15 +291,13 @@ else
private EquipmentEditDto? _equipment; private EquipmentEditDto? _equipment;
private FormModel _form = new(); private FormModel _form = new();
private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>(); private IReadOnlyList<(string Id, string Display)> _lineOptions = Array.Empty<(string, string)>();
private IReadOnlyList<(string Id, string Display)> _driverOptions = Array.Empty<(string, string)>();
// --- Tags tab state. _tags is null until the tab is first activated (drives the lazy load + spinner). --- // --- Tags tab state (v3 reference-only). _refs is null until the tab is first activated (lazy load +
private IReadOnlyList<EquipmentTagRow>? _tags; // spinner). _overrideEdits carries the per-row editable display-name override, keyed by reference id. ---
private string? _tagError; private IReadOnlyList<EquipmentReferenceRow>? _refs;
private bool _tagModalVisible; private readonly Dictionary<string, string?> _overrideEdits = new(StringComparer.Ordinal);
private bool _tagModalIsNew; private string? _refError;
private TagEditDto? _tagModalExisting; private bool _refModalVisible;
private IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> _tagDriverOptions = Array.Empty<(string, string, string, string)>();
// --- Virtual Tags tab state. _vtags is null until the tab is first activated. --- // --- Virtual Tags tab state. _vtags is null until the tab is first activated. ---
private IReadOnlyList<EquipmentVirtualTagRow>? _vtags; private IReadOnlyList<EquipmentVirtualTagRow>? _vtags;
@@ -329,54 +328,48 @@ else
{ {
_activeTab = tab; _activeTab = tab;
if (IsNew) { return; } if (IsNew) { return; }
if (tab == "tags" && _tags is null) { await ReloadTagsAsync(); } if (tab == "tags" && _refs is null) { await ReloadReferencesAsync(); }
else if (tab == "vtags" && _vtags is null) { await ReloadVirtualTagsAsync(); } else if (tab == "vtags" && _vtags is null) { await ReloadVirtualTagsAsync(); }
else if (tab == "alarms" && _alarms is null) { await ReloadAlarmsAsync(); } else if (tab == "alarms" && _alarms is null) { await ReloadAlarmsAsync(); }
} }
// --- Tags tab handlers (mirror GlobalUns; the owning equipment is fixed = EquipmentId) --- // --- Tags tab handlers (v3 reference-only; the owning equipment is fixed = EquipmentId) ---
private async Task ReloadTagsAsync() private async Task ReloadReferencesAsync()
{ {
_tags = await Svc.LoadTagsForEquipmentAsync(EquipmentId!); _refs = await Svc.LoadReferencesForEquipmentAsync(EquipmentId!);
// Seed the per-row override edit buffer so each row's <input> binds to a live value.
_overrideEdits.Clear();
foreach (var r in _refs) { _overrideEdits[r.UnsTagReferenceId] = r.DisplayNameOverride; }
} }
private async Task OpenAddTag() private void OpenAddReference()
{ {
_tagError = null; _refError = null;
_tagModalIsNew = true; _refModalVisible = true;
_tagModalExisting = null;
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
_tagModalVisible = true;
} }
private async Task OpenEditTag(string tagId) private async Task OnReferencesCommittedAsync()
{ {
_tagError = null; _refModalVisible = false;
var dto = await Svc.LoadTagAsync(tagId); await ReloadReferencesAsync();
if (dto is null) { _tagError = "That tag no longer exists; the list was refreshed."; await ReloadTagsAsync(); return; }
_tagModalIsNew = false;
_tagModalExisting = dto;
_tagDriverOptions = await Svc.LoadTagDriversForEquipmentAsync(EquipmentId!);
_tagModalVisible = true;
} }
private async Task OnTagSavedAsync() private async Task SaveOverride(EquipmentReferenceRow r)
{ {
_tagModalVisible = false; _refError = null;
await ReloadTagsAsync(); var edited = _overrideEdits.GetValueOrDefault(r.UnsTagReferenceId);
var res = await Svc.SetReferenceOverrideAsync(r.UnsTagReferenceId, edited, r.RowVersion);
if (res.Ok) { await ReloadReferencesAsync(); }
else { _refError = res.Error; }
} }
private async Task DeleteTag(string tagId) private async Task RemoveReference(EquipmentReferenceRow r)
{ {
_tagModalVisible = false; _refError = null;
_tagError = null; var res = await Svc.RemoveReferenceAsync(r.UnsTagReferenceId, r.RowVersion);
// Load the tag fresh to capture its current RowVersion for the optimistic-concurrency delete. if (res.Ok) { await ReloadReferencesAsync(); }
var dto = await Svc.LoadTagAsync(tagId); else { _refError = res.Error; }
if (dto is null) { await ReloadTagsAsync(); return; }
var r = await Svc.DeleteTagAsync(tagId, dto.RowVersion);
if (r.Ok) { await ReloadTagsAsync(); }
else { _tagError = r.Error; }
} }
// --- Virtual Tags tab handlers --- // --- Virtual Tags tab handlers ---
@@ -478,7 +471,7 @@ else
// path lands on Details because the field initializes to "details" and a fresh page instance // path lands on Details because the field initializes to "details" and a fresh page instance
// starts with that initial value. The Tags/Virtual Tags lists are reset to null below so the // starts with that initial value. The Tags/Virtual Tags lists are reset to null below so the
// lazy loaders re-fetch for the (possibly different) equipment this parameter set targets. // lazy loaders re-fetch for the (possibly different) equipment this parameter set targets.
_tags = null; _refs = null;
_vtags = null; _vtags = null;
_alarms = null; _alarms = null;
if (!IsNew) if (!IsNew)
@@ -489,7 +482,6 @@ else
LoadFormFrom(_equipment); LoadFormFrom(_equipment);
var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId); var ctx = await Svc.LoadEquipmentPickContextAsync(_equipment.UnsLineId);
_lineOptions = ctx.Lines; _lineOptions = ctx.Lines;
_driverOptions = ctx.Drivers;
} }
} }
else else
@@ -497,7 +489,6 @@ else
_form = new FormModel { UnsLineId = LineId ?? "" }; _form = new FormModel { UnsLineId = LineId ?? "" };
var ctx = await Svc.LoadEquipmentPickContextAsync(LineId); var ctx = await Svc.LoadEquipmentPickContextAsync(LineId);
_lineOptions = ctx.Lines; _lineOptions = ctx.Lines;
_driverOptions = ctx.Drivers;
} }
_loading = false; _loading = false;
} }
@@ -510,7 +501,6 @@ else
Name = e.Name, Name = e.Name,
MachineCode = e.MachineCode, MachineCode = e.MachineCode,
UnsLineId = e.UnsLineId, UnsLineId = e.UnsLineId,
DriverInstanceId = e.DriverInstanceId,
ZTag = e.ZTag, ZTag = e.ZTag,
SAPID = e.SAPID, SAPID = e.SAPID,
Manufacturer = e.Manufacturer, Manufacturer = e.Manufacturer,
@@ -536,7 +526,6 @@ else
_form.Name, _form.Name,
_form.MachineCode, _form.MachineCode,
_form.UnsLineId, _form.UnsLineId,
_form.DriverInstanceId,
_form.ZTag, _form.ZTag,
_form.SAPID, _form.SAPID,
_form.Manufacturer, _form.Manufacturer,
@@ -582,7 +571,6 @@ else
public string Name { get; set; } = ""; public string Name { get; set; } = "";
[Required] public string MachineCode { get; set; } = ""; [Required] public string MachineCode { get; set; } = "";
[Required] public string UnsLineId { get; set; } = ""; [Required] public string UnsLineId { get; set; } = "";
public string? DriverInstanceId { get; set; }
public string? ZTag { get; set; } public string? ZTag { get; set; }
public string? SAPID { get; set; } public string? SAPID { get; set; }
public string? Manufacturer { get; set; } public string? Manufacturer { get; set; }
@@ -11,7 +11,6 @@
<ul class="nav nav-tabs mb-3"> <ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link @Active("overview")" href="/clusters/@ClusterId">Overview</a></li> <li class="nav-item"><a class="nav-link @Active("overview")" href="/clusters/@ClusterId">Overview</a></li>
<li class="nav-item"><a class="nav-link @Active("namespaces")" href="/clusters/@ClusterId/namespaces">Namespaces</a></li> <li class="nav-item"><a class="nav-link @Active("namespaces")" href="/clusters/@ClusterId/namespaces">Namespaces</a></li>
<li class="nav-item"><a class="nav-link @Active("drivers")" href="/clusters/@ClusterId/drivers">Drivers</a></li>
<li class="nav-item"><a class="nav-link @Active("acls")" href="/clusters/@ClusterId/acls">ACLs</a></li> <li class="nav-item"><a class="nav-link @Active("acls")" href="/clusters/@ClusterId/acls">ACLs</a></li>
<li class="nav-item"><a class="nav-link @Active("audit")" href="/clusters/@ClusterId/audit">Audit</a></li> <li class="nav-item"><a class="nav-link @Active("audit")" href="/clusters/@ClusterId/audit">Audit</a></li>
<li class="nav-item"><a class="nav-link @Active("redundancy")" href="/clusters/@ClusterId/redundancy">Redundancy</a></li> <li class="nav-item"><a class="nav-link @Active("redundancy")" href="/clusters/@ClusterId/redundancy">Redundancy</a></li>
@@ -0,0 +1,194 @@
@* Reusable right-click / context menu.
Consumers get two (independently optional) triggers over one item list:
• ChildContent — wrapped as a right-clickable surface (browser menu suppressed).
• ShowEllipsis — a "⋯" button, the keyboard/touch fallback for the right-click.
Mouse/touch open the menu anchored at the pointer (fixed positioning). Keyboard
activation of the "⋯" button carries no pointer coordinates, so the menu instead
anchors just below the button (absolute positioning inside the trigger wrap).
Outside-click (or a right-click elsewhere) closes via a transparent full-viewport
backdrop — no JS alert/confirm. Keyboard: Arrow up/down move selection, Enter/Space
activate, Esc closes; focus returns to the "⋯" trigger on close. *@
@if (ChildContent is not null)
{
<span class="ctx-surface" @oncontextmenu="OpenAt" @oncontextmenu:preventDefault="true">
@ChildContent
</span>
}
@if (_open)
{
@* Transparent catch-all: any click — or a right-click — that misses the menu closes it. *@
<div class="ctx-backdrop" @onclick="Close" @oncontextmenu="Close" @oncontextmenu:preventDefault="true"></div>
}
@if (ShowEllipsis)
{
<span class="ctx-ellipsis-wrap">
<button type="button" class="ctx-ellipsis" title="More actions"
aria-haspopup="true" aria-expanded="@_open" @ref="_ellipsisRef" @onclick="OpenAt">
&#x22EF;@* ⋯ MIDLINE HORIZONTAL ELLIPSIS *@
</button>
@if (_open && _anchorToEllipsis)
{
@RenderMenu("ctx-menu ctx-menu--anchored", null)
}
</span>
}
@if (_open && !_anchorToEllipsis)
{
@RenderMenu("ctx-menu", $"top:{_y.ToString(Culture)}px; left:{_x.ToString(Culture)}px")
}
@code {
private static readonly System.Globalization.CultureInfo Culture =
System.Globalization.CultureInfo.InvariantCulture;
/// <summary>The rows to show. Separators are permitted.</summary>
[Parameter] public IReadOnlyList<ContextMenuItem> Items { get; set; } = Array.Empty<ContextMenuItem>();
/// <summary>Wrapped as the right-clickable surface. Optional.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Render the "⋯" trigger button (keyboard/touch fallback).</summary>
[Parameter] public bool ShowEllipsis { get; set; } = true;
private bool _open;
private double _x;
private double _y;
private bool _anchorToEllipsis;
private int _activeIndex = -1;
private ElementReference _menuRef;
private ElementReference _ellipsisRef;
private bool _focusMenuPending;
private bool _focusEllipsisPending;
// Mouse right-click / touch-tap carry real pointer coordinates → anchor the menu there
// (fixed). Keyboard activation of the "⋯" button dispatches a click with clientX/Y == 0
// (no pointer) → anchor the menu below the button instead of at viewport origin (0,0).
private void OpenAt(MouseEventArgs e)
{
_anchorToEllipsis = e.ClientX == 0 && e.ClientY == 0;
_x = e.ClientX;
_y = e.ClientY;
_activeIndex = FirstEnabledIndex();
_open = true;
_focusMenuPending = true;
}
private void Close()
{
if (!_open) return;
var returnFocus = _anchorToEllipsis; // keyboard-opened via the ⋯ button — restore its focus
_open = false;
_activeIndex = -1;
_anchorToEllipsis = false;
if (returnFocus && ShowEllipsis) _focusEllipsisPending = true;
}
private async Task Activate(ContextMenuItem item)
{
if (item.Disabled || item.IsSeparator) return;
Close();
if (item.OnClick is not null) await item.OnClick.Invoke();
}
private async Task OnKeyDown(KeyboardEventArgs e)
{
switch (e.Key)
{
case "Escape":
Close();
break;
case "ArrowDown":
Move(1);
break;
case "ArrowUp":
Move(-1);
break;
case "Enter":
case " ":
if (_activeIndex >= 0 && _activeIndex < Items.Count)
{
await Activate(Items[_activeIndex]);
}
break;
}
}
// Advance the selection in the given direction, skipping separators and
// disabled rows, wrapping around the ends.
private void Move(int dir)
{
if (Items.Count == 0) return;
var i = _activeIndex;
for (var step = 0; step < Items.Count; step++)
{
i = (i + dir + Items.Count) % Items.Count;
var item = Items[i];
if (!item.IsSeparator && !item.Disabled)
{
_activeIndex = i;
return;
}
}
}
private int FirstEnabledIndex()
{
for (var i = 0; i < Items.Count; i++)
{
if (!Items[i].IsSeparator && !Items[i].Disabled) return i;
}
return -1;
}
// The menu body, shared between the pointer-anchored (fixed) and ellipsis-anchored
// (absolute) placements so the item loop lives in exactly one place.
private RenderFragment RenderMenu(string cssClass, string? style) => __builder =>
{
<div class="@cssClass" style="@style" role="menu" tabindex="0" @ref="_menuRef" @onkeydown="OnKeyDown">
@for (var i = 0; i < Items.Count; i++)
{
var item = Items[i];
if (item.IsSeparator)
{
<div class="ctx-separator" role="separator"></div>
}
else
{
var index = i;
<button type="button" role="menuitem"
class="ctx-item @(index == _activeIndex ? "active" : "")"
disabled="@item.Disabled"
@onclick="() => Activate(item)">
@if (!string.IsNullOrEmpty(item.Icon))
{
<span class="ctx-icon">@item.Icon</span>
}
<span class="ctx-label">@item.Label</span>
</button>
}
}
</div>
};
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_focusMenuPending)
{
_focusMenuPending = false;
try { await _menuRef.FocusAsync(); }
catch { /* the menu may have closed before the focus round-trip completed */ }
}
if (_focusEllipsisPending)
{
_focusEllipsisPending = false;
try { await _ellipsisRef.FocusAsync(); }
catch { /* the trigger may have unmounted */ }
}
}
}
@@ -0,0 +1,107 @@
/* Scoped styles for ContextMenu. Colours come from the shared ZB.MOM.WW.Theme
tokens (theme.css) so the menu tracks the app palette in light and dark. */
.ctx-surface {
display: inline;
}
/* Positioned wrap so a keyboard-opened menu can anchor below the "⋯" button. */
.ctx-ellipsis-wrap {
position: relative;
display: inline-block;
}
/* The "⋯" fallback trigger — a compact, quiet icon button. */
.ctx-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: var(--ink-soft);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.ctx-ellipsis:hover,
.ctx-ellipsis[aria-expanded="true"] {
background: var(--hover-bg);
border-color: var(--bs-border-color);
color: var(--ink);
}
/* Transparent full-viewport catch-all; sits just under the menu. */
.ctx-backdrop {
position: fixed;
inset: 0;
z-index: 1080;
background: transparent;
}
.ctx-menu {
position: fixed;
z-index: 1081; /* above the backdrop, below toasts (1090) */
min-width: 11rem;
max-width: 20rem;
padding: 0.25rem;
background: var(--card);
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
outline: none;
}
/* Keyboard-opened placement: anchored just below the "⋯" trigger instead of the pointer. */
.ctx-menu--anchored {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
}
.ctx-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.35rem 0.6rem;
border: 0;
border-radius: 0.375rem;
background: transparent;
color: var(--ink);
font-size: 0.875rem;
text-align: left;
cursor: pointer;
}
.ctx-item:hover:not(:disabled),
.ctx-item.active:not(:disabled) {
background: var(--hover-bg);
}
.ctx-item:disabled {
color: var(--ink-faint);
cursor: not-allowed;
}
.ctx-icon {
display: inline-flex;
flex: 0 0 auto;
width: 1.1rem;
justify-content: center;
}
.ctx-label {
flex: 1 1 auto;
}
.ctx-separator {
height: 1px;
margin: 0.25rem 0.3rem;
background: var(--bs-border-color);
}
@@ -0,0 +1,33 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
/// <summary>
/// One entry in a <see cref="ContextMenu"/>. An item is either an actionable
/// row (<see cref="Label"/> + <see cref="OnClick"/>) or a visual divider
/// (<see cref="IsSeparator"/> = true, in which case the other members are
/// ignored). Use the <see cref="Separator"/> factory for dividers.
/// </summary>
public sealed class ContextMenuItem
{
/// <summary>Text shown for the row.</summary>
public string Label { get; init; } = "";
/// <summary>
/// Invoked when the row is activated (click / Enter). Async so per-node menu
/// actions (delete, redeploy, rename) can await their service calls without an
/// exception-swallowing <c>async void</c>; the menu awaits this and re-renders.
/// A synchronous handler returns <see cref="Task.CompletedTask"/>.
/// </summary>
public Func<Task>? OnClick { get; init; }
/// <summary>Optional leading icon — an emoji or single glyph.</summary>
public string? Icon { get; init; }
/// <summary>Renders the row greyed-out and non-interactive.</summary>
public bool Disabled { get; init; }
/// <summary>When true this entry is a horizontal divider, not a row.</summary>
public bool IsSeparator { get; init; }
/// <summary>A horizontal divider between groups of items.</summary>
public static ContextMenuItem Separator() => new() { IsSeparator = true };
}
@@ -0,0 +1,54 @@
@* Embeddable AB CIP device (connection endpoint) form body — authors HostAddress/PlcFamily into the
Device's DeviceConfig (PascalCase, matching AbCipDeviceOptions). Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Host address</label>
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="ab://gateway/1,0" />
</div>
<div class="col-md-3">
<label class="form-label">PLC family</label>
<InputSelect @bind-Value="_model.PlcFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<AbCipPlcFamily>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase HostAddress/PlcFamily) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private AbCipDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = AbCipDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,51 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for an AB CIP <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c>/<c>PlcFamily</c> keys that
/// <c>AbCipDeviceOptions</c> binds — the exact casing the seed and runtime probe
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class AbCipDeviceModel
{
/// <summary>libplctag gateway/path host address (e.g. <c>ab://gateway/1,0</c>).</summary>
public string HostAddress { get; set; } = "";
/// <summary>Allen-Bradley PLC family.</summary>
public AbCipPlcFamily PlcFamily { get; set; } = AbCipPlcFamily.ControlLogix;
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="AbCipDeviceModel"/>.</returns>
public static AbCipDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new AbCipDeviceModel
{
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
PlcFamily = TagConfigJson.GetEnum(o, "PlcFamily", AbCipPlcFamily.ControlLogix),
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
/// keys over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
TagConfigJson.Set(_bag, "PlcFamily", PlcFamily);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
}
@@ -0,0 +1,54 @@
@* Embeddable AB Legacy device (connection endpoint) form body — authors HostAddress/PlcFamily into the
Device's DeviceConfig (PascalCase, matching AbLegacyDeviceOptions). Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Host address</label>
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="10.0.0.10" />
</div>
<div class="col-md-6">
<label class="form-label">PLC family</label>
<InputSelect @bind-Value="_model.PlcFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<AbLegacyPlcFamily>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase HostAddress/PlcFamily) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private AbLegacyDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = AbLegacyDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,51 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for an AB Legacy <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c>/<c>PlcFamily</c> keys that
/// <c>AbLegacyDeviceOptions</c> binds — the exact casing the seed and runtime probe
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class AbLegacyDeviceModel
{
/// <summary>PLC host / IP address.</summary>
public string HostAddress { get; set; } = "";
/// <summary>Allen-Bradley legacy PLC family (SLC500 / MicroLogix / PLC-5).</summary>
public AbLegacyPlcFamily PlcFamily { get; set; } = AbLegacyPlcFamily.Slc500;
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="AbLegacyDeviceModel"/>.</returns>
public static AbLegacyDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new AbLegacyDeviceModel
{
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
PlcFamily = TagConfigJson.GetEnum(o, "PlcFamily", AbLegacyPlcFamily.Slc500),
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
/// keys over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
TagConfigJson.Set(_bag, "PlcFamily", PlcFamily);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
}
@@ -0,0 +1,54 @@
@* Embeddable FOCAS device (connection endpoint) form body — authors HostAddress/Series into the
Device's DeviceConfig (PascalCase, matching FocasDeviceOptions). Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Host address</label>
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="192.168.0.10:8193" />
</div>
<div class="col-md-6">
<label class="form-label">CNC series</label>
<InputSelect @bind-Value="_model.Series" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<FocasCncSeries>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase HostAddress/Series) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private FocasDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = FocasDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,51 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for a FOCAS <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c>/<c>Series</c> keys that
/// <c>FocasDeviceOptions</c> binds — the exact casing the seed and runtime probe
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class FocasDeviceModel
{
/// <summary>CNC host address (host or host:port, e.g. <c>192.168.0.10:8193</c>).</summary>
public string HostAddress { get; set; } = "";
/// <summary>CNC series, enabling per-series address validation.</summary>
public FocasCncSeries Series { get; set; } = FocasCncSeries.Unknown;
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="FocasDeviceModel"/>.</returns>
public static FocasDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new FocasDeviceModel
{
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
Series = TagConfigJson.GetEnum(o, "Series", FocasCncSeries.Unknown),
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
/// keys over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
TagConfigJson.Set(_bag, "Series", Series.ToString());
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
}
@@ -0,0 +1,34 @@
@* Galaxy device form — informational only. Galaxy connects to a single mxaccessgw gateway configured on
the driver, so there is no per-device connection endpoint to author. The DeviceConfig is round-tripped
verbatim (Test-connect uses the driver's gateway settings via the merged config). *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel notice rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem" class="text-muted">
Galaxy connects to a single <strong>mxaccessgw</strong> gateway configured on the driver — there is
no per-device endpoint to author here. Edit the gateway endpoint / API key on the driver's config.
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (round-tripped verbatim — Galaxy has no per-device endpoint).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired when the (preserved) DeviceConfig changes — no-op UI, kept for the modal contract.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private GalaxyDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = GalaxyDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the (preserved) DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
}
@@ -0,0 +1,29 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Device model for the AVEVA Galaxy driver. Galaxy connects to a SINGLE mxaccessgw gateway configured
/// on the <b>driver</b> (nested Gateway/MxAccess records) — there is no flat per-device connection
/// endpoint to author, so this model authors no endpoint keys. It simply round-trips the DeviceConfig
/// JSON verbatim (preserving any keys) so the device row stays valid. (Flagged: Galaxy has no v3
/// endpoint→DeviceConfig split; its connection lives on the driver config.)
/// </summary>
public sealed class GalaxyDeviceModel
{
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, retaining every original key.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="GalaxyDeviceModel"/>.</returns>
public static GalaxyDeviceModel FromJson(string? json)
=> new() { _bag = TagConfigJson.ParseOrNew(json) };
/// <summary>Serializes the (preserved) DeviceConfig back to a JSON string.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson() => TagConfigJson.Serialize(_bag);
/// <summary>Validation hook; Galaxy device rows carry no endpoint, so always valid.</summary>
/// <returns><c>null</c> — no per-device endpoint to validate.</returns>
public string? Validate() => null;
}
@@ -0,0 +1,52 @@
@* Embeddable Modbus device (connection endpoint) form body — authors Host/Port/UnitId into the
Device's DeviceConfig (PascalCase, matching ModbusDriverOptions). Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Host</label>
<InputText @bind-Value="_model.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="127.0.0.1" />
</div>
<div class="col-md-3">
<label class="form-label">Port</label>
<InputNumber @bind-Value="_model.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-3">
<label class="form-label">Unit ID (slave ID)</label>
<InputNumber @bind-Value="_model.UnitId" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase Host/Port/UnitId) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private ModbusDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = ModbusDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,55 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for a Modbus <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
/// split). Authors the <b>PascalCase</b> <c>Host</c>/<c>Port</c>/<c>UnitId</c> keys that
/// <c>ModbusDriverOptions</c> binds — the exact casing the seed and runtime probe
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class ModbusDeviceModel
{
/// <summary>PLC host / IP address.</summary>
public string Host { get; set; } = "127.0.0.1";
/// <summary>Modbus/TCP port (usually 502).</summary>
public int Port { get; set; } = 502;
/// <summary>Modbus unit / slave id (0-255).</summary>
public int UnitId { get; set; } = 1;
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="ModbusDeviceModel"/>.</returns>
public static ModbusDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new ModbusDeviceModel
{
Host = TagConfigJson.GetString(o, "Host") ?? "127.0.0.1",
Port = TagConfigJson.GetInt(o, "Port", 502),
UnitId = TagConfigJson.GetInt(o, "UnitId", 1),
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
/// keys over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "Host", Host);
TagConfigJson.Set(_bag, "Port", Port);
TagConfigJson.Set(_bag, "UnitId", UnitId);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(Host) ? "Host is required." : null;
}
@@ -0,0 +1,45 @@
@* Embeddable OPC UA Client device (connection endpoint) form body — authors EndpointUrl into the
Device's DeviceConfig. Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Endpoint</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Endpoint URL</label>
<InputText @bind-Value="_model.EndpointUrl" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="opc.tcp://plc.internal:4840" />
<div class="form-text">The upstream server. Used only when the driver's EndpointUrls failover list is empty.</div>
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase EndpointUrl) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private OpcUaClientDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = OpcUaClientDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,50 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for an OPC UA Client <c>Device</c>'s connection endpoint. Authors the PascalCase
/// <c>EndpointUrl</c> key that <c>OpcUaClientDriverOptions</c> binds. (The driver-level <c>EndpointUrls</c>
/// failover list stays on the driver config; a device's <c>EndpointUrl</c> is used only when that list
/// is empty.) Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class OpcUaClientDeviceModel
{
/// <summary>The single upstream server endpoint URL (opc.tcp://…).</summary>
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="OpcUaClientDeviceModel"/>.</returns>
public static OpcUaClientDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new OpcUaClientDeviceModel
{
EndpointUrl = TagConfigJson.GetString(o, "EndpointUrl") ?? "opc.tcp://localhost:4840",
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase
/// <c>EndpointUrl</c> key over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "EndpointUrl", EndpointUrl);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
{
if (string.IsNullOrWhiteSpace(EndpointUrl)) return "Endpoint URL is required.";
if (!EndpointUrl.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
return "Endpoint URL must start with opc.tcp://";
return null;
}
}
@@ -0,0 +1,56 @@
@* Embeddable S7 device (connection endpoint) form body — authors Host/Port/Rack/Slot into the
Device's DeviceConfig (PascalCase, matching S7DriverOptions). Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Host</label>
<InputText @bind-Value="_model.Host" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="192.168.0.1" />
</div>
<div class="col-md-2">
<label class="form-label">Port</label>
<InputNumber @bind-Value="_model.Port" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2">
<label class="form-label">Rack</label>
<InputNumber @bind-Value="_model.Rack" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-2">
<label class="form-label">Slot</label>
<InputNumber @bind-Value="_model.Slot" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase Host/Port/Rack/Slot) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private S7DeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = S7DeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,60 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for an S7 <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
/// split). Authors the <b>PascalCase</b> <c>Host</c>/<c>Port</c>/<c>Rack</c>/<c>Slot</c> keys that
/// <c>S7DriverOptions</c> binds — the exact casing the seed and runtime probe
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class S7DeviceModel
{
/// <summary>PLC host / IP address.</summary>
public string Host { get; set; } = "127.0.0.1";
/// <summary>ISO-on-TCP port (usually 102).</summary>
public int Port { get; set; } = 102;
/// <summary>PLC rack number (almost always 0).</summary>
public int Rack { get; set; } = 0;
/// <summary>PLC slot number (S7-300/400 = 2; S7-1200/1500 = 0).</summary>
public int Slot { get; set; } = 0;
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="S7DeviceModel"/>.</returns>
public static S7DeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new S7DeviceModel
{
Host = TagConfigJson.GetString(o, "Host") ?? "127.0.0.1",
Port = TagConfigJson.GetInt(o, "Port", 102),
Rack = TagConfigJson.GetInt(o, "Rack", 0),
Slot = TagConfigJson.GetInt(o, "Slot", 0),
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
/// keys over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "Host", Host);
TagConfigJson.Set(_bag, "Port", Port);
TagConfigJson.Set(_bag, "Rack", Rack);
TagConfigJson.Set(_bag, "Slot", Slot);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(Host) ? "Host is required." : null;
}
@@ -0,0 +1,44 @@
@* Embeddable TwinCAT device (connection endpoint) form body — authors HostAddress (AMS Net Id:port) into
the Device's DeviceConfig (PascalCase, matching TwinCATDeviceOptions). Hosted by DeviceModal. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
<section class="panel rise" style="animation-delay:.04s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-12">
<label class="form-label">Host address (AMS Net Id:port)</label>
<InputText @bind-Value="_model.HostAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="192.168.0.1.1.1:851" />
</div>
</div>
</div>
</section>
@code {
/// <summary>The device's DeviceConfig JSON (endpoint/connection keys).</summary>
[Parameter] public string DeviceConfigJson { get; set; } = "{}";
/// <summary>Fired (PascalCase HostAddress) whenever the endpoint changes — enables @bind-DeviceConfigJson.</summary>
[Parameter] public EventCallback<string> DeviceConfigJsonChanged { get; set; }
private TwinCATDeviceModel _model = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DeviceConfigJson, StringComparison.Ordinal))
{
_model = TwinCATDeviceModel.FromJson(DeviceConfigJson);
_lastParsed = DeviceConfigJson;
}
}
/// <summary>Serializes the current endpoint to DeviceConfig JSON.</summary>
public string GetConfigJson() => _model.ToJson();
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DeviceConfigJsonChanged.InvokeAsync(json);
}
}
@@ -0,0 +1,45 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms;
/// <summary>
/// Typed working model for a TwinCAT <c>Device</c>'s connection endpoint (the v3 endpoint→DeviceConfig
/// split). Authors the <b>PascalCase</b> <c>HostAddress</c> key (the AMS Net Id:port host string) that
/// <c>TwinCATDeviceOptions</c> binds — the exact casing the seed and runtime probe
/// (<c>PropertyNameCaseInsensitive</c>) expect. Preserves unrecognised keys across a load→save.
/// </summary>
public sealed class TwinCATDeviceModel
{
/// <summary>AMS Net Id:port host string (e.g. <c>192.168.0.1.1.1:851</c>).</summary>
public string HostAddress { get; set; } = "";
private System.Text.Json.Nodes.JsonObject _bag = new();
/// <summary>Loads a model from a DeviceConfig JSON string, defaulting absent keys and retaining
/// every original key so fields this editor doesn't expose survive a load→save.</summary>
/// <param name="json">The raw DeviceConfig JSON string, or null for a new/empty device.</param>
/// <returns>The populated <see cref="TwinCATDeviceModel"/>.</returns>
public static TwinCATDeviceModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new TwinCATDeviceModel
{
HostAddress = TagConfigJson.GetString(o, "HostAddress") ?? "",
_bag = o,
};
}
/// <summary>Serializes this model back to a DeviceConfig JSON string, writing the PascalCase endpoint
/// key over the preserved key bag.</summary>
/// <returns>The serialised DeviceConfig JSON string.</returns>
public string ToJson()
{
TagConfigJson.Set(_bag, "HostAddress", HostAddress);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
/// <returns>An error message describing the validation failure, or <c>null</c> when the model is valid.</returns>
public string? Validate()
=> string.IsNullOrWhiteSpace(HostAddress) ? "Host address is required." : null;
}
@@ -0,0 +1,224 @@
@* Create / edit modal for a raw-tree Device's connection endpoint (v3 endpoint→DeviceConfig split).
Dispatches to the right per-driver device form by DriverType. Test-connect builds the merged
Driver+Device config transiently (so unsaved edits probe correctly), via DriverDeviceConfigMerger —
the same merge LoadMergedProbeConfigAsync uses. The coordinator wires this into RawTree via @bind-Visible. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.DeviceForms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@inject IRawTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(_isNew ? "New device" : "Edit device") &middot; <span class="mono">@_driverType</span></h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
@if (_loadError is not null)
{
<div class="panel notice mb-3" style="border-color:var(--alert)">@_loadError</div>
}
else
{
<div class="row g-3 mb-2">
<div class="col-md-6">
<label class="form-label">Device name</label>
<input class="form-control form-control-sm mono" @bind="_name" placeholder="Device1" />
<div class="form-text">A RawPath segment — letters, digits, dash, underscore.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<input type="checkbox" class="form-check-input" id="devEnabled" @bind="_enabled" />
<label class="form-check-label" for="devEnabled">Enabled</label>
</div>
</div>
</div>
@switch (_driverType)
{
case DriverTypeNames.Modbus:
<ModbusDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.S7:
<S7DeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.AbCip:
<AbCipDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.AbLegacy:
<AbLegacyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.TwinCAT:
<TwinCATDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.FOCAS:
<FocasDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.OpcUaClient:
<OpcUaClientDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
case DriverTypeNames.Galaxy:
<GalaxyDeviceForm @bind-DeviceConfigJson="_deviceConfigJson" />
break;
default:
<div class="alert alert-warning">No typed device form for driver type <span class="mono">@_driverType</span>.</div>
break;
}
<div class="mt-3">
<DriverTestConnectButton DriverType="@_driverType" GetConfigJson="@BuildMergedProbeConfig" />
</div>
@if (_saveError is not null)
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_saveError</div>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
@if (_loadError is null)
{
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(_isNew ? "Create device" : "Save changes")
</button>
}
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown (supports @bind-Visible).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Two-way visibility callback.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The device to edit; null ⇒ create a new device under <see cref="DriverInstanceId"/>.</summary>
[Parameter] public string? DeviceId { get; set; }
/// <summary>The owning driver instance (required for create; resolved from the device on edit).</summary>
[Parameter] public string? DriverInstanceId { get; set; }
/// <summary>The driver type (required for create; joined in from the device on edit).</summary>
[Parameter] public string? DriverType { get; set; }
/// <summary>Raised after a successful create/update so the tree can refresh.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private bool _isNew;
private string _driverType = "";
private string? _resolvedDriverInstanceId;
private string _name = "";
private bool _enabled = true;
private string _deviceConfigJson = "{}";
private byte[] _rowVersion = [];
private string _parentDriverConfig = "{}";
private bool _busy;
private string? _loadError;
private string? _saveError;
private bool _openHandled;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_openHandled)
{
_openHandled = true;
await LoadAsync();
}
else if (!Visible)
{
_openHandled = false;
}
}
private async Task LoadAsync()
{
_busy = false; _loadError = null; _saveError = null;
_isNew = string.IsNullOrEmpty(DeviceId);
if (_isNew)
{
_resolvedDriverInstanceId = DriverInstanceId;
_driverType = DriverType ?? "";
_name = "Device1";
_enabled = true;
_deviceConfigJson = "{}";
_rowVersion = [];
}
else
{
var dto = await Svc.LoadDeviceForEditAsync(DeviceId!);
if (dto is null) { _loadError = $"Device '{DeviceId}' was not found."; return; }
_resolvedDriverInstanceId = dto.DriverInstanceId;
_driverType = dto.DriverType;
_name = dto.Name;
_enabled = dto.Enabled;
_deviceConfigJson = string.IsNullOrWhiteSpace(dto.DeviceConfig) ? "{}" : dto.DeviceConfig;
_rowVersion = dto.RowVersion;
}
// The parent driver's DriverConfig — folded with the (possibly unsaved) DeviceConfig for Test-connect.
if (!string.IsNullOrEmpty(_resolvedDriverInstanceId))
{
var drv = await Svc.LoadDriverForEditAsync(_resolvedDriverInstanceId);
_parentDriverConfig = drv?.DriverConfig ?? "{}";
}
}
/// <summary>Builds the merged Driver+Device probe config from the CURRENT (possibly unsaved) form —
/// same merge as <c>LoadMergedProbeConfigAsync</c>, so Test-connect works before the device is saved.</summary>
private string BuildMergedProbeConfig()
=> DriverDeviceConfigMerger.Merge(
_parentDriverConfig,
new[] { new DriverDeviceConfigMerger.DeviceRow(_name, _deviceConfigJson) },
Array.Empty<RawTagEntry>());
private async Task SaveAsync()
{
_busy = true; _saveError = null;
try
{
UnsMutationResult result;
if (_isNew)
{
if (string.IsNullOrEmpty(_resolvedDriverInstanceId))
{
_saveError = "No owning driver instance was supplied.";
return;
}
result = await Svc.CreateDeviceAsync(_resolvedDriverInstanceId, _name, _deviceConfigJson);
}
else
{
result = await Svc.UpdateDeviceAsync(DeviceId!, _name, _deviceConfigJson, _enabled, _rowVersion);
}
if (!result.Ok)
{
_saveError = result.Error ?? "Save failed.";
return;
}
await OnSaved.InvokeAsync();
await SetVisibleAsync(false);
}
catch (Exception ex) { _saveError = ex.Message; }
finally { _busy = false; }
}
private Task CloseAsync() => SetVisibleAsync(false);
private async Task SetVisibleAsync(bool value)
{
Visible = value;
_openHandled = value;
await VisibleChanged.InvokeAsync(value);
}
}
@@ -20,7 +20,7 @@
} }
else else
{ {
@foreach (var n in _roots) { @RenderNode(n, 0) } @foreach (var n in _roots) { @RenderNode(n, 0, System.Array.Empty<string>()) }
} }
</div> </div>
@@ -34,6 +34,19 @@
/// <summary>Fired when the user clicks a leaf (or any node — caller decides what to do with it).</summary> /// <summary>Fired when the user clicks a leaf (or any node — caller decides what to do with it).</summary>
[Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; } [Parameter] public EventCallback<BrowseNode> OnNodeSelected { get; set; }
/// <summary>When true, leaves render a selection checkbox and clicking a leaf toggles it (WP6 "Browse
/// device…" multi-select) instead of single-selecting. Folders still expand/navigate as usual. Default
/// false preserves the single-select address-picker behavior.</summary>
[Parameter] public bool MultiSelect { get; set; }
/// <summary>In multi-select mode, the set of currently-selected leaf NodeIds (drives checkbox state +
/// row highlight). Owned by the parent; the tree only reads it.</summary>
[Parameter] public IReadOnlyCollection<string>? SelectedLeafIds { get; set; }
/// <summary>In multi-select mode, fired when a leaf's checkbox is toggled — carries the leaf plus its
/// captured ancestor-folder display-name path (root→parent) for optional TagGroup mirroring.</summary>
[Parameter] public EventCallback<ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection> OnLeafToggled { get; set; }
private bool _loading = true; private bool _loading = true;
private string? _error; private string? _error;
private List<TreeItem>? _roots; private List<TreeItem>? _roots;
@@ -86,10 +99,28 @@
await OnNodeSelected.InvokeAsync(item.Node); await OnNodeSelected.InvokeAsync(item.Node);
} }
private RenderFragment RenderNode(TreeItem item, int depth) => __builder => private async Task ToggleLeafAsync(TreeItem item, IReadOnlyList<string> ancestorPath)
{
await OnLeafToggled.InvokeAsync(
new ZB.MOM.WW.OtOpcUa.AdminUI.Browsing.BrowseLeafSelection(item.Node, ancestorPath));
}
private bool IsLeafSelected(string nodeId) => SelectedLeafIds?.Contains(nodeId) == true;
private static IReadOnlyList<string> Append(IReadOnlyList<string> path, string segment)
{
var list = new List<string>(path.Count + 1);
list.AddRange(path);
list.Add(segment);
return list;
}
private RenderFragment RenderNode(TreeItem item, int depth, IReadOnlyList<string> ancestorPath) => __builder =>
{ {
var indent = $"padding-left:{depth * 18}px"; var indent = $"padding-left:{depth * 18}px";
var selectedCls = _selectedNodeIdLocal == item.Node.NodeId ? "bg-primary-subtle" : ""; var isMultiLeaf = MultiSelect && item.Node.Kind == BrowseNodeKind.Leaf;
var selectedCls = (isMultiLeaf ? IsLeafSelected(item.Node.NodeId) : _selectedNodeIdLocal == item.Node.NodeId)
? "bg-primary-subtle" : "";
<div class="d-flex align-items-center gap-1 py-1 @selectedCls" style="@indent"> <div class="d-flex align-items-center gap-1 py-1 @selectedCls" style="@indent">
@if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint) @if (item.Node.Kind == BrowseNodeKind.Folder && item.Node.HasChildrenHint)
{ {
@@ -102,8 +133,18 @@
{ {
<span style="width:18px"></span> <span style="width:18px"></span>
} }
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault @if (isMultiLeaf)
class="text-decoration-none mono small">@item.Node.DisplayName</a> {
<input type="checkbox" class="form-check-input" checked="@IsLeafSelected(item.Node.NodeId)"
@onchange="@(() => ToggleLeafAsync(item, ancestorPath))" />
<a href="#" @onclick="@(() => ToggleLeafAsync(item, ancestorPath))" @onclick:preventDefault
class="text-decoration-none mono small">@item.Node.DisplayName</a>
}
else
{
<a href="#" @onclick="@(() => SelectAsync(item))" @onclick:preventDefault
class="text-decoration-none mono small">@item.Node.DisplayName</a>
}
@if (item.Node.Kind == BrowseNodeKind.Leaf) @if (item.Node.Kind == BrowseNodeKind.Leaf)
{ {
<span class="chip chip-idle ms-1" style="font-size:0.7rem">leaf</span> <span class="chip chip-idle ms-1" style="font-size:0.7rem">leaf</span>
@@ -129,7 +170,7 @@
@bind="item.Filter" @bind:event="oninput" /> @bind="item.Filter" @bind:event="oninput" />
@foreach (var c in FilterChildren(item)) @foreach (var c in FilterChildren(item))
{ {
@RenderNode(c, depth + 1) @RenderNode(c, depth + 1, Append(ancestorPath, item.Node.DisplayName))
} }
} }
}; };
@@ -0,0 +1,201 @@
@* Create / edit modal for a raw-tree DriverInstance's channel/protocol config (endpoint lives on the
device — see DeviceModal). Dispatches to the right per-driver form body by DriverType; the form body
carries its own resilience section. The coordinator wires this into RawTree via @bind-Visible. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@inject IRawTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(_isNew ? "New driver" : "Configure driver") &middot; <span class="mono">@_driverType</span></h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
@if (_loadError is not null)
{
<div class="panel notice mb-3" style="border-color:var(--alert)">@_loadError</div>
}
else
{
<div class="row g-3 mb-2">
<div class="col-md-6">
<label class="form-label">Driver name</label>
<input class="form-control form-control-sm mono" @bind="_name" placeholder="line3-modbus" />
<div class="form-text">A RawPath segment — letters, digits, dash, underscore.</div>
</div>
</div>
@switch (_driverType)
{
case DriverTypeNames.Modbus:
<ModbusDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.S7:
<S7DriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.AbCip:
<AbCipDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.AbLegacy:
<AbLegacyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.TwinCAT:
<TwinCATDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.FOCAS:
<FocasDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.OpcUaClient:
<OpcUaClientDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
case DriverTypeNames.Galaxy:
<GalaxyDriverForm @bind-DriverConfigJson="_driverConfigJson" @bind-ResilienceConfig="_resilienceConfig" />
break;
default:
<div class="alert alert-warning">No typed config form for driver type <span class="mono">@_driverType</span>.</div>
break;
}
<p class="form-text mt-3 mb-0">
The connection endpoint + Test-connect live on the driver's <strong>device</strong> — open the device modal to author host/port and verify connectivity.
</p>
@if (_saveError is not null)
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@_saveError</div>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
@if (_loadError is null)
{
<button type="button" class="btn btn-primary" @onclick="SaveAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(_isNew ? "Create driver" : "Save changes")
</button>
}
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown (supports @bind-Visible).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Two-way visibility callback.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The driver instance to edit; null ⇒ create a new driver.</summary>
[Parameter] public string? DriverInstanceId { get; set; }
// --- Create-mode inputs (ignored on edit) ---
/// <summary>The owning cluster (required for create).</summary>
[Parameter] public string? ClusterId { get; set; }
/// <summary>The containing folder, or null for a cluster-root driver (create).</summary>
[Parameter] public string? FolderId { get; set; }
/// <summary>The driver type to create (required for create; immutable on edit).</summary>
[Parameter] public string? DriverType { get; set; }
/// <summary>Raised after a successful create/update so the tree can refresh.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private bool _isNew;
private string _driverType = "";
private string _name = "";
private string _driverConfigJson = "{}";
private string? _resilienceConfig;
private byte[] _rowVersion = [];
private bool _busy;
private string? _loadError;
private string? _saveError;
private bool _openHandled;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_openHandled)
{
_openHandled = true;
await LoadAsync();
}
else if (!Visible)
{
_openHandled = false;
}
}
private async Task LoadAsync()
{
_busy = false; _loadError = null; _saveError = null;
_isNew = string.IsNullOrEmpty(DriverInstanceId);
if (_isNew)
{
_driverType = DriverType ?? "";
_name = "";
_driverConfigJson = "{}";
_resilienceConfig = null;
_rowVersion = [];
}
else
{
var dto = await Svc.LoadDriverForEditAsync(DriverInstanceId!);
if (dto is null) { _loadError = $"Driver '{DriverInstanceId}' was not found."; return; }
_driverType = dto.DriverType;
_name = dto.Name;
_driverConfigJson = string.IsNullOrWhiteSpace(dto.DriverConfig) ? "{}" : dto.DriverConfig;
_resilienceConfig = dto.ResilienceConfig;
_rowVersion = dto.RowVersion;
}
}
private async Task SaveAsync()
{
_busy = true; _saveError = null;
try
{
UnsMutationResult result;
if (_isNew)
{
if (string.IsNullOrEmpty(ClusterId))
{
_saveError = "No owning cluster was supplied.";
return;
}
result = await Svc.CreateDriverAsync(ClusterId, FolderId, _name, _driverType, _driverConfigJson);
}
else
{
result = await Svc.UpdateDriverAsync(DriverInstanceId!, _name, _driverConfigJson, _resilienceConfig, _rowVersion);
}
if (!result.Ok)
{
_saveError = result.Error ?? "Save failed.";
return;
}
await OnSaved.InvokeAsync();
await SetVisibleAsync(false);
}
catch (Exception ex) { _saveError = ex.Message; }
finally { _busy = false; }
}
private Task CloseAsync() => SetVisibleAsync(false);
private async Task SetVisibleAsync(bool value)
{
Visible = value;
_openHandled = value;
await VisibleChanged.InvokeAsync(value);
}
}
@@ -0,0 +1,215 @@
@* Embeddable AB CIP driver (protocol/operation) config form body. The Devices collection moved out to
AbCipDeviceForm in v3 (its devices are separate entities); this form preserves the driver's existing
Devices across a load→save and the merge reconstructs the array from the device rows at deploy/probe
time. Hosted by the routed AbCipDriverPage and by DriverConfigModal. Exposes GetConfigJson() for the
parent to pull at save + Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
@* Operation timeout *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Timeout (seconds)</label>
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default libplctag call timeout. Default 2 s.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.EnableControllerBrowse" @bind-Value:after="EmitAsync" class="form-check-input" id="enableBrowse" />
<label class="form-check-label" for="enableBrowse">Enable controller browse</label>
</div>
<div class="form-text">Walk the Logix symbol table and surface globals under Discovered/.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.EnableDeclarationOnlyUdtGrouping" @bind-Value:after="EmitAsync" class="form-check-input" id="enableUdtGroup" />
<label class="form-check-label" for="enableUdtGroup">Enable declaration-only UDT grouping</label>
</div>
<div class="form-text">Only enable when UDT member declaration order matches controller compiled layout.</div>
</div>
</div>
</div>
</section>
@* Alarm projection *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Alarm projection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.EnableAlarmProjection" @bind-Value:after="EmitAsync" class="form-check-input" id="enableAlarm" />
<label class="form-check-label" for="enableAlarm">Enable ALMD alarm projection</label>
</div>
<div class="form-text">Surfaces ALMD tags as alarm conditions via IAlarmSource. Default off.</div>
</div>
<div class="col-md-3">
<label class="form-label">Alarm poll interval (seconds)</label>
<InputNumber @bind-Value="_form.AlarmPollIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 1 s.</div>
</div>
</div>
</div>
</section>
@* Connectivity probe *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="probeEnabled" />
<label class="form-check-label" for="probeEnabled">Enabled</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Interval (seconds)</label>
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Timeout (seconds)</label>
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 2 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe tag path</label>
<InputText @bind-Value="_form.ProbeTagPath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="e.g. Program:Main.SomeTag" />
<div class="form-text">Required when probe is enabled. Leave blank and an operator warning is logged.</div>
</div>
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (protocol/operation; endpoints live on the devices).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
// Preserve the driver's existing Devices across a load→save (the device form authors the real endpoints;
// the merge reconstructs the Devices array from the device rows at deploy/probe time).
private IReadOnlyList<AbCipDeviceOptions> _preservedDevices = [];
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new AbCipDriverOptions();
_form = FormModel.FromOptions(opts);
_preservedDevices = opts.Devices;
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current protocol/operation config to camelCase JSON, carrying the preserved
/// Devices array forward (the device form authors the real endpoints; the merge reconstructs the array
/// from the device rows at deploy/probe time).</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static AbCipDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<AbCipDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices) are preserved on the component and passed in on ToOptions().
public sealed class FormModel
{
// Operation
public int TimeoutSeconds { get; set; } = 2;
public bool EnableControllerBrowse { get; set; } = false;
public bool EnableDeclarationOnlyUdtGrouping { get; set; } = false;
// Alarm projection
public bool EnableAlarmProjection { get; set; } = false;
public int AlarmPollIntervalSeconds { get; set; } = 1;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public string? ProbeTagPath { get; set; }
// Admin UI probe timeout
public int AdminProbeTimeoutSeconds { get; set; } = 5;
public static FormModel FromOptions(AbCipDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
EnableControllerBrowse = o.EnableControllerBrowse,
EnableDeclarationOnlyUdtGrouping = o.EnableDeclarationOnlyUdtGrouping,
EnableAlarmProjection = o.EnableAlarmProjection,
AlarmPollIntervalSeconds = (int)o.AlarmPollInterval.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
ProbeTagPath = o.Probe.ProbeTagPath,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public AbCipDriverOptions ToOptions(
IReadOnlyList<AbCipDeviceOptions> devices) => new()
{
Devices = devices,
Probe = new AbCipProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
ProbeTagPath = string.IsNullOrWhiteSpace(ProbeTagPath) ? null : ProbeTagPath,
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
EnableControllerBrowse = EnableControllerBrowse,
EnableAlarmProjection = EnableAlarmProjection,
AlarmPollInterval = TimeSpan.FromSeconds(AlarmPollIntervalSeconds),
EnableDeclarationOnlyUdtGrouping = EnableDeclarationOnlyUdtGrouping,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
}
@@ -0,0 +1,170 @@
@* Embeddable AB Legacy driver (channel/protocol) config form body. The Devices collection is authored
as separate v3 entities (the merge rebuilds the Devices array at deploy/probe time), so this form does
NOT edit it — the driver's existing devices are preserved verbatim. Hosted by the routed
AbLegacyDriverPage. Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and
fires DriverConfigJsonChanged for @bind support. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies
@* Operation settings *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Operation settings</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Timeout (seconds)</label>
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default read/write timeout. Default 2 s.</div>
</div>
</div>
</div>
</section>
@* Connectivity probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="probeEnabled" />
<label class="form-check-label" for="probeEnabled">Enabled</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Interval (seconds)</label>
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Timeout (seconds)</label>
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 2 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe address</label>
<InputText @bind-Value="_form.ProbeAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="S:0" />
<div class="form-text">PCCC file address to read for probe. Default S:0 (status file word 0).</div>
</div>
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (protocol/channel; devices live on separate v3 entities).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
// Devices are separate v3 entities — this form doesn't edit them, but preserves the driver's existing
// Devices array verbatim across a load→save (the merge rebuilds it at deploy/probe time).
private IReadOnlyList<AbLegacyDeviceOptions> _preservedDevices = [];
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new AbLegacyDriverOptions();
_form = FormModel.FromOptions(opts);
_preservedDevices = opts.Devices;
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current channel/protocol config to camelCase JSON, preserving the driver's
/// existing Devices array (authored as separate v3 entities).</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static AbLegacyDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<AbLegacyDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Operation
public int TimeoutSeconds { get; set; } = 2;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public string? ProbeAddress { get; set; } = "S:0";
// Admin UI probe timeout
public int AdminProbeTimeoutSeconds { get; set; } = 5;
// Persistence
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(AbLegacyDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
ProbeAddress = o.Probe.ProbeAddress,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public AbLegacyDriverOptions ToOptions(
IReadOnlyList<AbLegacyDeviceOptions> devices) => new()
{
Devices = devices,
Probe = new AbLegacyProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
ProbeAddress = string.IsNullOrWhiteSpace(ProbeAddress) ? null : ProbeAddress,
},
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
}
@@ -0,0 +1,282 @@
@* Embeddable Fanuc FOCAS driver (channel/protocol) config form body. Endpoint fields (per-device
HostAddress/Series) moved to FocasDeviceForm in v3 (they live in the Device's DeviceConfig / the
multi-device Devices collection). Hosted by the routed FocasDriverPage and by DriverConfigModal.
Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and fires
DriverConfigJsonChanged for @bind support. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label" for="focasTimeoutSec">Timeout (seconds)</label>
<InputNumber id="focasTimeoutSec" @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">Per-operation timeout. Default 2 s.</div>
</div>
</div>
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="focasProbeEnabled" @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync"
class="form-check-input" />
<label class="form-check-label" for="focasProbeEnabled">Probe enabled</label>
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasProbeInterval">Probe interval (s)</label>
<InputNumber id="focasProbeInterval" @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasProbeTimeout">Probe timeout (s)</label>
<InputNumber id="focasProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasAdminProbe">Admin probe timeout (s)</label>
<InputNumber id="focasAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
@* Alarm projection *@
<section class="panel rise mt-3" style="animation-delay:.11s">
<div class="panel-head">Alarm projection</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="focasAlarmEnabled" @bind-Value="_form.AlarmProjectionEnabled" @bind-Value:after="EmitAsync"
class="form-check-input" />
<label class="form-check-label" for="focasAlarmEnabled">Alarm projection enabled</label>
</div>
<div class="form-text">Surfaces FOCAS alarms via IAlarmSource.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasAlarmPoll">Alarm poll interval (s)</label>
<InputNumber id="focasAlarmPoll" @bind-Value="_form.AlarmProjectionPollIntervalSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">One cnc_rdalmmsg2 call per device per tick. Default 2 s.</div>
</div>
</div>
</div>
</section>
@* Handle recycle *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Handle recycle</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="focasRecycleEnabled" @bind-Value="_form.HandleRecycleEnabled" @bind-Value:after="EmitAsync"
class="form-check-input" />
<label class="form-check-label" for="focasRecycleEnabled">Handle recycle enabled</label>
</div>
<div class="form-text">Proactive FWLIB session recycle to prevent handle pool exhaustion. Default off.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasRecycleInterval">Recycle interval (minutes)</label>
<InputNumber id="focasRecycleInterval" @bind-Value="_form.HandleRecycleIntervalMinutes" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">Typical: 30 min (shared pool) or 360 min (single client).</div>
</div>
</div>
</div>
</section>
@* Fixed tree *@
<section class="panel rise mt-3" style="animation-delay:.17s">
<div class="panel-head">Fixed-node tree</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="focasFixedTree" @bind-Value="_form.FixedTreeEnabled" @bind-Value:after="EmitAsync"
class="form-check-input" />
<label class="form-check-label" for="focasFixedTree">Fixed tree enabled</label>
</div>
<div class="form-text">Exposes Identity/, Axes/, etc. from cnc_sysinfo/cnc_rdaxisname/cnc_rddynamic2. Default off.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasAxisPoll">Axis poll interval (ms)</label>
<InputNumber id="focasAxisPoll" @bind-Value="_form.FixedTreePollIntervalMs" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">cnc_rddynamic2 cadence per axis. Default 250 ms.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasProgramPoll">Program poll interval (s)</label>
<InputNumber id="focasProgramPoll" @bind-Value="_form.FixedTreeProgramPollIntervalSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">Program/mode info cadence. 0 = disabled. Default 1 s.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="focasTimerPoll">Timer poll interval (s)</label>
<InputNumber id="focasTimerPoll" @bind-Value="_form.FixedTreeTimerPollIntervalSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">Power-on/cutting/cycle timer cadence. 0 = disabled. Default 30 s.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (protocol/channel; per-device endpoint lives in the device).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
// FOCAS is multi-device; the Devices collection is authored on the Raw tree, not on this form.
// Preserve whatever devices the persisted config carried so a driver-form load→save round-trip
// never drops them.
private IReadOnlyList<FocasDeviceOptions> _preservedDevices = [];
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new FocasDriverOptions();
_form = FormModel.FromOptions(opts);
_preservedDevices = opts.Devices;
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current channel/protocol config to camelCase JSON, re-attaching the
/// preserved multi-device collection (authored on the Raw tree).</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static FocasDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<FocasDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// Collections (Devices, Tags) are kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Connection
public int TimeoutSeconds { get; set; } = 2;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 10;
// Alarm projection
public bool AlarmProjectionEnabled { get; set; } = false;
public int AlarmProjectionPollIntervalSeconds { get; set; } = 2;
// Handle recycle
public bool HandleRecycleEnabled { get; set; } = false;
public int HandleRecycleIntervalMinutes { get; set; } = 60;
// Fixed tree
public bool FixedTreeEnabled { get; set; } = false;
public int FixedTreePollIntervalMs { get; set; } = 250;
public int FixedTreeProgramPollIntervalSeconds { get; set; } = 1;
public int FixedTreeTimerPollIntervalSeconds { get; set; } = 30;
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(FocasDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
AlarmProjectionEnabled = o.AlarmProjection.Enabled,
AlarmProjectionPollIntervalSeconds = (int)o.AlarmProjection.PollInterval.TotalSeconds,
HandleRecycleEnabled = o.HandleRecycle.Enabled,
HandleRecycleIntervalMinutes = (int)o.HandleRecycle.Interval.TotalMinutes,
FixedTreeEnabled = o.FixedTree.Enabled,
FixedTreePollIntervalMs = (int)o.FixedTree.PollInterval.TotalMilliseconds,
FixedTreeProgramPollIntervalSeconds = (int)o.FixedTree.ProgramPollInterval.TotalSeconds,
FixedTreeTimerPollIntervalSeconds = (int)o.FixedTree.TimerPollInterval.TotalSeconds,
};
public FocasDriverOptions ToOptions(
IReadOnlyList<FocasDeviceOptions> devices) => new()
{
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new FocasProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
AlarmProjection = new FocasAlarmProjectionOptions
{
Enabled = AlarmProjectionEnabled,
PollInterval = TimeSpan.FromSeconds(AlarmProjectionPollIntervalSeconds),
},
HandleRecycle = new FocasHandleRecycleOptions
{
Enabled = HandleRecycleEnabled,
Interval = TimeSpan.FromMinutes(HandleRecycleIntervalMinutes),
},
FixedTree = new FocasFixedTreeOptions
{
Enabled = FixedTreeEnabled,
PollInterval = TimeSpan.FromMilliseconds(FixedTreePollIntervalMs),
ProgramPollInterval = TimeSpan.FromSeconds(FixedTreeProgramPollIntervalSeconds),
TimerPollInterval = TimeSpan.FromSeconds(FixedTreeTimerPollIntervalSeconds),
},
Devices = devices,
};
}
}
@@ -0,0 +1,293 @@
@* Embeddable AVEVA Galaxy driver config form body. Galaxy connects to a SINGLE mxaccessgw gateway
(nested Gateway/MxAccess/Repository/Reconnect records) — there is no flat per-device endpoint to split
out, so the whole connection stays here (GalaxyDeviceForm is informational only). Hosted by the routed
page + DriverConfigModal. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config
@* mxaccessgw connection *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">mxaccessgw connection</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Gateway endpoint</label>
<InputText @bind-Value="_form.Galaxy.GatewayEndpoint" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="https://localhost:5001" />
<div class="form-text">gRPC endpoint of the mxaccessgw process.</div>
</div>
<div class="col-md-6">
<label class="form-label">API key secret ref</label>
<InputText @bind-Value="_form.Galaxy.GatewayApiKeySecretRef" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="env:MX_API_KEY" />
<div class="form-text">Forms: <code>env:NAME</code>, <code>file:PATH</code>, <code>dev:KEY</code>. Cleartext is accepted but triggers a startup warning.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.Galaxy.GatewayUseTls" @bind-Value:after="EmitAsync" class="form-check-input" id="gwTls" />
<label class="form-check-label" for="gwTls">Use TLS</label>
</div>
</div>
<div class="col-md-5">
<label class="form-label">CA certificate path (optional)</label>
<InputText @bind-Value="_form.Galaxy.GatewayCaCertificatePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="C:\certs\ca.pem" />
</div>
<div class="col-md-2">
<label class="form-label">Connect timeout (s)</label>
<InputNumber @bind-Value="_form.Galaxy.GatewayConnectTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 10 s.</div>
</div>
<div class="col-md-2">
<label class="form-label">Call timeout (s)</label>
<InputNumber @bind-Value="_form.Galaxy.GatewayDefaultCallTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 30 s.</div>
</div>
<div class="col-md-2">
<label class="form-label">Stream timeout (s, 0 = unlimited)</label>
<InputNumber @bind-Value="_form.Galaxy.GatewayStreamTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 0 (lifetime of driver).</div>
</div>
</div>
</div>
</section>
@* MXAccess *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">MXAccess</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Client name</label>
<InputText @bind-Value="_form.Galaxy.MxClientName" @bind-Value:after="EmitAsync" class="form-control form-control-sm"
placeholder="OtOpcUa-Primary" />
<div class="form-text">Must be unique per OtOpcUa instance — redundancy pairs each get a distinct name.</div>
</div>
<div class="col-md-3">
<label class="form-label">Publishing interval (ms)</label>
<InputNumber @bind-Value="_form.Galaxy.MxPublishingIntervalMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 1000 ms.</div>
</div>
<div class="col-md-2">
<label class="form-label">Write user ID</label>
<InputNumber @bind-Value="_form.Galaxy.MxWriteUserId" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">0 = anonymous.</div>
</div>
<div class="col-md-3">
<label class="form-label">Event pump channel capacity</label>
<InputNumber @bind-Value="_form.Galaxy.MxEventPumpChannelCapacity" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 50000. Raise if events.dropped appears.</div>
</div>
</div>
</div>
</section>
@* Repository *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Galaxy repository</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Discover page size</label>
<InputNumber @bind-Value="_form.Galaxy.RepositoryDiscoverPageSize" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 5000 objects per page.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.Galaxy.RepositoryWatchDeployEvents" @bind-Value:after="EmitAsync" class="form-check-input" id="watchDeploy" />
<label class="form-check-label" for="watchDeploy">Watch deploy events</label>
</div>
<div class="form-text mt-0">Triggers address-space rebuild on Galaxy re-deploy.</div>
</div>
</div>
</div>
</section>
@* Reconnect *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Reconnect backoff</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Initial backoff (ms)</label>
<InputNumber @bind-Value="_form.Galaxy.ReconnectInitialBackoffMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 500 ms.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max backoff (ms)</label>
<InputNumber @bind-Value="_form.Galaxy.ReconnectMaxBackoffMs" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 30000 ms.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.Galaxy.ReconnectReplayOnSessionLost" @bind-Value:after="EmitAsync" class="form-check-input" id="replayOnLost" />
<label class="form-check-label" for="replayOnLost">Replay subscriptions on session lost</label>
</div>
</div>
</div>
</div>
</section>
@* Diagnostics *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Diagnostics</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.Galaxy.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 30.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
[Parameter] public string DriverConfigJson { get; set; } = "{}";
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
[Parameter] public string? ResilienceConfig { get; set; }
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? CreateDefaultOptions();
_form = new FormModel { Galaxy = GalaxyFormModel.FromRecord(opts) };
_lastParsed = DriverConfigJson;
}
}
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.Galaxy.ToRecord(), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static GalaxyDriverOptions CreateDefaultOptions() => new(
Gateway: new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY"),
MxAccess: new GalaxyMxAccessOptions("OtOpcUa"),
Repository: new GalaxyRepositoryOptions(),
Reconnect: new GalaxyReconnectOptions());
private static GalaxyDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<GalaxyDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
public sealed class FormModel
{
public GalaxyFormModel Galaxy { get; set; } = new();
}
/// <summary>Mutable flat mirror of <see cref="GalaxyDriverOptions"/> and its nested records.</summary>
public sealed class GalaxyFormModel
{
// GalaxyGatewayOptions
public string GatewayEndpoint { get; set; } = "https://localhost:5001";
public string GatewayApiKeySecretRef { get; set; } = "env:MX_API_KEY";
public bool GatewayUseTls { get; set; } = true;
public string? GatewayCaCertificatePath { get; set; }
public int GatewayConnectTimeoutSeconds { get; set; } = 10;
public int GatewayDefaultCallTimeoutSeconds { get; set; } = 30;
public int GatewayStreamTimeoutSeconds { get; set; } = 0;
// GalaxyMxAccessOptions
public string MxClientName { get; set; } = "OtOpcUa";
public int MxPublishingIntervalMs { get; set; } = 1000;
public int MxWriteUserId { get; set; } = 0;
public int MxEventPumpChannelCapacity { get; set; } = 50_000;
// GalaxyRepositoryOptions
public int RepositoryDiscoverPageSize { get; set; } = 5000;
public bool RepositoryWatchDeployEvents { get; set; } = true;
// GalaxyReconnectOptions
public int ReconnectInitialBackoffMs { get; set; } = 500;
public int ReconnectMaxBackoffMs { get; set; } = 30_000;
public bool ReconnectReplayOnSessionLost { get; set; } = true;
// GalaxyDriverOptions top-level
public int ProbeTimeoutSeconds { get; set; } = 30;
public static GalaxyFormModel FromRecord(GalaxyDriverOptions r)
{
var gw = r.Gateway ?? new GalaxyGatewayOptions("https://localhost:5001", "env:MX_API_KEY");
var mx = r.MxAccess ?? new GalaxyMxAccessOptions("OtOpcUa");
var repo = r.Repository ?? new GalaxyRepositoryOptions();
var rc = r.Reconnect ?? new GalaxyReconnectOptions();
return new()
{
GatewayEndpoint = gw.Endpoint,
GatewayApiKeySecretRef = gw.ApiKeySecretRef,
GatewayUseTls = gw.UseTls,
GatewayCaCertificatePath = gw.CaCertificatePath,
GatewayConnectTimeoutSeconds = gw.ConnectTimeoutSeconds,
GatewayDefaultCallTimeoutSeconds = gw.DefaultCallTimeoutSeconds,
GatewayStreamTimeoutSeconds = gw.StreamTimeoutSeconds,
MxClientName = mx.ClientName,
MxPublishingIntervalMs = mx.PublishingIntervalMs,
MxWriteUserId = mx.WriteUserId,
MxEventPumpChannelCapacity = mx.EventPumpChannelCapacity,
RepositoryDiscoverPageSize = repo.DiscoverPageSize,
RepositoryWatchDeployEvents = repo.WatchDeployEvents,
ReconnectInitialBackoffMs = rc.InitialBackoffMs,
ReconnectMaxBackoffMs = rc.MaxBackoffMs,
ReconnectReplayOnSessionLost = rc.ReplayOnSessionLost,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
};
}
public GalaxyDriverOptions ToRecord() => new(
Gateway: new GalaxyGatewayOptions(
Endpoint: GatewayEndpoint,
ApiKeySecretRef: GatewayApiKeySecretRef,
UseTls: GatewayUseTls,
CaCertificatePath: string.IsNullOrWhiteSpace(GatewayCaCertificatePath) ? null : GatewayCaCertificatePath,
ConnectTimeoutSeconds: GatewayConnectTimeoutSeconds,
DefaultCallTimeoutSeconds: GatewayDefaultCallTimeoutSeconds,
StreamTimeoutSeconds: GatewayStreamTimeoutSeconds),
MxAccess: new GalaxyMxAccessOptions(
ClientName: MxClientName,
PublishingIntervalMs: MxPublishingIntervalMs,
WriteUserId: MxWriteUserId,
EventPumpChannelCapacity: MxEventPumpChannelCapacity),
Repository: new GalaxyRepositoryOptions(
DiscoverPageSize: RepositoryDiscoverPageSize,
WatchDeployEvents: RepositoryWatchDeployEvents),
Reconnect: new GalaxyReconnectOptions(
InitialBackoffMs: ReconnectInitialBackoffMs,
MaxBackoffMs: ReconnectMaxBackoffMs,
ReplayOnSessionLost: ReconnectReplayOnSessionLost))
{
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
};
}
}
@@ -0,0 +1,410 @@
@* Embeddable Modbus driver (channel/protocol) config form body. Endpoint fields (Host/Port/UnitId)
moved to ModbusDeviceForm in v3 (they live in Device.DeviceConfig). Hosted by the routed
ModbusDriverPage and by DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save +
Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@
@using System.Text.Json
@using System.Text.Json.Nodes
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
@* Family / transport-flags *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Protocol</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Timeout (seconds)</label>
<InputNumber @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 2 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">PLC family</label>
<InputSelect @bind-Value="_form.Family" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<ModbusFamily>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
<div class="col-md-3">
<label class="form-label">MELSEC sub-family</label>
<InputSelect @bind-Value="_form.MelsecSubFamily" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<MelsecFamily>())
{
<option value="@e">@e</option>
}
</InputSelect>
<div class="form-text">Only used when Family = MELSEC.</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.AutoReconnect" @bind-Value:after="EmitAsync" class="form-check-input" id="autoReconnect" />
<label class="form-check-label" for="autoReconnect">Auto-reconnect</label>
</div>
</div>
</div>
</div>
</section>
@* Batch sizes *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Batch sizes</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Max registers per read</label>
<InputNumber @bind-Value="_form.MaxRegistersPerRead" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Spec max 125. Reduce for limited devices.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max registers per write</label>
<InputNumber @bind-Value="_form.MaxRegistersPerWrite" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Spec max 123.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max coils per read</label>
<InputNumber @bind-Value="_form.MaxCoilsPerRead" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Spec max 2000.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max read gap (coalescing)</label>
<InputNumber @bind-Value="_form.MaxReadGap" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">0 = no coalescing. Typical 532.</div>
</div>
</div>
</div>
</section>
@* Write options *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Write options</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.UseFC15ForSingleCoilWrites" @bind-Value:after="EmitAsync" class="form-check-input" id="useFC15" />
<label class="form-check-label" for="useFC15">Use FC15 for single coil writes</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.UseFC16ForSingleRegisterWrites" @bind-Value:after="EmitAsync" class="form-check-input" id="useFC16" />
<label class="form-check-label" for="useFC16">Use FC16 for single register writes</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.DisableFC23" @bind-Value:after="EmitAsync" class="form-check-input" id="disableFC23" />
<label class="form-check-label" for="disableFC23">Disable FC23 (reserved — no effect today)</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.WriteOnChangeOnly" @bind-Value:after="EmitAsync" class="form-check-input" id="writeOnChangeOnly" />
<label class="form-check-label" for="writeOnChangeOnly">Write on change only</label>
</div>
</div>
</div>
</div>
</section>
@* Keep-alive *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">TCP keep-alive</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.KeepAliveEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="kaEnabled" />
<label class="form-check-label" for="kaEnabled">Enabled</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Time (seconds)</label>
<InputNumber @bind-Value="_form.KeepAliveTimeSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Idle time before first probe. Default 30 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Interval (seconds)</label>
<InputNumber @bind-Value="_form.KeepAliveIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Between probes once started. Default 10 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Retry count</label>
<InputNumber @bind-Value="_form.KeepAliveRetryCount" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Probes before declaring socket dead. Default 3.</div>
</div>
</div>
</div>
</section>
@* Reconnect backoff *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Reconnect backoff</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Initial delay (seconds)</label>
<InputNumber @bind-Value="_form.ReconnectInitialDelaySeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">0 = immediate retry.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max delay (seconds)</label>
<InputNumber @bind-Value="_form.ReconnectMaxDelaySeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 30 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Backoff multiplier</label>
<InputNumber @bind-Value="_form.ReconnectBackoffMultiplier" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 2.0 (doubles each step).</div>
</div>
<div class="col-md-3">
<label class="form-label">Idle disconnect (seconds, 0 = off)</label>
<InputNumber @bind-Value="_form.IdleDisconnectTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Proactive close after idle period. 0 = disabled.</div>
</div>
</div>
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.16s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="probeEnabled" />
<label class="form-check-label" for="probeEnabled">Enabled</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe interval (seconds)</label>
<InputNumber @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 2 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Probe register address</label>
<InputNumber @bind-Value="_form.ProbeAddress" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Zero-based. Default 0 (FC03 at register 0).</div>
</div>
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 5.</div>
</div>
<div class="col-md-3">
<label class="form-label">Auto-prohibit re-probe interval (seconds, 0 = off)</label>
<InputNumber @bind-Value="_form.AutoProhibitReprobeIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Interval to retry auto-prohibited coalesced ranges.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (protocol/channel; endpoint lives in the device).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new ModbusDriverOptions();
_form = FormModel.FromOptions(opts);
_lastParsed = DriverConfigJson;
}
}
// v3: the endpoint lives on the Device (DeviceConfig), not the driver. Strip the endpoint keys from
// the serialized channel config so DeviceConfig is the SOLE endpoint source — otherwise a stale
// driver-level default (host=127.0.0.1) is carried in DriverConfig and shadows the device on any
// driver that reads a single top-level Host, silently, as soon as a 2nd device exists (reviewer H1).
private static readonly string[] EndpointKeys = ["host", "port", "unitId"];
/// <summary>Serializes the current channel/protocol config to camelCase JSON, with the endpoint keys
/// removed (the device's DeviceConfig is the sole endpoint source, merged up at deploy/probe time).</summary>
public string GetConfigJson()
{
var node = JsonSerializer.SerializeToNode(_form.ToOptions(), _jsonOpts)!.AsObject();
foreach (var key in EndpointKeys) node.Remove(key);
return node.ToJsonString(_jsonOpts);
}
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static ModbusDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<ModbusDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Flat mutable model — retains Host/Port/UnitId at defaults (no UI; the device form authors the real
// endpoint into DeviceConfig, which merges up and overrides these at deploy/probe time).
public sealed class FormModel
{
// Transport endpoint (no UI — authored via the device form; kept at default so ToOptions is valid)
public string Host { get; set; } = "127.0.0.1";
public int Port { get; set; } = 502;
public int UnitId { get; set; } = 1;
public int TimeoutSeconds { get; set; } = 2;
// Family
public ModbusFamily Family { get; set; } = ModbusFamily.Generic;
public MelsecFamily MelsecSubFamily { get; set; } = MelsecFamily.Q_L_iQR;
// Transport flags
public bool AutoReconnect { get; set; } = true;
public int IdleDisconnectTimeoutSeconds { get; set; } = 0;
// Batch sizes (int; clamped to ushort on ToOptions)
public int MaxRegistersPerRead { get; set; } = 125;
public int MaxRegistersPerWrite { get; set; } = 123;
public int MaxCoilsPerRead { get; set; } = 2000;
public int MaxReadGap { get; set; } = 0;
// Write options
public bool UseFC15ForSingleCoilWrites { get; set; } = false;
public bool UseFC16ForSingleRegisterWrites { get; set; } = false;
public bool DisableFC23 { get; set; } = false;
public bool WriteOnChangeOnly { get; set; } = false;
// Keep-alive
public bool KeepAliveEnabled { get; set; } = true;
public int KeepAliveTimeSeconds { get; set; } = 30;
public int KeepAliveIntervalSeconds { get; set; } = 10;
public int KeepAliveRetryCount { get; set; } = 3;
// Reconnect backoff
public int ReconnectInitialDelaySeconds { get; set; } = 0;
public int ReconnectMaxDelaySeconds { get; set; } = 30;
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public int ProbeAddress { get; set; } = 0;
// Auto-prohibit re-probe (0 = disabled)
public int AutoProhibitReprobeIntervalSeconds { get; set; } = 0;
// Admin UI probe timeout
public int AdminProbeTimeoutSeconds { get; set; } = 5;
public static FormModel FromOptions(ModbusDriverOptions o) => new()
{
Host = o.Host,
Port = o.Port,
UnitId = o.UnitId,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Family = o.Family,
MelsecSubFamily = o.MelsecSubFamily,
AutoReconnect = o.AutoReconnect,
IdleDisconnectTimeoutSeconds = o.IdleDisconnectTimeout.HasValue ? (int)o.IdleDisconnectTimeout.Value.TotalSeconds : 0,
MaxRegistersPerRead = o.MaxRegistersPerRead,
MaxRegistersPerWrite = o.MaxRegistersPerWrite,
MaxCoilsPerRead = o.MaxCoilsPerRead,
MaxReadGap = o.MaxReadGap,
UseFC15ForSingleCoilWrites = o.UseFC15ForSingleCoilWrites,
UseFC16ForSingleRegisterWrites = o.UseFC16ForSingleRegisterWrites,
DisableFC23 = o.DisableFC23,
WriteOnChangeOnly = o.WriteOnChangeOnly,
KeepAliveEnabled = o.KeepAlive.Enabled,
KeepAliveTimeSeconds = (int)o.KeepAlive.Time.TotalSeconds,
KeepAliveIntervalSeconds = (int)o.KeepAlive.Interval.TotalSeconds,
KeepAliveRetryCount = o.KeepAlive.RetryCount,
ReconnectInitialDelaySeconds = (int)o.Reconnect.InitialDelay.TotalSeconds,
ReconnectMaxDelaySeconds = (int)o.Reconnect.MaxDelay.TotalSeconds,
ReconnectBackoffMultiplier = o.Reconnect.BackoffMultiplier,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
ProbeAddress = o.Probe.ProbeAddress,
AutoProhibitReprobeIntervalSeconds = o.AutoProhibitReprobeInterval.HasValue
? (int)o.AutoProhibitReprobeInterval.Value.TotalSeconds : 0,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public ModbusDriverOptions ToOptions() => new()
{
Host = Host,
Port = Port,
UnitId = (byte)Math.Clamp(UnitId, 0, 255),
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new ModbusProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
ProbeAddress = (ushort)Math.Clamp(ProbeAddress, 0, 65535),
},
MaxRegistersPerRead = (ushort)Math.Clamp(MaxRegistersPerRead, 0, 65535),
MaxRegistersPerWrite = (ushort)Math.Clamp(MaxRegistersPerWrite, 0, 65535),
MaxCoilsPerRead = (ushort)Math.Clamp(MaxCoilsPerRead, 0, 65535),
UseFC15ForSingleCoilWrites = UseFC15ForSingleCoilWrites,
UseFC16ForSingleRegisterWrites = UseFC16ForSingleRegisterWrites,
DisableFC23 = DisableFC23,
AutoProhibitReprobeInterval = AutoProhibitReprobeIntervalSeconds > 0
? TimeSpan.FromSeconds(AutoProhibitReprobeIntervalSeconds) : null,
MaxReadGap = (ushort)Math.Clamp(MaxReadGap, 0, 65535),
Family = Family,
MelsecSubFamily = MelsecSubFamily,
WriteOnChangeOnly = WriteOnChangeOnly,
AutoReconnect = AutoReconnect,
KeepAlive = new ModbusKeepAliveOptions
{
Enabled = KeepAliveEnabled,
Time = TimeSpan.FromSeconds(KeepAliveTimeSeconds),
Interval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
RetryCount = KeepAliveRetryCount,
},
IdleDisconnectTimeout = IdleDisconnectTimeoutSeconds > 0
? TimeSpan.FromSeconds(IdleDisconnectTimeoutSeconds) : null,
Reconnect = new ModbusReconnectOptions
{
InitialDelay = TimeSpan.FromSeconds(ReconnectInitialDelaySeconds),
MaxDelay = TimeSpan.FromSeconds(ReconnectMaxDelaySeconds),
BackoffMultiplier = ReconnectBackoffMultiplier,
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
}
@@ -0,0 +1,376 @@
@* Embeddable OPC UA Client driver config form body. The single connection Endpoint URL moved to
OpcUaClientDeviceForm (Device.DeviceConfig) in v3; the EndpointUrls FAILOVER LIST stays here (it is a
driver-level policy). Hosted by the routed page + DriverConfigModal. *@
@using System.Text.Json
@using System.Text.Json.Nodes
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient
@* Endpoint (single Endpoint URL moved to the device form) *@
<section class="panel rise mt-3" style="animation-delay:.06s">
<div class="panel-head">Endpoint</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Browse root NodeId (blank = ObjectsFolder)</label>
<InputText @bind-Value="_form.OpcUa.BrowseRoot" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="i=85" />
<div class="form-text">Restrict mirroring to a sub-tree.</div>
</div>
<div class="col-md-4">
<label class="form-label">Application URI</label>
<InputText @bind-Value="_form.OpcUa.ApplicationUri" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" />
</div>
<div class="col-md-4">
<label class="form-label">Session name</label>
<InputText @bind-Value="_form.OpcUa.SessionName" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-4">
<div class="form-check form-switch mt-4">
<InputCheckbox @bind-Value="_form.OpcUa.AutoAcceptCertificates" @bind-Value:after="EmitAsync" class="form-check-input" id="autoAcceptCerts" />
<label class="form-check-label" for="autoAcceptCerts">Auto-accept certificates <span class="badge bg-warning text-dark">Dev only</span></label>
</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-3">
<label class="form-label">Per-endpoint connect timeout (s)</label>
<InputNumber @bind-Value="_form.OpcUa.PerEndpointConnectTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 3 s — failover sweep budget.</div>
</div>
<div class="col-md-3">
<label class="form-label">Operation timeout (s)</label>
<InputNumber @bind-Value="_form.OpcUa.TimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 10 s — steady-state reads/writes.</div>
</div>
<div class="col-md-3">
<label class="form-label">Session timeout (s)</label>
<InputNumber @bind-Value="_form.OpcUa.SessionTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 120 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Keep-alive interval (s)</label>
<InputNumber @bind-Value="_form.OpcUa.KeepAliveIntervalSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Reconnect period (s)</label>
<InputNumber @bind-Value="_form.OpcUa.ReconnectPeriodSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Initial delay after session drop. Default 5 s.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max discovered nodes</label>
<InputNumber @bind-Value="_form.OpcUa.MaxDiscoveredNodes" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 10000.</div>
</div>
<div class="col-md-3">
<label class="form-label">Max browse depth</label>
<InputNumber @bind-Value="_form.OpcUa.MaxBrowseDepth" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Default 10.</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-12">
<CollectionEditor TRow="EndpointUrlRow" Items="_endpoints" ItemsChanged="EmitAsync"
Title="Endpoint URLs" ItemNoun="endpoint" AnimationDelay=".07s"
NewRow="@(() => new EndpointUrlRow())" Clone="@(r => r.Clone())"
Validate="EndpointUrlRow.ValidateRow">
<HeaderTemplate>
<tr><th>Endpoint URL (failover list — first reachable wins)</th><th></th></tr>
</HeaderTemplate>
<RowTemplate Context="e">
<td class="mono">@e.Url</td>
</RowTemplate>
<EditTemplate Context="e">
<label class="form-label">Endpoint URL</label>
<input class="form-control form-control-sm mono" @bind="e.Url"
placeholder="opc.tcp://plc.internal:4840" />
<div class="form-text">Failover list. The per-device Endpoint URL (on the device) is used only when this list is empty.</div>
</EditTemplate>
</CollectionEditor>
</div>
</div>
</div>
</section>
@* Security *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Security</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Security mode</label>
<InputSelect @bind-Value="_form.OpcUa.SecurityMode" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaSecurityMode>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
<div class="col-md-4">
<label class="form-label">Security policy</label>
<InputSelect @bind-Value="_form.OpcUa.SecurityPolicy" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaSecurityPolicy>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
</div>
</div>
</section>
@* Authentication *@
<section class="panel rise mt-3" style="animation-delay:.10s">
<div class="panel-head">Authentication</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Auth type</label>
<InputSelect @bind-Value="_form.OpcUa.AuthType" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaAuthType>())
{
<option value="@e">@e</option>
}
</InputSelect>
</div>
@if (_form.OpcUa.AuthType == OpcUaAuthType.Username)
{
<div class="col-md-4">
<label class="form-label">Username</label>
<InputText @bind-Value="_form.OpcUa.Username" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
</div>
<div class="col-md-4">
<label class="form-label">Password</label>
<InputText @bind-Value="_form.OpcUa.Password" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
</div>
}
@if (_form.OpcUa.AuthType == OpcUaAuthType.Certificate)
{
<div class="col-md-6">
<label class="form-label">User certificate path (PFX/PEM)</label>
<InputText @bind-Value="_form.OpcUa.UserCertificatePath" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono"
placeholder="C:\certs\user.pfx" />
</div>
<div class="col-md-4">
<label class="form-label">Certificate password (if PFX-locked)</label>
<InputText @bind-Value="_form.OpcUa.UserCertificatePassword" @bind-Value:after="EmitAsync" type="password" class="form-control form-control-sm" autocomplete="new-password" />
</div>
}
</div>
</div>
</section>
@* Namespace mapping *@
<section class="panel rise mt-3" style="animation-delay:.12s">
<div class="panel-head">Namespace mapping</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Target namespace kind</label>
<InputSelect @bind-Value="_form.OpcUa.TargetNamespaceKind" @bind-Value:after="EmitAsync" class="form-select form-select-sm">
@foreach (var e in Enum.GetValues<OpcUaTargetNamespaceKind>())
{
<option value="@e">@e</option>
}
</InputSelect>
<div class="form-text">Equipment = raw data re-mapped to UNS. SystemPlatform = processed data; hierarchy preserved as-is.</div>
</div>
<div class="col-12">
<label class="form-label">UNS mapping table (read-only — edit via raw JSON import)</label>
<pre class="form-control form-control-sm mono" style="min-height:3rem;overflow:auto;white-space:pre-wrap;">@_unsMappingTableJson</pre>
<div class="form-text">Keys = remote browse-path prefixes; values = UNS Area/Line/Name paths. Required when TargetNamespaceKind = Equipment.</div>
</div>
</div>
</div>
</section>
@* Diagnostics *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Diagnostics</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.OpcUa.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
[Parameter] public string DriverConfigJson { get; set; } = "{}";
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
[Parameter] public string? ResilienceConfig { get; set; }
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private List<EndpointUrlRow> _endpoints = [];
private string _unsMappingTableJson = "{}";
private string? _lastParsed;
protected override void OnParametersSet()
{
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new OpcUaClientDriverOptions();
_form = new FormModel { OpcUa = OpcUaClientFormModel.FromRecord(opts) };
_endpoints = opts.EndpointUrls.Select(EndpointUrlRow.FromUrl).ToList();
_unsMappingTableJson = JsonSerializer.Serialize(opts.UnsMappingTable, _jsonOpts);
_lastParsed = DriverConfigJson;
}
}
public string GetConfigJson()
{
// v3 (reviewer H1): the convenience scalar endpoint lives on the Device (DeviceConfig). Strip the
// scalar "endpointUrl" so the device is its sole source; the "endpointUrls" failover list stays
// here (it is a driver-level policy and wins over the scalar at runtime when populated).
var node = JsonSerializer.SerializeToNode(
_form.OpcUa.ToRecord(_endpoints.Select(r => r.ToUrl()).ToList()), _jsonOpts)!.AsObject();
node.Remove("endpointUrl");
return node.ToJsonString(_jsonOpts);
}
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static OpcUaClientDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<OpcUaClientDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
public sealed class FormModel
{
public OpcUaClientFormModel OpcUa { get; set; } = new();
}
/// <summary>Mutable VM for a single failover endpoint URL row.</summary>
public sealed class EndpointUrlRow
{
public string Url { get; set; } = "";
public EndpointUrlRow Clone() => (EndpointUrlRow)MemberwiseClone();
public static EndpointUrlRow FromUrl(string u) => new() { Url = u };
public string ToUrl() => Url.Trim();
public static string? ValidateRow(EndpointUrlRow row, IReadOnlyList<EndpointUrlRow> all, int? editIndex)
{
if (string.IsNullOrWhiteSpace(row.Url)) return "URL is required.";
if (!row.Url.Trim().StartsWith("opc.tcp://", StringComparison.OrdinalIgnoreCase))
return "Endpoint URL must start with opc.tcp://";
for (var i = 0; i < all.Count; i++)
if (i != editIndex && string.Equals(all[i].Url.Trim(), row.Url.Trim(), StringComparison.OrdinalIgnoreCase))
return $"Duplicate endpoint '{row.Url}'.";
return null;
}
}
/// <summary>Mutable mirror of <see cref="OpcUaClientDriverOptions"/> (int wrappers for TimeSpans).
/// EndpointUrl (single) has NO UI — the device form authors it; kept at default so ToRecord is valid.</summary>
public sealed class OpcUaClientFormModel
{
// Connection (EndpointUrl authored on the device — no UI here)
public string EndpointUrl { get; set; } = "opc.tcp://localhost:4840";
public string? BrowseRoot { get; set; }
public string ApplicationUri { get; set; } = "urn:localhost:OtOpcUa:GatewayClient";
public string SessionName { get; set; } = "OtOpcUa-Gateway";
public bool AutoAcceptCertificates { get; set; } = false;
public int PerEndpointConnectTimeoutSeconds { get; set; } = 3;
public int TimeoutSeconds { get; set; } = 10;
public int SessionTimeoutSeconds { get; set; } = 120;
public int KeepAliveIntervalSeconds { get; set; } = 5;
public int ReconnectPeriodSeconds { get; set; } = 5;
public int MaxDiscoveredNodes { get; set; } = 10_000;
public int MaxBrowseDepth { get; set; } = 10;
public OpcUaSecurityMode SecurityMode { get; set; } = OpcUaSecurityMode.None;
public OpcUaSecurityPolicy SecurityPolicy { get; set; } = OpcUaSecurityPolicy.None;
public OpcUaAuthType AuthType { get; set; } = OpcUaAuthType.Anonymous;
public string? Username { get; set; }
public string? Password { get; set; }
public string? UserCertificatePath { get; set; }
public string? UserCertificatePassword { get; set; }
public OpcUaTargetNamespaceKind TargetNamespaceKind { get; set; } = OpcUaTargetNamespaceKind.Equipment;
public int ProbeTimeoutSeconds { get; set; } = 15;
internal IReadOnlyDictionary<string, string> _unsMappingTable = new Dictionary<string, string>();
public static OpcUaClientFormModel FromRecord(OpcUaClientDriverOptions r) => new()
{
EndpointUrl = r.EndpointUrl,
BrowseRoot = r.BrowseRoot,
ApplicationUri = r.ApplicationUri,
SessionName = r.SessionName,
AutoAcceptCertificates = r.AutoAcceptCertificates,
PerEndpointConnectTimeoutSeconds = (int)r.PerEndpointConnectTimeout.TotalSeconds,
TimeoutSeconds = (int)r.Timeout.TotalSeconds,
SessionTimeoutSeconds = (int)r.SessionTimeout.TotalSeconds,
KeepAliveIntervalSeconds = (int)r.KeepAliveInterval.TotalSeconds,
ReconnectPeriodSeconds = (int)r.ReconnectPeriod.TotalSeconds,
MaxDiscoveredNodes = r.MaxDiscoveredNodes,
MaxBrowseDepth = r.MaxBrowseDepth,
SecurityMode = r.SecurityMode,
SecurityPolicy = r.SecurityPolicy,
AuthType = r.AuthType,
Username = r.Username,
Password = r.Password,
UserCertificatePath = r.UserCertificatePath,
UserCertificatePassword = r.UserCertificatePassword,
TargetNamespaceKind = r.TargetNamespaceKind,
ProbeTimeoutSeconds = r.ProbeTimeoutSeconds,
_unsMappingTable = r.UnsMappingTable,
};
public OpcUaClientDriverOptions ToRecord(IReadOnlyList<string> endpointUrls) => new()
{
EndpointUrl = EndpointUrl,
EndpointUrls = endpointUrls,
BrowseRoot = string.IsNullOrWhiteSpace(BrowseRoot) ? null : BrowseRoot,
ApplicationUri = ApplicationUri,
SessionName = SessionName,
AutoAcceptCertificates = AutoAcceptCertificates,
PerEndpointConnectTimeout = TimeSpan.FromSeconds(PerEndpointConnectTimeoutSeconds),
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
SessionTimeout = TimeSpan.FromSeconds(SessionTimeoutSeconds),
KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveIntervalSeconds),
ReconnectPeriod = TimeSpan.FromSeconds(ReconnectPeriodSeconds),
MaxDiscoveredNodes = MaxDiscoveredNodes,
MaxBrowseDepth = MaxBrowseDepth,
SecurityMode = SecurityMode,
SecurityPolicy = SecurityPolicy,
AuthType = AuthType,
Username = string.IsNullOrWhiteSpace(Username) ? null : Username,
Password = string.IsNullOrWhiteSpace(Password) ? null : Password,
UserCertificatePath = string.IsNullOrWhiteSpace(UserCertificatePath) ? null : UserCertificatePath,
UserCertificatePassword = string.IsNullOrWhiteSpace(UserCertificatePassword) ? null : UserCertificatePassword,
TargetNamespaceKind = TargetNamespaceKind,
UnsMappingTable = _unsMappingTable,
ProbeTimeoutSeconds = ProbeTimeoutSeconds,
};
}
}
@@ -0,0 +1,187 @@
@* Embeddable Siemens S7 driver (protocol) config form body. Endpoint fields (Host/Port/Rack/Slot)
moved to S7DeviceForm in v3 (they live in Device.DeviceConfig). Hosted by the routed
S7DriverPage and by DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save +
Test-connect time, and fires DriverConfigJsonChanged for @bind support. *@
@using System.Text.Json
@using System.Text.Json.Nodes
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.S7
@* Connection *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Connection</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="s7TimeoutSec">Timeout (seconds)</label>
<InputNumber id="s7TimeoutSec" @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
</div>
<div class="col-md-4 mb-3">
<label class="form-label" for="s7CpuType">CPU type</label>
<InputSelect id="s7CpuType" @bind-Value="_form.CpuType" @bind-Value:after="EmitAsync"
class="form-select form-select-sm">
@foreach (var v in Enum.GetValues<S7CpuType>())
{
<option value="@v">@v</option>
}
</InputSelect>
<div class="form-text">Controls ISO-TSAP slot byte during handshake.</div>
</div>
</div>
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="s7ProbeEnabled" @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync"
class="form-check-input" />
<label class="form-check-label" for="s7ProbeEnabled">Probe enabled</label>
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7ProbeIntervalSec">Probe interval (s)</label>
<InputNumber id="s7ProbeIntervalSec" @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7ProbeTimeoutSec">Probe timeout (s)</label>
<InputNumber id="s7ProbeTimeoutSec" @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="s7AdminProbeTimeout">Admin probe timeout (s)</label>
<InputNumber id="s7AdminProbeTimeout" @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (protocol; endpoint lives in the device).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a protocol field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new S7DriverOptions();
_form = FormModel.FromOptions(opts);
_lastParsed = DriverConfigJson;
}
}
// v3: the endpoint lives on the Device (DeviceConfig). Strip the endpoint keys so DeviceConfig is the
// sole endpoint source — a stale driver-level default would otherwise shadow the device on the
// single-top-level-Host read path as soon as a 2nd device exists (reviewer H1).
private static readonly string[] EndpointKeys = ["host", "port", "rack", "slot"];
/// <summary>Serializes the current protocol config to camelCase JSON, with the endpoint keys removed
/// (the device's DeviceConfig is the sole endpoint source, merged up at deploy/probe time).</summary>
public string GetConfigJson()
{
var node = JsonSerializer.SerializeToNode(_form.ToOptions(), _jsonOpts)!.AsObject();
foreach (var key in EndpointKeys) node.Remove(key);
return node.ToJsonString(_jsonOpts);
}
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static S7DriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<S7DriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
public sealed class FormModel
{
// Connection
public string Host { get; set; } = "127.0.0.1";
public int Port { get; set; } = 102;
public S7CpuType CpuType { get; set; } = S7CpuType.S71500;
public short Rack { get; set; } = 0;
public short Slot { get; set; } = 0;
public int TimeoutSeconds { get; set; } = 5;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 5;
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(S7DriverOptions o) => new()
{
Host = o.Host,
Port = o.Port,
CpuType = o.CpuType,
Rack = o.Rack,
Slot = o.Slot,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public S7DriverOptions ToOptions() => new()
{
Host = Host,
Port = Port,
CpuType = CpuType,
Rack = Rack,
Slot = Slot,
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
Probe = new S7ProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
}
@@ -0,0 +1,194 @@
@* Embeddable TwinCAT driver (channel/protocol) config form body. Endpoint/device fields (AMS Net Id:port)
moved to TwinCATDeviceForm in v3 (they live in Device.DeviceConfig); this form never edits the Devices
collection — it preserves it verbatim across a load→save. Hosted by the routed TwinCATDriverPage and by
DriverConfigModal. Exposes GetConfigJson() for the parent to pull at save + Test-connect time, and fires
DriverConfigJsonChanged for @bind support. *@
@using System.Text.Json
@using System.Text.Json.Serialization
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT
@* Options *@
<section class="panel rise mt-3" style="animation-delay:.05s">
<div class="panel-head">Options</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label" for="tcTimeoutSec">Timeout (seconds)</label>
<InputNumber id="tcTimeoutSec" @bind-Value="_form.TimeoutSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">Default 2 s per operation.</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-4">
<InputCheckbox id="tcNativeNotif" @bind-Value="_form.UseNativeNotifications" @bind-Value:after="EmitAsync"
class="form-check-input" />
<label class="form-check-label" for="tcNativeNotif">Use native ADS notifications</label>
</div>
<div class="form-text">Recommended. Disable for AMS routers with notification limits.</div>
</div>
<div class="col-md-4 mb-3">
<div class="form-check form-switch mt-4">
<InputCheckbox id="tcControllerBrowse" @bind-Value="_form.EnableControllerBrowse" @bind-Value:after="EmitAsync"
class="form-check-input" />
<label class="form-check-label" for="tcControllerBrowse">Enable controller browse</label>
</div>
<div class="form-text">Walks the symbol table via SymbolLoaderFactory at discovery. Default off.</div>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="tcNotifDelay">Notification max delay (ms)</label>
<InputNumber id="tcNotifDelay" @bind-Value="_form.NotificationMaxDelayMs" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">0 = push immediately. Increase to coalesce high-churn signals.</div>
</div>
</div>
</div>
</section>
@* Probe *@
<section class="panel rise mt-3" style="animation-delay:.08s">
<div class="panel-head">Connectivity probe</div>
<div style="padding:1rem">
<div class="row">
<div class="col-md-3 mb-3">
<div class="form-check form-switch mt-2">
<InputCheckbox id="tcProbeEnabled" @bind-Value="_form.ProbeEnabled" @bind-Value:after="EmitAsync"
class="form-check-input" />
<label class="form-check-label" for="tcProbeEnabled">Probe enabled</label>
</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcProbeInterval">Probe interval (s)</label>
<InputNumber id="tcProbeInterval" @bind-Value="_form.ProbeIntervalSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcProbeTimeout">Probe timeout (s)</label>
<InputNumber id="tcProbeTimeout" @bind-Value="_form.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="tcAdminProbe">Admin probe timeout (s)</label>
<InputNumber id="tcAdminProbe" @bind-Value="_form.AdminProbeTimeoutSeconds" @bind-Value:after="EmitAsync"
class="form-control form-control-sm" />
<div class="form-text">Test Connect timeout (160 s).</div>
</div>
</div>
</div>
</section>
<DriverResilienceSection ResilienceConfig="@ResilienceConfig" ResilienceConfigChanged="OnResilienceChanged" />
@code {
/// <summary>The driver-level DriverConfig JSON (protocol/channel; endpoint lives in the device).</summary>
[Parameter] public string DriverConfigJson { get; set; } = "{}";
/// <summary>Fired (camelCase-serialized) whenever a channel field changes — enables @bind-DriverConfigJson.</summary>
[Parameter] public EventCallback<string> DriverConfigJsonChanged { get; set; }
/// <summary>The per-instance resilience-pipeline overrides JSON, or null.</summary>
[Parameter] public string? ResilienceConfig { get; set; }
/// <summary>Fired when the resilience overrides change.</summary>
[Parameter] public EventCallback<string?> ResilienceConfigChanged { get; set; }
private static readonly JsonSerializerOptions _jsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
WriteIndented = false,
Converters = { new JsonStringEnumConverter() },
};
private FormModel _form = new();
private string? _lastParsed;
// TwinCAT is multi-device: this form never edits the Devices collection — it preserves whatever was
// authored (via the device modal on the Raw tree) verbatim across a load→save.
private IReadOnlyList<TwinCATDeviceOptions> _preservedDevices = [];
protected override void OnParametersSet()
{
// Re-parse only when the inbound value actually changed (don't clobber in-progress edits).
if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
{
var opts = TryDeserialize(DriverConfigJson) ?? new TwinCATDriverOptions();
_form = FormModel.FromOptions(opts);
_preservedDevices = opts.Devices;
_lastParsed = DriverConfigJson;
}
}
/// <summary>Serializes the current channel/protocol config to camelCase JSON, preserving the Devices
/// collection (authored elsewhere) verbatim.</summary>
public string GetConfigJson()
=> JsonSerializer.Serialize(_form.ToOptions(_preservedDevices), _jsonOpts);
private async Task EmitAsync()
{
var json = GetConfigJson();
_lastParsed = json;
await DriverConfigJsonChanged.InvokeAsync(json);
}
private async Task OnResilienceChanged(string? r)
{
ResilienceConfig = r;
await ResilienceConfigChanged.InvokeAsync(r);
}
private static TwinCATDriverOptions? TryDeserialize(string json)
{
try { return JsonSerializer.Deserialize<TwinCATDriverOptions>(json, _jsonOpts); }
catch { return null; }
}
// Flat mutable model — all scalar properties settable for Blazor @bind-Value.
// The Devices collection is kept on the component and passed in on ToOptions().
public sealed class FormModel
{
// Options
public int TimeoutSeconds { get; set; } = 2;
public bool UseNativeNotifications { get; set; } = true;
public bool EnableControllerBrowse { get; set; } = false;
public int NotificationMaxDelayMs { get; set; } = 0;
// Probe
public bool ProbeEnabled { get; set; } = true;
public int ProbeIntervalSeconds { get; set; } = 5;
public int ProbeTimeoutSeconds { get; set; } = 2;
public int AdminProbeTimeoutSeconds { get; set; } = 10;
// Common
public string? ResilienceConfig { get; set; }
public byte[] RowVersion { get; set; } = [];
public static FormModel FromOptions(TwinCATDriverOptions o) => new()
{
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
UseNativeNotifications = o.UseNativeNotifications,
EnableControllerBrowse = o.EnableControllerBrowse,
NotificationMaxDelayMs = o.NotificationMaxDelayMs,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
AdminProbeTimeoutSeconds = o.ProbeTimeoutSeconds,
};
public TwinCATDriverOptions ToOptions(
IReadOnlyList<TwinCATDeviceOptions> devices) => new()
{
Timeout = TimeSpan.FromSeconds(TimeoutSeconds),
UseNativeNotifications = UseNativeNotifications,
EnableControllerBrowse = EnableControllerBrowse,
NotificationMaxDelayMs = NotificationMaxDelayMs,
Probe = new TwinCATProbeOptions
{
Enabled = ProbeEnabled,
Interval = TimeSpan.FromSeconds(ProbeIntervalSeconds),
Timeout = TimeSpan.FromSeconds(ProbeTimeoutSeconds),
},
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
Devices = devices,
};
}
}
@@ -29,6 +29,13 @@
[Parameter] public bool ReadOnly { get; set; } = false; [Parameter] public bool ReadOnly { get; set; } = false;
[Parameter] public bool ShowToolbar { get; set; } = true; [Parameter] public bool ShowToolbar { get; set; } = true;
/// <summary>
/// Owning-equipment context for equipment-relative <c>{{equip}}/&lt;RefName&gt;</c> completion +
/// diagnostics. Set on the per-equipment VirtualTag / ScriptedAlarm modals; left null on the shared
/// ScriptEdit page (where the token cannot resolve to a single equipment).
/// </summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary> /// <summary>
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic /// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
/// debounce). The marker DTO is not modelled yet — typed as object[] until a /// debounce). The marker DTO is not modelled yet — typed as object[] until a
@@ -61,7 +68,8 @@
{ {
value = Value ?? "", value = Value ?? "",
language = Language, language = Language,
readOnly = ReadOnly readOnly = ReadOnly,
equipmentId = EquipmentId
}, },
_dotNetRef); _dotNetRef);
_initialized = true; _initialized = true;
@@ -0,0 +1,339 @@
@* WP6 "Browse device…" re-target for the v3 /raw tree. Opens a live driver-browse session against the
merged Driver+Device config (endpoint lives in DeviceConfig in v3), renders the shared DriverBrowseTree in
multi-select mode, and commits the selected leaves as raw Tag rows under the target Device/TagGroup via
IRawTreeService.ImportTagsAsync. Each selected leaf's driver reference is written into the driver-typed
TagConfig as an address field (NOT identity — identity is the RawPath). An opt-in toggle mirrors the
captured browse-folder nesting onto nested TagGroups. Visibility is @bind-Visible; a successful commit
raises OnSaved so the host refreshes the tree.
Two-tier browse gate (reused from BrowserSessionService): a bespoke IDriverBrowser (OpcUaClient, Galaxy)
wins; otherwise the universal DiscoveryDriverBrowser serves any driver whose ITagDiscovery reports
SupportsOnlineDiscovery (AbCip/TwinCAT/FOCAS); otherwise browse is unavailable and the modal grays out
with a tooltip. *@
@implements IAsyncDisposable
@using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.Commons.Browsing
@inject IBrowserSessionService BrowserService
@inject IRawTreeService Svc
@inject RawTagCsvExportReader ExportReader
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Browse device <span class="text-muted small">(@_driverType)</span>
</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
@if (_resolving)
{
<div class="text-muted small"><span class="spinner-border spinner-border-sm me-1"></span>Loading device config&hellip;</div>
}
else if (!_canBrowse)
{
<div class="alert alert-secondary mb-0" title="@_disabledReason">
<strong>Browsing unavailable.</strong>
<div class="small">@_disabledReason</div>
</div>
}
else
{
<div class="mb-2 small text-muted">
Committing under: <code class="mono">@TargetLabel()</code>
</div>
@if (_token == Guid.Empty)
{
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-outline-primary btn-sm"
disabled="@_opening" @onclick="OpenBrowseAsync">
@if (_opening) { <span class="spinner-border spinner-border-sm me-1"></span> }
Connect &amp; browse
</button>
@if (_openError is not null)
{
<span class="chip chip-bad" title="@_openError">@Truncate(_openError, 70)</span>
}
</div>
}
else
{
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="chip chip-ok">Browser open</span>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="raw-browse-mirror"
checked="@_createGroups" @onchange="@(e => _createGroups = e.Value is true)" />
<label class="form-check-label small" for="raw-browse-mirror">
Create matching tag-groups (mirror browse folders)
</label>
</div>
</div>
<DriverBrowseTree SessionToken="_token" MultiSelect="true"
SelectedLeafIds="_selectedIds" OnLeafToggled="OnLeafToggledAsync" />
<div class="mt-2 small">
<strong>@_selected.Count</strong> tag@(_selected.Count == 1 ? "" : "s") selected.
</div>
}
@if (_commitErrors.Count > 0)
{
<div class="alert alert-warning py-2 mt-2 mb-0">
<div class="small mb-1">Nothing was committed — fix these and retry:</div>
<ul class="mb-0 small">
@foreach (var err in _commitErrors)
{
<li>@err</li>
}
</ul>
</div>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="CommitAsync"
disabled="@(_busy || _selected.Count == 0)">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Add @_selected.Count tag@(_selected.Count == 1 ? "" : "s")
</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Fallback OPC-UA built-in type for browse-committed leaves whose browser cannot report a
/// type (the OPC UA Client tree). Driver-typed browsers (AbCip/TwinCAT/FOCAS) report the real type.</summary>
private const string DefaultDataType = "Double";
/// <summary>Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Raised when <see cref="Visible"/> changes (self-close). Completes the @bind-Visible contract.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The device the browsed tags are committed under.</summary>
[Parameter] public string DeviceId { get; set; } = "";
/// <summary>The target tag group (its device-relative path is prepended to every committed tag), or null
/// to commit directly under the device.</summary>
[Parameter] public string? TagGroupId { get; set; }
/// <summary>The owning device's driver type (informational header; the live gate + config come from the
/// merged probe config).</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Raised after a successful commit so the host can refresh the affected subtree.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private bool _open; // guards re-seeding on unrelated re-renders
private bool _resolving;
private bool _canBrowse;
private string? _disabledReason;
private string _driverType = "";
private string _mergedConfig = "{}";
private string _effectiveDeviceId = "";
private string? _groupPrefix;
private Guid _token = Guid.Empty;
private bool _opening;
private string? _openError;
private bool _createGroups;
private bool _busy;
private List<string> _commitErrors = new();
private readonly Dictionary<string, SelectedLeaf> _selected = new(StringComparer.Ordinal);
private readonly HashSet<string> _selectedIds = new(StringComparer.Ordinal);
protected override async Task OnParametersSetAsync()
{
if (!Visible)
{
_open = false;
return;
}
if (_open) { return; }
_open = true;
await ResetAndResolveAsync();
}
private async Task ResetAndResolveAsync()
{
_token = Guid.Empty;
_opening = false;
_openError = null;
_createGroups = false;
_busy = false;
_commitErrors = new();
_selected.Clear();
_selectedIds.Clear();
_driverType = DriverType;
_mergedConfig = "{}";
_effectiveDeviceId = DeviceId;
_groupPrefix = null;
_canBrowse = false;
_disabledReason = null;
_resolving = true;
StateHasChanged();
try
{
// Merged Driver+Device config — endpoint lives in DeviceConfig in v3, so the browser must dial
// the merged blob (same merge the driver-probe/test-connect path uses).
var merged = await Svc.LoadMergedProbeConfigAsync(DeviceId);
if (merged is { } m)
{
_driverType = m.DriverType;
_mergedConfig = m.MergedConfigJson;
}
// Resolve the target group's device-relative path prefix (prepended to every committed tag).
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
if (ctx is { } c)
{
_groupPrefix = c.GroupPath;
if (string.IsNullOrEmpty(_effectiveDeviceId)) { _effectiveDeviceId = c.DeviceId; }
}
}
// Two-tier gate: bespoke browser OR universal discovery browser can serve this driver+config.
_canBrowse = BrowserService.CanBrowse(_driverType, _mergedConfig);
if (!_canBrowse)
{
_disabledReason =
$"The {_driverType} driver has no online discovery — author tags manually (Add tags ▸ Manual entry).";
}
}
catch (Exception ex)
{
_canBrowse = false;
_disabledReason = $"Could not load device config: {ex.Message}";
}
finally
{
_resolving = false;
StateHasChanged();
}
}
private async Task OpenBrowseAsync()
{
_opening = true;
_openError = null;
StateHasChanged();
try
{
var result = await BrowserService.OpenAsync(_driverType, _mergedConfig, default);
if (result.Ok) { _token = result.Token; }
else { _openError = result.Message; }
}
finally
{
_opening = false;
StateHasChanged();
}
}
private async Task OnLeafToggledAsync(BrowseLeafSelection sel)
{
var nodeId = sel.Leaf.NodeId;
if (_selectedIds.Contains(nodeId))
{
_selectedIds.Remove(nodeId);
_selected.Remove(nodeId);
return;
}
// Resolve the leaf's browse name + driver data type from the attribute side-channel. Universal
// (AbCip/TwinCAT/FOCAS) reports both; the OPC UA Client tree reports nothing (empty) — fall back to
// the display name + the default data type.
string name = sel.Leaf.DisplayName;
string? driverDataType = null;
try
{
var attrs = await BrowserService.AttributesAsync(_token, nodeId, default);
if (attrs.Count > 0)
{
name = string.IsNullOrWhiteSpace(attrs[0].Name) ? sel.Leaf.DisplayName : attrs[0].Name;
driverDataType = attrs[0].DriverDataType;
}
}
catch
{
// Attribute lookup is best-effort — a leaf is still selectable with display-name + default type.
}
_selectedIds.Add(nodeId);
_selected[nodeId] = new SelectedLeaf(nodeId, name, driverDataType, sel.FolderPath);
}
private async Task CommitAsync()
{
_busy = true;
_commitErrors = new();
StateHasChanged();
try
{
var rows = _selected.Values
.Select(s => RawBrowseCommitMapper.MapLeaf(
_driverType, s.NodeId, s.Name, s.DriverDataType, DefaultDataType,
_groupPrefix, s.FolderPath, _createGroups))
.ToList();
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
if (outcome.Errors.Count > 0)
{
_commitErrors = outcome.Errors.ToList();
return;
}
await OnSaved.InvokeAsync();
await CloseAsync();
}
finally
{
_busy = false;
}
}
private async Task CloseAsync()
{
var t = _token;
_token = Guid.Empty;
Visible = false;
_open = false;
await VisibleChanged.InvokeAsync(false);
if (t != Guid.Empty) { await BrowserService.CloseAsync(t); }
}
private string TargetLabel()
=> string.IsNullOrEmpty(_groupPrefix) ? "(device root)" : _groupPrefix!;
private static string Truncate(string s, int max) => s.Length > max ? s[..max] + "…" : s;
public ValueTask DisposeAsync()
{
if (_token != Guid.Empty)
{
// Fire-and-forget — don't block circuit teardown on a slow remote.
_ = BrowserService.CloseAsync(_token);
}
return ValueTask.CompletedTask;
}
private sealed record SelectedLeaf(string NodeId, string Name, string? DriverDataType, IReadOnlyList<string> FolderPath);
}
@@ -0,0 +1,73 @@
@* Reusable confirm dialog for the /raw destructive operations (delete folder / driver / device /
tag-group / tag). Raises OnConfirm when the operator confirms; the caller runs the delete service
call and surfaces any UnsMutationResult.Error (a blocked delete names its blocker) via a follow-up
message. Markup mirrors the other /raw modal shells. *@
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
</div>
<div class="modal-body">
<p class="mb-0">@Message</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel" disabled="@_busy">Cancel</button>
<button type="button" class="btn btn-danger" @onclick="Confirm" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@ConfirmLabel
</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the dialog is shown (supports <c>@bind-Visible</c>).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Two-way visibility callback.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The dialog title (e.g. "Delete driver").</summary>
[Parameter] public string Title { get; set; } = "Confirm";
/// <summary>The confirmation body message.</summary>
[Parameter] public string Message { get; set; } = "";
/// <summary>The confirm-button label.</summary>
[Parameter] public string ConfirmLabel { get; set; } = "Delete";
/// <summary>Raised when the operator confirms. The dialog awaits it, then self-closes.</summary>
[Parameter] public EventCallback OnConfirm { get; set; }
private bool _busy;
private async Task Confirm()
{
_busy = true;
try
{
await OnConfirm.InvokeAsync();
}
finally
{
_busy = false;
}
await Close();
}
private Task Cancel() => Close();
private async Task Close()
{
Visible = false;
await VisibleChanged.InvokeAsync(false);
}
}
@@ -0,0 +1,170 @@
@* CSV tag export for a Raw-tree Device / TagGroup (B2-WP5).
Reads the device's tags (via the WP5-owned RawTagCsvExportReader — no IRawTreeService extension) and
renders them in the same column shape RawCsvImportModal consumes (T0-2 CsvWriter, per-driver
CsvColumnMap). The CSV is offered as a self-contained data-URI download anchor (no JS interop, no
external host — matches the app's CSP posture) plus a preview textarea.
Modal contract (the RawTree coordinator wires this in place of the OnExportCsv stub):
<RawCsvExportModal @bind-Visible="_exportVisible"
DeviceId="@_exportDeviceId" (may be blank if TagGroupId is supplied)
TagGroupId="@_exportTagGroupId" (when set, only that group's subtree is exported)
DriverType="@_exportDriverType" /> *@
@using System.Text
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject RawTagCsvExportReader ExportReader
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Export tags — CSV</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Close"></button>
</div>
<div class="modal-body">
@if (_loading)
{
<p class="text-muted"><span class="spinner-border spinner-border-sm"></span> Reading tags…</p>
}
else if (_error is not null)
{
<div class="alert alert-danger">@_error</div>
}
else
{
<p class="text-muted">
<span class="badge bg-secondary">@_rowCount tags</span>
for <span class="mono">@DriverType</span> device
@if (!string.IsNullOrEmpty(_groupFilter))
{
<span>, group <span class="mono">@_groupFilter</span></span>
}
</p>
<textarea class="form-control mono small" rows="10" readonly>@_csv</textarea>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Close</button>
@if (!_loading && _error is null)
{
<a class="btn btn-primary" download="@_fileName" href="@_dataUri">Download CSV</a>
}
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown (two-way bound).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Raised when <see cref="Visible"/> changes (supports <c>@bind-Visible</c>).</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The device to export. May be blank when <see cref="TagGroupId"/> is supplied.</summary>
[Parameter] public string DeviceId { get; set; } = "";
/// <summary>When set, only tags under this group's subtree are exported; null exports the whole device.</summary>
[Parameter] public string? TagGroupId { get; set; }
/// <summary>The device's driver-type string — selects the per-driver typed column set.</summary>
[Parameter] public string DriverType { get; set; } = "";
private bool _loading;
private bool _built;
private string? _error;
private string _csv = "";
private string _dataUri = "";
private string _fileName = "tags.csv";
private int _rowCount;
private string? _groupFilter;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_built)
{
_built = true;
await BuildAsync();
}
else if (!Visible)
{
_built = false;
}
}
private async Task BuildAsync()
{
_loading = true;
_error = null;
try
{
var deviceId = DeviceId;
_groupFilter = null;
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
if (ctx is { } c)
{
_groupFilter = c.GroupPath;
if (string.IsNullOrEmpty(deviceId))
{
deviceId = c.DeviceId;
}
}
}
if (string.IsNullOrEmpty(deviceId))
{
_error = "Could not resolve the target device.";
return;
}
var tags = await ExportReader.ReadDeviceTagsAsync(deviceId);
// When exporting a single group, keep only that group's subtree (its path + descendants).
if (!string.IsNullOrEmpty(_groupFilter))
{
var prefix = _groupFilter;
tags = tags
.Where(t => t.TagGroupPath is not null &&
(t.TagGroupPath == prefix || t.TagGroupPath.StartsWith(prefix + "/", StringComparison.Ordinal)))
.ToList();
}
_rowCount = tags.Count;
_csv = RawTagCsvMapper.Export(DriverType, tags);
_fileName = BuildFileName(deviceId);
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(_csv));
_dataUri = $"data:text/csv;charset=utf-8;base64,{base64}";
}
catch (Exception ex)
{
_error = ex.Message;
}
finally
{
_loading = false;
}
}
private static string BuildFileName(string deviceId)
{
var safe = new string(deviceId.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray());
return $"tags-{safe}.csv";
}
private async Task Close()
{
_built = false;
_csv = "";
_dataUri = "";
Visible = false;
await VisibleChanged.InvokeAsync(false);
}
}
@@ -0,0 +1,318 @@
@* Staged CSV tag import for a Raw-tree Device / TagGroup (B2-WP5).
Flow: upload a file (InputFile) → parse via the T0-2 CsvParser + the per-driver CsvColumnMap →
a REVIEW GRID with a per-row verdict (OK / the validation error) and, per row, which columns were
typed vs. taken from the TagConfigJson fallback → Commit, which calls IRawTreeService.ImportTagsAsync.
ImportTagsAsync is all-or-nothing, so Commit is BLOCKED while any row is invalid — no partial commit.
Modal contract (the RawTree coordinator wires this in place of the OnImportCsv stub):
<RawCsvImportModal @bind-Visible="_importVisible"
DeviceId="@_importDeviceId" (may be blank if TagGroupId is supplied)
TagGroupId="@_importTagGroupId" (target group; its path is prepended to every row)
DriverType="@_importDriverType"
OnSaved="ReloadNodeChildren" />
For a Device node: DeviceId = node.EntityId, TagGroupId = null.
For a TagGroup node: TagGroupId = node.EntityId (DeviceId is resolved from it if not passed). *@
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IRawTreeService Svc
@inject RawTagCsvExportReader ExportReader
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import tags — CSV</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Close"></button>
</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(_fatal))
{
<div class="alert alert-danger">@_fatal</div>
}
@if (_result is null)
{
<p class="text-muted">
Upload a CSV whose header row carries the columns for a
<span class="mono">@DriverType</span> device.
Target: <span class="mono">@TargetLabel</span>.
</p>
<InputFile OnChange="OnFileSelected" accept=".csv,text/csv" class="form-control" />
@if (_parsing)
{
<p class="text-muted mt-2"><span class="spinner-border spinner-border-sm"></span> Parsing…</p>
}
}
else
{
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<span class="badge bg-secondary">@_result.Rows.Count rows</span>
<span class="badge bg-success">@OkCount valid</span>
@if (ErrorCount > 0)
{
<span class="badge bg-danger">@ErrorCount invalid</span>
}
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="Reset">Choose another file</button>
</div>
@if (ErrorCount > 0)
{
<div class="alert alert-warning py-1">
Import is all-or-nothing — fix or remove the invalid rows before committing.
</div>
}
<div class="table-responsive" style="max-height:50vh;overflow:auto">
<table class="table table-sm table-hover align-middle">
<thead class="sticky-top bg-body">
<tr>
<th style="width:2.5rem"></th>
<th>Row</th>
<th>Name</th>
<th>Group</th>
<th>Verdict</th>
<th>Provenance</th>
<th style="width:2.5rem"></th>
</tr>
</thead>
<tbody>
@foreach (var row in _result.Rows)
{
if (_removed.Contains(row.RowNumber)) { continue; }
<tr class="@(row.Ok ? "" : "table-danger")">
<td>@(row.Ok ? "✓" : "✕")</td>
<td class="mono">@row.RowNumber</td>
<td class="mono">@row.Name</td>
<td class="mono small">@(row.EffectiveGroupPath.Length == 0 ? "(device root)" : row.EffectiveGroupPath)</td>
<td>
@if (row.Ok)
{
<span class="text-success">OK</span>
}
else
{
<span class="text-danger">@row.Error</span>
}
</td>
<td class="small">
@if (row.TypedColumnsUsed.Count > 0)
{
<span class="text-muted">typed: </span>
<span class="mono">@string.Join(", ", row.TypedColumnsUsed)</span>
}
@if (row.FallbackKeys.Count > 0)
{
<br /><span class="text-muted">TagConfigJson: </span>
<span class="mono">@string.Join(", ", row.FallbackKeys)</span>
}
@if (row.TypedOverrodeFallback.Count > 0)
{
<br /><span class="badge bg-info text-dark" title="A typed column overrode the same key in TagConfigJson">
typed wins: @string.Join(", ", row.TypedOverrodeFallback)
</span>
}
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-danger py-0"
title="Remove this row from the import"
@onclick="() => RemoveRow(row.RowNumber)">✕</button>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (_commitErrors.Count > 0)
{
<div class="alert alert-danger mt-2">
<strong>Commit rejected:</strong>
<ul class="mb-0">
@foreach (var e in _commitErrors)
{
<li>@e</li>
}
</ul>
</div>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Cancel</button>
<button type="button" class="btn btn-primary"
disabled="@(!CanCommit || _committing)"
@onclick="Commit">
@if (_committing)
{
<span class="spinner-border spinner-border-sm"></span>
}
Commit @RemainingCount tag@(RemainingCount == 1 ? "" : "s")
</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown (two-way bound).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Raised when <see cref="Visible"/> changes (supports <c>@bind-Visible</c>).</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The target device's logical id. May be blank when <see cref="TagGroupId"/> is supplied
/// (the device is then resolved from the group).</summary>
[Parameter] public string DeviceId { get; set; } = "";
/// <summary>The target tag-group's logical id, or null to import at the device root. Its device-relative
/// path is prepended to every imported row's <c>TagGroupPath</c>.</summary>
[Parameter] public string? TagGroupId { get; set; }
/// <summary>The device's driver-type string — selects the per-driver typed column set.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Raised after a successful commit so the coordinator can reload the node's children.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private RawCsvParseResult? _result;
private readonly HashSet<int> _removed = new();
private List<string> _commitErrors = new();
private string? _fatal;
private bool _parsing;
private bool _committing;
private bool _resolved;
private string _effectiveDeviceId = "";
private string? _groupPrefix;
private string TargetLabel =>
string.IsNullOrEmpty(_groupPrefix) ? _effectiveDeviceId : $"{_effectiveDeviceId} / {_groupPrefix}";
private int OkCount => _result?.Rows.Count(r => r.Ok && !_removed.Contains(r.RowNumber)) ?? 0;
private int ErrorCount => _result?.Rows.Count(r => !r.Ok && !_removed.Contains(r.RowNumber)) ?? 0;
private int RemainingCount => _result?.Rows.Count(r => !_removed.Contains(r.RowNumber)) ?? 0;
// Commit is unblocked only when every remaining row is valid AND at least one row remains.
private bool CanCommit =>
_result is not null && _fatal is null && RemainingCount > 0 && ErrorCount == 0;
protected override async Task OnParametersSetAsync()
{
if (Visible && !_resolved)
{
await ResolveTargetAsync();
_resolved = true;
}
else if (!Visible)
{
_resolved = false;
}
}
// Resolve the effective device id + the group-path prefix once per open.
private async Task ResolveTargetAsync()
{
_effectiveDeviceId = DeviceId;
_groupPrefix = null;
if (!string.IsNullOrEmpty(TagGroupId))
{
var ctx = await ExportReader.ResolveGroupContextAsync(TagGroupId!);
if (ctx is { } c)
{
_groupPrefix = c.GroupPath;
if (string.IsNullOrEmpty(_effectiveDeviceId))
{
_effectiveDeviceId = c.DeviceId;
}
}
}
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
_fatal = null;
_commitErrors = new();
_removed.Clear();
_parsing = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 16 * 1024 * 1024);
using var reader = new StreamReader(stream);
var text = await reader.ReadToEndAsync();
_result = RawTagCsvMapper.Parse(DriverType, text, _groupPrefix);
_fatal = _result.FatalError;
}
catch (Exception ex)
{
_fatal = $"Could not read the file: {ex.Message}";
_result = null;
}
finally
{
_parsing = false;
}
}
private void RemoveRow(int rowNumber) => _removed.Add(rowNumber);
private void Reset()
{
_result = null;
_fatal = null;
_commitErrors = new();
_removed.Clear();
}
private async Task Commit()
{
if (_result is null || !CanCommit)
{
return;
}
_committing = true;
_commitErrors = new();
try
{
var rows = _result.Rows
.Where(r => r is { Ok: true, ImportRow: not null } && !_removed.Contains(r.RowNumber))
.Select(r => r.ImportRow!)
.ToList();
var outcome = await Svc.ImportTagsAsync(_effectiveDeviceId, rows);
if (outcome.Errors.Count > 0)
{
_commitErrors = outcome.Errors.ToList();
return;
}
await OnSaved.InvokeAsync();
await Close();
}
catch (Exception ex)
{
_commitErrors = new() { ex.Message };
}
finally
{
_committing = false;
}
}
private async Task Close()
{
Reset();
Visible = false;
_resolved = false;
await VisibleChanged.InvokeAsync(false);
}
}
@@ -0,0 +1,105 @@
@* Driver-type picker for the /raw "New driver" action: pick a driver type + name, then the caller
creates a minimal driver (CreateDriverAsync with "{}") and the operator configures it afterwards via
the Configure-driver modal. Calculation is included so a Calculation driver row is authorable now
(its factory lands in Wave C). Name is inline-validated as a RawPath segment. Markup mirrors the other
/raw modal shells. *@
@using ZB.MOM.WW.OtOpcUa.Commons.Types
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New driver</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="raw-drv-type">Driver type</label>
<select id="raw-drv-type" class="form-select form-select-sm" @bind="_type">
@foreach (var t in Types)
{
<option value="@t.Value">@t.Label</option>
}
</select>
</div>
<div class="mb-1">
<label class="form-label" for="raw-drv-name">Name</label>
<input id="raw-drv-name" class="form-control form-control-sm mono"
@bind="_name" @bind:event="oninput" placeholder="line3-modbus" />
<div class="form-text">A RawPath segment — no '/', no leading or trailing spaces.</div>
</div>
@if (_error is not null)
{
<div class="text-danger small mt-1">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Submit">Create</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the dialog is shown (supports <c>@bind-Visible</c>).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Two-way visibility callback.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>Raised with the chosen driver type + name when the operator confirms; the dialog self-closes after.</summary>
[Parameter] public EventCallback<(string DriverType, string Name)> OnSubmit { get; set; }
// Label → DriverType value. Galaxy's factory registers as "GalaxyMxGateway"; Calculation is
// authorable now though its factory arrives in Wave C.
private static readonly (string Label, string Value)[] Types =
[
("Modbus", DriverTypeNames.Modbus),
("S7", DriverTypeNames.S7),
("AbCip", DriverTypeNames.AbCip),
("AbLegacy", DriverTypeNames.AbLegacy),
("TwinCAT", DriverTypeNames.TwinCAT),
("FOCAS", DriverTypeNames.FOCAS),
("OpcUaClient", DriverTypeNames.OpcUaClient),
("Galaxy", DriverTypeNames.Galaxy),
("Calculation", "Calculation"),
];
private string _type = DriverTypeNames.Modbus;
private string _name = "";
private string? _error;
private bool _open; // seed once per open; preserve edits across unrelated re-renders
protected override void OnParametersSet()
{
if (!Visible) { _open = false; return; }
if (_open) { return; }
_open = true;
_type = DriverTypeNames.Modbus;
_name = "";
_error = null;
}
private async Task Submit()
{
var err = RawPaths.ValidateSegment(_name);
if (err is not null) { _error = err; return; }
await OnSubmit.InvokeAsync((_type, _name));
await Close();
}
private Task Cancel() => Close();
private async Task Close()
{
Visible = false;
_open = false;
await VisibleChanged.InvokeAsync(false);
}
}
@@ -0,0 +1,289 @@
@* Bulk manual tag-entry grid for the v3 /raw tree: add MANY new tags at once under a Device or a
TagGroup. Each row carries Name / DataType / AccessLevel / WriteIdempotent / PollGroup plus a
per-row driver-typed TagConfig editor (revealed in an expandable full-width sub-row — the grid stays
compact). The owning DriverType (fixed by the device) drives the typed-editor dispatch. On commit each
row is created via CreateTagAsync; committed rows drop off, failed rows stay with their inline error so
nothing is lost silently. Visibility is @bind-Visible; a successful commit raises OnSaved. *@
@using Microsoft.AspNetCore.Components.Forms
@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
@inject IRawTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-xl" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add tags — manual entry <span class="text-muted small">(@DriverType)</span></h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th style="width:22%">Name</th>
<th style="width:14%">Data type</th>
<th style="width:14%">Access</th>
<th style="width:12%">WriteIdemp.</th>
<th style="width:18%">PollGroup</th>
<th style="width:12%">Config</th>
<th style="width:8%"></th>
</tr>
</thead>
<tbody>
@foreach (var row in _rows)
{
<tr @key="row.Key">
<td>
<input class="form-control form-control-sm" placeholder="TagName"
value="@row.Name" @onchange="@(e => row.Name = e.Value?.ToString() ?? "")" />
@if (row.Error is not null)
{
<div class="text-danger small">@row.Error</div>
}
</td>
<td>
<select class="form-select form-select-sm" value="@row.DataType"
@onchange="@(e => row.DataType = e.Value?.ToString() ?? "Float")">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</select>
</td>
<td>
<select class="form-select form-select-sm" value="@row.AccessLevel"
@onchange="@(e => row.AccessLevel = ParseAccess(e.Value))">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
</select>
</td>
<td class="text-center">
<input type="checkbox" class="form-check-input" checked="@row.WriteIdempotent"
@onchange="@(e => row.WriteIdempotent = e.Value is true)" />
</td>
<td>
<input class="form-control form-control-sm mono" placeholder="(optional)"
value="@row.PollGroupId" @onchange="@(e => row.PollGroupId = e.Value?.ToString())" />
</td>
<td>
<button type="button" class="btn btn-sm btn-outline-secondary"
@onclick="@(() => row.Expanded = !row.Expanded)">
@(row.Expanded ? "Hide" : "Configure")
</button>
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger"
@onclick="@(() => RemoveRow(row))" disabled="@(_rows.Count == 1)">✕</button>
</td>
</tr>
@if (row.Expanded)
{
<tr @key="@(row.Key + "-cfg")">
<td colspan="7">
<div class="border rounded p-2 bg-body-tertiary">
@{
var editorType = TagConfigEditorMap.Resolve(DriverType);
}
@if (editorType is not null)
{
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters(row)" />
}
else
{
<textarea class="form-control form-control-sm mono" rows="4"
placeholder='{ "FullName": "tag_name.AttributeName" }'
value="@row.TagConfig"
@onchange="@(e => row.TagConfig = e.Value?.ToString() ?? "{}")"></textarea>
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
}
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" @onclick="AddRow">+ Add row</button>
@if (!string.IsNullOrWhiteSpace(_summary))
{
<div class="alert @(_summaryOk ? "alert-success" : "alert-warning") py-2 mt-3 mb-0">@_summary</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="CommitAsync" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Create @_rows.Count tag@(_rows.Count == 1 ? "" : "s")
</button>
</div>
</div>
</div>
</div>
}
@code {
private static readonly string[] DataTypes =
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
/// <summary>Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Raised when <see cref="Visible"/> changes (self-close). Completes the @bind-Visible contract.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The device the new tags are created under.</summary>
[Parameter] public string DeviceId { get; set; } = "";
/// <summary>The containing tag group, or null to create the tags directly under the device.</summary>
[Parameter] public string? TagGroupId { get; set; }
/// <summary>The owning device's driver type, used for typed TagConfig-editor dispatch.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Raised after at least one tag is committed so the host can refresh the tree.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private readonly List<RowModel> _rows = new();
private bool _busy;
private string? _summary;
private bool _summaryOk;
private bool _open; // guards re-seeding rows on unrelated re-renders
protected override void OnParametersSet()
{
if (!Visible)
{
_open = false;
return;
}
if (_open) { return; } // same open, re-render → preserve in-progress rows
_open = true;
_rows.Clear();
_rows.Add(new RowModel());
_summary = null;
_summaryOk = false;
}
private IDictionary<string, object> BuildEditorParameters(RowModel row) => new Dictionary<string, object>
{
["ConfigJson"] = row.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => row.TagConfig = v),
["DriverType"] = DriverType,
["GetDriverConfigJson"] = (Func<string>)(() => "{}"),
};
private void AddRow() => _rows.Add(new RowModel());
private void RemoveRow(RowModel row)
{
if (_rows.Count > 1) { _rows.Remove(row); }
}
private static TagAccessLevel ParseAccess(object? v)
=> Enum.TryParse<TagAccessLevel>(v?.ToString(), out var a) ? a : TagAccessLevel.Read;
private async Task CommitAsync()
{
_busy = true;
_summary = null;
try
{
// --- Client-side validation first: name segment + per-driver config + intra-grid dup names. ---
foreach (var row in _rows) { row.Error = null; }
var seen = new HashSet<string>(RawPaths.Comparer);
var anyClientError = false;
foreach (var row in _rows)
{
var nameError = RawPaths.ValidateSegment(row.Name);
if (nameError is not null) { row.Error = nameError; anyClientError = true; continue; }
if (!seen.Add(row.Name)) { row.Error = "Duplicate name in this batch."; anyClientError = true; continue; }
var configError = TagConfigValidator.Validate(DriverType, row.TagConfig);
if (configError is not null) { row.Error = configError; anyClientError = true; }
}
if (anyClientError)
{
_summaryOk = false;
_summary = "Fix the highlighted rows and try again.";
return;
}
// --- Commit each row; keep failures (with their error), drop committed rows. ---
var created = 0;
var failed = 0;
foreach (var row in _rows)
{
var input = new RawTagInput(
row.Name,
row.DataType,
row.AccessLevel,
row.WriteIdempotent,
string.IsNullOrWhiteSpace(row.PollGroupId) ? null : row.PollGroupId,
string.IsNullOrWhiteSpace(row.TagConfig) ? "{}" : row.TagConfig);
var result = await Svc.CreateTagAsync(DeviceId, TagGroupId, input);
if (result.Ok)
{
row.Committed = true;
created++;
}
else
{
row.Error = result.Error;
failed++;
}
}
_rows.RemoveAll(r => r.Committed);
if (failed == 0)
{
if (created > 0) { await OnSaved.InvokeAsync(); }
await CloseAsync();
}
else
{
// Some succeeded, some failed: refresh the tree for the good ones, keep the modal open on
// the failed rows so the operator can correct them without re-entering the whole batch.
if (created > 0) { await OnSaved.InvokeAsync(); }
_summaryOk = false;
_summary = $"{created} tag(s) created; {failed} failed. Fix the highlighted rows and retry.";
}
}
finally
{
_busy = false;
}
}
private async Task CloseAsync()
{
Visible = false;
_open = false;
await VisibleChanged.InvokeAsync(false);
}
private sealed class RowModel
{
public string Key { get; } = Guid.NewGuid().ToString("N");
public string Name { get; set; } = "";
public string DataType { get; set; } = "Float";
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
public bool WriteIdempotent { get; set; }
public string? PollGroupId { get; set; }
public string TagConfig { get; set; } = "{}";
public bool Expanded { get; set; }
public bool Committed { get; set; }
public string? Error { get; set; }
}
}
@@ -0,0 +1,88 @@
@* Reusable name-input dialog for the /raw create + rename operations (New folder / New tag-group /
New group / Rename folder-driver-device-tag-group). Inline-validates the input as a RawPath segment
(RawPaths.ValidateSegment) before raising OnSubmit; a valid submit self-closes the dialog and the
caller runs the create/rename service call + surfaces any server-side error/warnings. Markup mirrors
the other /raw modal shells (RawStubModal). *@
@using ZB.MOM.WW.OtOpcUa.Commons.Types
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="Cancel"></button>
</div>
<div class="modal-body">
<label class="form-label" for="raw-name-input">Name</label>
<input id="raw-name-input" class="form-control form-control-sm mono"
@bind="_value" @bind:event="oninput" @onkeydown="OnKeyDown"
placeholder="segment-name" />
<div class="form-text">A RawPath segment — no '/', no leading or trailing spaces.</div>
@if (_error is not null)
{
<div class="text-danger small mt-1">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="Submit">OK</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the dialog is shown (supports <c>@bind-Visible</c>).</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Two-way visibility callback.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The dialog title (e.g. "New folder", "Rename driver").</summary>
[Parameter] public string Title { get; set; } = "Name";
/// <summary>The initial value seeded into the input on open (blank for create, current name for rename).</summary>
[Parameter] public string Value { get; set; } = "";
/// <summary>Raised with the validated name when the operator confirms. The dialog self-closes after.</summary>
[Parameter] public EventCallback<string> OnSubmit { get; set; }
private string _value = "";
private string? _error;
private bool _open; // seed the input once per open; preserve typing across unrelated re-renders
protected override void OnParametersSet()
{
if (!Visible) { _open = false; return; }
if (_open) { return; }
_open = true;
_value = Value ?? "";
_error = null;
}
private async Task Submit()
{
var err = RawPaths.ValidateSegment(_value);
if (err is not null) { _error = err; return; }
await OnSubmit.InvokeAsync(_value);
await Close();
}
private async Task OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter") { await Submit(); }
}
private Task Cancel() => Close();
private async Task Close()
{
Visible = false;
_open = false;
await VisibleChanged.InvokeAsync(false);
}
}
@@ -0,0 +1,49 @@
@* Placeholder modal for the /raw context-menu actions whose real dialogs
(Configure / Device / TagModal / CSV import-export / Browse device / Rename)
land in later Batch-2 waves (B/C). This wave wires every menu action to a
named handler that opens this "coming soon" placeholder, so the tree + lazy
expansion + menus can be live-verified without the real modals existing yet.
Wave B/C replaces each handler body with the real modal invocation and can
then delete this component. Markup mirrors the GlobalUns delete-confirm modal. *@
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@Title</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="OnClose"></button>
</div>
<div class="modal-body">
<p>@Message</p>
@if (!string.IsNullOrWhiteSpace(NodeName))
{
<p class="text-muted small mb-0">Target: <span class="mono">@NodeName</span></p>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="OnClose">Close</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the placeholder is shown.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Modal title — the action name (e.g. "Configure driver").</summary>
[Parameter] public string Title { get; set; } = "";
/// <summary>"Coming soon" body message.</summary>
[Parameter] public string Message { get; set; } = "";
/// <summary>The target node's display name, shown as context.</summary>
[Parameter] public string? NodeName { get; set; }
/// <summary>Raised when the placeholder is dismissed.</summary>
[Parameter] public EventCallback OnClose { get; set; }
}
@@ -0,0 +1,257 @@
@* Edit modal for a single raw Tag in the v3 /raw tree. Unlike the equipment TagModal there is NO
driver selector — the driver is fixed by the device the tag lives under, so the owning DriverType is
passed in and drives the typed TagConfig editor dispatch (TagConfigEditorMap; raw-JSON fallback for
unmapped drivers, e.g. Galaxy). Edit-only: the host opens it for an existing tree node (TagId), it
loads via LoadTagForEditAsync, and saves via UpdateTagAsync. Visibility is @bind-Visible; a successful
save raises OnSaved so the coordinator can refresh the tree. *@
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@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
@inject IRawTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="rawTagModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">Edit tag</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseAsync"></button>
</div>
<div class="modal-body">
@if (_loadError is not null)
{
<div class="alert alert-danger py-2">@_loadError</div>
}
else
{
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="raw-tag-name">Name</label>
<InputText id="raw-tag-name" @bind-Value="_form.Name" class="form-control form-control-sm"
placeholder="Temperature" />
<ValidationMessage For="@(() => _form.Name)" />
@if (_nameError is not null)
{
<div class="text-danger small">@_nameError</div>
}
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="raw-tag-dtype">Data type</label>
<InputSelect id="raw-tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="raw-tag-access">Access level</label>
<InputSelect id="raw-tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">WriteIdempotent</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.WriteIdempotent" class="form-check-input" />
<label class="form-check-label">Safe to retry writes</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="raw-tag-pgroup">PollGroupId (optional)</label>
<InputText id="raw-tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
</div>
<div class="mb-3">
<label class="form-label">Tag config <span class="text-muted small">(@DriverType)</span></label>
@{
var editorType = TagConfigEditorMap.Resolve(DriverType);
}
@if (editorType is not null)
{
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
}
else
{
<InputTextArea id="raw-tag-config" @bind-Value="_form.TagConfig" rows="6"
class="form-control form-control-sm mono"
placeholder='{ "FullName": "tag_name.AttributeName" }' />
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
}
</div>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@(_busy || _loadError is not null)">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save changes
</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
private static readonly string[] DataTypes =
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
/// <summary>Whether the modal is shown. Two-way (@bind-Visible) so the modal can self-close.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>Raised when <see cref="Visible"/> changes (self-close). Completes the @bind-Visible contract.</summary>
[Parameter] public EventCallback<bool> VisibleChanged { get; set; }
/// <summary>The raw tag to edit. The modal loads it via <c>LoadTagForEditAsync</c> on open.</summary>
[Parameter] public string? TagId { get; set; }
/// <summary>The owning device's driver type, used for typed TagConfig-editor dispatch.</summary>
[Parameter] public string? DriverType { get; set; }
/// <summary>Raised after a successful save so the host can refresh the tree.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
private FormModel _form = new();
private byte[] _rowVersion = Array.Empty<byte>();
private bool _busy;
private string? _error;
private string? _nameError;
private string? _loadError;
// Tracks which (open, tag) this modal last loaded for, so unrelated Blazor Server re-renders don't
// reload + clobber in-progress edits. Null while closed.
private string? _loadedKey;
protected override async Task OnParametersSetAsync()
{
if (!Visible)
{
_loadedKey = null; // closed → next open reloads fresh
return;
}
var key = TagId;
if (key == _loadedKey) { return; } // same open, re-render → preserve in-progress edits
_loadedKey = key;
_error = null;
_nameError = null;
_loadError = null;
if (string.IsNullOrEmpty(TagId))
{
_loadError = "No tag selected.";
return;
}
var dto = await Svc.LoadTagForEditAsync(TagId);
// RawTagEditDto is a record struct → a missing tag comes back as default (all-zero), not null.
if (dto is not { } d || string.IsNullOrEmpty(d.TagId))
{
_loadError = "This tag no longer exists.";
return;
}
_form = new FormModel
{
Name = d.Name,
DataType = string.IsNullOrEmpty(d.DataType) ? "Float" : d.DataType,
AccessLevel = d.AccessLevel,
WriteIdempotent = d.WriteIdempotent,
PollGroupId = d.PollGroupId,
TagConfig = string.IsNullOrWhiteSpace(d.TagConfig) ? "{}" : d.TagConfig,
};
_rowVersion = d.RowVersion;
}
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
{
["ConfigJson"] = _form.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
["DriverType"] = DriverType ?? "",
["GetDriverConfigJson"] = (Func<string>)(() => "{}"),
};
private async Task SaveAsync()
{
_busy = true;
_error = null;
_nameError = null;
try
{
// Client-side name validation (RawPath segment) so a bad name is caught before the round-trip.
var nameError = RawPaths.ValidateSegment(_form.Name);
if (nameError is not null)
{
_nameError = nameError;
return;
}
// Client-side per-driver config validation (the typed editor's Validate()).
var configError = TagConfigValidator.Validate(DriverType, _form.TagConfig);
if (configError is not null)
{
_error = configError;
return;
}
var input = new RawTagInput(
_form.Name,
_form.DataType,
_form.AccessLevel,
_form.WriteIdempotent,
string.IsNullOrWhiteSpace(_form.PollGroupId) ? null : _form.PollGroupId,
string.IsNullOrWhiteSpace(_form.TagConfig) ? "{}" : _form.TagConfig);
var result = await Svc.UpdateTagAsync(TagId!, input, _rowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
await CloseAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private async Task CloseAsync()
{
Visible = false;
_loadedKey = null;
await VisibleChanged.InvokeAsync(false);
}
private sealed class FormModel
{
[Required] public string Name { get; set; } = "";
public string DataType { get; set; } = "Float";
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
public bool WriteIdempotent { get; set; }
public string? PollGroupId { get; set; }
[Required] public string TagConfig { get; set; } = "{}";
}
}
@@ -0,0 +1,878 @@
@* Recursive, lazily-expanding renderer for the v3 Raw project tree (/raw).
Unlike UnsTree (which eager-loads the whole structural tree and never touches
the RawNode lazy fields), THIS component actually exercises the lazy plumbing:
expanding an unloaded container calls IRawTreeService.LoadChildrenAsync, appends
the returned level into node.Children and flips Loaded/Loading, showing a spinner
in flight and an error row on failure. Per-node context menus (right-click surface
+ "⋯" fallback) are built per RawNodeKind.
Wave-B integration (this pass): every named handler now opens the REAL modal/dialog
(Configure driver/device, Edit/Manual/CSV tag surfaces) or calls IRawTreeService
directly (New folder/driver/group, Rename, Delete, Toggle) via the small dialogs
built alongside — RawNameDialog / RawConfirmDialog / RawDriverTypeDialog — and
refreshes the affected subtree afterwards (reload the container for create-under
ops, the parent for item-level ops). Only Browse device stays a placeholder (Wave C
owns it). The former RawStubModal is repurposed here as the shared message surface
for delete-blockers, rename warnings, and the Browse "coming soon" note. This
component is a single instance that recurses via the RenderNode RenderFragment, so it
owns one set of modals + one injected service for the whole tree. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@inject IRawTreeService Svc
@foreach (var root in Roots)
{
@RenderNode(root, 0)
}
@* --- Real modals + dialogs (one instance each for the whole recursive tree) --- *@
<DriverConfigModal @bind-Visible="_driverCfgVisible" DriverInstanceId="@_driverCfgId" OnSaved="DriverCfgSaved" />
<DeviceModal @bind-Visible="_deviceVisible" DeviceId="@_deviceEditId"
DriverInstanceId="@_deviceCreateDriverId" DriverType="@_deviceDriverType" OnSaved="DeviceSaved" />
<RawTagModal @bind-Visible="_tagVisible" TagId="@_tagId" DriverType="@_tagDriverType" OnSaved="TagSaved" />
<RawManualTagEntryModal @bind-Visible="_manualVisible" DeviceId="@_manualDeviceId"
TagGroupId="@_manualTagGroupId" DriverType="@_manualDriverType" OnSaved="ManualSaved" />
<RawCsvImportModal @bind-Visible="_csvImportVisible" DeviceId="@_csvImportDeviceId"
TagGroupId="@_csvImportTagGroupId" DriverType="@_csvImportDriverType" OnSaved="CsvImportSaved" />
<RawCsvExportModal @bind-Visible="_csvExportVisible" DeviceId="@_csvExportDeviceId"
TagGroupId="@_csvExportTagGroupId" DriverType="@_csvExportDriverType" />
<RawBrowseModal @bind-Visible="_browseVisible" DeviceId="@_browseDeviceId"
TagGroupId="@_browseTagGroupId" DriverType="@_browseDriverType" OnSaved="BrowseSaved" />
<RawNameDialog @bind-Visible="_nameVisible" Title="@_nameTitle" Value="@_nameInitial" OnSubmit="NameSubmitted" />
<RawConfirmDialog @bind-Visible="_confirmVisible" Title="@_confirmTitle" Message="@_confirmMessage"
OnConfirm="Confirmed" />
<RawDriverTypeDialog @bind-Visible="_driverTypeVisible" OnSubmit="DriverTypeSubmitted" />
@* Shared message surface (repurposed RawStubModal) — delete blockers, rename warnings, Browse note. *@
<RawStubModal Visible="_msgVisible" Title="@_msgTitle" Message="@_msgMessage"
NodeName="@_msgNodeName" OnClose="CloseMessage" />
@code {
/// <summary>The top-level Raw nodes to render (the enterprise roots from <c>LoadRootsAsync</c>).</summary>
[Parameter, EditorRequired] public IReadOnlyList<RawNode> Roots { get; set; } = default!;
/// <summary>
/// Optional safety valve: raised when a mutation's affected node has no in-tree parent to reload
/// (only reachable for a cluster-less top-level node). Every menu-bearing node's parent is in the
/// tree today, so this is normally a no-op; the page may wire it to reload roots if that ever changes.
/// </summary>
[Parameter] public EventCallback OnRootsChanged { get; set; }
// ---------------------------------------------------------------------------
// v3 Batch 3 — raw-tag PICKER mode (opt-in; default off keeps the /raw usage byte-unchanged).
// In picker mode the mutation context-menus are suppressed: Tag leaves render a multi-select
// checkbox, and Device/TagGroup containers offer a "select all tags below" menu. The equipment
// page's "+ Add reference" modal drives this against a single cluster-scoped root.
// ---------------------------------------------------------------------------
/// <summary>When <c>true</c>, render the tree as a raw-tag picker (checkboxes + select-all) instead of
/// the editing tree. Default <c>false</c> — the /raw editing usage is unaffected.</summary>
[Parameter] public bool PickerMode { get; set; }
/// <summary>The shared, caller-owned set of selected raw <c>TagId</c>s (picker mode only). The component
/// mutates it in place and raises <see cref="OnSelectionChanged"/> after every change.</summary>
[Parameter] public HashSet<string>? SelectedTagIds { get; set; }
/// <summary>Raised after the selection set changes (picker mode) so the host can update its count / footer.</summary>
[Parameter] public EventCallback OnSelectionChanged { get; set; }
/// <summary>Supplies the raw <c>TagId</c>s beneath a Device/TagGroup node for the "select all tags below"
/// affordance (picker mode). The host wires this to <c>IUnsTreeService.LoadDescendantTagIdsAsync</c>.</summary>
[Parameter] public Func<RawNode, Task<IReadOnlyList<string>>>? ResolveDescendantTagIds { get; set; }
// --- Configure-driver modal (edit) ---
private bool _driverCfgVisible;
private string? _driverCfgId;
private RawNode? _driverCfgNode;
// --- Device modal (create + edit) ---
private bool _deviceVisible;
private string? _deviceEditId;
private string? _deviceCreateDriverId;
private string? _deviceDriverType;
private RawNode? _deviceNode;
private bool _deviceIsCreate;
// --- Edit-tag modal ---
private bool _tagVisible;
private string? _tagId;
private string? _tagDriverType;
private RawNode? _tagNode;
// --- Manual tag-entry modal ---
private bool _manualVisible;
private string _manualDeviceId = "";
private string? _manualTagGroupId;
private string _manualDriverType = "";
private RawNode? _manualNode;
// --- CSV import modal ---
private bool _csvImportVisible;
private string _csvImportDeviceId = "";
private string? _csvImportTagGroupId;
private string _csvImportDriverType = "";
private RawNode? _csvImportNode;
// --- CSV export modal (read-only, no refresh) ---
private bool _csvExportVisible;
private string _csvExportDeviceId = "";
private string? _csvExportTagGroupId;
private string _csvExportDriverType = "";
// --- Browse-device modal (WP6 discovery-browser re-target) ---
private bool _browseVisible;
private string _browseDeviceId = "";
private string? _browseTagGroupId;
private string _browseDriverType = "";
private RawNode? _browseNode;
// --- Name dialog (New folder/group, Rename) ---
private bool _nameVisible;
private string _nameTitle = "";
private string _nameInitial = "";
private Func<string, Task>? _nameSubmit;
// --- Confirm dialog (deletes) ---
private bool _confirmVisible;
private string _confirmTitle = "";
private string _confirmMessage = "";
private Func<Task>? _confirmAction;
// --- Driver-type dialog (New driver) ---
private bool _driverTypeVisible;
private Func<(string DriverType, string Name), Task>? _driverTypeSubmit;
// --- Shared message surface (repurposed RawStubModal): delete blockers, rename warnings, Browse note ---
private bool _msgVisible;
private string _msgTitle = "";
private string _msgMessage = "";
private string? _msgNodeName;
// ---------------------------------------------------------------------------
// Row rendering
// ---------------------------------------------------------------------------
private RenderFragment RenderNode(RawNode node, int depth) => __builder =>
{
var indent = $"padding-left:{depth * 18}px";
var hasExpander = node.HasLazyChildren || node.Children.Count > 0;
var muted = node.Enabled == false && node.Kind is RawNodeKind.Driver or RawNodeKind.Device;
var items = MenuFor(node);
<div @key="node.Key" class="d-flex align-items-center gap-1 py-1" style="@indent">
@if (hasExpander)
{
<button type="button" class="btn btn-sm btn-link p-0" style="width:18px"
@onclick="@(() => ToggleAsync(node))">
@(node.Expanded ? "▼" : "▶")
</button>
}
else
{
<span style="width:18px"></span>
}
@if (items.Count > 0)
{
<ContextMenu Items="items" ShowEllipsis="true">
@RenderRowBody(node, muted)
</ContextMenu>
}
else
{
@RenderRowBody(node, muted)
}
</div>
@if (node.Expanded && node.Loading)
{
<div class="small text-muted" style="@($"padding-left:{(depth + 1) * 18}px")">
<span class="spinner-border spinner-border-sm me-1"></span>Loading&hellip;
</div>
}
else if (node.Expanded && node.Error is not null)
{
<div class="small text-danger" style="@($"padding-left:{(depth + 1) * 18}px")">
@node.Error
</div>
}
else if (node.Expanded)
{
@foreach (var child in node.Children)
{
@RenderNode(child, depth + 1)
}
}
};
// The icon + label + badge shown for a node — the ContextMenu right-click surface.
private RenderFragment RenderRowBody(RawNode node, bool muted) => __builder =>
{
<span class="d-inline-flex align-items-center gap-1">
@if (PickerMode && node.Kind == RawNodeKind.Tag)
{
<input type="checkbox" class="form-check-input me-1"
checked="@IsTagSelected(node)"
@onchange="@(e => ToggleTagSelection(node, e.Value is true))" />
}
<span aria-hidden="true">@KindIcon(node.Kind)</span>
<span class="mono small @(muted ? "text-muted" : "")">@node.DisplayName</span>
@if (muted)
{
<span class="chip chip-idle ms-1" style="font-size:0.65rem">disabled</span>
}
@if (node.ChildCount > 0)
{
<span class="chip chip-idle ms-1" style="font-size:0.7rem">@node.ChildCount</span>
}
</span>
};
private static string KindIcon(RawNodeKind kind) => kind switch
{
RawNodeKind.Enterprise => "🏢",
RawNodeKind.Cluster => "🖥️",
RawNodeKind.Folder => "📁",
RawNodeKind.Driver => "🔌",
RawNodeKind.Device => "📟",
RawNodeKind.TagGroup => "🗂️",
RawNodeKind.Tag => "🏷️",
_ => "•",
};
// ---------------------------------------------------------------------------
// Lazy expansion — the whole point of WP2 (UNS never did this).
// ---------------------------------------------------------------------------
/// <summary>
/// Toggles a node's expansion. On first expand of an unloaded container this sets
/// <c>Loading</c>, awaits <see cref="IRawTreeService.LoadChildrenAsync"/>, appends the
/// returned level into <c>Children</c>, flips <c>Loaded</c>, and re-renders — showing a
/// spinner in flight and an error row on failure. A leaf (Tag) has no expander so never
/// reaches here.
/// </summary>
private async Task ToggleAsync(RawNode node)
{
if (node.Loading) { return; }
node.Expanded = !node.Expanded;
if (node.Expanded && node.HasLazyChildren && !node.Loaded)
{
node.Loading = true;
node.Error = null;
StateHasChanged();
try
{
var children = await Svc.LoadChildrenAsync(node);
node.Children.Clear();
node.Children.AddRange(children);
node.ChildCount = node.Children.Count;
node.Loaded = true;
}
catch (Exception ex)
{
node.Error = ex.Message;
}
finally
{
node.Loading = false;
}
StateHasChanged();
}
}
// ---------------------------------------------------------------------------
// Per-kind context menus. Every OnClick opens a placeholder in this wave; the
// named handlers below are the seam Wave B/C replaces with the real modals.
// ---------------------------------------------------------------------------
private IReadOnlyList<ContextMenuItem> MenuFor(RawNode node)
{
// Picker mode: no mutation menus. Device/TagGroup containers get a "select all tags below"
// affordance; every other kind (and Tag leaves, which carry the checkbox) has no menu.
if (PickerMode)
{
return node.Kind is RawNodeKind.Device or RawNodeKind.TagGroup
? PickerContainerMenu(node)
: Array.Empty<ContextMenuItem>();
}
return node.Kind switch
{
RawNodeKind.Cluster => ClusterMenu(node),
RawNodeKind.Folder => FolderMenu(node),
RawNodeKind.Driver => DriverMenu(node),
RawNodeKind.Device => DeviceMenu(node),
RawNodeKind.TagGroup => TagGroupMenu(node),
RawNodeKind.Tag => TagMenu(node),
_ => Array.Empty<ContextMenuItem>(), // Enterprise: label only, no menu.
};
}
// --- Picker-mode menu + selection helpers ---
private List<ContextMenuItem> PickerContainerMenu(RawNode node) => new()
{
new() { Label = "Select all tags below", Icon = "☑", OnClick = () => SelectAllUnderAsync(node) },
new() { Label = "Clear all tags below", Icon = "☐", OnClick = () => ClearAllUnderAsync(node) },
};
private bool IsTagSelected(RawNode node) =>
node.EntityId is not null && SelectedTagIds is not null && SelectedTagIds.Contains(node.EntityId);
private async Task ToggleTagSelection(RawNode node, bool selected)
{
if (SelectedTagIds is null || node.EntityId is null) { return; }
if (selected) { SelectedTagIds.Add(node.EntityId); }
else { SelectedTagIds.Remove(node.EntityId); }
await OnSelectionChanged.InvokeAsync();
StateHasChanged();
}
private async Task SelectAllUnderAsync(RawNode node)
{
if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
var ids = await ResolveDescendantTagIds(node);
foreach (var id in ids) { SelectedTagIds.Add(id); }
await OnSelectionChanged.InvokeAsync();
StateHasChanged();
}
private async Task ClearAllUnderAsync(RawNode node)
{
if (SelectedTagIds is null || ResolveDescendantTagIds is null) { return; }
var ids = await ResolveDescendantTagIds(node);
foreach (var id in ids) { SelectedTagIds.Remove(id); }
await OnSelectionChanged.InvokeAsync();
StateHasChanged();
}
private List<ContextMenuItem> ClusterMenu(RawNode node) => new()
{
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) },
};
private List<ContextMenuItem> FolderMenu(RawNode node) => new()
{
new() { Label = "New folder", Icon = "📁", OnClick = () => OnNewFolder(node) },
new() { Label = "New driver", Icon = "🔌", OnClick = () => OnNewDriver(node) },
ContextMenuItem.Separator(),
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameFolder(node) },
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteFolder(node) },
};
private List<ContextMenuItem> DriverMenu(RawNode node) => new()
{
new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDriver(node) },
new() { Label = "New device", Icon = "📟", OnClick = () => OnNewDevice(node) },
ContextMenuItem.Separator(),
new() { Label = node.Enabled ? "Disable" : "Enable", Icon = node.Enabled ? "⏸" : "▶",
OnClick = () => OnToggleDriverEnabled(node) },
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameDriver(node) },
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDriver(node) },
};
private List<ContextMenuItem> DeviceMenu(RawNode node) => new()
{
new() { Label = "Configure", Icon = "⚙", OnClick = () => OnConfigureDevice(node) },
new() { Label = "New tag-group", Icon = "🗂️", OnClick = () => OnNewTagGroup(node) },
ContextMenuItem.Separator(),
// "Add tags ▸" flattened — ContextMenu is single-level, so the submenu is a labelled group.
new() { Label = "Add tags", Disabled = true },
new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) },
new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) },
new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) },
new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) },
ContextMenuItem.Separator(),
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteDevice(node) },
};
private List<ContextMenuItem> TagGroupMenu(RawNode node) => new()
{
new() { Label = "New group", Icon = "🗂️", OnClick = () => OnNewGroup(node) },
ContextMenuItem.Separator(),
new() { Label = "Add tags", Disabled = true },
new() { Label = "Manual entry", Icon = "✍️", OnClick = () => OnAddManualTags(node) },
new() { Label = "Import CSV", Icon = "📥", OnClick = () => OnImportCsv(node) },
new() { Label = "Export CSV", Icon = "📤", OnClick = () => OnExportCsv(node) },
new() { Label = "Browse device…", Icon = "🔭", OnClick = () => OnBrowseDevice(node) },
ContextMenuItem.Separator(),
new() { Label = "Rename", Icon = "✏️", OnClick = () => OnRenameTagGroup(node) },
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTagGroup(node) },
};
private List<ContextMenuItem> TagMenu(RawNode node) => new()
{
new() { Label = "Edit", Icon = "✏️", OnClick = () => OnEditTag(node) },
new() { Label = "Delete", Icon = "🗑️", OnClick = () => OnDeleteTag(node) },
};
// ---------------------------------------------------------------------------
// Named action handlers — each opens the real modal/dialog or calls the service
// directly, then refreshes the affected subtree. Only OnBrowseDevice is a
// placeholder (Wave C owns device browse).
// ---------------------------------------------------------------------------
// --- Folder / Cluster ---
// New folder under a Cluster (parentFolderId=null) or a Folder (parentFolderId=this folder).
private Task OnNewFolder(RawNode node)
{
var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId;
var parentFolderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null;
ShowNameDialog("New folder", "", async name =>
{
var res = await Svc.CreateFolderAsync(clusterId ?? "", parentFolderId, name);
if (!res.Ok) { ShowMessage("New folder failed", res.Error ?? "Create failed.", node.DisplayName); return; }
await ReloadNodeAsync(node);
});
return Task.CompletedTask;
}
// New driver: pick type + name, then create a minimal driver ("{}") — configure it afterward.
private Task OnNewDriver(RawNode node)
{
var clusterId = node.Kind == RawNodeKind.Cluster ? (node.ClusterId ?? node.EntityId) : node.ClusterId;
var folderId = node.Kind == RawNodeKind.Folder ? node.EntityId : null;
ShowDriverTypeDialog(async choice =>
{
var res = await Svc.CreateDriverAsync(clusterId ?? "", folderId, choice.Name, choice.DriverType, "{}");
if (!res.Ok) { ShowMessage("New driver failed", res.Error ?? "Create failed.", node.DisplayName); return; }
await ReloadNodeAsync(node);
});
return Task.CompletedTask;
}
private Task OnRenameFolder(RawNode node)
{
ShowNameDialog("Rename folder", node.DisplayName, async name =>
await AfterRename(await Svc.RenameFolderAsync(node.EntityId!, name, node.RowVersion), node));
return Task.CompletedTask;
}
private Task OnDeleteFolder(RawNode node)
{
ShowConfirm("Delete folder", $"Delete folder “{node.DisplayName}”?", async () =>
await AfterDelete(await Svc.DeleteFolderAsync(node.EntityId!, node.RowVersion), node));
return Task.CompletedTask;
}
// --- Driver ---
private Task OnConfigureDriver(RawNode node)
{
_driverCfgId = node.EntityId;
_driverCfgNode = node;
_driverCfgVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private Task OnNewDevice(RawNode node)
{
_deviceIsCreate = true;
_deviceEditId = null;
_deviceCreateDriverId = node.EntityId;
_deviceDriverType = node.DriverType;
_deviceNode = node;
_deviceVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private async Task OnToggleDriverEnabled(RawNode node)
{
var res = await Svc.SetDriverEnabledAsync(node.EntityId!, !node.Enabled, node.RowVersion);
if (!res.Ok) { ShowMessage("Update failed", res.Error ?? "Could not change enabled state.", node.DisplayName); return; }
await ReloadParentAsync(node);
}
private Task OnRenameDriver(RawNode node)
{
ShowNameDialog("Rename driver", node.DisplayName, async name =>
await AfterRename(await Svc.RenameDriverAsync(node.EntityId!, name, node.RowVersion), node));
return Task.CompletedTask;
}
private Task OnDeleteDriver(RawNode node)
{
ShowConfirm("Delete driver", $"Delete driver “{node.DisplayName}” and its empty devices?", async () =>
await AfterDelete(await Svc.DeleteDriverAsync(node.EntityId!, node.RowVersion), node));
return Task.CompletedTask;
}
// --- Device ---
private Task OnConfigureDevice(RawNode node)
{
_deviceIsCreate = false;
_deviceEditId = node.EntityId;
_deviceCreateDriverId = null;
_deviceDriverType = node.DriverType;
_deviceNode = node;
_deviceVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private Task OnNewTagGroup(RawNode node)
{
// node is a Device — its root tag-groups.
ShowNameDialog("New tag-group", "", async name =>
{
var res = await Svc.CreateTagGroupAsync(node.EntityId!, null, name);
if (!res.Ok) { ShowMessage("New tag-group failed", res.Error ?? "Create failed.", node.DisplayName); return; }
await ReloadNodeAsync(node);
});
return Task.CompletedTask;
}
private Task OnDeleteDevice(RawNode node)
{
ShowConfirm("Delete device", $"Delete device “{node.DisplayName}”?", async () =>
await AfterDelete(await Svc.DeleteDeviceAsync(node.EntityId!, node.RowVersion), node));
return Task.CompletedTask;
}
// --- Add-tags surfaces (shared by Device + TagGroup) ---
private Task OnAddManualTags(RawNode node)
{
// Manual entry needs the device id: for a Device node it's node itself; for a TagGroup walk up.
_manualDeviceId = FindAncestorDevice(node)?.EntityId ?? "";
_manualTagGroupId = node.Kind == RawNodeKind.TagGroup ? node.EntityId : null;
_manualDriverType = node.DriverType ?? "";
_manualNode = node;
_manualVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private Task OnImportCsv(RawNode node)
{
// Device node → DeviceId; TagGroup node → TagGroupId (the modal self-resolves its device).
if (node.Kind == RawNodeKind.Device)
{
_csvImportDeviceId = node.EntityId ?? "";
_csvImportTagGroupId = null;
}
else
{
_csvImportDeviceId = "";
_csvImportTagGroupId = node.EntityId;
}
_csvImportDriverType = node.DriverType ?? "";
_csvImportNode = node;
_csvImportVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private Task OnExportCsv(RawNode node)
{
if (node.Kind == RawNodeKind.Device)
{
_csvExportDeviceId = node.EntityId ?? "";
_csvExportTagGroupId = null;
}
else
{
_csvExportDeviceId = "";
_csvExportTagGroupId = node.EntityId;
}
_csvExportDriverType = node.DriverType ?? "";
_csvExportVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
// Opens the discovery-browser modal against the target device/group. On a Device node the browse commits
// at the device root; on a TagGroup node the group's device-relative path is prepended to every tag.
private Task OnBrowseDevice(RawNode node)
{
_browseDeviceId = FindAncestorDevice(node)?.EntityId ?? "";
_browseTagGroupId = node.Kind == RawNodeKind.TagGroup ? node.EntityId : null;
_browseDriverType = node.DriverType ?? "";
_browseNode = node;
_browseVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
// --- TagGroup ---
private Task OnNewGroup(RawNode node)
{
// node is a TagGroup — create a sub-group under it (needs its owning device id).
var deviceId = FindAncestorDevice(node)?.EntityId ?? "";
ShowNameDialog("New group", "", async name =>
{
var res = await Svc.CreateTagGroupAsync(deviceId, node.EntityId, name);
if (!res.Ok) { ShowMessage("New group failed", res.Error ?? "Create failed.", node.DisplayName); return; }
await ReloadNodeAsync(node);
});
return Task.CompletedTask;
}
private Task OnRenameTagGroup(RawNode node)
{
ShowNameDialog("Rename tag-group", node.DisplayName, async name =>
await AfterRename(await Svc.RenameTagGroupAsync(node.EntityId!, name, node.RowVersion), node));
return Task.CompletedTask;
}
private Task OnDeleteTagGroup(RawNode node)
{
ShowConfirm("Delete tag-group", $"Delete tag-group “{node.DisplayName}”?", async () =>
await AfterDelete(await Svc.DeleteTagGroupAsync(node.EntityId!, node.RowVersion), node));
return Task.CompletedTask;
}
// --- Tag ---
private Task OnEditTag(RawNode node)
{
_tagId = node.EntityId;
_tagDriverType = node.DriverType;
_tagNode = node;
_tagVisible = true;
StateHasChanged();
return Task.CompletedTask;
}
private Task OnDeleteTag(RawNode node)
{
ShowConfirm("Delete tag", $"Delete tag “{node.DisplayName}”?", async () =>
await AfterDelete(await Svc.DeleteTagAsync(node.EntityId!, node.RowVersion), node));
return Task.CompletedTask;
}
// ---------------------------------------------------------------------------
// Modal OnSaved callbacks — refresh the affected subtree.
// ---------------------------------------------------------------------------
private async Task DriverCfgSaved()
{
if (_driverCfgNode is not null) { await ReloadParentAsync(_driverCfgNode); }
}
private async Task DeviceSaved()
{
if (_deviceNode is null) { return; }
// Create → reload the driver container; edit → reload the device's parent driver.
if (_deviceIsCreate) { await ReloadNodeAsync(_deviceNode); }
else { await ReloadParentAsync(_deviceNode); }
}
private async Task TagSaved()
{
if (_tagNode is not null) { await ReloadParentAsync(_tagNode); }
}
private async Task ManualSaved()
{
if (_manualNode is not null) { await ReloadNodeAsync(_manualNode); }
}
private async Task CsvImportSaved()
{
if (_csvImportNode is not null) { await ReloadNodeAsync(_csvImportNode); }
}
private async Task BrowseSaved()
{
// New tags (and any mirrored groups) land under the browsed node — reload its children.
if (_browseNode is not null) { await ReloadNodeAsync(_browseNode); }
}
// ---------------------------------------------------------------------------
// Rename / delete outcome surfacing.
// ---------------------------------------------------------------------------
private async Task AfterRename(RawRenameResult res, RawNode node)
{
if (res.Ok) { await ReloadParentAsync(node); }
if (!res.Ok)
{
ShowMessage("Rename failed", res.Error ?? "Rename failed.", node.DisplayName);
}
else if (res.Warnings.Count > 0)
{
ShowMessage("Renamed — with warnings", string.Join(" • ", res.Warnings), node.DisplayName);
}
}
private async Task AfterDelete(UnsMutationResult res, RawNode node)
{
if (!res.Ok) { ShowMessage("Delete blocked", res.Error ?? "Delete failed.", node.DisplayName); return; }
await ReloadParentAsync(node);
}
// ---------------------------------------------------------------------------
// Dialog show-helpers (one instance each; a pending delegate carries the op).
// ---------------------------------------------------------------------------
private void ShowNameDialog(string title, string initial, Func<string, Task> submit)
{
_nameTitle = title;
_nameInitial = initial;
_nameSubmit = submit;
_nameVisible = true;
StateHasChanged();
}
private async Task NameSubmitted(string name)
{
var submit = _nameSubmit;
if (submit is not null) { await submit(name); }
}
private void ShowConfirm(string title, string message, Func<Task> action)
{
_confirmTitle = title;
_confirmMessage = message;
_confirmAction = action;
_confirmVisible = true;
StateHasChanged();
}
private async Task Confirmed()
{
var action = _confirmAction;
if (action is not null) { await action(); }
}
private void ShowDriverTypeDialog(Func<(string DriverType, string Name), Task> submit)
{
_driverTypeSubmit = submit;
_driverTypeVisible = true;
StateHasChanged();
}
private async Task DriverTypeSubmitted((string DriverType, string Name) choice)
{
var submit = _driverTypeSubmit;
if (submit is not null) { await submit(choice); }
}
// ---------------------------------------------------------------------------
// Shared message surface (repurposed RawStubModal).
// ---------------------------------------------------------------------------
private void ShowMessage(string title, string message, string? nodeName)
{
_msgTitle = title;
_msgMessage = message;
_msgNodeName = nodeName;
_msgVisible = true;
StateHasChanged();
}
private void CloseMessage()
{
_msgVisible = false;
_msgTitle = "";
_msgMessage = "";
_msgNodeName = null;
}
// ---------------------------------------------------------------------------
// Tree refresh + parent lookup.
// ---------------------------------------------------------------------------
/// <summary>
/// Reloads a container node's children in place (create-under refresh): clears its loaded level and
/// re-runs the lazy load, keeping it expanded. A refresh failure is captured on the node, never thrown.
/// </summary>
private async Task ReloadNodeAsync(RawNode container)
{
try
{
container.Expanded = true;
container.Loaded = false;
container.Children.Clear();
container.Error = null;
if (container.HasLazyChildren)
{
container.Loading = true;
StateHasChanged();
var children = await Svc.LoadChildrenAsync(container);
container.Children.Clear();
container.Children.AddRange(children);
container.ChildCount = container.Children.Count;
container.Loaded = true;
}
}
catch (Exception ex)
{
container.Error = ex.Message;
}
finally
{
container.Loading = false;
}
StateHasChanged();
}
/// <summary>
/// Reloads the parent container of an item-level node (rename/delete/toggle/edit refresh) so a changed
/// name / removed row / muted styling is reflected while preserving surrounding expansion state. Every
/// menu-bearing node's parent is in the loaded tree; the OnRootsChanged fallback is a belt-and-braces
/// path for a would-be cluster-less top-level node.
/// </summary>
private async Task ReloadParentAsync(RawNode node)
{
var parent = FindParent(node);
if (parent is not null) { await ReloadNodeAsync(parent); }
else { await OnRootsChanged.InvokeAsync(); }
}
/// <summary>Finds a node's parent by searching the currently-loaded tree; null for a root.</summary>
private RawNode? FindParent(RawNode target)
{
foreach (var root in Roots)
{
var found = FindParentIn(root, target);
if (found is not null) { return found; }
}
return null;
}
private static RawNode? FindParentIn(RawNode node, RawNode target)
{
foreach (var child in node.Children)
{
if (ReferenceEquals(child, target)) { return node; }
var deeper = FindParentIn(child, target);
if (deeper is not null) { return deeper; }
}
return null;
}
/// <summary>Walks up from <paramref name="node"/> (inclusive) to the nearest Device node, or null.</summary>
private RawNode? FindAncestorDevice(RawNode node)
{
var current = node;
while (current is not null)
{
if (current.Kind == RawNodeKind.Device) { return current; }
current = FindParent(current);
}
return null;
}
}
@@ -0,0 +1,125 @@
@* v3 Batch 3 "+ Add reference" picker: reuses the Raw project tree (RawTree) in picker mode to
multi-select raw tags, scoped to the equipment's own cluster. The cluster scope is structural —
IUnsTreeService.LoadReferencePickerRootAsync hands back a SINGLE cluster root, so raw tags of any
other cluster are simply unreachable through the tree, not merely hidden. On commit the selected
tag ids are added as UnsTagReference rows via AddReferencesAsync; the host reloads its Tags list. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add tag references</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-2">
Pick raw tags to reference under this equipment. The tree is scoped to the
equipment's cluster. Tick individual tags, or use a device / tag-group's
<em>“Select all tags below”</em> menu to pull many at once.
</p>
@if (_loading)
{
<p class="text-muted"><span class="spinner-border spinner-border-sm me-1"></span>Loading…</p>
}
else if (_roots.Count == 0)
{
<p class="text-muted">This equipment does not resolve to a cluster, so there are no raw tags to reference.</p>
}
else
{
<div class="border rounded p-2" style="max-height:50vh; overflow:auto">
<RawTree Roots="_roots" PickerMode="true" SelectedTagIds="_selected"
OnSelectionChanged="OnSelectionChanged"
ResolveDescendantTagIds="ResolveDescendantsAsync" />
</div>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="CommitAsync" disabled="@(_busy || _selected.Count == 0)">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Add @_selected.Count reference@(_selected.Count == 1 ? "" : "s")
</button>
</div>
</div>
</div>
</div>
}
@code {
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary>The equipment the selected raw tags are referenced under.</summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>Raised after references are committed so the host can reload its Tags list and close.</summary>
[Parameter] public EventCallback OnCommitted { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private IReadOnlyList<RawNode> _roots = Array.Empty<RawNode>();
private readonly HashSet<string> _selected = new(StringComparer.Ordinal);
private bool _busy;
private bool _loading;
private string? _error;
private bool _wasVisible;
protected override async Task OnParametersSetAsync()
{
// Reset + reload only on the false→true transition so re-renders while open don't wipe state.
if (Visible && !_wasVisible)
{
_selected.Clear();
_error = null;
_roots = Array.Empty<RawNode>();
_loading = true;
StateHasChanged();
var root = string.IsNullOrEmpty(EquipmentId)
? null
: await Svc.LoadReferencePickerRootAsync(EquipmentId);
_roots = root is null ? Array.Empty<RawNode>() : new[] { root };
_loading = false;
}
_wasVisible = Visible;
}
private Task<IReadOnlyList<string>> ResolveDescendantsAsync(RawNode node) =>
Svc.LoadDescendantTagIdsAsync(node);
// RawTree mutates _selected in place; this just re-renders the footer count.
private void OnSelectionChanged() => StateHasChanged();
private async Task CommitAsync()
{
if (_selected.Count == 0) { return; }
_busy = true;
_error = null;
try
{
var res = await Svc.AddReferencesAsync(EquipmentId!, _selected.ToList());
if (res.Ok) { await OnCommitted.InvokeAsync(); }
else { _error = res.Error; }
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
}
@@ -2,9 +2,10 @@
the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the the modal parses it into EquipmentInput rows and calls ImportEquipmentAsync, then shows the
Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported Inserted / Skipped / Errors summary in place. The host owns visibility; "Close" raises OnImported
so the page can reload the whole tree (an import can add equipment across many lines/clusters). so the page can reload the whole tree (an import can add equipment across many lines/clusters).
Required header columns (in order): Name, MachineCode, UnsLineId, DriverInstanceId. Required header columns (in order): Name, MachineCode, UnsLineId.
Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and Optional: ZTag, SAPID, Manufacturer, Model. Existing rows are detected by MachineCode and
skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page. *@ skipped (additive-only — no updates), matching the retired /clusters/{id}/equipment/import page.
v3: equipment no longer carries a driver, so the old DriverInstanceId column is gone. *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns @using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@inject IUnsTreeService Svc @inject IUnsTreeService Svc
@@ -21,7 +22,7 @@
<div class="modal-body"> <div class="modal-body">
<p class="text-muted small mb-2"> <p class="text-muted small mb-2">
Paste CSV below. Required header columns (in order): Paste CSV below. Required header columns (in order):
<span class="mono">Name, MachineCode, UnsLineId, DriverInstanceId</span>. <span class="mono">Name, MachineCode, UnsLineId</span>.
Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>. Optional: <span class="mono">ZTag, SAPID, Manufacturer, Model</span>.
Each row inserts one equipment with a freshly-generated EquipmentId. Existing rows Each row inserts one equipment with a freshly-generated EquipmentId. Existing rows
are detected by MachineCode and skipped (additive-only — no updates). are detected by MachineCode and skipped (additive-only — no updates).
@@ -29,7 +30,7 @@
<textarea class="form-control form-control-sm mono" rows="12" <textarea class="form-control form-control-sm mono" rows="12"
@bind="_csv" @bind:event="oninput" disabled="@_busy" @bind="_csv" @bind:event="oninput" disabled="@_busy"
placeholder="Name,MachineCode,UnsLineId,DriverInstanceId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,LINE-1,drv-modbus-01,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea> placeholder="Name,MachineCode,UnsLineId,ZTag,SAPID,Manufacturer,Model&#10;mixer-01,MX_001,LINE-1,ZT-12345,SAP-9876,Siemens,SIMATIC-1500"></textarea>
<div class="form-text">Simple comma-separated values only — fields containing commas are not supported.</div> <div class="form-text">Simple comma-separated values only — fields containing commas are not supported.</div>
@if (!string.IsNullOrWhiteSpace(_parseError)) @if (!string.IsNullOrWhiteSpace(_parseError))
@@ -72,8 +73,8 @@
} }
@code { @code {
// Required, in order; DriverInstanceId may be left blank per row (driver-less equipment). // Required, in order. v3: equipment no longer binds a driver, so DriverInstanceId is gone.
private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId", "DriverInstanceId"]; private static readonly string[] RequiredColumns = ["Name", "MachineCode", "UnsLineId"];
/// <summary>Whether the modal is shown. The host owns this flag.</summary> /// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; } [Parameter] public bool Visible { get; set; }
@@ -133,7 +134,7 @@
/// <summary> /// <summary>
/// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first /// Parses the pasted CSV into <see cref="EquipmentInput"/> rows. Requires a header row whose first
/// four columns are Name, MachineCode, UnsLineId, DriverInstanceId (case-insensitive); the optional /// three columns are Name, MachineCode, UnsLineId (case-insensitive); the optional
/// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns /// ZTag/SAPID/Manufacturer/Model follow. Blank optional cells parse as <c>null</c>. Returns
/// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong. /// <c>null</c> (and sets <see cref="_parseError"/>) when the CSV is empty or the header is wrong.
/// </summary> /// </summary>
@@ -169,11 +170,10 @@
Name: parts[0], Name: parts[0],
MachineCode: parts[1], MachineCode: parts[1],
UnsLineId: parts[2], UnsLineId: parts[2],
DriverInstanceId: NullIfEmpty(parts, 3), ZTag: NullIfEmpty(parts, 3),
ZTag: NullIfEmpty(parts, 4), SAPID: NullIfEmpty(parts, 4),
SAPID: NullIfEmpty(parts, 5), Manufacturer: NullIfEmpty(parts, 5),
Manufacturer: NullIfEmpty(parts, 6), Model: NullIfEmpty(parts, 6),
Model: NullIfEmpty(parts, 7),
SerialNumber: null, SerialNumber: null,
HardwareRevision: null, HardwareRevision: null,
SoftwareRevision: null, SoftwareRevision: null,
@@ -0,0 +1,279 @@
@* Typed TagConfig editor for the Calculation pseudo-driver. Authors the calc binding —
scriptId + change/timer triggers — reusing the VirtualTagModal's script dropdown, "New
script" action, and inline Monaco source panel (all backed by the same IUnsTreeService
script seams). Dispatched from RawTagModal / RawManualTagEntryModal via TagConfigEditorMap,
so it takes the same (ConfigJson/ConfigJsonChanged/DriverType/GetDriverConfigJson) parameter
shape every typed editor takes; DriverType/GetDriverConfigJson are accepted for dispatch
uniformity but unused (a calc tag has no address to browse). *@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@inject IUnsTreeService Svc
<div class="row g-2">
<div class="col-md-6">
<label class="form-label">Script</label>
<select class="form-select form-select-sm" value="@_m.ScriptId" @onchange="OnScriptChangedAsync">
<option value="">— pick script —</option>
@foreach (var (id, display) in _scripts)
{
<option value="@id">@display</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label">Change-triggered</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="calc-change-@_uid"
checked="@_m.ChangeTriggered"
@onchange="@(e => Update(() => _m.ChangeTriggered = e.Value is true))" />
<label class="form-check-label small" for="calc-change-@_uid">On dependency change</label>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Timer (ms)</label>
<input type="number" min="50" step="1" class="form-control form-control-sm"
value="@_m.TimerIntervalMs"
@onchange="@(e => Update(() => _m.TimerIntervalMs = ParseTimer(e.Value)))" />
<div class="form-text">Blank = change-trigger only. Min 50 ms.</div>
</div>
</div>
@* No script bound yet → offer a one-click "New script" that creates a blank Script row, binds
it, and expands the inline editor so the operator can author the body without leaving. *@
@if (string.IsNullOrEmpty(_m.ScriptId))
{
<div class="mt-2">
<button type="button" class="btn btn-outline-primary btn-sm"
@onclick="CreateNewScriptAsync" disabled="@_scriptCreating">
@if (_scriptCreating) { <span class="spinner-border spinner-border-sm me-1"></span> }
New script
</button>
<span class="form-text ms-2">Creates a blank C# script bound to this calc tag.</span>
@if (!string.IsNullOrWhiteSpace(_scriptCreateError))
{
<div class="text-danger small mt-2">@_scriptCreateError</div>
}
</div>
}
@* Inline script-source editor — shown only when a script is bound. Saves the SHARED Script row on
its own concurrency-guarded button; independent of the enclosing tag save. *@
@if (!string.IsNullOrEmpty(_m.ScriptId) && _scriptLoaded)
{
<div class="card mt-2">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<span class="fw-semibold">Script source</span>
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none"
@onclick="ToggleScriptPanel">
@(_scriptExpanded ? "Hide" : "Edit source")
</button>
</div>
@if (_scriptExpanded)
{
<div class="card-body">
<div class="alert alert-warning py-2 small mb-2">
Editing shared script "<span class="mono">@_scriptName</span>" — used by
@_scriptUsageCount virtual tag(s). Changes affect every tag using it.
</div>
<MonacoEditor @bind-Value="_scriptSource" Height="300px" />
@if (!string.IsNullOrWhiteSpace(_scriptError))
{
<div class="text-danger small mt-2">@_scriptError</div>
}
else if (_scriptSaved)
{
<div class="text-success small mt-2">Script saved.</div>
}
<div class="mt-2">
<button type="button" class="btn btn-outline-primary btn-sm"
@onclick="SaveScriptAsync" disabled="@_scriptSaving">
@if (_scriptSaving) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save script
</button>
</div>
</div>
}
</div>
}
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
/// <summary>DriverType of the selected driver — accepted for dispatch uniformity; unused here.</summary>
[Parameter] public string DriverType { get; set; } = "";
/// <summary>Live accessor for the selected driver's DriverConfig — accepted for dispatch uniformity;
/// unused (a calc tag has no address to browse).</summary>
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
private CalculationTagConfigModel _m = new();
private string? _lastConfigJson;
// Unique suffix so multiple editor instances (e.g. manual-entry grid rows) get distinct control ids.
private readonly string _uid = Guid.NewGuid().ToString("N")[..8];
// Script dropdown options — loaded once, plus any inline-created script appended.
private List<(string Id, string Display)> _scripts = new();
// Inline script-source editor state (independent of the tag save).
private string _scriptSource = "";
private bool _scriptLoaded;
private byte[] _scriptRowVersion = Array.Empty<byte>();
private string? _scriptName;
private int _scriptUsageCount;
private bool _scriptExpanded;
private bool _scriptSaving;
private bool _scriptSaved;
private string? _scriptError;
// "New script" action state.
private bool _scriptCreating;
private string? _scriptCreateError;
protected override async Task OnInitializedAsync()
{
var scripts = await Svc.LoadScriptsAsync();
_scripts = scripts.Select(s => (s.ScriptId, s.Display)).ToList();
}
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
// (Blazor Server live-status pushes) can't reset the operator's in-progress edits.
protected override async Task OnParametersSetAsync()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = CalculationTagConfigModel.FromJson(ConfigJson);
await LoadScriptSourceAsync();
}
private async Task Update(Action apply)
{
apply();
await WriteBackAsync();
}
// Push the model back to the host as JSON and pin _lastConfigJson so the echo doesn't re-parse.
private async Task WriteBackAsync()
{
var json = _m.ToJson();
_lastConfigJson = json;
await ConfigJsonChanged.InvokeAsync(json);
}
private static int? ParseTimer(object? v)
=> int.TryParse(v?.ToString(), out var i) && i > 0 ? i : null;
/// <summary>Refreshes the inline script panel + writes the new binding back when the operator picks
/// a different script.</summary>
private async Task OnScriptChangedAsync(ChangeEventArgs e)
{
_m.ScriptId = e.Value?.ToString() ?? "";
await WriteBackAsync();
await LoadScriptSourceAsync();
}
/// <summary>Loads the bound script's source for the inline panel; clears it when no script is bound.</summary>
private async Task LoadScriptSourceAsync()
{
_scriptSaved = false;
_scriptError = null;
if (string.IsNullOrEmpty(_m.ScriptId))
{
_scriptSource = "";
_scriptLoaded = false;
_scriptRowVersion = Array.Empty<byte>();
_scriptName = null;
_scriptUsageCount = 0;
return;
}
var loaded = await Svc.GetScriptSourceAsync(_m.ScriptId);
if (loaded is null)
{
_scriptSource = "";
_scriptLoaded = false;
_scriptRowVersion = Array.Empty<byte>();
_scriptName = null;
_scriptUsageCount = 0;
return;
}
_scriptSource = loaded.Value.SourceCode;
_scriptLoaded = true;
_scriptRowVersion = loaded.Value.RowVersion;
_scriptName = loaded.Value.Name;
_scriptUsageCount = await Svc.CountVirtualTagsUsingScriptAsync(_m.ScriptId);
}
/// <summary>Creates a blank script, binds this calc tag to it, and expands the inline editor. Persists
/// only the new Script row — the tag binding lives in the model until the host's save.</summary>
private async Task CreateNewScriptAsync()
{
_scriptCreating = true;
_scriptCreateError = null;
try
{
const string seedName = "Calc script";
var result = await Svc.CreateScriptAsync(seedName);
if (!result.Ok || string.IsNullOrEmpty(result.CreatedId))
{
_scriptCreateError = result.Error ?? "Could not create the script.";
return;
}
// Add the option BEFORE binding so the <select> can resolve the label on first render.
if (!_scripts.Any(s => s.Id == result.CreatedId))
{
_scripts.Add((result.CreatedId, $"{seedName} (CSharp)"));
}
_m.ScriptId = result.CreatedId;
await WriteBackAsync();
await LoadScriptSourceAsync();
_scriptExpanded = true;
}
finally
{
_scriptCreating = false;
}
}
/// <summary>Toggles the inline script-source panel, clearing a stale "saved" banner on (re)expand.</summary>
private void ToggleScriptPanel()
{
_scriptExpanded = !_scriptExpanded;
if (_scriptExpanded) { _scriptSaved = false; }
}
/// <summary>Saves the edited script body via its own RowVersion-guarded call. Wholly separate from the
/// tag save: never touches the model or closes the enclosing modal.</summary>
private async Task SaveScriptAsync()
{
if (string.IsNullOrEmpty(_m.ScriptId) || !_scriptLoaded) { return; }
_scriptSaving = true;
_scriptSaved = false;
_scriptError = null;
try
{
var result = await Svc.UpdateScriptSourceAsync(_m.ScriptId, _scriptSource, _scriptRowVersion);
if (result.Ok)
{
var reloaded = await Svc.GetScriptSourceAsync(_m.ScriptId);
if (reloaded is not null)
{
_scriptSource = reloaded.Value.SourceCode;
_scriptRowVersion = reloaded.Value.RowVersion;
_scriptName = reloaded.Value.Name;
}
_scriptSaved = true;
}
else
{
_scriptError = result.Error;
}
}
finally
{
_scriptSaving = false;
}
}
}
@@ -1,557 +0,0 @@
@* Create/edit modal for an equipment-bound tag, wired straight into IUnsTreeService. The host page
owns visibility and supplies the owning equipment id (create) or the loaded TagEditDto (edit), plus
the equipment-scoped candidate-driver list. Tree tags are always equipment-bound (decision #110), so
the legacy SystemPlatform/FolderPath branch is dropped entirely — there is no equipment selector
either, the owning equipment is fixed. On a successful save it raises OnSaved so the host can
refresh the equipment's children in place. *@
@using System.ComponentModel.DataAnnotations
@using System.Text.Json
@using Microsoft.AspNetCore.Components.Forms
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
@inject IUnsTreeService Svc
@if (Visible)
{
<div class="modal-backdrop fade show" style="display:block"></div>
<div class="modal fade show" tabindex="-1" role="dialog" style="display:block">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<EditForm Model="_form" OnValidSubmit="SaveAsync" FormName="tagModal">
<DataAnnotationsValidator />
<div class="modal-header">
<h5 class="modal-title">@(IsNew ? "New tag" : "Edit tag")</h5>
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelAsync"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-id">TagId</label>
<InputText id="tag-id" @bind-Value="_form.TagId" disabled="@(!IsNew)"
class="form-control form-control-sm mono"
placeholder="tag-line3-temp-01" />
<ValidationMessage For="@(() => _form.TagId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-name">Name</label>
<InputText id="tag-name" @bind-Value="_form.Name" class="form-control form-control-sm"
placeholder="Temperature setpoint" />
<ValidationMessage For="@(() => _form.Name)" />
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-driver">Driver instance</label>
<InputSelect id="tag-driver" @bind-Value="_form.DriverInstanceId" @bind-Value:after="OnDriverChanged" class="form-select form-select-sm">
<option value="">— pick a driver —</option>
@foreach (var d in Drivers)
{
<option value="@d.Id">@d.Display</option>
}
</InputSelect>
<ValidationMessage For="@(() => _form.DriverInstanceId)" />
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-dtype">Data type</label>
<InputSelect id="tag-dtype" @bind-Value="_form.DataType" class="form-select form-select-sm">
@foreach (var dt in DataTypes)
{
<option value="@dt">@dt</option>
}
</InputSelect>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="tag-access">Access level</label>
<InputSelect id="tag-access" @bind-Value="_form.AccessLevel" class="form-select form-select-sm">
<option value="@TagAccessLevel.Read">Read</option>
<option value="@TagAccessLevel.ReadWrite">ReadWrite</option>
</InputSelect>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">WriteIdempotent</label>
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.WriteIdempotent" class="form-check-input" />
<label class="form-check-label">Safe to retry writes (decision #4445)</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="tag-pgroup">PollGroupId (optional)</label>
<InputText id="tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
</div>
<div class="mb-3">
<label class="form-label">Tag config</label>
@{
var editorType = TagConfigEditorMap.Resolve(SelectedDriverType);
}
@if (string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="form-text">Pick a driver above to configure this tag.</div>
}
else if (IsGalaxyDriver)
{
@* GalaxyMxGateway has no typed TagConfigEditorMap editor; instead a Galaxy point is
authored as {"FullName":"tag_name.AttributeName"}. Offer the live-browse picker
(against the selected gateway's DriverConfig) plus a manual raw-JSON fallback. *@
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
@onclick="@(() => _showGalaxyPicker = true)">
Browse Galaxy
</button>
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="3"
class="form-control form-control-sm mono"
placeholder='{ "FullName": "DelmiaReceiver_001.DownloadPath" }' />
<div class="form-text">
The Galaxy reference, stored as <code>{"FullName":"tag_name.AttributeName"}</code>.
Pick one via <strong>Browse Galaxy</strong> or edit the JSON directly.
</div>
@if (_showGalaxyPicker)
{
<DriverTagPicker @bind-Visible="_showGalaxyPicker"
Title="Galaxy address"
CurrentAddress="@_galaxyAddress"
OnPickAddress="@OnGalaxyAddressPicked">
<GalaxyAddressPickerBody CurrentAddress="@_galaxyAddress"
CurrentAddressChanged="@((s) => _galaxyAddress = s)"
@bind-SelectedIsAlarm="_galaxyPickedIsAlarm"
GetConfigJson="@(() => SelectedDriverConfig)" />
</DriverTagPicker>
}
}
else if (editorType is not null)
{
<DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
}
else
{
<InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="6"
class="form-control form-control-sm mono"
placeholder='{ "register": 40001, "scale": 0.1 }' />
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
}
<ValidationMessage For="@(() => _form.TagConfig)" />
</div>
@* Driver-agnostic server-side HistoryRead intent. Distinct from the native-alarm
"Historize to AVEVA" toggle below: THIS gates TAG-VALUE history (root keys
`isHistorized` / `historianTagname`, read by AddressSpaceComposer.ExtractTagHistorize),
merged onto the canonical TagConfig via the pure TagHistorizeConfig seam so it
composes with the typed editor's driver-specific fields (both preserve unknown keys).
Shown for EVERY driver once one is picked. *@
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="mb-3">
<label class="form-label">History</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="tag-historize"
checked="@_historizeState.IsHistorized"
@onchange="OnHistorizeChanged" />
<label class="form-check-label" for="tag-historize">Historize this tag (expose OPC UA HistoryRead)</label>
</div>
<div class="form-text">
When checked, the server serves OPC UA HistoryRead over this tag's value
from the configured historian.
</div>
@if (_historizeState.IsHistorized)
{
<div class="mt-2">
<label class="form-label" for="tag-historian-tagname">Historian tagname (override, optional)</label>
<input type="text" class="form-control form-control-sm mono" id="tag-historian-tagname"
value="@_historizeState.HistorianTagname"
@onchange="OnHistorianTagnameChanged" />
<div class="form-text">Blank defaults the historian tagname to this tag's driver FullName.</div>
</div>
}
</div>
}
@* Driver-agnostic array-shape intent. Merges the root `isArray` / `arrayLength`
keys onto the canonical TagConfig via the pure TagArrayConfig seam so it composes
with the typed editor's driver-specific fields (both preserve unknown keys). When
checked, the server materialises a 1-D array node (ValueRank=1). Shown for EVERY
driver once one is picked — same place/pattern as the historize control above. *@
@if (!string.IsNullOrEmpty(_form.DriverInstanceId))
{
<div class="mb-3">
<label class="form-label">Array</label>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="tag-is-array"
checked="@_arrayState.IsArray"
@onchange="OnIsArrayChanged" />
<label class="form-check-label" for="tag-is-array">This tag is an array (1-D)</label>
</div>
<div class="form-text">
When checked, the server materialises this tag as a fixed-length 1-D array node.
</div>
@if (_arrayState.IsArray)
{
<div class="mt-2">
<label class="form-label" for="tag-array-length">Array length</label>
<input type="number" min="1" step="1" class="form-control form-control-sm mono" id="tag-array-length"
value="@_arrayState.ArrayLength"
@onchange="OnArrayLengthChanged" />
<div class="form-text">Number of elements (must be a positive whole number).</div>
</div>
}
</div>
}
@* Native-alarm options: shown only when the TagConfig carries an `alarm` object (the tag
is a Part 9 condition). The "Historize to AVEVA" toggle edits the alarm.historizeToAveva
opt-out (bool?, unchecked-via-clear ⇒ absent ⇒ historize default-on at the server gate;
explicit false suppresses the durable AVEVA write — same posture as scripted alarms). *@
@if (HasNativeAlarm)
{
<div class="mb-3">
<label class="form-label">Native alarm</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="tag-alarm-historize"
checked="@AlarmHistorizeToAveva"
@onchange="OnAlarmHistorizeChanged" />
<label class="form-check-label" for="tag-alarm-historize">Historize to AVEVA</label>
</div>
<div class="form-text">
When unchecked, this alarm's transitions are NOT written to the AVEVA historian
(the live alerts feed is unaffected). Checked is the default.
</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(_error))
{
<div class="text-danger small mt-2">@_error</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="CancelAsync" disabled="@_busy">Cancel</button>
<button type="submit" class="btn btn-primary" disabled="@_busy">
@if (_busy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@(IsNew ? "Create" : "Save changes")
</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
private static readonly string[] DataTypes =
["Boolean", "SByte", "Byte", "Int16", "UInt16", "Int32", "UInt32",
"Int64", "UInt64", "Float", "Double", "String", "DateTime", "Guid", "ByteString"];
/// <summary>Whether the modal is shown. The host owns this flag.</summary>
[Parameter] public bool Visible { get; set; }
/// <summary><c>true</c> to create a new tag; <c>false</c> to edit <see cref="Existing"/>.</summary>
[Parameter] public bool IsNew { get; set; }
/// <summary>The owning equipment id the created tag binds to (used only on create).</summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>The tag being edited, when <see cref="IsNew"/> is <c>false</c>.</summary>
[Parameter] public TagEditDto? Existing { get; set; }
/// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as
/// <c>(Id, Display, DriverType, DriverConfig)</c> tuples. <c>DriverType</c> drives typed-editor dispatch;
/// <c>DriverConfig</c> feeds the Galaxy live-browse picker for GalaxyMxGateway drivers.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display, string DriverType, string DriverConfig)> Drivers { get; set; } = Array.Empty<(string, string, string, string)>();
/// <summary>Raised after a successful create/save so the host can refresh the equipment's children and close.</summary>
[Parameter] public EventCallback OnSaved { get; set; }
/// <summary>Raised when the user cancels so the host can close.</summary>
[Parameter] public EventCallback OnCancel { get; set; }
private FormModel _form = new();
private bool _busy;
private string? _error;
// Galaxy live-browse picker state. Only meaningful when the selected driver is a GalaxyMxGateway.
private bool _showGalaxyPicker;
private string _galaxyAddress = "";
// True when the attribute most-recently selected in the Galaxy picker is itself an alarm
// (DriverAttributeInfo.IsAlarm). On commit, this pre-fills a default native-alarm object into the
// TagConfig (when none is present) so the operator can author the alarm in one pass.
private bool _galaxyPickedIsAlarm;
// Driver-agnostic server-side HistoryRead intent (root `isHistorized` / `historianTagname`), reflected
// for the "Historize this tag" controls. Re-read from _form.TagConfig whenever the modal (re)opens or
// the driver changes; the change handlers merge it back onto _form.TagConfig via TagHistorizeConfig.
private TagHistorizeConfig.HistorizeState _historizeState;
// Driver-agnostic array-shape intent (root `isArray` / `arrayLength`), reflected for the array controls.
// Re-read from _form.TagConfig whenever the modal (re)opens or the driver changes; the change handlers
// merge it back onto _form.TagConfig via TagArrayConfig (same pattern as _historizeState above).
private TagArrayConfig.ArrayState _arrayState;
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
private string? SelectedDriverType =>
Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;
// The DriverConfig JSON of the currently-selected driver — fed to the Galaxy picker so it browses the
// right gateway. Defaults to "{}" when no driver is chosen or the config is empty.
private string SelectedDriverConfig
{
get
{
var cfg = Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverConfig;
return string.IsNullOrEmpty(cfg) ? "{}" : cfg;
}
}
// True when the selected driver is a GalaxyMxGateway — Galaxy points are authored as
// {"FullName":"tag_name.AttributeName"} via the live-browse picker rather than a typed editor.
private bool IsGalaxyDriver => SelectedDriverType == "GalaxyMxGateway";
// When the operator switches drivers, the previous driver's TagConfig schema no longer applies —
// reset it so the newly-dispatched typed editor starts clean (no stale/leaked keys from the old
// driver). Fires only on a user dropdown change (@bind-Value:after), not on the initial edit-load.
private void OnDriverChanged()
{
_form.TagConfig = "{}";
// The Galaxy reference belongs to the previous driver; clear the picker's working address too.
_galaxyAddress = "";
_galaxyPickedIsAlarm = false;
// The reset TagConfig carries no history intent — reflect that in the historize controls.
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
// Likewise the reset TagConfig carries no array intent.
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// The operator picked a Galaxy reference (tag_name.AttributeName); store it as the canonical
// {"FullName":"..."} TagConfig the Galaxy driver resolves to an MXAccess reference. Default
// (PascalCase) serialization yields the "FullName" key the driver/walker reads.
//
// When the picked attribute is itself an alarm (DriverAttributeInfo.IsAlarm), pre-seed a default
// native-alarm `alarm` object so the tag materialises as a Part 9 condition and Task 3's
// "Historize to AVEVA" toggle auto-appears — sparing the operator hand-written JSON. NativeAlarmModel
// .SeedDefaultAlarm only seeds when absent (never overwrites an authored alarm) and preserves FullName.
private void OnGalaxyAddressPicked(string address)
{
_galaxyAddress = address;
// Re-picking a Galaxy address owns ONLY the address-derived FullName key — apply it over the EXISTING
// TagConfig so every other user-edited field survives verbatim. This preserves a hand-authored `alarm`
// object (FB-4: a re-pick must never clobber edited alarm fields) as well as the root history/array
// intent and any driver/unknown keys. TagConfigJson.SetFullName uses the same preserve-unknown idiom
// as the historize/array merge seams.
var config = TagConfigJson.SetFullName(_form.TagConfig, address);
_form.TagConfig = _galaxyPickedIsAlarm
? NativeAlarmModel.SeedDefaultAlarm(config)
: config;
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
{
["ConfigJson"] = _form.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
["DriverType"] = SelectedDriverType ?? "",
["GetDriverConfigJson"] = (Func<string>)(() => SelectedDriverConfig),
};
// Cache a single NativeAlarmModel parse keyed on the current TagConfig string, so the two computed
// properties below don't each re-parse the JSON on every render. Re-parsed only when TagConfig changes.
private NativeAlarmModel _nativeAlarm = NativeAlarmModel.FromJson("{}");
private string? _nativeAlarmSource;
private NativeAlarmModel NativeAlarm
{
get
{
if (_nativeAlarmSource != _form.TagConfig)
{
_nativeAlarmSource = _form.TagConfig;
_nativeAlarm = NativeAlarmModel.FromJson(_form.TagConfig);
}
return _nativeAlarm;
}
}
// True when the current TagConfig carries an `alarm` object — i.e. the tag is materialised as a Part 9
// native-alarm condition rather than a value variable. Gates the "Historize to AVEVA" toggle's visibility.
private bool HasNativeAlarm => NativeAlarm.IsAlarm;
// The native alarm's HistorizeToAveva intent reflected for the checkbox: absent (null) ⇒ historize
// (default-on at the server gate), so the box is checked for both null and explicit true; only an
// explicit false leaves it unchecked.
private bool AlarmHistorizeToAveva => NativeAlarm.HistorizeToAveva != false;
// Toggle the alarm.historizeToAveva opt-out in the raw TagConfig. Checked ⇒ remove the key (null ⇒
// absent ⇒ historize default-on); unchecked ⇒ write an explicit false (suppress the durable AVEVA row).
// Unknown keys at the root + inside `alarm` are preserved across the edit (NativeAlarmModel round-trip).
private void OnAlarmHistorizeChanged(ChangeEventArgs e)
{
var model = NativeAlarmModel.FromJson(_form.TagConfig);
if (!model.IsAlarm) { return; }
model.HistorizeToAveva = e.Value is true ? null : false;
_form.TagConfig = model.ToJson();
}
// Toggle the root `isHistorized` tag-value history intent in the raw TagConfig, merged via the pure
// TagHistorizeConfig seam so the typed editor's driver-specific keys are preserved. Distinct from the
// native-alarm "Historize to AVEVA" opt-out above (that gates alarm-transition history, not tag values).
private void OnHistorizeChanged(ChangeEventArgs e)
{
var isHistorized = e.Value is true;
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, isHistorized, _historizeState.HistorianTagname);
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
// Merge the optional historian-tagname override (root `historianTagname`) into the raw TagConfig.
private void OnHistorianTagnameChanged(ChangeEventArgs e)
{
var tagname = e.Value?.ToString() ?? string.Empty;
_form.TagConfig = TagHistorizeConfig.Set(_form.TagConfig, _historizeState.IsHistorized, tagname);
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
}
// Toggle the root `isArray` array-shape intent in the raw TagConfig, merged via the pure TagArrayConfig
// seam so the typed editor's driver-specific keys are preserved. Clearing it drops `arrayLength` too
// (no orphan length), so the carried _arrayState.ArrayLength is irrelevant when unchecking.
private void OnIsArrayChanged(ChangeEventArgs e)
{
var isArray = e.Value is true;
_form.TagConfig = TagArrayConfig.Set(_form.TagConfig, isArray, _arrayState.ArrayLength);
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Merge the array length (root `arrayLength`) into the raw TagConfig. A blank/zero/negative/non-numeric
// entry parses to null, so the key is dropped until a positive length is typed (and SaveAsync rejects
// an array with no positive length).
private void OnArrayLengthChanged(ChangeEventArgs e)
{
var length = ParsePositiveLength(e.Value?.ToString());
_form.TagConfig = TagArrayConfig.Set(_form.TagConfig, _arrayState.IsArray, length);
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Parse the numeric-input string to a positive uint, or null for blank/zero/negative/overflow/non-numeric.
private static uint? ParsePositiveLength(string? raw)
=> uint.TryParse(raw, out var u) && u > 0 ? u : null;
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
if (IsNew)
{
_form = new FormModel();
}
else if (Existing is not null)
{
_form = new FormModel
{
TagId = Existing.TagId,
Name = Existing.Name,
DriverInstanceId = Existing.DriverInstanceId,
DataType = Existing.DataType,
AccessLevel = Existing.AccessLevel,
WriteIdempotent = Existing.WriteIdempotent,
PollGroupId = Existing.PollGroupId,
TagConfig = Existing.TagConfig,
};
}
_error = null;
_showGalaxyPicker = false;
_galaxyPickedIsAlarm = false;
// Seed the picker's working address from any existing {"FullName":"..."} so it opens pre-populated.
_galaxyAddress = ReadFullName(_form.TagConfig);
// Seed the historize controls from any existing root isHistorized/historianTagname keys.
_historizeState = TagHistorizeConfig.Read(_form.TagConfig);
// Seed the array controls from any existing root isArray/arrayLength keys.
_arrayState = TagArrayConfig.Read(_form.TagConfig);
}
// Best-effort extraction of FullName from a Galaxy TagConfig; returns "" when absent or unparseable.
private static string ReadFullName(string? configJson)
{
if (string.IsNullOrWhiteSpace(configJson)) return "";
try
{
using var doc = JsonDocument.Parse(configJson);
return doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("FullName", out var fn)
&& fn.ValueKind == JsonValueKind.String
? fn.GetString() ?? ""
: "";
}
catch (JsonException)
{
return "";
}
}
private async Task SaveAsync()
{
_busy = true;
_error = null;
try
{
// Client-side per-driver config validation (the typed editor's Validate()), so a blank
// required field is caught here rather than silently saving and failing at deploy/connect.
var configError = TagConfigValidator.Validate(SelectedDriverType, _form.TagConfig);
if (configError is not null)
{
_error = configError;
return;
}
// Driver-agnostic array-shape validation: an array tag needs a positive length. Mirrors the
// per-driver config validation above so a missing length is caught here rather than at deploy.
var arrayError = TagArrayConfig.Validate(_arrayState.IsArray, _arrayState.ArrayLength);
if (arrayError is not null)
{
_error = arrayError;
return;
}
var input = new TagInput(
_form.TagId,
_form.Name,
_form.DriverInstanceId,
_form.DataType,
_form.AccessLevel,
_form.WriteIdempotent,
_form.PollGroupId,
_form.TagConfig);
var result = IsNew
? await Svc.CreateTagAsync(EquipmentId!, input)
: await Svc.UpdateTagAsync(Existing!.TagId, input, Existing.RowVersion);
if (result.Ok)
{
await OnSaved.InvokeAsync();
}
else
{
_error = result.Error;
}
}
finally
{
_busy = false;
}
}
private Task CancelAsync() => OnCancel.InvokeAsync();
private sealed class FormModel
{
[Required, RegularExpression("^[A-Za-z0-9_-]+$")] public string TagId { get; set; } = "";
[Required] public string Name { get; set; } = "";
[Required] public string DriverInstanceId { get; set; } = "";
public string DataType { get; set; } = "Float";
public TagAccessLevel AccessLevel { get; set; } = TagAccessLevel.Read;
public bool WriteIdempotent { get; set; }
public string? PollGroupId { get; set; }
[Required] public string TagConfig { get; set; } = "{}";
}
}

Some files were not shown because too many files have changed in this diff Show More