9 Commits

Author SHA1 Message Date
Joseph Doherty d29e2190a9 fix(uns): reset TagConfig when the operator switches drivers (F-uns-1 integration review)
v2-ci / build (push) Failing after 49s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped
2026-06-09 10:00:28 -04:00
Joseph Doherty 8973ecdf7c feat(uns): register S7/AbCip/AbLegacy/TwinCAT/Focas tag editors in the map (F-uns-1 T9) 2026-06-09 09:51:26 -04:00
Joseph Doherty c0afecda50 fix(uns): omit blank optional keys from TagConfig + add omission tests; drop unused ParseInt (T4-T8 review) 2026-06-09 09:49:33 -04:00
Joseph Doherty 75021fa2c9 feat(uns): S7/AbCip/AbLegacy/TwinCAT/Focas typed tag-config editors (F-uns-1 T4-T8) 2026-06-09 09:42:40 -04:00
Joseph Doherty 5990b673cc feat(uns): Modbus typed tag-config editor (F-uns-1 T3) 2026-06-09 09:36:19 -04:00
Joseph Doherty fd9fa75d0e feat(uns): TagConfig JSON helper + editor map + TagModal dispatch scaffold (F-uns-1 T2) 2026-06-09 09:26:50 -04:00
Joseph Doherty d9dbd7917a feat(uns): surface DriverType to the TagModal driver dropdown (F-uns-1 T1) 2026-06-09 09:16:07 -04:00
Joseph Doherty cc53fc8feb docs(plan): implementation plan for driver-typed tag editors (F-uns-1 / #135)
10-task plan: (1) surface DriverType to the TagModal driver dropdown,
(2) shared TagConfigJson util + empty TagConfigEditorMap + DynamicComponent
dispatch scaffold, (3) Modbus editor as the worked example, (4-8) S7/AbCip/
AbLegacy/TwinCAT/Focas editors (parallelizable, disjoint files), (9) register
the five in the map, (10) docker-dev live verify (needs a non-Galaxy driver in
the rig). Each editor = pure FromJson/ToJson/Validate model (unit-tested) + thin
razor shell; preserves unknown JSON keys; driver pages untouched. Co-located
.tasks.json for resume.
2026-06-09 09:11:02 -04:00
Joseph Doherty 913fea7a3c docs(plan): design for driver-typed tag editors in the UNS TagModal (F-uns-1 / #135)
Approved design: 6 new per-driver TagConfig editor components
(Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas) dispatched by the selected driver's
DriverType via a TagConfigEditorMap + DynamicComponent; the 3 unmapped drivers
keep the generic raw-JSON editor; no raw-JSON toggle on typed drivers. Editors
reuse the drivers' enums + JSON property names (not razor markup); driver pages
untouched. Pure FromJson/ToJson/Validate helpers are unit-tested (no bUnit);
live verify needs a non-Galaxy driver added to docker-dev. AdminUI-only, no
data-model change.
2026-06-09 09:03:22 -04:00
29 changed files with 2056 additions and 16 deletions
@@ -0,0 +1,170 @@
# Driver-typed Tag Editors in the UNS TagModal — Design
**Date:** 2026-06-09
**Task:** #135 (F-uns-1, deferred follow-up from the global-UNS feature)
**Status:** Approved (design); pending implementation plan
**Scope:** AdminUI only. No data-model / migration change.
## Goal
Replace the single generic raw-`TagConfig`-JSON textarea in the global UNS
`TagModal` (`Components/Shared/Uns/TagModal.razor`) with **per-driver typed tag
editors** that dispatch on the selected driver's `DriverType`, so an operator
authoring an equipment-bound tag fills in driver-shaped fields
(Modbus register/address/byte-order, S7 address, AbCip tag path, …) instead of
hand-writing JSON.
This was specified at a high level in the global-UNS design
(`docs/plans/2026-06-08-global-uns-management-design.md`, "TagModal —
driver-typed, mirroring DriverEditRouter"). This document settles the
implementation decisions.
## Decisions locked during brainstorming
1. **New UNS editors; reuse contracts, not markup.** Build per-driver tag-config
editor components specifically for the TagModal. They **reuse each driver's
enums** (`ModbusRegion`, `ModbusDataType`, …) and emit the driver's
**camelCase JSON property names**, but do **not** reuse the driver pages'
razor markup. The existing driver edit pages are left untouched (their field
set differs — see §"Field mapping"). Lower risk, contained.
2. **All 6 typed editors this pass.** Modbus, S7, AbCip, AbLegacy, TwinCAT,
Focas each get a typed editor. OpcUaClient, Galaxy (`GalaxyMxGateway`), and
Historian.Wonderware keep the existing generic raw-JSON editor (they have no
tag editor in their driver pages either; Galaxy tags are browse-sourced).
3. **No raw-JSON escape hatch on typed drivers.** Typed drivers render typed
fields only — there is no per-modal "edit raw JSON" toggle. Consequence: each
typed editor must cover the driver's **full curated field set** — the same
fields the driver page's tag `EditTemplate` exposes (minus the fields the
TagModal owns at top level). The generic textarea remains only for the 3
unmapped drivers.
## Architecture
### Components (`Components/Shared/Uns/TagEditors/`)
One Razor component per typed driver:
`ModbusTagConfigEditor.razor`, `S7TagConfigEditor.razor`,
`AbCipTagConfigEditor.razor`, `AbLegacyTagConfigEditor.razor`,
`TwinCATTagConfigEditor.razor`, `FocasTagConfigEditor.razor`.
**Component contract (convention, not a C# interface — these are components):**
```
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
```
Behaviour: on `OnParametersSet`, parse `ConfigJson` → a typed working model
(falling back to defaults on null/blank/malformed). Render the fields bound to
the model. On any field change, serialize the model → JSON and raise
`ConfigJsonChanged`. The TagModal binds it as `@bind-ConfigJson="_form.TagConfig"`.
**Pure mapping helpers.** Each editor's JSON↔model mapping lives in a pure,
static, side-effect-free helper (e.g. `ModbusTagConfigModel.FromJson(string?)`,
`.ToJson()`, `.Validate()`), so the (de)serialization and validation are
unit-testable in `AdminUI.Tests` **without bUnit** (which this project lacks).
The razor component is a thin shell over the helper.
### Dispatch map
A static `TagConfigEditorMap``IReadOnlyDictionary<string, Type>` keyed by
`DriverType` (case-insensitive), mirroring `DriverEditRouter._componentMap`:
```
["ModbusTcp"] = typeof(ModbusTagConfigEditor),
["S7"] = typeof(S7TagConfigEditor),
["AbCip"] = typeof(AbCipTagConfigEditor),
["AbLegacy"] = typeof(AbLegacyTagConfigEditor),
["TwinCat"] = typeof(TwinCATTagConfigEditor),
["Focas"] = typeof(FocasTagConfigEditor),
```
Drivers not in the map → the generic textarea. (Kept deliberately separate from
`DriverEditRouter._componentMap` to avoid coupling tag editing to driver-page
editing; both are short and stable.)
### TagModal integration — learning the driver type
- `IUnsTreeService.LoadTagDriversForEquipmentAsync` changes its return shape from
`(string DriverInstanceId, string Display)` to
`(string DriverInstanceId, string Display, string DriverType)`. (`UnsTreeService`
already loads `DriverInstance`; it just projects `DriverType` too.)
- The TagModal keeps a lookup `DriverInstanceId → DriverType` from those options
and resolves the **currently-selected** driver's type (the driver is chosen in
the modal's existing dropdown and can change while open).
- Render logic in the modal body, where the raw textarea is today:
- no driver selected → a muted "pick a driver to configure this tag" hint;
- selected type in `TagConfigEditorMap` → `<DynamicComponent Type="…"
Parameters="…">` passing `ConfigJson = _form.TagConfig` and a
`ConfigJsonChanged` `EventCallback<string>` that writes back to
`_form.TagConfig`;
- otherwise → the existing raw `InputTextArea` over `_form.TagConfig`.
- When the selected driver changes to a different `DriverType`, the editor swaps;
`_form.TagConfig` carries over (a mismatched blob is simply re-parsed to
defaults by the new editor, and the user re-enters fields).
## Field mapping (Tag columns vs TagConfig)
These stay **top-level TagModal fields** (already present): `Tag.Name`,
`Tag.DataType` (the OPC UA type), `Tag.AccessLevel`, `Tag.WriteIdempotent`,
`Tag.PollGroupId`.
`TagConfig` holds **only the driver-specific addressing/encoding fields** — the
driver page's tag `EditTemplate` fields minus the ones the modal owns:
| Driver | TagConfig fields (editor) | Excluded (lives elsewhere) |
|---|---|---|
| Modbus (`ModbusTcp`) | region, address, dataType (wire), byteOrder, bitIndex, stringLength | name → Tag.Name; writable → AccessLevel |
| S7 | address, stringLength | name; dataType → Tag.DataType; writable |
| AbCip | deviceHostAddress, tagPath | name; dataType; writable |
| AbLegacy | deviceHostAddress, address | name; dataType; writable |
| TwinCAT | deviceHostAddress, symbolPath | name; dataType; writable |
| Focas | deviceHostAddress, address | name; dataType; writable |
> Note: a driver's *wire* data type (e.g. `ModbusDataType` = how to decode the
> register) is distinct from `Tag.DataType` (the OPC UA type) and belongs in
> TagConfig; S7/AbCip/… have no separate wire type, so their data type is the
> top-level `Tag.DataType` only. The exact per-driver field list is finalized in
> the plan against each driver's tag DTO property names.
**Assumption / out of scope:** this assumes the runtime materialises a per-driver
`TagDefinition` for equipment-bound tags from the `Tag` columns + `TagConfig`
JSON. Confirming or wiring that runtime consumption is **not** part of this UI
feature — the editors faithfully capture the driver-shaped fields under the
driver's JSON names so the config is correct when consumed.
## Validation
Each editor helper exposes a light `Validate()` (required fields / simple ranges,
e.g. Modbus requires an address, AbCip requires a tag path) returning an error
string or null. The TagModal surfaces it as its existing inline error and blocks
Save. The current "valid JSON" guard (`JsonException`) still protects the generic
textarea path. **Server-side deploy validation remains authoritative.**
## Testing
No bUnit in this project, so:
- **Unit tests** (`AdminUI.Tests`, xUnit + Shouldly): each editor's
`FromJson` / `ToJson` / `Validate` helper — round-trip fidelity, defaults on
null/blank/malformed, property-name correctness (the emitted JSON matches the
driver's DTO names), and validation pass/fail cases.
- **Live verify** (docker-dev `/run`): the rig's Main cluster currently has only
the Galaxy driver (no typed editor), so verifying a typed editor first needs a
**non-Galaxy driver** in the rig. Steps: add a Modbus driver instance to the
Main cluster, bind an equipment to it, open the equipment's `+ Tag` modal →
confirm the Modbus typed fields render, fill them, Save, and inspect the
persisted `TagConfig` JSON. Spot-check a second driver (AbCip) and confirm an
OpcUaClient/Galaxy-bound equipment still shows the generic textarea.
Gate = build clean + `dotnet test` green + the docker-dev live verify above.
## Constraints / non-goals
- AdminUI only; **no** Configuration entity or migration change.
- Driver edit pages are **not** modified (their inline tag editors stay).
- Not changing how the runtime consumes tags; not adding fields the driver pages
don't already expose.
- Git safety: stage by path (never `git add .`), never stage `sql_login.txt` /
`pki/`, never echo the gateway API key into a new tracked file, no force-push /
no `--no-verify`.
@@ -0,0 +1,411 @@
# Driver-typed Tag Editors (F-uns-1) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Replace the UNS `TagModal`'s single generic raw-JSON `TagConfig` textarea with per-driver typed editors that dispatch on the selected driver's `DriverType` (typed for Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas; generic textarea for OpcUaClient/Galaxy/Historian.Wonderware).
**Architecture:** Each typed driver gets a small Razor editor component under `Components/Shared/Uns/TagEditors/` that is a thin shell over a pure, unit-testable model with `FromJson`/`ToJson`/`Validate`. A static `TagConfigEditorMap` (`DriverType → Type`, mirroring `DriverEditRouter._componentMap`) drives a `<DynamicComponent>` dispatch in `TagModal`. The editors reuse the drivers' enums + camelCase JSON property names (driver pages untouched) and preserve unrecognised JSON keys across a load→save. No data-model or migration change.
**Tech Stack:** .NET 10 Blazor Server (`InteractiveServer`), `System.Text.Json` (incl. `JsonObject`), xUnit + Shouldly (no bUnit), docker-dev rig for live verify.
**Design doc:** `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`
**Hard rules (every task):** AdminUI-only, no Configuration entity/migration change; driver pages NOT modified; stage by path (never `git add .`); never stage `sql_login.txt` / `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; never echo the gateway API key into a new tracked file; no force-push, no `--no-verify`.
---
### Task 1: Surface DriverType to the TagModal driver dropdown
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (foundational — Task 2 depends on it)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` (the `LoadTagDriversForEquipmentAsync` signature)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (impl ~line 707-734)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor` (the `_tagModalDriverOptions` field + its two assignment sites in `HandleAddChild` and `HandleEdit`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor` (the `Drivers` `[Parameter]` type + the driver `<InputSelect>` loop)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceTagDriversTests.cs` (new; or extend an existing `*Tests.cs` in that folder)
**Context:** Today `LoadTagDriversForEquipmentAsync` returns `IReadOnlyList<(string DriverInstanceId, string Display)>`. The TagModal needs the driver's `DriverType` to choose an editor. Change the tuple to carry it. The service already loads `DriverInstance` rows — just project `DriverType` too (see `UnsTreeService.cs:725-733`).
**Step 1 — Write the failing test.** In the new test file, using the in-memory EF pattern from the existing `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/` suites (find one that seeds a cluster + Equipment-namespace driver, e.g. `EquipmentTests`/`TagTests`, and copy its `UnsTreeTestDb` setup): seed an equipment in a cluster that has one Equipment-namespace driver of `DriverType="ModbusTcp"`, call `LoadTagDriversForEquipmentAsync(equipmentId)`, and assert the returned item exposes `DriverType == "ModbusTcp"` (e.g. `result[0].DriverType.ShouldBe("ModbusTcp")`).
**Step 2 — Run it; expect compile failure** (`DriverType` member doesn't exist on the tuple).
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter FullyQualifiedName~UnsTreeServiceTagDriversTests`
**Step 3 — Implement.**
- `IUnsTreeService.cs`: change the return type to `Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>>`.
- `UnsTreeService.cs`: in the final projection add `d.DriverType`, and return `(d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverType)`.
- `GlobalUns.razor`: change `private IReadOnlyList<(string Id, string Display)> _tagModalDriverOptions = Array.Empty<(string, string)>();` to `(string Id, string Display, string DriverType)` (and the `Array.Empty<(string, string, string)>()` initialiser). The two assignment sites already call `await Svc.LoadTagDriversForEquipmentAsync(...)` so they compile unchanged.
- `TagModal.razor`: change `[Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>();` to the 3-tuple; the dropdown `@foreach (var (id, display) in Drivers)` becomes `@foreach (var (id, display, _) in Drivers)`.
**Step 4 — Run tests + build.**
Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj && dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests`
Expected: build clean; new test passes; existing 216 still pass.
**Step 5 — Commit** (stage the 4 source files + the test file by path).
---
### Task 2: Shared JSON util + TagConfigEditorMap + TagModal dispatch scaffold
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 3 depends on it)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs` (shared parse/serialize util)
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (the dispatch map — starts EMPTY)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor` (replace the raw `InputTextArea` block with the dispatch)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigJsonTests.cs` (new)
**Context:** The shared util gives every editor a round-trip-safe way to read/write known fields while **preserving unrecognised keys** (mirrors how `ModbusTagRow` keeps its `_source` record). The map is the dispatch table; it starts empty so this task is a no-behaviour-change scaffold (everything still falls to the generic textarea).
**Step 1 — `TagConfigJson.cs`** (full):
```csharp
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Helpers for per-driver tag-config editors: parse a TagConfig JSON string into a mutable
/// <see cref="JsonObject"/> (preserving every key, so fields the editor doesn't expose survive a
/// load→save), read typed scalars, and serialise back.
/// </summary>
public static class TagConfigJson
{
private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false };
/// <summary>Parses <paramref name="json"/> into a mutable object; returns a fresh empty object on null/blank/malformed/non-object input.</summary>
public static JsonObject ParseOrNew(string? json)
{
if (string.IsNullOrWhiteSpace(json)) { return new JsonObject(); }
try { return JsonNode.Parse(json) as JsonObject ?? new JsonObject(); }
catch (JsonException) { return new JsonObject(); }
}
/// <summary>Serialises the object to compact JSON.</summary>
public static string Serialize(JsonObject obj) => obj.ToJsonString(Compact);
/// <summary>Reads a string value, or null if absent/null.</summary>
public static string? GetString(JsonObject o, string name)
=> o.TryGetPropertyValue(name, out var n) && n is not null ? n.GetValue<string>() : null;
/// <summary>Reads an int value, or <paramref name="fallback"/> if absent/null/non-numeric.</summary>
public static int GetInt(JsonObject o, string name, int fallback = 0)
=> o.TryGetPropertyValue(name, out var n) && n is not null && n.AsValue().TryGetValue<int>(out var v) ? v : fallback;
/// <summary>Reads an enum by its serialised name, or <paramref name="fallback"/> if absent/unparseable.</summary>
public static TEnum GetEnum<TEnum>(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum
=> GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : fallback;
/// <summary>Sets a string/number/enum-name value (enums via ToString()). Null removes the key.</summary>
public static void Set(JsonObject o, string name, object? value)
=> o[name] = value is null ? null : JsonValue.Create(value is Enum e ? e.ToString() : value);
}
```
**Step 2 — `TagConfigEditorMap.cs`** (starts empty; editors register here as later tasks land):
```csharp
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Maps a driver's <c>DriverType</c> string to its typed tag-config editor component (mirrors
/// <c>DriverEditRouter._componentMap</c>). Drivers absent from the map fall back to the generic
/// raw-JSON textarea in the TagModal.
/// </summary>
public static class TagConfigEditorMap
{
private static readonly IReadOnlyDictionary<string, Type> Map =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
// Editors registered by later tasks, e.g.:
// ["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
public static Type? Resolve(string? driverType)
=> driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null;
}
```
> Note on the registration-coupling: the `Map` dictionary literal is the single edit point for registering an editor. Tasks 48 deliberately do NOT touch it (so they're parallel-safe); Task 9 registers their five editors in one edit.
**Step 3 — TagModal dispatch.** In `TagModal.razor`, replace the current `Tag config (JSON)` block (the `<label> + <InputTextArea @bind-Value="_form.TagConfig" …>` + form-text + `ValidationMessage`) with:
```razor
<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 (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>
```
Add to `TagModal.razor` `@code` (and `@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors`):
```csharp
// 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;
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
{
["ConfigJson"] = _form.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
};
```
> `Drivers.FirstOrDefault(...)` returns a default tuple (all-null) when nothing matches, so `.DriverType` is null — `Resolve(null)` returns null → generic textarea. Good.
**Step 4 — `TagConfigJsonTests.cs`:** `ParseOrNew` on null/""/"{bad"/"[1,2]" → empty object; round-trip preserves unknown keys (`{"foo":"bar","region":"X"}` → set `region` → still has `foo`); `GetEnum` parses by name + falls back; `Set` writes enum as its name string.
**Step 5 — Build + test** (216 + new pass; UI unchanged because the map is empty). **Commit** by path.
---
### Task 3: Modbus tag editor (the worked example)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (establishes the pattern Tasks 48 copy)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (register `ModbusTcp`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusTagConfigModelTests.cs`
**Reference (read these, do not edit):** `ModbusDriverPage.razor:286-313` (the EditTemplate fields) and `:481-532` (`ModbusTagRow`); `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs:282-319` (`ModbusTagDto` — the canonical JSON names). Enums (`ModbusRegion`, `ModbusDataType`, `ModbusByteOrder`) are in namespace `ZB.MOM.WW.OtOpcUa.Driver.Modbus` (the driver page `@using`es it; the AdminUI csproj already references the Modbus contracts — confirm the `@using`/reference compiles).
**Fields the editor owns (→ TagConfig JSON, camelCase names):**
| UI field | Model type | JSON name | Default |
|---|---|---|---|
| Region | `ModbusRegion` | `region` | `HoldingRegisters` |
| Address | `int` | `address` | `0` |
| Data type (wire) | `ModbusDataType` | `dataType` | `Int16` |
| Byte order | `ModbusByteOrder` | `byteOrder` | `BigEndian` |
| Bit index | `int` | `bitIndex` | `0` |
| String length | `int` | `stringLength` | `0` |
Excluded (owned by TagModal/Tag columns): `name` (Tag.Name), `writable`/`writeIdempotent` (AccessLevel/WriteIdempotent). Enum values serialise as their NAME (e.g. `"HoldingRegisters"`) to match `ModbusTagDto` (its `Region`/`DataType`/`ByteOrder` are `string?`).
**Step 1 — Write `ModbusTagConfigModelTests.cs` (failing):**
- `FromJson("{}")` → defaults (Region `HoldingRegisters`, DataType `Int16`, ByteOrder `BigEndian`, Address 0).
- Round-trip: `FromJson(m.ToJson())` equals `m` for a populated model.
- `ToJson()` emits exactly the 6 camelCase keys with enum NAMES (assert the JSON string contains `"region":"HoldingRegisters"` etc.).
- **Preserve unknown:** `FromJson("""{"region":"InputRegisters","addressString":"40001:F"}""").ToJson()` still contains `addressString` (the editor must not drop keys it doesn't expose).
- `Validate()` returns null for a default model (Modbus needs no required free-text — address defaults 0). (If you add a rule, test it.)
**Step 2 — Run; expect compile failure.**
**Step 3 — Implement `ModbusTagConfigModel.cs`:** hold the 6 typed properties + a private `JsonObject _bag` (the parsed source, for key preservation). `FromJson(string?)``TagConfigJson.ParseOrNew`, read the 6 fields via `TagConfigJson.GetEnum/GetInt`, stash the bag. `ToJson()``TagConfigJson.Set` the 6 fields onto the bag, then `TagConfigJson.Serialize(bag)`. `Validate()` → null (or required-field checks). Example:
```csharp
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
public sealed class ModbusTagConfigModel
{
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
public int Address { get; set; }
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
public int BitIndex { get; set; }
public int StringLength { get; set; }
private JsonObject _bag = new();
public static ModbusTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new ModbusTagConfigModel
{
Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters),
Address = TagConfigJson.GetInt(o, "address"),
DataType = TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
StringLength = TagConfigJson.GetInt(o, "stringLength"),
_bag = o,
};
}
public string ToJson()
{
TagConfigJson.Set(_bag, "region", Region);
TagConfigJson.Set(_bag, "address", Address);
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "byteOrder", ByteOrder);
TagConfigJson.Set(_bag, "bitIndex", BitIndex);
TagConfigJson.Set(_bag, "stringLength", StringLength);
return TagConfigJson.Serialize(_bag);
}
public string? Validate() => null;
}
```
**Step 4 — Implement `ModbusTagConfigEditor.razor`** — the thin shell. Parse on `OnParametersSet`, bind fields, raise `ConfigJsonChanged` on change:
```razor
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Region</label>
<select class="form-select form-select-sm" value="@_m.Region" @onchange="@(e => Update(() => _m.Region = Enum.Parse<ModbusRegion>(e.Value!.ToString()!)))">
@foreach (var v in Enum.GetValues<ModbusRegion>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-md-2"><label class="form-label">Address</label>
<input type="number" class="form-control form-control-sm" value="@_m.Address" @onchange="@(e => Update(() => _m.Address = ParseInt(e.Value)))" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = Enum.Parse<ModbusDataType>(e.Value!.ToString()!)))">
@foreach (var v in Enum.GetValues<ModbusDataType>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Byte order</label>
<select class="form-select form-select-sm" value="@_m.ByteOrder" @onchange="@(e => Update(() => _m.ByteOrder = Enum.Parse<ModbusByteOrder>(e.Value!.ToString()!)))">
@foreach (var v in Enum.GetValues<ModbusByteOrder>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-md-2"><label class="form-label">Bit index</label>
<input type="number" class="form-control form-control-sm" value="@_m.BitIndex" @onchange="@(e => Update(() => _m.BitIndex = ParseInt(e.Value)))" /></div>
<div class="col-md-2"><label class="form-label">String len</label>
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => Update(() => _m.StringLength = ParseInt(e.Value)))" /></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
private ModbusTagConfigModel _m = new();
protected override void OnParametersSet() => _m = ModbusTagConfigModel.FromJson(ConfigJson);
private static int ParseInt(object? v) => int.TryParse(v?.ToString(), out var i) ? i : 0;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
```
> Use plain `<select>`/`<input>` + `@onchange` (not `@bind`) so each edit immediately pushes the serialised JSON up via `ConfigJsonChanged` — that's what the TagModal's `_form.TagConfig` binds to. (DynamicComponent can't forward `@bind-Value`, so we drive it through the `ConfigJsonChanged` callback explicitly.)
**Step 5 — Register Modbus** in `TagConfigEditorMap.Map`: `["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),`. **Build + test. Commit** by path.
---
### Tasks 48: S7 / AbCip / AbLegacy / TwinCAT / Focas tag editors
Each of these is the SAME shape as Task 3 (model + razor shell + tests), parameterised by the driver's fields below. **Do NOT edit `TagConfigEditorMap.cs`** (Task 9 registers all five at once, so these tasks stay parallel-safe). Reuse `TagConfigJson` and copy the Modbus model/editor/test structure exactly. All five drivers' enums live in the driver namespace listed; all serialise enum values as their NAME; all preserve unknown keys; all exclude `name`/`writable`/`writeIdempotent`.
Each task's **Files:** Create `Uns/TagEditors/<Driver>TagConfigModel.cs` + `Components/Shared/Uns/TagEditors/<Driver>TagConfigEditor.razor`; Test `tests/.../Uns/<Driver>TagConfigModelTests.cs`. Each task's tests mirror Task 3's (defaults, round-trip, JSON-name correctness, preserve-unknown, validate). Build + test + commit per task.
#### Task 4: S7
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 5, 6, 7, 8
- `@using ZB.MOM.WW.OtOpcUa.Driver.S7`. Read `S7DriverPage.razor:339-381` (`S7TagRow`) + the S7 tag DTO in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/`.
- Fields: `address` (string, required — `Validate` returns "Address is required." when blank), `dataType` (`S7DataType` enum, default the enum's first/`Int16`-equivalent — confirm against the enum), `stringLength` (int).
- Excluded: name, writable, writeIdempotent.
#### Task 5: AbCip
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4, 6, 7, 8
- `@using ZB.MOM.WW.OtOpcUa.Driver.AbCip`. Read `AbCipDriverPage.razor:425-467` + the AbCip tag DTO.
- Fields: `deviceHostAddress` (string), `tagPath` (string, required), `dataType` (`AbCipDataType` enum). (Do NOT add the `members`/`safetyTag` advanced fields — the driver page's tag EditTemplate doesn't expose them; preserve-unknown keeps any that exist.)
- Excluded: name, writable, writeIdempotent.
#### Task 6: AbLegacy
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4, 5, 7, 8
- `@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy`. Read `AbLegacyDriverPage.razor:391-433` + the AbLegacy tag DTO.
- Fields: `deviceHostAddress` (string), `address` (string, required — PCCC file address e.g. `N7:0`), `dataType` (`AbLegacyDataType` enum).
- Excluded: name, writable, writeIdempotent.
#### Task 7: TwinCAT
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4, 5, 6, 8
- `@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT`. Read `TwinCATDriverPage.razor:395-437` + the TwinCAT tag DTO.
- Fields: `deviceHostAddress` (string), `symbolPath` (string, required — e.g. `MAIN.bStart`), `dataType` (`TwinCATDataType` enum).
- Excluded: name, writable, writeIdempotent.
#### Task 8: Focas
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4, 5, 6, 7
- `@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS`. Read `FocasDriverPage.razor:457-499` + the Focas tag DTO.
- Fields: `deviceHostAddress` (string), `address` (string, required — e.g. `R100`, `MACRO:500`), `dataType` (`FocasDataType` enum).
- Excluded: name, writable, writeIdempotent.
---
### Task 9: Register the five editors in TagConfigEditorMap
**Classification:** small
**Estimated implement time:** ~2 min
**Parallelizable with:** none (depends on Tasks 48)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`
Add the five entries (Modbus already there from Task 3):
```csharp
["S7"] = typeof(Components.Shared.Uns.TagEditors.S7TagConfigEditor),
["AbCip"] = typeof(Components.Shared.Uns.TagEditors.AbCipTagConfigEditor),
["AbLegacy"] = typeof(Components.Shared.Uns.TagEditors.AbLegacyTagConfigEditor),
["TwinCat"] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
["Focas"] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
```
Build + run full `AdminUI.Tests` suite (all editor-model tests + 216 baseline). Commit by path.
---
### Task 10: Live-verify in docker-dev
**Classification:** standard (verification — no production code)
**Estimated implement time:** ~6 min (interactive; allowed to exceed the 5-min guidance because it's a manual run, not a code edit)
**Parallelizable with:** none (depends on Task 9)
**Context:** The docker-dev Main cluster has only the Galaxy driver (no typed editor), so a typed editor can't be exercised until a non-Galaxy driver exists. Galaxy lives in a `SystemPlatform` namespace; a Modbus driver needs an **Equipment-kind** namespace (decision #110) so equipment can bind to it.
**Steps (no repo change unless a step says so):**
1. Ensure the rig is up and central runs the latest master (rebuild central per `docs/v2/dev-environment.md` / the project's docker-dev recipe if needed). Sign-in is the user's action (agent must not enter the password).
2. In AdminUI: confirm the Main cluster has an **Equipment-kind** namespace; if not, create one (`/clusters/MAIN/namespaces` → New, Kind=Equipment). Then create a **Modbus** driver instance in that namespace (`/clusters/MAIN/drivers` → New driver → Modbus), endpoint can be the docker host `10.100.0.35:5020` (the rig's Modbus sim) or any placeholder — it isn't connected for this UI check.
3. In `/uns`: pick an equipment, **Edit** it, bind it to the new Modbus driver, Save. Then **+ Tag** on that equipment → confirm the driver dropdown lists the Modbus driver, select it, and confirm the **Modbus typed fields** (Region / Address / Data type / Byte order / Bit index / String len) render instead of the JSON textarea. Fill Name + a driver + the fields, Create.
4. Verify persistence: query `dbo.Tag` (via the sql container, as in the cluster-seed verification) for the new tag's `TagConfig` and confirm it's the structured JSON (`{"region":"…","address":…,"dataType":"…",…}`).
5. Spot-check **AbCip**: temporarily add an AbCip driver + bind, open + Tag, confirm AbCip typed fields render. (Tear down the temp AbCip driver after.)
6. Confirm **fallback**: a Galaxy-bound equipment's + Tag still shows the generic JSON textarea (no typed editor for `GalaxyMxGateway`).
7. Record the outcome; if any editor mis-renders, that's a bug to fix before marking #135 done (per the no-bUnit live-verify rule — `dotnet test` green is necessary but not sufficient for AdminUI razor).
**Done gate for the whole feature:** build clean + full `AdminUI.Tests` green + the live verify above passes.
---
## Notes for the executor
- **Parallel dispatch:** Tasks 48 are mutually parallelizable (disjoint files; the shared map is untouched until Task 9). Dispatch their implementers concurrently.
- **Serial spine:** 1 → 2 → 3 → {4,5,6,7,8} → 9 → 10.
- **No bUnit:** every razor editor's logic lives in its `*Model` helper so it's unit-tested; the razor shell itself is only proven by Task 10's live run.
- **Preserve-unknown** is a real requirement, not a nicety: a tag may carry config keys the editor doesn't surface (e.g. Modbus `addressString`, AbCip `members`); the model must round-trip them.
- **Known limitation (out of scope):** for Modbus, an existing `addressString` takes runtime precedence over the structured fields; this editor edits the structured fields and preserves `addressString` but does not surface it. A future follow-up could add an addressString field.
@@ -0,0 +1,16 @@
{
"planPath": "docs/plans/2026-06-09-driver-typed-tag-editors.md",
"tasks": [
{"id": 1, "nativeId": 146, "subject": "Task 1: Surface DriverType to TagModal driver dropdown", "classification": "standard", "status": "pending"},
{"id": 2, "nativeId": 147, "subject": "Task 2: TagConfigJson util + EditorMap + TagModal dispatch scaffold", "classification": "standard", "status": "pending", "blockedBy": [1]},
{"id": 3, "nativeId": 148, "subject": "Task 3: Modbus tag editor (worked example)", "classification": "standard", "status": "pending", "blockedBy": [2]},
{"id": 4, "nativeId": 149, "subject": "Task 4: S7 tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [5, 6, 7, 8]},
{"id": 5, "nativeId": 150, "subject": "Task 5: AbCip tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [4, 6, 7, 8]},
{"id": 6, "nativeId": 151, "subject": "Task 6: AbLegacy tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [4, 5, 7, 8]},
{"id": 7, "nativeId": 152, "subject": "Task 7: TwinCAT tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [4, 5, 6, 8]},
{"id": 8, "nativeId": 153, "subject": "Task 8: Focas tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [4, 5, 6, 7]},
{"id": 9, "nativeId": 154, "subject": "Task 9: Register the 5 editors in TagConfigEditorMap", "classification": "small", "status": "pending", "blockedBy": [4, 5, 6, 7, 8]},
{"id": 10, "nativeId": 155, "subject": "Task 10: Live-verify typed editors in docker-dev", "classification": "verification", "status": "pending", "blockedBy": [9]}
],
"lastUpdated": "2026-06-09"
}
@@ -157,7 +157,7 @@
private bool _tagModalIsNew;
private string? _tagModalEquipmentId;
private TagEditDto? _tagModalExisting;
private IReadOnlyList<(string Id, string Display)> _tagModalDriverOptions = Array.Empty<(string, string)>();
private IReadOnlyList<(string Id, string Display, string DriverType)> _tagModalDriverOptions = Array.Empty<(string, string, string)>();
// --- Virtual-tag modal state ---
private bool _vtagModalVisible;
@@ -613,7 +613,7 @@
_tagModalIsNew = false;
_tagModalEquipmentId = null;
_tagModalExisting = null;
_tagModalDriverOptions = Array.Empty<(string, string)>();
_tagModalDriverOptions = Array.Empty<(string, string, string)>();
_vtagModalVisible = false;
_vtagModalIsNew = false;
_vtagModalEquipmentId = null;
@@ -0,0 +1,40 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Device host</label>
<input class="form-control form-control-sm mono" value="@_m.DeviceHostAddress" placeholder="ab://gateway[:port]/cip-path" @onchange="@(e => Update(() => _m.DeviceHostAddress = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-5"><label class="form-label">Tag path</label>
<input class="form-control form-control-sm mono" value="@_m.TagPath" placeholder="e.g. Motor1.Speed" @onchange="@(e => Update(() => _m.TagPath = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, AbCipDataType.DInt)))">
@foreach (var v in Enum.GetValues<AbCipDataType>()) { <option value="@v">@v</option> }
</select></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
private AbCipTagConfigModel _m = new();
private string? _lastConfigJson;
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = AbCipTagConfigModel.FromJson(ConfigJson);
}
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -0,0 +1,40 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Device host</label>
<input class="form-control form-control-sm mono" value="@_m.DeviceHostAddress" placeholder="ab://host" @onchange="@(e => Update(() => _m.DeviceHostAddress = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-5"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" value="@_m.Address" placeholder="e.g. N7:0, B3:0/0" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, AbLegacyDataType.Int)))">
@foreach (var v in Enum.GetValues<AbLegacyDataType>()) { <option value="@v">@v</option> }
</select></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
private AbLegacyTagConfigModel _m = new();
private string? _lastConfigJson;
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = AbLegacyTagConfigModel.FromJson(ConfigJson);
}
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -0,0 +1,40 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Device host</label>
<input class="form-control form-control-sm mono" value="@_m.DeviceHostAddress" placeholder="host[:port]" @onchange="@(e => Update(() => _m.DeviceHostAddress = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-5"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" value="@_m.Address" placeholder="e.g. X0.0, R100, PARAM:1815/0, MACRO:500" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, FocasDataType.Int32)))">
@foreach (var v in Enum.GetValues<FocasDataType>()) { <option value="@v">@v</option> }
</select></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
private FocasTagConfigModel _m = new();
private string? _lastConfigJson;
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = FocasTagConfigModel.FromJson(ConfigJson);
}
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -0,0 +1,52 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Region</label>
<select class="form-select form-select-sm" value="@_m.Region" @onchange="@(e => Update(() => _m.Region = ParseEnum(e.Value, ModbusRegion.HoldingRegisters)))">
@foreach (var v in Enum.GetValues<ModbusRegion>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-md-2"><label class="form-label">Address</label>
<input type="number" class="form-control form-control-sm" value="@_m.Address" @onchange="@(e => Update(() => _m.Address = ParseInt(e.Value)))" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, ModbusDataType.Int16)))">
@foreach (var v in Enum.GetValues<ModbusDataType>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-md-3"><label class="form-label">Byte order</label>
<select class="form-select form-select-sm" value="@_m.ByteOrder" @onchange="@(e => Update(() => _m.ByteOrder = ParseEnum(e.Value, ModbusByteOrder.BigEndian)))">
@foreach (var v in Enum.GetValues<ModbusByteOrder>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-md-2"><label class="form-label">Bit index</label>
<input type="number" class="form-control form-control-sm" value="@_m.BitIndex" @onchange="@(e => Update(() => _m.BitIndex = ParseInt(e.Value)))" /></div>
<div class="col-md-2"><label class="form-label">String len</label>
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => Update(() => _m.StringLength = ParseInt(e.Value)))" /></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
private ModbusTagConfigModel _m = new();
private string? _lastConfigJson;
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = ModbusTagConfigModel.FromJson(ConfigJson);
}
private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback;
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -0,0 +1,42 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.S7
<div class="row g-2">
<div class="col-md-5"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" value="@_m.Address" placeholder="e.g. DB1.DBW0, M0.0, I0.0, QD4" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-4"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, S7DataType.Int16)))">
@foreach (var v in Enum.GetValues<S7DataType>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-md-3"><label class="form-label">String len</label>
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => Update(() => _m.StringLength = ParseInt(e.Value)))" /></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
private S7TagConfigModel _m = new();
private string? _lastConfigJson;
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = S7TagConfigModel.FromJson(ConfigJson);
}
private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback;
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -0,0 +1,40 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Device host</label>
<input class="form-control form-control-sm mono" value="@_m.DeviceHostAddress" placeholder="AmsNetId[:port]" @onchange="@(e => Update(() => _m.DeviceHostAddress = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-5"><label class="form-label">Symbol path</label>
<input class="form-control form-control-sm mono" value="@_m.SymbolPath" placeholder="e.g. MAIN.bStart, GVL.Counter" @onchange="@(e => Update(() => _m.SymbolPath = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, TwinCATDataType.DInt)))">
@foreach (var v in Enum.GetValues<TwinCATDataType>()) { <option value="@v">@v</option> }
</select></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
private TwinCATTagConfigModel _m = new();
private string? _lastConfigJson;
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
protected override void OnParametersSet()
{
if (ConfigJson == _lastConfigJson) { return; }
_lastConfigJson = ConfigJson;
_m = TwinCATTagConfigModel.FromJson(ConfigJson);
}
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -7,6 +7,7 @@
@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.Configuration.Enums
@inject IUnsTreeService Svc
@@ -41,9 +42,9 @@
<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" class="form-select form-select-sm">
<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 (id, display) in Drivers)
@foreach (var (id, display, _) in Drivers)
{
<option value="@id">@display</option>
}
@@ -81,11 +82,25 @@
<InputText id="tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
</div>
<div class="mb-3">
<label class="form-label" for="tag-config">Tag config (JSON)</label>
<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 — register / address / scaling / byte-order. Validated server-side at deploy.</div>
<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 (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>
@@ -125,7 +140,7 @@
[Parameter] public TagEditDto? Existing { get; set; }
/// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
[Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>();
[Parameter] public IReadOnlyList<(string Id, string Display, string DriverType)> Drivers { get; set; } = Array.Empty<(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; }
@@ -137,6 +152,21 @@
private bool _busy;
private string? _error;
// 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;
// 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 = "{}";
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
{
["ConfigJson"] = _form.TagConfig,
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
};
protected override void OnParametersSet()
{
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
@@ -316,8 +316,9 @@ public interface IUnsTreeService
/// </summary>
/// <param name="equipmentId">The equipment whose candidate drivers to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The eligible drivers projected to <c>(DriverInstanceId, Display)</c> pairs.</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <returns>The eligible drivers projected to <c>(DriverInstanceId, Display, DriverType)</c> triples,
/// where <c>DriverType</c> lets the TagModal dispatch to a per-driver-type typed config editor.</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 —
@@ -0,0 +1,48 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for an AB CIP / EtherNet-IP tag's TagConfig JSON (the driver-specific addressing/encoding
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
public sealed class AbCipTagConfigModel
{
/// <summary>Which device (<c>AbCipDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
public string DeviceHostAddress { get; set; } = "";
/// <summary>Logix symbolic path (controller or program scope). Required.</summary>
public string TagPath { get; set; } = "";
/// <summary>Logix atomic type, or <c>AbCipDataType.Structure</c> for UDT-typed tags.</summary>
public AbCipDataType DataType { get; set; } = AbCipDataType.DInt;
private JsonObject _bag = new();
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
public static AbCipTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new AbCipTagConfigModel
{
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
TagPath = TagConfigJson.GetString(o, "tagPath") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", AbCipDataType.DInt),
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
/// (enum as its name string; the optional host address omitted when blank) over the preserved key bag.</summary>
public string ToJson()
{
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
TagConfigJson.Set(_bag, "tagPath", TagPath.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate()
=> string.IsNullOrWhiteSpace(TagPath) ? "Tag path is required." : null;
}
@@ -0,0 +1,48 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for an AB Legacy (PCCC) tag's TagConfig JSON (the driver-specific addressing/encoding
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
public sealed class AbLegacyTagConfigModel
{
/// <summary>Which device (<c>AbLegacyDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
public string DeviceHostAddress { get; set; } = "";
/// <summary>Canonical PCCC file-address string, e.g. <c>N7:0</c>, <c>B3:0/0</c>. Required.</summary>
public string Address { get; set; } = "";
/// <summary>PCCC data type that maps onto SLC / MicroLogix / PLC-5 files.</summary>
public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int;
private JsonObject _bag = new();
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
public static AbLegacyTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new AbLegacyTagConfigModel
{
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
Address = TagConfigJson.GetString(o, "address") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", AbLegacyDataType.Int),
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
/// (enum as its name string; the optional host address omitted when blank) over the preserved key bag.</summary>
public string ToJson()
{
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
TagConfigJson.Set(_bag, "address", Address.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate()
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
}
@@ -0,0 +1,48 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for a FOCAS (FANUC CNC) tag's TagConfig JSON (the driver-specific addressing/encoding
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
public sealed class FocasTagConfigModel
{
/// <summary>Which device (<c>FocasDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
public string DeviceHostAddress { get; set; } = "";
/// <summary>Canonical FOCAS address string, e.g. <c>X0.0</c>, <c>R100</c>, <c>PARAM:1815/0</c>, <c>MACRO:500</c>. Required.</summary>
public string Address { get; set; } = "";
/// <summary>FOCAS atomic data type.</summary>
public FocasDataType DataType { get; set; } = FocasDataType.Int32;
private JsonObject _bag = new();
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
public static FocasTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new FocasTagConfigModel
{
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
Address = TagConfigJson.GetString(o, "address") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", FocasDataType.Int32),
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
/// (enum as its name string; the optional host address omitted when blank) over the preserved key bag.</summary>
public string ToJson()
{
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
TagConfigJson.Set(_bag, "address", Address.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate()
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
}
@@ -0,0 +1,62 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for a Modbus tag's TagConfig JSON (the driver-specific addressing/encoding
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
public sealed class ModbusTagConfigModel
{
/// <summary>Register region (HoldingRegisters/InputRegisters/Coils/DiscreteInputs).</summary>
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
/// <summary>Starting register/coil address.</summary>
public int Address { get; set; }
/// <summary>Wire value type the register block decodes to.</summary>
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
/// <summary>Word/byte ordering for multi-register values.</summary>
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
/// <summary>Bit index (0-15) for BitInRegister tags.</summary>
public int BitIndex { get; set; }
/// <summary>String length in characters for String tags.</summary>
public int StringLength { get; set; }
private JsonObject _bag = new();
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
public static ModbusTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new ModbusTagConfigModel
{
Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters),
Address = TagConfigJson.GetInt(o, "address"),
DataType = TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
StringLength = TagConfigJson.GetInt(o, "stringLength"),
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string, writing the six exposed fields
/// (enums as their name strings) over the preserved key bag.</summary>
public string ToJson()
{
TagConfigJson.Set(_bag, "region", Region);
TagConfigJson.Set(_bag, "address", Address);
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "byteOrder", ByteOrder);
TagConfigJson.Set(_bag, "bitIndex", BitIndex);
TagConfigJson.Set(_bag, "stringLength", StringLength);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate() => null;
}
@@ -0,0 +1,48 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for a Siemens S7 tag's TagConfig JSON (the driver-specific addressing/encoding
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
public sealed class S7TagConfigModel
{
/// <summary>S7 address string, e.g. <c>DB1.DBW0</c>, <c>M0.0</c>, <c>I0.0</c>, <c>QD4</c>. Required.</summary>
public string Address { get; set; } = "";
/// <summary>Logical data type — drives the underlying S7.Net read/write width.</summary>
public S7DataType DataType { get; set; } = S7DataType.Int16;
/// <summary>For <c>DataType = String</c>: S7-string max length (max 254).</summary>
public int StringLength { get; set; }
private JsonObject _bag = new();
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
public static S7TagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new S7TagConfigModel
{
Address = TagConfigJson.GetString(o, "address") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", S7DataType.Int16),
StringLength = TagConfigJson.GetInt(o, "stringLength"),
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
/// (enum as its name string) over the preserved key bag.</summary>
public string ToJson()
{
TagConfigJson.Set(_bag, "address", Address.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "stringLength", StringLength);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate()
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
}
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Maps a driver's <c>DriverType</c> string to its typed tag-config editor component (mirrors
/// <c>DriverEditRouter._componentMap</c>). Drivers absent from the map fall back to the generic
/// raw-JSON textarea in the TagModal.
/// </summary>
public static class TagConfigEditorMap
{
private static readonly IReadOnlyDictionary<string, Type> Map =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
{
["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),
["S7"] = typeof(Components.Shared.Uns.TagEditors.S7TagConfigEditor),
["AbCip"] = typeof(Components.Shared.Uns.TagEditors.AbCipTagConfigEditor),
["AbLegacy"] = typeof(Components.Shared.Uns.TagEditors.AbLegacyTagConfigEditor),
["TwinCat"] = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
["Focas"] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
};
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
public static Type? Resolve(string? driverType)
=> driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null;
}
@@ -0,0 +1,42 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>
/// Helpers for per-driver tag-config editors: parse a TagConfig JSON string into a mutable
/// <see cref="JsonObject"/> (preserving every key, so fields the editor doesn't expose survive a
/// load→save), read typed scalars, and serialise back.
/// </summary>
public static class TagConfigJson
{
/// <summary>Parses <paramref name="json"/> into a mutable object; returns a fresh empty object on null/blank/malformed/non-object input.</summary>
public static JsonObject ParseOrNew(string? json)
{
if (string.IsNullOrWhiteSpace(json)) { return new JsonObject(); }
try { return JsonNode.Parse(json) as JsonObject ?? new JsonObject(); }
catch (JsonException) { return new JsonObject(); }
}
/// <summary>Serialises the object to compact JSON (JsonNode.ToJsonString() defaults to non-indented).</summary>
public static string Serialize(JsonObject obj) => obj.ToJsonString();
/// <summary>Reads a string value, or null if absent/null/non-string.</summary>
public static string? GetString(JsonObject o, string name)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
/// <summary>Reads an int value, or <paramref name="fallback"/> if absent/null/non-numeric (incl. object/array nodes).</summary>
public static int GetInt(JsonObject o, string name, int fallback = 0)
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<int>(out var i) ? i : fallback;
/// <summary>Reads an enum by its serialised name, or <paramref name="fallback"/> if absent/unparseable.</summary>
public static TEnum GetEnum<TEnum>(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum
=> GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : fallback;
/// <summary>Sets a string/number/enum-name value (enums via ToString()). A null value REMOVES the key, so it is omitted from the serialised JSON.</summary>
public static void Set(JsonObject o, string name, object? value)
{
if (value is null) { o.Remove(name); return; }
o[name] = JsonValue.Create(value is Enum e ? e.ToString() : value);
}
}
@@ -0,0 +1,48 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>Typed working model for a TwinCAT ADS tag's TagConfig JSON (the driver-specific addressing/encoding
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
public sealed class TwinCATTagConfigModel
{
/// <summary>Which device (<c>TwinCATDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
public string DeviceHostAddress { get; set; } = "";
/// <summary>Full TwinCAT symbolic name, e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>. Required.</summary>
public string SymbolPath { get; set; } = "";
/// <summary>TwinCAT / IEC 61131-3 atomic data type.</summary>
public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt;
private JsonObject _bag = new();
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
public static TwinCATTagConfigModel FromJson(string? json)
{
var o = TagConfigJson.ParseOrNew(json);
return new TwinCATTagConfigModel
{
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
SymbolPath = TagConfigJson.GetString(o, "symbolPath") ?? "",
DataType = TagConfigJson.GetEnum(o, "dataType", TwinCATDataType.DInt),
_bag = o,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
/// (enum as its name string; the optional host address omitted when blank) over the preserved key bag.</summary>
public string ToJson()
{
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
TagConfigJson.Set(_bag, "symbolPath", SymbolPath.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate()
=> string.IsNullOrWhiteSpace(SymbolPath) ? "Symbol path is required." : null;
}
@@ -704,7 +704,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
/// <inheritdoc />
public async Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadTagDriversForEquipmentAsync(
public async Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync(
string equipmentId,
CancellationToken ct = default)
{
@@ -713,7 +713,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (equipmentCluster is null)
{
return Array.Empty<(string, string)>();
return Array.Empty<(string, string, string)>();
}
// Drivers in the equipment's cluster whose namespace is Equipment-kind (decision #110).
@@ -725,11 +725,11 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
var drivers = await db.DriverInstances
.Where(d => d.ClusterId == equipmentCluster && equipmentNamespaceIds.Contains(d.NamespaceId))
.OrderBy(d => d.DriverInstanceId)
.Select(d => new { d.DriverInstanceId, d.Name })
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType })
.ToListAsync(ct);
return drivers
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}"))
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverType))
.ToList();
}
@@ -0,0 +1,102 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class AbCipTagConfigModelTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
public void FromJson_returns_defaults_for_empty_input(string? json)
{
var m = AbCipTagConfigModel.FromJson(json);
m.DeviceHostAddress.ShouldBe("");
m.TagPath.ShouldBe("");
m.DataType.ShouldBe(AbCipDataType.DInt);
}
[Fact]
public void Round_trip_preserves_all_fields()
{
var m = new AbCipTagConfigModel
{
DeviceHostAddress = "ab://gw/1,0",
TagPath = "Program:Main.Speed",
DataType = AbCipDataType.Real,
};
var json = m.ToJson();
var m2 = AbCipTagConfigModel.FromJson(json);
m2.DeviceHostAddress.ShouldBe("ab://gw/1,0");
m2.TagPath.ShouldBe("Program:Main.Speed");
m2.DataType.ShouldBe(AbCipDataType.Real);
}
[Fact]
public void ToJson_emits_camelCase_keys_with_enum_names()
{
var m = new AbCipTagConfigModel
{
DeviceHostAddress = "ab://gw/1,0",
TagPath = "Motor1.Status",
DataType = AbCipDataType.DInt,
};
var json = m.ToJson();
json.ShouldContain("\"deviceHostAddress\":\"ab://gw/1,0\"");
json.ShouldContain("\"tagPath\":\"Motor1.Status\"");
json.ShouldContain("\"dataType\":\"DInt\"");
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys()
{
var json = AbCipTagConfigModel
.FromJson("""{"tagPath":"Motor1.Status","extraKey":"keepme"}""")
.ToJson();
json.ShouldContain("extraKey");
json.ShouldContain("keepme");
json.ShouldContain("\"tagPath\":\"Motor1.Status\"");
}
[Fact]
public void Validate_returns_error_when_tagPath_blank()
{
new AbCipTagConfigModel { TagPath = "" }.Validate().ShouldNotBeNull();
}
[Fact]
public void Validate_returns_null_when_tagPath_filled()
{
new AbCipTagConfigModel { TagPath = "Motor1.Status" }.Validate().ShouldBeNull();
}
[Fact]
public void ToJson_omits_blank_deviceHostAddress()
{
var m = new AbCipTagConfigModel { TagPath = "Motor1.Status", DeviceHostAddress = "" };
var json = m.ToJson();
json.ShouldNotContain("deviceHostAddress");
}
[Fact]
public void ToJson_includes_deviceHostAddress_when_set()
{
var m = new AbCipTagConfigModel { TagPath = "Motor1.Status", DeviceHostAddress = "ab://host" };
var json = m.ToJson();
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
}
}
@@ -0,0 +1,102 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class AbLegacyTagConfigModelTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
public void FromJson_returns_defaults_for_empty_input(string? json)
{
var m = AbLegacyTagConfigModel.FromJson(json);
m.DeviceHostAddress.ShouldBe("");
m.Address.ShouldBe("");
m.DataType.ShouldBe(AbLegacyDataType.Int);
}
[Fact]
public void Round_trip_preserves_all_fields()
{
var m = new AbLegacyTagConfigModel
{
DeviceHostAddress = "ab://10.0.0.5",
Address = "N7:0",
DataType = AbLegacyDataType.Float,
};
var json = m.ToJson();
var m2 = AbLegacyTagConfigModel.FromJson(json);
m2.DeviceHostAddress.ShouldBe("ab://10.0.0.5");
m2.Address.ShouldBe("N7:0");
m2.DataType.ShouldBe(AbLegacyDataType.Float);
}
[Fact]
public void ToJson_emits_camelCase_keys_with_enum_names()
{
var m = new AbLegacyTagConfigModel
{
DeviceHostAddress = "ab://10.0.0.5",
Address = "N7:0",
DataType = AbLegacyDataType.Int,
};
var json = m.ToJson();
json.ShouldContain("\"deviceHostAddress\":\"ab://10.0.0.5\"");
json.ShouldContain("\"address\":\"N7:0\"");
json.ShouldContain("\"dataType\":\"Int\"");
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys()
{
var json = AbLegacyTagConfigModel
.FromJson("""{"address":"N7:0","extraKey":"keepme"}""")
.ToJson();
json.ShouldContain("extraKey");
json.ShouldContain("keepme");
json.ShouldContain("\"address\":\"N7:0\"");
}
[Fact]
public void Validate_returns_error_when_address_blank()
{
new AbLegacyTagConfigModel { Address = "" }.Validate().ShouldNotBeNull();
}
[Fact]
public void Validate_returns_null_when_address_filled()
{
new AbLegacyTagConfigModel { Address = "N7:0" }.Validate().ShouldBeNull();
}
[Fact]
public void ToJson_omits_blank_deviceHostAddress()
{
var m = new AbLegacyTagConfigModel { Address = "N7:0", DeviceHostAddress = "" };
var json = m.ToJson();
json.ShouldNotContain("deviceHostAddress");
}
[Fact]
public void ToJson_includes_deviceHostAddress_when_set()
{
var m = new AbLegacyTagConfigModel { Address = "N7:0", DeviceHostAddress = "ab://host" };
var json = m.ToJson();
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
}
}
@@ -0,0 +1,102 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class FocasTagConfigModelTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
public void FromJson_returns_defaults_for_empty_input(string? json)
{
var m = FocasTagConfigModel.FromJson(json);
m.DeviceHostAddress.ShouldBe("");
m.Address.ShouldBe("");
m.DataType.ShouldBe(FocasDataType.Int32);
}
[Fact]
public void Round_trip_preserves_all_fields()
{
var m = new FocasTagConfigModel
{
DeviceHostAddress = "192.168.0.1:8193",
Address = "MACRO:500",
DataType = FocasDataType.Float64,
};
var json = m.ToJson();
var m2 = FocasTagConfigModel.FromJson(json);
m2.DeviceHostAddress.ShouldBe("192.168.0.1:8193");
m2.Address.ShouldBe("MACRO:500");
m2.DataType.ShouldBe(FocasDataType.Float64);
}
[Fact]
public void ToJson_emits_camelCase_keys_with_enum_names()
{
var m = new FocasTagConfigModel
{
DeviceHostAddress = "192.168.0.1:8193",
Address = "R100",
DataType = FocasDataType.Int32,
};
var json = m.ToJson();
json.ShouldContain("\"deviceHostAddress\":\"192.168.0.1:8193\"");
json.ShouldContain("\"address\":\"R100\"");
json.ShouldContain("\"dataType\":\"Int32\"");
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys()
{
var json = FocasTagConfigModel
.FromJson("""{"address":"R100","extraKey":"keepme"}""")
.ToJson();
json.ShouldContain("extraKey");
json.ShouldContain("keepme");
json.ShouldContain("\"address\":\"R100\"");
}
[Fact]
public void Validate_returns_error_when_address_blank()
{
new FocasTagConfigModel { Address = "" }.Validate().ShouldNotBeNull();
}
[Fact]
public void Validate_returns_null_when_address_filled()
{
new FocasTagConfigModel { Address = "R100" }.Validate().ShouldBeNull();
}
[Fact]
public void ToJson_omits_blank_deviceHostAddress()
{
var m = new FocasTagConfigModel { Address = "R100", DeviceHostAddress = "" };
var json = m.ToJson();
json.ShouldNotContain("deviceHostAddress");
}
[Fact]
public void ToJson_includes_deviceHostAddress_when_set()
{
var m = new FocasTagConfigModel { Address = "R100", DeviceHostAddress = "ab://host" };
var json = m.ToJson();
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
}
}
@@ -0,0 +1,92 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class ModbusTagConfigModelTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
public void FromJson_returns_defaults_for_empty_input(string? json)
{
var m = ModbusTagConfigModel.FromJson(json);
m.Region.ShouldBe(ModbusRegion.HoldingRegisters);
m.Address.ShouldBe(0);
m.DataType.ShouldBe(ModbusDataType.Int16);
m.ByteOrder.ShouldBe(ModbusByteOrder.BigEndian);
m.BitIndex.ShouldBe(0);
m.StringLength.ShouldBe(0);
}
[Fact]
public void Round_trip_preserves_all_six_fields()
{
var m = new ModbusTagConfigModel
{
Region = ModbusRegion.InputRegisters,
Address = 40001,
DataType = ModbusDataType.Float32,
ByteOrder = ModbusByteOrder.WordSwap,
BitIndex = 3,
StringLength = 16,
};
var json = m.ToJson();
var m2 = ModbusTagConfigModel.FromJson(json);
m2.Region.ShouldBe(ModbusRegion.InputRegisters);
m2.Address.ShouldBe(40001);
m2.DataType.ShouldBe(ModbusDataType.Float32);
m2.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap);
m2.BitIndex.ShouldBe(3);
m2.StringLength.ShouldBe(16);
}
[Fact]
public void ToJson_emits_camelCase_keys_with_enum_names()
{
var m = new ModbusTagConfigModel
{
Region = ModbusRegion.HoldingRegisters,
Address = 100,
DataType = ModbusDataType.Int16,
ByteOrder = ModbusByteOrder.BigEndian,
BitIndex = 0,
StringLength = 0,
};
var json = m.ToJson();
json.ShouldContain("\"region\":\"HoldingRegisters\"");
json.ShouldContain("\"dataType\":\"Int16\"");
json.ShouldContain("\"byteOrder\":\"BigEndian\"");
json.ShouldContain("\"address\":100");
json.ShouldContain("\"bitIndex\":0");
json.ShouldContain("\"stringLength\":0");
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys()
{
var json = ModbusTagConfigModel
.FromJson("""{"region":"InputRegisters","addressString":"40001:F"}""")
.ToJson();
json.ShouldContain("addressString");
json.ShouldContain("40001:F");
// and the exposed field still round-trips
json.ShouldContain("\"region\":\"InputRegisters\"");
}
[Fact]
public void Validate_returns_null_for_default_model()
{
new ModbusTagConfigModel().Validate().ShouldBeNull();
}
}
@@ -0,0 +1,82 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class S7TagConfigModelTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
public void FromJson_returns_defaults_for_empty_input(string? json)
{
var m = S7TagConfigModel.FromJson(json);
m.Address.ShouldBe("");
m.DataType.ShouldBe(S7DataType.Int16);
m.StringLength.ShouldBe(0);
}
[Fact]
public void Round_trip_preserves_all_fields()
{
var m = new S7TagConfigModel
{
Address = "DB1.DBW0",
DataType = S7DataType.Float32,
StringLength = 32,
};
var json = m.ToJson();
var m2 = S7TagConfigModel.FromJson(json);
m2.Address.ShouldBe("DB1.DBW0");
m2.DataType.ShouldBe(S7DataType.Float32);
m2.StringLength.ShouldBe(32);
}
[Fact]
public void ToJson_emits_camelCase_keys_with_enum_names()
{
var m = new S7TagConfigModel
{
Address = "M0.0",
DataType = S7DataType.Bool,
StringLength = 0,
};
var json = m.ToJson();
json.ShouldContain("\"address\":\"M0.0\"");
json.ShouldContain("\"dataType\":\"Bool\"");
json.ShouldContain("\"stringLength\":0");
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys()
{
var json = S7TagConfigModel
.FromJson("""{"address":"DB1.DBW0","extraKey":"keepme"}""")
.ToJson();
json.ShouldContain("extraKey");
json.ShouldContain("keepme");
json.ShouldContain("\"address\":\"DB1.DBW0\"");
}
[Fact]
public void Validate_returns_error_when_address_blank()
{
new S7TagConfigModel { Address = "" }.Validate().ShouldNotBeNull();
}
[Fact]
public void Validate_returns_null_when_address_filled()
{
new S7TagConfigModel { Address = "DB1.DBW0" }.Validate().ShouldBeNull();
}
}
@@ -0,0 +1,135 @@
using System.Text.Json.Nodes;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class TagConfigJsonTests
{
private enum Flavor { None, Vanilla, Chocolate }
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{not json")]
[InlineData("[1,2]")]
public void ParseOrNew_returns_empty_object_for_unusable_input(string? json)
{
var obj = TagConfigJson.ParseOrNew(json);
obj.ShouldNotBeNull();
obj.Count.ShouldBe(0);
}
[Fact]
public void ParseOrNew_parses_an_object()
{
var obj = TagConfigJson.ParseOrNew("""{"foo":"bar"}""");
obj.Count.ShouldBe(1);
TagConfigJson.GetString(obj, "foo").ShouldBe("bar");
}
[Fact]
public void RoundTrip_preserves_unknown_keys()
{
var obj = TagConfigJson.ParseOrNew("""{"foo":"bar","region":"X"}""");
TagConfigJson.Set(obj, "region", "Y");
var json = TagConfigJson.Serialize(obj);
json.ShouldContain("\"foo\":\"bar\"");
json.ShouldContain("\"region\":\"Y\"");
}
[Fact]
public void GetString_returns_null_when_absent()
{
var obj = new JsonObject();
TagConfigJson.GetString(obj, "missing").ShouldBeNull();
}
[Fact]
public void GetInt_reads_an_int()
{
var obj = TagConfigJson.ParseOrNew("""{"register":40001}""");
TagConfigJson.GetInt(obj, "register").ShouldBe(40001);
}
[Fact]
public void GetInt_falls_back_when_absent()
{
var obj = new JsonObject();
TagConfigJson.GetInt(obj, "register", fallback: 7).ShouldBe(7);
}
[Fact]
public void GetInt_falls_back_when_value_is_a_non_scalar_node()
{
// A nested object/array under the key must not throw — GetInt returns the fallback.
var obj = TagConfigJson.ParseOrNew("""{"register":{},"other":[1,2]}""");
TagConfigJson.GetInt(obj, "register", fallback: 7).ShouldBe(7);
TagConfigJson.GetInt(obj, "other", fallback: 9).ShouldBe(9);
}
[Fact]
public void GetString_returns_null_when_value_is_a_non_string_node()
{
var obj = TagConfigJson.ParseOrNew("""{"register":40001,"nested":{}}""");
TagConfigJson.GetString(obj, "register").ShouldBeNull();
TagConfigJson.GetString(obj, "nested").ShouldBeNull();
}
[Fact]
public void GetEnum_parses_by_name_case_insensitive()
{
var obj = TagConfigJson.ParseOrNew("""{"flavor":"chocolate"}""");
TagConfigJson.GetEnum(obj, "flavor", Flavor.None).ShouldBe(Flavor.Chocolate);
}
[Fact]
public void GetEnum_falls_back_when_absent()
{
var obj = new JsonObject();
TagConfigJson.GetEnum(obj, "flavor", Flavor.Vanilla).ShouldBe(Flavor.Vanilla);
}
[Fact]
public void GetEnum_falls_back_on_garbage()
{
var obj = TagConfigJson.ParseOrNew("""{"flavor":"strawberry"}""");
TagConfigJson.GetEnum(obj, "flavor", Flavor.Vanilla).ShouldBe(Flavor.Vanilla);
}
[Fact]
public void Set_writes_an_enum_as_its_name_string()
{
var obj = new JsonObject();
TagConfigJson.Set(obj, "flavor", Flavor.Chocolate);
var json = TagConfigJson.Serialize(obj);
json.ShouldContain($"\"flavor\":\"{Flavor.Chocolate}\"");
}
[Fact]
public void Set_with_null_removes_the_key()
{
var obj = TagConfigJson.ParseOrNew("""{"region":"X"}""");
TagConfigJson.Set(obj, "region", null);
obj.ContainsKey("region").ShouldBeFalse();
TagConfigJson.Serialize(obj).ShouldNotContain("region");
}
}
@@ -0,0 +1,102 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
public sealed class TwinCATTagConfigModelTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("{}")]
public void FromJson_returns_defaults_for_empty_input(string? json)
{
var m = TwinCATTagConfigModel.FromJson(json);
m.DeviceHostAddress.ShouldBe("");
m.SymbolPath.ShouldBe("");
m.DataType.ShouldBe(TwinCATDataType.DInt);
}
[Fact]
public void Round_trip_preserves_all_fields()
{
var m = new TwinCATTagConfigModel
{
DeviceHostAddress = "5.1.2.3.1.1:851",
SymbolPath = "MAIN.bStart",
DataType = TwinCATDataType.Real,
};
var json = m.ToJson();
var m2 = TwinCATTagConfigModel.FromJson(json);
m2.DeviceHostAddress.ShouldBe("5.1.2.3.1.1:851");
m2.SymbolPath.ShouldBe("MAIN.bStart");
m2.DataType.ShouldBe(TwinCATDataType.Real);
}
[Fact]
public void ToJson_emits_camelCase_keys_with_enum_names()
{
var m = new TwinCATTagConfigModel
{
DeviceHostAddress = "5.1.2.3.1.1:851",
SymbolPath = "GVL.Counter",
DataType = TwinCATDataType.DInt,
};
var json = m.ToJson();
json.ShouldContain("\"deviceHostAddress\":\"5.1.2.3.1.1:851\"");
json.ShouldContain("\"symbolPath\":\"GVL.Counter\"");
json.ShouldContain("\"dataType\":\"DInt\"");
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys()
{
var json = TwinCATTagConfigModel
.FromJson("""{"symbolPath":"GVL.Counter","extraKey":"keepme"}""")
.ToJson();
json.ShouldContain("extraKey");
json.ShouldContain("keepme");
json.ShouldContain("\"symbolPath\":\"GVL.Counter\"");
}
[Fact]
public void Validate_returns_error_when_symbolPath_blank()
{
new TwinCATTagConfigModel { SymbolPath = "" }.Validate().ShouldNotBeNull();
}
[Fact]
public void Validate_returns_null_when_symbolPath_filled()
{
new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart" }.Validate().ShouldBeNull();
}
[Fact]
public void ToJson_omits_blank_deviceHostAddress()
{
var m = new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart", DeviceHostAddress = "" };
var json = m.ToJson();
json.ShouldNotContain("deviceHostAddress");
}
[Fact]
public void ToJson_includes_deviceHostAddress_when_set()
{
var m = new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart", DeviceHostAddress = "ab://host" };
var json = m.ToJson();
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
}
}
@@ -0,0 +1,73 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Verifies that <see cref="UnsTreeService.LoadTagDriversForEquipmentAsync"/> surfaces each
/// candidate driver's <c>DriverType</c> alongside its id and display string, so the UNS TagModal
/// can later dispatch to a per-driver-type typed tag-config editor (F-uns-1).
/// </summary>
[Trait("Category", "Unit")]
public sealed class UnsTreeServiceTagDriversTests
{
/// <summary>
/// A driver loaded for an equipment carries its <c>DriverType</c> in the returned tuple.
/// </summary>
[Fact]
public async Task LoadTagDriversForEquipment_surfaces_driver_type()
{
var dbName = $"uns-tagdrivers-{Guid.NewGuid():N}";
using (var db = UnsTreeTestDb.CreateNamed(dbName))
{
db.ServerClusters.Add(new ServerCluster
{
ClusterId = "MAIN",
Name = "Main",
Enterprise = "zb",
Site = "warsaw-west",
RedundancyMode = RedundancyMode.None,
CreatedBy = "test",
});
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" });
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
db.Equipment.Add(new Equipment
{
EquipmentId = "EQ-1",
EquipmentUuid = Guid.NewGuid(),
UnsLineId = "LINE-1",
Name = "machine-1",
MachineCode = "machine_001",
});
db.Namespaces.Add(new Namespace
{
NamespaceId = "NS-EQ",
ClusterId = "MAIN",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:zb:eq",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-EQ",
ClusterId = "MAIN",
NamespaceId = "NS-EQ",
Name = "equipment driver",
DriverType = "ModbusTcp",
DriverConfig = "{}",
});
db.SaveChanges();
}
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
var drivers = await service.LoadTagDriversForEquipmentAsync("EQ-1");
drivers.Count.ShouldBe(1);
drivers[0].DriverInstanceId.ShouldBe("DRV-EQ");
drivers[0].DriverType.ShouldBe("ModbusTcp");
}
}